diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0200346..4218dbabb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,14 @@ jobs: run: | cargo build --release --manifest-path gems/fact-mine/Cargo.toml cargo build --release --manifest-path gems/nil-kill/Cargo.toml + # The collector IS this extension -- a collect loads it and no nil-kill + # Ruby at all -- so without it every spec that runs one fails to require + # it, and the specs guarded on its presence quietly test nothing. + - name: Build the collector extension + run: | + cd gems/nil-kill/ext/nil_kill_trace + ruby extconf.rb + make - run: bundle exec rspec gems/nil-kill/spec env: FACT_MINE_RUST_BINARY: ./gems/fact-mine/target/release/fact-mine-rust @@ -170,16 +178,16 @@ jobs: - name: Install Jest oracle fixture runtime run: npm install --no-save --no-package-lock jest@30.0.5 - uses: dtolnay/rust-toolchain@stable - - name: Build Lineage release binary + - name: Build Gigasail release binary # The ingestion round-trip test below skips silently (not fails) # when this binary is absent, so without this step CI could report # green while that contract was never actually exercised. - run: cargo build --release --manifest-path gems/lineage/Cargo.toml + run: cargo build --release --manifest-path gems/gigasail/Cargo.toml --workspace - name: Verify runner adapters, dynamic planning, and canonical checkpoints run: bundle exec ruby -I gems/test-miser/test gems/test-miser/test/test_miser_test.rb - name: Verify PR step summary tallies run: bundle exec ruby -I gems/test-miser/test gems/test-miser/test/step_summary_test.rb - - name: Verify Lineage corpus ingestion round-trip + - name: Verify Gigasail corpus ingestion round-trip run: bundle exec ruby -I gems/test-miser/test gems/test-miser/test/lineage_ingest_integration_test.rb - name: Verify Test Miser evidence analyzers and product formats run: | @@ -229,7 +237,7 @@ jobs: ruby-gems-coverage: name: Ruby gems coverage - needs: changes + needs: [changes, sarif-rust-binaries] if: ${{ needs.changes.outputs.run_gems == 'true' }} runs-on: ubuntu-latest env: @@ -250,18 +258,27 @@ jobs: - name: Install Tree-sitter grammars run: npm install --legacy-peer-deps - uses: dtolnay/rust-toolchain@stable - - name: Build Rust binaries + - uses: actions/download-artifact@v4 + with: + name: sarif-rust-binaries + path: tmp/sarif-binaries + - name: Place the shared Rust binaries where the tests look for them # gems/slopcop/test/cross_gem_contract_integration_test.rb and # gems/espalier/test/architecture_tools_test.rb both exercise the - # Lineage binary; building it here is required, not optional - the + # gigasail binary; having it here is required, not optional - the # SlopCop test hard-flunks without it (this job would otherwise be # broken), and the Espalier one silently skips without it (so its # contract would go untested with no visible failure). + # + # They are built once by `sarif-rust-binaries` and copied to the + # target/release paths the tests resolve, rather than four crates + # being compiled again on this runner. run: | - cargo build --release --manifest-path gems/fact-mine/Cargo.toml - cargo build --release --manifest-path gems/nil-kill/Cargo.toml - cargo build --release --manifest-path gems/decomplex/Cargo.toml - cargo build --release --manifest-path gems/lineage/Cargo.toml + chmod +x tmp/sarif-binaries/* + install -Dm755 tmp/sarif-binaries/fact-mine-rust gems/fact-mine/target/release/fact-mine-rust + install -Dm755 tmp/sarif-binaries/nil-kill-infer-rust gems/nil-kill/target/release/nil-kill-infer-rust + install -Dm755 tmp/sarif-binaries/decomplex-rust gems/decomplex/target/release/decomplex-rust + install -Dm755 tmp/sarif-binaries/giga gems/gigasail/target/release/giga - name: Run Ruby gem tests with SimpleCov run: bundle exec ruby tools/run_ruby_gem_coverage.rb env: @@ -356,8 +373,8 @@ jobs: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} - lineage-rust-coverage: - name: Lineage Rust coverage + gigasail-rust-coverage: + name: Gigasail Rust coverage needs: changes if: ${{ needs.changes.outputs.run_gems == 'true' }} runs-on: ubuntu-latest @@ -365,24 +382,24 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-llvm-cov - - name: Run cargo llvm-cov for Lineage + - name: Run cargo llvm-cov for Gigasail run: | - mkdir -p tmp/lineage-rust-coverage + mkdir -p tmp/gigasail-rust-coverage cargo llvm-cov \ - --manifest-path gems/lineage/Cargo.toml \ + --manifest-path gems/gigasail/Cargo.toml --workspace --all-targets \ --all-features \ --cobertura \ - --output-path ${{ github.workspace }}/tmp/lineage-rust-coverage/cobertura.xml + --output-path ${{ github.workspace }}/tmp/gigasail-rust-coverage/cobertura.xml - uses: actions/upload-artifact@v4 with: - name: lineage-rust-coverage - path: tmp/lineage-rust-coverage/cobertura.xml + name: gigasail-rust-coverage + path: tmp/gigasail-rust-coverage/cobertura.xml retention-days: 7 - uses: codecov/codecov-action@v5 with: - files: ./tmp/lineage-rust-coverage/cobertura.xml + files: ./tmp/gigasail-rust-coverage/cobertura.xml disable_search: true - flags: lineage-rust + flags: gigasail-rust fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -439,7 +456,7 @@ jobs: fact-mine-rust-coverage: name: FactMine Rust coverage - needs: changes + needs: [changes, sarif-rust-binaries] if: ${{ needs.changes.outputs.run_gems == 'true' }} runs-on: ubuntu-latest steps: @@ -461,19 +478,25 @@ jobs: run: npm install --legacy-peer-deps - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-llvm-cov + - uses: actions/download-artifact@v4 + with: + name: sarif-rust-binaries + path: tmp/sarif-binaries - name: Run cargo llvm-cov for FactMine run: | 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 --release --manifest-path gems/fact-mine/Cargo.toml - cargo build --release --manifest-path gems/nil-kill/Cargo.toml - cargo build --release --manifest-path gems/decomplex/Cargo.toml - cargo build --release --manifest-path gems/lineage/Cargo.toml - # The release FactMine binary is built once above. The focused Ruby - # consumer suite reuses that same executable; LLVM coverage comes - # from FactMine's instrumented Rust test run rather than rebuilding - # a second binary for every cross-gem invocation. + # Only the instrumented build happens here -- it has to, coverage + # comes from it. The release binaries the consumer suite calls are + # the ones `sarif-rust-binaries` already built, placed where the + # tests look: several resolve target/release directly rather than + # reading the environment. + chmod +x tmp/sarif-binaries/* + install -Dm755 tmp/sarif-binaries/fact-mine-rust gems/fact-mine/target/release/fact-mine-rust + install -Dm755 tmp/sarif-binaries/nil-kill-infer-rust gems/nil-kill/target/release/nil-kill-infer-rust + install -Dm755 tmp/sarif-binaries/decomplex-rust gems/decomplex/target/release/decomplex-rust + install -Dm755 tmp/sarif-binaries/giga gems/gigasail/target/release/giga FACT_MINE_RUST_BINARY=${{ github.workspace }}/gems/fact-mine/target/release/fact-mine-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 \ @@ -800,6 +823,12 @@ jobs: with: ruby-version: ${{ env.RUBY_VERSION }} bundler-cache: true + # The corpus is traced by the collector, which is the C extension. + - name: Build the collector extension + run: | + cd gems/nil-kill/ext/nil_kill_trace + ruby extconf.rb + make - run: NIL_KILL_JOBS="$(nproc)" tools/clear-nil-kill-transpile-corpus.sh env: NO_COLOR: "1" @@ -1526,7 +1555,7 @@ jobs: # any sanitizer constraint blocking. name: SlopCop constraint coverage SARIF runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 75 needs: - changes - zig-special-coverage @@ -1579,7 +1608,7 @@ jobs: args+=(--coverage="nil-kill:${path}") done < <(find tmp/ruby-coverage-unit \( -name 'cobertura.xml' -o -name 'coverage.xml' \) -type f | sort) fi - timeout 20m env FACT_MINE_RUST_BINARY=tmp/sarif-binaries/fact-mine-rust bundle exec ruby gems/slopcop/exe/slopcop constraints \ + timeout 60m env FACT_MINE_RUST_BINARY=tmp/sarif-binaries/fact-mine-rust bundle exec ruby gems/slopcop/exe/slopcop constraints \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -1601,23 +1630,32 @@ jobs: retention-days: 7 architecture-sarif: - # Architecture findings on changed files: dependency cycles - # (espalier/tools/cycle_report.rb), cross-module private reach-through - # (espalier/tools/reach_through_report.rb), and cross-module change - # coupling (lineage/tools/change_coupling.rb). All three are advisory - # (warning/note level); they annotate PRs and feed the Lineage ledger via - # SARIF, they do not block merges. + # Cross-module change coupling (gigasail/tools/change_coupling.rb), + # advisory (warning/note level): it annotates PRs and feeds the Lineage + # ledger via SARIF, it does not block merges. + # + # cycle_report.rb and reach_through_report.rb are deliberately NOT run + # here. Both are WIP experiments (beb2fe8cf, "WIP anti-pattern experiment + # tools") and both cost a whole-corpus FactMine projection - together they + # were the entire runtime of this job. + # + # reach_through additionally cannot model package-scoped languages: it + # derives owners per file, so two files in one Go package read as a + # cross-module private call, which is what its 81 self-quarantined + # "suspect" findings are. + # + # cycle_report is the sounder of the two - its four findings here are real + # cycles, not artifacts - but it is off pending a measurement of whether + # those findings are worth acting on. If they are, it belongs inside + # Espalier as a library check next to privacy_analyzer.rb, not as a tool + # shelling out to a whole-corpus projection. The tools and their tests stay + # in the tree meanwhile. name: Architecture SARIF runs-on: ubuntu-latest - # FactMine performs whole-corpus structural analysis. Keep one bounded - # job-level deadline, rather than killing its Ruby parent mid-subprocess - # and leaving Open3 reader threads to report a misleading stream-closed - # error. - timeout-minutes: 45 + timeout-minutes: 15 needs: - changes - - sarif-rust-binaries - if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true') && needs.sarif-rust-binaries.result == 'success' }} + if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true') }} permissions: contents: read security-events: write @@ -1629,33 +1667,13 @@ jobs: with: ruby-version: ${{ env.RUBY_VERSION }} bundler-cache: true - - uses: actions/download-artifact@v4 - with: - name: sarif-rust-binaries - path: tmp/sarif-binaries - name: Generate architecture SARIFs run: | - chmod +x tmp/sarif-binaries/fact-mine-rust git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" mkdir -p tmp - export FACT_MINE_RUST_BINARY=$PWD/tmp/sarif-binaries/fact-mine-rust base="origin/${{ github.event.pull_request.base.ref }}" - ruby gems/espalier/tools/cycle_report.rb . \ - --base="$base" --sarif=tmp/arch-cycles.sarif - ruby gems/espalier/tools/reach_through_report.rb . \ - --base="$base" --sarif=tmp/arch-reach-through.sarif - ruby gems/lineage/tools/change_coupling.rb . 8 \ + ruby gems/gigasail/tools/change_coupling.rb . 8 \ --base="$base" --sarif=tmp/arch-change-coupling.sarif - - name: Upload cycles SARIF - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: tmp/arch-cycles.sarif - category: architecture-cycles - - name: Upload reach-through SARIF - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: tmp/arch-reach-through.sarif - category: architecture-reach-through - name: Upload change-coupling SARIF uses: github/codeql-action/upload-sarif@v4 with: @@ -1676,21 +1694,32 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 needs: changes - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true' || needs.changes.outputs.run_zig == 'true') }} + # Not restricted to same-repo pull requests any more: the coverage jobs + # consume these binaries too, and they run on pushes. The SARIF jobs keep + # their own restriction, which is about where a SARIF upload may come from + # rather than about compiling anything. + if: ${{ needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true' || needs.changes.outputs.run_zig == 'true' }} permissions: contents: read steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + # Every release binary the run consumes is built here, once, and + # downloaded by the jobs that need one. Three jobs used to build the same + # crates from scratch on three fresh runners. - name: Build release binaries once run: | cargo build --release --manifest-path gems/fact-mine/Cargo.toml cargo build --release --manifest-path gems/decomplex/Cargo.toml cargo build --release --manifest-path gems/sql-cov/Cargo.toml + cargo build --release --manifest-path gems/nil-kill/Cargo.toml + cargo build --release --manifest-path gems/gigasail/Cargo.toml --workspace mkdir -p tmp/sarif-binaries install -m 755 gems/fact-mine/target/release/fact-mine-rust tmp/sarif-binaries/fact-mine-rust install -m 755 gems/decomplex/target/release/decomplex-rust tmp/sarif-binaries/decomplex-rust install -m 755 gems/sql-cov/target/release/sql-cov tmp/sarif-binaries/sql-cov + install -m 755 gems/nil-kill/target/release/nil-kill-infer-rust tmp/sarif-binaries/nil-kill-infer-rust + install -m 755 gems/gigasail/target/release/giga tmp/sarif-binaries/giga - uses: actions/upload-artifact@v4 with: name: sarif-rust-binaries @@ -1701,7 +1730,7 @@ jobs: decomplex-sarif: name: Decomplex SARIF runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 50 needs: [changes, sarif-rust-binaries] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.sarif-rust-binaries.result == 'success' }} permissions: @@ -1723,7 +1752,7 @@ jobs: run: | chmod +x tmp/sarif-binaries/decomplex-rust git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - timeout 15m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ + timeout 40m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -1744,7 +1773,7 @@ jobs: boobytrap-sarif: name: Boobytrap SARIF runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 75 needs: - changes - ruby-gems-coverage @@ -1756,7 +1785,7 @@ jobs: - bc-lower-coverage-shard - zig-coverage - zig-mutants-coverage - - lineage-rust-coverage + - gigasail-rust-coverage if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true' || needs.changes.outputs.run_zig == 'true') && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} permissions: contents: read @@ -1785,12 +1814,12 @@ jobs: with: pattern: zig-coverage-* path: tmp/sarif-coverage - - name: Download Lineage Rust coverage artifact - if: ${{ needs['lineage-rust-coverage'].result == 'success' }} + - name: Download Gigasail Rust coverage artifact + if: ${{ needs['gigasail-rust-coverage'].result == 'success' }} uses: actions/download-artifact@v4 with: - name: lineage-rust-coverage - path: tmp/sarif-coverage/lineage-rust-coverage + name: gigasail-rust-coverage + path: tmp/sarif-coverage/gigasail-rust-coverage - name: Generate Boobytrap report run: | git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" @@ -1804,7 +1833,7 @@ jobs: ! -path '*zig-coverage-vopr-*' \ | sort ) - timeout 20m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ + timeout 60m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -1826,7 +1855,7 @@ jobs: slopcop-sarif: name: SlopCop SARIF runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 75 needs: - changes - ruby-gems-coverage @@ -1838,7 +1867,7 @@ jobs: - bc-lower-coverage-shard - zig-coverage - zig-mutants-coverage - - lineage-rust-coverage + - gigasail-rust-coverage if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.run_gems == 'true' || needs.changes.outputs.run_src == 'true' || needs.changes.outputs.run_zig == 'true') && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} permissions: contents: read @@ -1867,12 +1896,12 @@ jobs: with: pattern: zig-coverage-* path: tmp/sarif-coverage - - name: Download Lineage Rust coverage artifact - if: ${{ needs['lineage-rust-coverage'].result == 'success' }} + - name: Download Gigasail Rust coverage artifact + if: ${{ needs['gigasail-rust-coverage'].result == 'success' }} uses: actions/download-artifact@v4 with: - name: lineage-rust-coverage - path: tmp/sarif-coverage/lineage-rust-coverage + name: gigasail-rust-coverage + path: tmp/sarif-coverage/gigasail-rust-coverage - name: Generate SlopCop report run: | git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" @@ -1886,7 +1915,7 @@ jobs: ! -path '*zig-coverage-vopr-*' \ | sort ) - timeout 20m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ + timeout 60m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -1908,7 +1937,7 @@ jobs: espalier-sarif: name: Espalier SARIF runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 50 needs: [changes, sarif-rust-binaries] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.sarif-rust-binaries.result == 'success' }} permissions: @@ -1937,7 +1966,7 @@ jobs: run: | chmod +x tmp/sarif-binaries/fact-mine-rust git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - timeout 15m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ + timeout 40m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -1957,7 +1986,7 @@ jobs: nil-kill-sarif: name: Nil-Kill SARIF runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 50 needs: [changes, sarif-rust-binaries] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.sarif-rust-binaries.result == 'success' }} permissions: @@ -1990,7 +2019,7 @@ jobs: run: | chmod +x tmp/sarif-binaries/fact-mine-rust git fetch origin "+${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - timeout 15m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ + timeout 40m bundle exec ruby tools/generate_generalized_gem_sarif.rb \ --repo=. \ --base="origin/${{ github.event.pull_request.base.ref }}" \ --head=HEAD \ @@ -2032,7 +2061,7 @@ jobs: timeout 10m ./tools/generate_sql_cov_sarif.rb \ --repo=. \ --out-dir=tmp/sql-cov-sarif \ - --setup=gems/lineage/sql/storage/init_schema.sql \ + --setup=gems/gigasail/giga-core/sql/storage/init_schema.sql \ --sql-cov-bin=tmp/sarif-binaries/sql-cov - uses: github/codeql-action/upload-sarif@v4 with: @@ -2068,7 +2097,7 @@ jobs: --repo=. \ --out-dir=tmp/sqlfluff-sarif \ --config=.sqlfluff \ - --sql-path=gems/lineage/sql + --sql-path=gems/gigasail/giga-core/sql - uses: github/codeql-action/upload-sarif@v4 with: sarif_file: tmp/sqlfluff-sarif/sqlfluff.sarif diff --git a/.github/workflows/mutants-manual.yml b/.github/workflows/mutants-manual.yml index a1ef33099..318f7ffa8 100644 --- a/.github/workflows/mutants-manual.yml +++ b/.github/workflows/mutants-manual.yml @@ -175,13 +175,16 @@ jobs: git fetch --no-tags --depth=1 "https://github.com/${GITHUB_REPOSITORY}.git" "${BASE_SHA}" args+=(--since "${BASE_SHA}") fi - MUTANT_JOBS="$(nproc)" bundle exec ruby gems/lineage/tools/mutant-converters/ruby_mutant.rb "${args[@]}" + MUTANT_JOBS="$(nproc)" bundle exec ruby gems/gigasail/tools/mutant-converters/ruby_mutant.rb "${args[@]}" + # Store facts compressed (giga ingest-mutants reads .gz transparently). + facts="/tmp/clear-ruby-mutants-${{ matrix.shard }}/mutant-facts.json" + [ -f "$facts" ] && gzip -f "$facts" - uses: actions/upload-artifact@v4 if: always() with: name: ruby-mutant-facts-${{ matrix.shard }} path: | - /tmp/clear-ruby-mutants-${{ matrix.shard }}/mutant-facts.json + /tmp/clear-ruby-mutants-${{ matrix.shard }}/mutant-facts.json.gz /tmp/clear-ruby-mutants-${{ matrix.shard }}/*.log if-no-files-found: warn retention-days: 14 diff --git a/.gitignore b/.gitignore index f77339bcf..94e60e7a5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ !/GEMINI.md !/Gemfile !/Gemfile.lock +/.cache/ +/.ci-local/ +/kcov-bin/ +/nil-kill-golden*/ +/nil-kill-missing-evidence*/ !/gems/ !/gems/** !/LICENSE @@ -30,6 +35,11 @@ !/compiler/** !/docs/ !/examples/ +/.cache/ +/.ci-local/ +/kcov-bin/ +/nil-kill-golden*/ +/nil-kill-missing-evidence*/ !/gems/ !/manifesto/ !/site/ @@ -202,3 +212,4 @@ gems/decomplex/target/ gems/fact-mine/target/ gems/hazard-contract/target/ gems/nil-kill/target/ +kcov-bin/ diff --git a/Gemfile.lock b/Gemfile.lock index 19e47ddda..4b61b2fdf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,6 +37,7 @@ PATH boobytrap (>= 0.0.1) decomplex (= 0.0.1) espalier (>= 0.0.1) + google-protobuf (>= 4.31, < 5) parlour rbs-trace sorbet-runtime @@ -120,6 +121,9 @@ GEM path_expander (~> 2.0) prism (~> 1.7) sexp_processor (~> 4.8) + google-protobuf (4.35.1-x86_64-linux-gnu) + bigdecimal + rake (~> 13.3) highline (3.0.1) ice_nine (0.11.2) io-console (0.8.2) diff --git a/clear b/clear index 7e206c77c..b71b4cd14 100755 --- a/clear +++ b/clear @@ -1401,7 +1401,11 @@ when 'test' merged = ZigCoverageSupport.merge!('examples-benchmarks') puts "Merged Zig coverage: #{merged}" if merged end - exit(failed_names.any? ? 1 : 0) + # A detected memory leak is a failure, not a warning: the run prints + # "MEMORY LEAKS: N" but must also exit non-zero, or `clear test ` + # reports success on a leak and any caller (CI, an agent, a human checking + # $?) is silently told the suite is clean. + exit((failed_names.any? || leak_tests.any?) ? 1 : 0) else source = File.expand_path(source) gen_script = File.join(CLEAR_ROOT, 'transpile-tests', 'gen.rb') diff --git a/codecov.yml b/codecov.yml index bfae30e49..849fbb65d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -54,9 +54,9 @@ flags: - zig/ carryforward: false joined: false - lineage-rust: + gigasail-rust: paths: - - gems/lineage/ + - gems/gigasail/ carryforward: false joined: false go: diff --git a/compiler/spec/minivm_bc_run_env_spec.rb b/compiler/spec/minivm_bc_run_env_spec.rb index 58f606658..b9a98037a 100644 --- a/compiler/spec/minivm_bc_run_env_spec.rb +++ b/compiler/spec/minivm_bc_run_env_spec.rb @@ -21,8 +21,8 @@ it "preserves RUBYOPT while nil-kill source instrumentation is active" do ENV["NIL_KILL_TRACE"] = "1" - ENV["RUBYOPT"] = "-r./gems/nil-kill/lib/nil_kill/runtime_trace.rb" + ENV["RUBYOPT"] = "-r./gems/nil-kill/ext/nil_kill_trace/nil_kill_trace.so" - expect(Object.new.send(:clear_build_env)["RUBYOPT"]).to eq("-r./gems/nil-kill/lib/nil_kill/runtime_trace.rb") + expect(Object.new.send(:clear_build_env)["RUBYOPT"]).to eq("-r./gems/nil-kill/ext/nil_kill_trace/nil_kill_trace.so") end end diff --git a/compiler/spec/mutant_tools_spec.rb b/compiler/spec/mutant_tools_spec.rb index c9c74ede8..408260109 100644 --- a/compiler/spec/mutant_tools_spec.rb +++ b/compiler/spec/mutant_tools_spec.rb @@ -1,9 +1,9 @@ require "tmpdir" -require_relative "../../gems/lineage/tools/mutant-converters/support" unless defined?(MutationTesting) -require_relative "../../gems/lineage/tools/mutant-converters/ruby_mutant" unless defined?(RubySpecMutants) -require_relative "../../gems/lineage/tools/mutant-converters/semantic_mutant" unless defined?(SemanticMutants) -load File.expand_path("../../gems/lineage/tools/mutant-converters/zig-mutants", __dir__) unless defined?(Lineage::MutantConverters::ZigMutants) +require_relative "../../gems/gigasail/tools/mutant-converters/support" unless defined?(MutationTesting) +require_relative "../../gems/gigasail/tools/mutant-converters/ruby_mutant" unless defined?(RubySpecMutants) +require_relative "../../gems/gigasail/tools/mutant-converters/semantic_mutant" unless defined?(SemanticMutants) +load File.expand_path("../../gems/gigasail/tools/mutant-converters/zig-mutants", __dir__) unless defined?(Lineage::MutantConverters::ZigMutants) RSpec.describe MutationTesting do describe ".parse_mutant_summary" do diff --git a/compiler/spec/sql_cov_sarif_generator_spec.rb b/compiler/spec/sql_cov_sarif_generator_spec.rb index 1c632094c..95c405df8 100644 --- a/compiler/spec/sql_cov_sarif_generator_spec.rb +++ b/compiler/spec/sql_cov_sarif_generator_spec.rb @@ -14,7 +14,7 @@ it "uploads only native advisory hazards and reports unresolved facts" do Dir.mktmpdir("sql-cov-sarif-spec") do |dir| repo = File.join(dir, "repo") - sql_dir = File.join(repo, "gems/lineage/sql/storage") + sql_dir = File.join(repo, "gems/gigasail/giga-core/sql/storage") out_dir = File.join(dir, "out") fake_bin = File.join(dir, "fake-sql-cov") argv_log = File.join(dir, "argv.json") @@ -55,7 +55,7 @@ generator, "--repo=#{repo}", "--out-dir=#{out_dir}", - "--setup=gems/lineage/sql/storage/init_schema.sql", + "--setup=gems/gigasail/giga-core/sql/storage/init_schema.sql", "--sql-cov-bin=#{fake_bin}" ) expect(status).to be_success, stderr @@ -68,7 +68,7 @@ expect(run.dig("properties", "scannedFiles")).to eq(1) expect(run.dig("properties", "skippedNonQueryFiles")).to be_empty expect(run.dig("results", 0, "locations", 0, "physicalLocation", "artifactLocation", "uri")) - .to eq("gems/lineage/sql/storage/query.sql") + .to eq("gems/gigasail/giga-core/sql/storage/query.sql") markdown = File.read(File.join(out_dir, "sql-cov.md")) expect(markdown).to include("1 unresolved schema facts") @@ -80,7 +80,7 @@ it "normalizes generated identifiers, skips non-query SQL, and fails on scan errors" do Dir.mktmpdir("sql-cov-sarif-spec") do |dir| repo = File.join(dir, "repo") - sql_dir = File.join(repo, "gems/lineage/sql/storage") + sql_dir = File.join(repo, "gems/gigasail/giga-core/sql/storage") out_dir = File.join(dir, "out") fake_bin = File.join(dir, "fake-sql-cov") input_log = File.join(dir, "input.sql") @@ -107,7 +107,7 @@ generator, "--repo=#{repo}", "--out-dir=#{out_dir}", - "--setup=gems/lineage/sql/storage/init_schema.sql", + "--setup=gems/gigasail/giga-core/sql/storage/init_schema.sql", "--sql-cov-bin=#{fake_bin}" ) expect(status).to be_success, stderr @@ -116,7 +116,7 @@ run = JSON.parse(File.read(File.join(out_dir, "sql-cov.sarif"))).fetch("runs").first expect(run.dig("properties", "scannedFiles")).to eq(1) expect(run.dig("properties", "skippedNonQueryFiles")) - .to eq(["gems/lineage/sql/storage/configure.sql"]) + .to eq(["gems/gigasail/giga-core/sql/storage/configure.sql"]) File.write(fake_bin, "#!/usr/bin/env ruby\nwarn 'cannot scan'\nexit 2\n") _stdout, stderr, status = Open3.capture3( @@ -124,7 +124,7 @@ generator, "--repo=#{repo}", "--out-dir=#{out_dir}", - "--setup=gems/lineage/sql/storage/init_schema.sql", + "--setup=gems/gigasail/giga-core/sql/storage/init_schema.sql", "--sql-cov-bin=#{fake_bin}" ) expect(status).not_to be_success diff --git a/docs/agents/csmith.md b/docs/agents/csmith.md index 767e61927..403471af6 100644 --- a/docs/agents/csmith.md +++ b/docs/agents/csmith.md @@ -1049,7 +1049,7 @@ paired with much stronger mutation evidence. The permanent command is: ```text -bundle exec ruby gems/lineage/tools/mutant-converters/semantic_mutant.rb \ +bundle exec ruby gems/gigasail/tools/mutant-converters/semantic_mutant.rb \ --out /tmp/clear-semantic-mutants --jobs 32 --timeout 60 --min-new-kills 1 ``` @@ -1233,7 +1233,7 @@ the semantic adapter and historical template as independent coverage. The permanent differential command is: ```text -bundle exec ruby gems/lineage/tools/mutant-converters/semantic_mutant.rb \ +bundle exec ruby gems/gigasail/tools/mutant-converters/semantic_mutant.rb \ --out /tmp/clear-semantic-mutants-final-148 \ --jobs 32 --timeout 60 --min-new-kills 1 ``` diff --git a/docs/agents/mutants.md b/docs/agents/mutants.md index 68e01035e..c6d9aea38 100644 --- a/docs/agents/mutants.md +++ b/docs/agents/mutants.md @@ -3,7 +3,7 @@ This branch turns mutation testing on for the three test surfaces that carry compiler correctness: -- Ruby specs: `gems/lineage/tools/mutant-converters/ruby_mutant.rb` +- Ruby specs: `gems/gigasail/tools/mutant-converters/ruby_mutant.rb` - transpile-tests: `tools/mutants/transpile_tests.rb` - fuzz templates: `tools/fuzz/mutants/run.rb` @@ -42,7 +42,7 @@ cannot produce a summary, has no selected tests, drops below its baseline, or exceeds its timeout budget. Advisory subjects still run and report coverage, but do not block CI until they are promoted. -The subject matrix lives in `gems/lineage/tools/mutant-converters/src_subjects.yml`. +The subject matrix lives in `gems/gigasail/tools/mutant-converters/src_subjects.yml`. Current matrix: @@ -69,7 +69,7 @@ Important implementation details: Current local validation: ```sh -MUTANT_JOBS=32 bundle exec ruby gems/lineage/tools/mutant-converters/ruby_mutant.rb --since HEAD --out /tmp/clear-ruby-mutants-full-2 +MUTANT_JOBS=32 bundle exec ruby gems/gigasail/tools/mutant-converters/ruby_mutant.rb --since HEAD --out /tmp/clear-ruby-mutants-full-2 ``` Result: exit 0. Hard-gated changed subjects passed; untouched subjects skipped; @@ -251,5 +251,5 @@ Additional validation on this branch: - `bundle exec prspec`: 6,167 examples, 0 failures. - `bundle exec prspec spec/ --tag integration`: 237 examples, 0 failures. - `bundle exec srb tc`: no errors. -- Mutation tooling syntax check: all `gems/lineage/tools/mutant-converters/**/*.rb`, +- Mutation tooling syntax check: all `gems/gigasail/tools/mutant-converters/**/*.rb`, `tools/mutants/**/*.rb`, and `tools/fuzz/mutants/**/*.rb` parsed successfully. diff --git a/docs/agents/runtime-evidence-v1-design.md b/docs/agents/runtime-evidence-v1-design.md new file mode 100644 index 000000000..4fd416b8b --- /dev/null +++ b/docs/agents/runtime-evidence-v1-design.md @@ -0,0 +1,485 @@ +# Runtime Semantic Evidence v1 + +Status: implementation architecture. The prior ad-hoc, unused +`fact-mine.runtime-value-evidence.v1` shape is replaced in place. There is no +compatibility contract and no dual emission. + +## Decision + +Runtime analysis uses one versioned, language-neutral protocol with two +top-level messages: + +1. FactMine emits a **Trace Plan** containing exact, typed source anchors and the + evidence required at each anchor. +2. NilKill consumes that plan and emits **Runtime Semantic Evidence** containing + only observed runtime facts, capture status, and provenance for those exact + anchors. + +FactMine is the sole owner of source parsing, CFG/DFG propagation, semantic +closure, SCIP generation, and Big-O completeness. NilKill is the sole owner of +executing workloads and faithfully observing runtime values and dispatch. + +The canonical schema is Protobuf, like SCIP. Rust generates its binding during +the build and Ruby checks in its generated binding. ProtoJSON encoded through +those bindings and +compressed as `.json.gz` remains the default human-inspectable artifact. A +binary protobuf encoding may be added without changing protocol semantics. + +The protocols intentionally reuse SCIP concepts and types: + +- tool metadata; +- canonical project-relative documents; +- explicit position encoding; +- typed, zero-based, half-open ranges; +- canonical SCIP symbols for semantic entities; +- document-local symbols for source anchors. + +They do not pretend that ordinary SCIP can represent runtime values. Runtime +domains, execution counts, correlation, capture completeness, and run +provenance are companion facts referencing SCIP-style symbols and occurrences. + +## Why the prior ad-hoc shape is not an adequate specification + +The repository has a nominal v1 data shape, but it is not a complete protocol: + +- Its only canonical definition is a Rust `serde` struct. NilKill independently + constructs Ruby hashes and duplicates the schema-version string. +- Unknown fields are accepted, so a misspelled producer field can be silently + discarded. +- Methods and calls are joined by path, owner, name, kind, line, selector, and + suffix heuristics. The evidence does not reference FactMine-generated source + identities. +- No source or anchor digest proves that evidence belongs to the analyzed + source revision. +- A line and selector are not a unique callsite identity. +- Flattened `types`, `singletons`, `elements`, `targets`, result domains, and + truth values lose receiver-target-result correlations. +- `target_observation_complete` has no precise world or capture semantics. + Current merging can turn a mixed group complete when any row has a target. +- An absent record cannot distinguish unexecuted, not instrumented, unsupported, + dropped, filtered, stale, or producer failure. +- Producer-side filtering of test code discards observations and forces + receiver-only reconstruction in the consumer. +- Runtime type identities are unqualified strings rather than canonical + semantic entities. +- The consumer implementation combines validation, heuristic source matching, + value propagation, target inference, stdlib synthesis, SCIP encoding, and + several dozen regression tests in one approximately 5,000-line module. +- NilKill tests its serializer and some synthetic events; FactMine tests many + handwritten examples. The two projects do not run the same independent + conformance corpus. + +There are useful v1 tests, but they are regression tests for implementation +examples, not proof that an arbitrary conforming producer will work. + +## Semantic authority + +Runtime evidence never claims all possible program behavior. + +`MODELED_RUNS` means: + +- every execution of an instrumented anchor in the listed successful runs was + captured unless the anchor says otherwise; +- the value and target alternatives are complete only for those captured + executions; +- unobserved inputs, unexecuted branches, and future monkey-patching remain + outside the modeled world. + +FactMine may report `complete_under_modeled_runs`, but must not silently relabel +that as compiler-proven or universally complete. Every exported completeness +result retains its authority. + +`capture_complete` is distinct from `semantic_closed`: + +- capture completeness is a producer attestation about the selected executions; +- semantic closure is a FactMine proof that every alternative relevant to an + operation has a target and cost under the selected authority. + +Line coverage proves neither property. + +## Protocol A: Trace Plan + +FactMine generates the plan after parsing and normalizing source. NilKill does +not discover callsites or reconstruct source flow. + +Conceptual schema: + +```protobuf +message TracePlan { + uint32 protocol_version = 1; + ToolInfo producer = 2; + string project_root = 3; + bytes plan_digest = 4; + repeated PlannedDocument documents = 5; + repeated EvidenceRequest requests = 6; +} + +message PlannedDocument { + string relative_path = 1; + string language = 2; + scip.PositionEncoding position_encoding = 3; + bytes content_sha256 = 4; +} + +message SourceAnchor { + // A document-local SCIP symbol generated by FactMine. + string symbol = 1; + string relative_path = 2; + SourceRange range = 3; + AnchorKind kind = 4; + string enclosing_symbol = 5; + bytes semantic_digest = 6; + string display_name = 7; // informational, never a join key +} + +message EvidenceRequest { + SourceAnchor anchor = 1; + repeated EvidenceKind required = 2; + optional SourceAnchor activation_anchor = 3; + optional uint32 parameter_ordinal = 4; +} +``` + +Required anchor kinds include function entry, function return, call selector, +state read/write, callback entry, collection operation, and branch predicate. +The language adapter recognizes native syntax; the common profile pass assigns +anchors and requirements. + +Anchor IDs and semantic digests have separate purposes: + +- the local symbol is a stable lookup key across plan/evidence; +- the semantic digest determines whether cached evidence may be reused; +- the document digest detects stale source; +- only FactMine may relocate unchanged anchors into a new plan. + +This supplies the correctness foundation for incremental collection. NilKill +never guesses that an old path/line remains equivalent. + +## Protocol B: Runtime Semantic Evidence + +Evidence is grouped by exact plan anchor and correlated execution alternative. +It does not flatten independent unions. + +Conceptual schema: + +```protobuf +message RuntimeEvidence { + uint32 protocol_version = 1; + ToolInfo producer = 2; + Authority authority = 3; // MODELED_RUNS + bytes trace_plan_digest = 4; + repeated EnvironmentClaim environment = 5; + repeated Run runs = 6; + repeated AnchorEvidence anchors = 7; + repeated CorrelationEvidence correlations = 8; +} + +message AnchorEvidence { + string anchor_symbol = 1; + bytes anchor_semantic_digest = 2; + CaptureSummary capture = 3; + repeated ExecutionBucket executions = 4; +} + +message CaptureSummary { + CaptureStatus status = 1; + repeated string run_ids = 2; + uint64 observed_executions = 3; + uint64 dropped_executions = 4; + string reason = 5; + repeated EvidenceKind complete_kinds = 6; +} + +message CorrelationEvidence { + string group_id = 1; + repeated string candidate_anchor_symbols = 2; + CaptureSummary capture = 3; + repeated ExecutionBucket executions = 4; +} + +enum CaptureStatus { + CAPTURE_STATUS_UNSPECIFIED = 0; + COMPLETE_FOR_RUNS = 1; + NOT_EXECUTED = 2; + PARTIAL = 3; + NOT_INSTRUMENTED = 4; + UNSUPPORTED = 5; + STALE = 6; + FAILED_CAPTURE = 7; +} + +message ExecutionBucket { + uint64 count = 1; + ValueSet receiver = 2; + RuntimeTarget target = 3; + ValueSet result = 4; + optional bool boolean_result = 5; + Provenance provenance = 6; + ValueSet value = 7; // parameter, return, or state boundary +} +``` + +One bucket represents one correlated +`receiver -> actual target -> result -> predicate result` alternative. Identical +buckets may be counted and merged. Different alternatives must never be +cross-multiplied. + +Values are recursive, bounded summaries: + +```protobuf +message RuntimeValue { + string type_symbol = 1; + optional string singleton_symbol = 2; + SourceRole source_role = 3; + oneof shape { + SequenceShape sequence = 4; + MappingShape mapping = 5; + RecordShape record = 6; + TupleShape tuple = 7; + } + bool truncated = 8; +} + +message ValueSet { + repeated WeightedValue alternatives = 1; + bool truncated = 2; +} + +message WeightedValue { + RuntimeValue value = 1; + uint64 count = 2; +} +``` + +Container type and contained alternatives remain nested in the same value. +Record members remain attached to the exact record type. Mapping summaries +retain key/value association to the precision needed by FactMine. Depth, +cardinality, redaction, or sampling limits set `truncated`; they never silently +produce a closed domain. + +`RuntimeTarget` contains one canonical SCIP symbol when the provider can produce +one, the exact runtime definition identity when available, package/version +coordinates, native/workspace/dependency classification, and source role. The +producer records test doubles and mocking targets truthfully. FactMine applies +production-analysis policy; NilKill does not erase observations. + +Every requested anchor appears exactly once in the evidence, including +`NOT_EXECUTED` anchors. Missing anchors are protocol errors, not ordinary +unobserved calls. + +When a provider cannot distinguish two exact anchors—for example, identical +selectors on one source line—it emits one `CorrelationEvidence` row containing +the raw buckets and the sorted candidate-anchor set. It does not choose an +anchor or duplicate the bucket into every candidate. FactMine validates that +the candidates are compatible call anchors from the supplied plan, then may +bind the observation only through normalized CFG/DFG or an already-proven +static type. An unresolved group remains incomplete. + +## Responsibility boundary + +### FactMine owns + +- source parsing and language syntax normalization; +- trace-plan anchors, requirements, semantic digests, and relocation; +- protocol validation against the exact plan and source; +- normalized CFG/DFG propagation; +- callback, branch, iteration, state, and collection relationships; +- joining runtime targets to project/compiler SCIP; +- language-owned semantic normalization through an adapter; +- stdlib/dependency cost joins; +- closed-world checks and authority labels; +- inferred SCIP output and Big-O analysis. + +### NilKill shared infrastructure owns + +- executing complete or incremental workloads; +- run and shard identities; +- ensuring every requested anchor receives a capture status; +- lossless aggregation of identical correlated execution buckets; +- function-level evidence ownership and replacement; +- atomic compressed output; +- dropped-event accounting and producer attestation. + +### NilKill language providers own + +- VM/runtime hooks and source instrumentation; +- obtaining receiver, target, result, and predicate observations; +- mapping native runtime entities to canonical runtime SCIP symbols; +- bounded value/shape inspection; +- identifying the source and package coordinates of runtime entities; +- reporting unsupported evidence explicitly. + +They do not parse source for flow, infer owners through assignments, filter +non-production evidence, infer call targets at unexecuted sites, or decide +Big-O closure. + +### FactMine language adapters own + +A deliberately small runtime extension to the existing syntax adapter: + +- normalization of a provider's runtime entity identity; +- mapping native/stdlib runtime targets to the language's canonical SCIP + package identity; +- native dispatch relationships that the language runtime defines (for example + mixins or prototype ancestry); +- implicit runtime operations that have no explicit source call. + +The shared overlay never contains `if language == ...`. New adapter methods +must be justified by a cross-language normalized concept and exercised by the +adapter conformance suite. + +## Conformance testing + +Correctness is established in layers. Repository completion percentages are +benchmarks, not protocol tests. + +### 1. Schema and semantic validator + +`fact-mine runtime-evidence validate --plan PLAN --evidence EVIDENCE` +must reject: + +- unknown protocol versions and fields; +- malformed SCIP symbols or ranges; +- non-canonical paths or inconsistent position encodings; +- plan, document, or anchor digest mismatches; +- missing or duplicate requested anchors; +- counts inconsistent with capture summaries; +- `COMPLETE_FOR_RUNS` with dropped events, truncation, failed runs, or missing + required fields; +- targets or values whose source role/provenance is missing; +- evidence referring to a different enclosing symbol or anchor kind. + +Checked-in valid and invalid fixtures are consumed by both generated Rust and +Ruby bindings. + +### 2. FactMine consumer conformance + +FactMine tests use a synthetic normalized IR plus hand-authored, validator-clean +evidence. They do not invoke NilKill. Golden cases cover: + +- exact callsite and definition joins; +- parameter, return, state, and callback propagation; +- receiver-target-result correlation; +- multiple receiver alternatives where all targets close; +- one unresolved alternative preventing closure; +- collection element/key/value projections; +- record accessors and generated declarations; +- branch capability and truthiness refinement; +- test replacement evidence retained but not trusted as production identity; +- stdlib/native target normalization; +- unexecuted, partial, truncated, stale, and unsupported evidence; +- source movement with valid and invalid relocation; +- incremental merge and replacement. + +The expected result includes exact inferred SCIP occurrences, normalized +domains, call costs, completeness authority, and gap reasons. + +Property tests enforce monotonic safety: + +- reordering or duplicating identical buckets does not change the result; +- removing evidence cannot improve semantic closure; +- changing complete capture to partial cannot improve completeness; +- adding an unresolved alternative cannot preserve a closed callsite; +- stale or ambiguous evidence never joins; +- a covered line alone never resolves a call; +- no inference crosses an enclosing-function or anchor boundary. + +### 3. NilKill producer conformance + +A shared provider harness runs small real programs and compares emitted evidence +with semantic golden expectations. Ruby is the first implementation; Python, +JavaScript, and PHP must pass the same scenario catalog before integration. + +Provider fixtures cover: + +- instance, class/module, inherited, mixed-in, and native calls; +- overloaded/dynamic targets at one callsite; +- parameters, returns, state, callbacks, and nested containers; +- generated accessors invisible to ordinary call TracePoint; +- short-circuit and ternary branches; +- exceptions and non-local exits; +- test doubles and monkey patches with correct source roles; +- anonymous classes/records; +- dropped/truncated evidence; +- zero executions; +- multiple runs and incremental replacement. + +These tests assert evidence only. They do not accept a favorable Big-O result as +proof that tracing was correct. + +### 4. End-to-end contract fixtures + +For each supported runtime language: + +``` +fixture source + fixture workload + -> FactMine trace plan + -> NilKill provider + -> protocol validator + -> FactMine runtime SCIP + -> exact expected SCIP + Big-O result +``` + +The same fixture is also run with a synthetic perfect producer. A failure then +localizes to plan/consumer, provider, or integration rather than becoming a +repository-level metric mystery. + +## Implementation layout + +```text +gems/protocol/runtime-evidence/v1/ + runtime_evidence.proto + conformance/ + +gems/fact-mine/src/ + runtime_protocol.rs # generated binding wrapper, plan, strict validator + runtime_evidence.rs # canonical-to-normalized overlay and SCIP export + +gems/nil-kill/lib/nil_kill/runtime/ + protocol/runtime_evidence_pb.rb + evidence_protocol.rb # generated binding adapter only + value_evidence_emitter.rb + evidence_merger.rb + scip_emitter.rb + +gems/nil-kill/lib/nil_kill/languages/providers// + runtime_tracer.* + runtime_identity.* +``` + +No language-specific code belongs in a shared file. + +## Cutover implemented + +1. The v1 `.proto`, generated bindings, strict validator, and shared valid and + invalid corpus are the only public runtime-evidence contract. +2. FactMine generates the exact plan and a private anchor-to-normalized-IR + binding table from the same source snapshot. +3. NilKill reads the plan and writes every artifact through the generated Ruby + binding. Unknown or malformed fields fail before collection or merge. +4. FactMine rebuilds the plan, requires the same plan digest, validates every + evidence row, and joins only through the private exact binding table. +5. The former path/name/line ad-hoc JSON shape has no production parser or CLI + entry point. FactMine's private normalized facts are not a wire format. +6. Incremental merge replaces evidence by run and anchor. Unchanged semantic + anchors may relocate; changed or new anchors become `STALE`. + +## Cutover acceptance criteria + +- One canonical protocol definition generates both producer and consumer types. +- The validator proves exact plan/source compatibility before analysis. +- Every planned anchor has explicit capture status. +- Receiver, target, result, and predicate observations remain correlated. +- No source path/name/line heuristic is used to join runtime evidence. +- FactMine passes the consumer golden corpus without NilKill. +- Ruby passes the shared producer corpus without relying on FactMine inference. +- End-to-end fixtures pass with exact SCIP and completeness outputs. +- All safety properties pass under randomized ordering, merging, omission, and + alternative expansion. +- Incremental and full collection produce semantically identical evidence for + unchanged final source and workload sets. +- SlopCop regressions are explained by authority/gap diagnostics; an older, + unsound percentage is not restored merely to hit a number. + +Only after these criteria hold should work resume on repository-specific +covered-line gaps. diff --git a/docs/agents/spec-mutant-burndown.md b/docs/agents/spec-mutant-burndown.md index 24552dcf4..c53a5ed4e 100644 --- a/docs/agents/spec-mutant-burndown.md +++ b/docs/agents/spec-mutant-burndown.md @@ -69,7 +69,7 @@ load-bearing. ## Current Findings -- `gems/lineage/tools/mutant-converters/ruby_mutant.rb` only accepted one `spec:` file per subject. +- `gems/gigasail/tools/mutant-converters/ruby_mutant.rb` only accepted one `spec:` file per subject. That hid existing load-bearing specs from mutant for broad subjects such as `FsmTransform::Emit`. - The runner did not accept raw class/module names such as diff --git a/gems/boobytrap/README.md b/gems/boobytrap/README.md index 8ab4d7553..a284957e4 100644 --- a/gems/boobytrap/README.md +++ b/gems/boobytrap/README.md @@ -10,7 +10,7 @@ Nil-kill, lint violations, etc. help explain what *might* be wrong when you get there. > [!NOTE] -> Boobytrap uses [Lineage](../lineage/README.md) to track changes to +> Boobytrap uses [Lineage](../gigasail/README.md) to track changes to > lines across files over time, and to avoid penalizing non-semantic > changes like whitespace or comments. @@ -222,7 +222,7 @@ the heuristic is reliable here. control-flow pressure. - [SlopCop](../slopcop/README.md): categorizes uncovered branches and ranks the true test gaps. -- [Lineage](../lineage/README.md): renders history and verification +- [Lineage](../gigasail/README.md): renders history and verification evidence next to source. - [Nil-kill](../nil-kill/README.md): traces nil and type pressure back to its source. diff --git a/gems/boobytrap/src/giga_e2e_test.go b/gems/boobytrap/src/giga_e2e_test.go new file mode 100644 index 000000000..d45461c07 --- /dev/null +++ b/gems/boobytrap/src/giga_e2e_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "testing" +) + +// TestGigaLineageIntegrationE2E drives the real `giga summary --format json` +// binary through loadLineage and asserts the units parse. Skips unless the +// E2E_* env vars point at a built giga binary and a giga-built database. +func TestGigaLineageIntegrationE2E(t *testing.T) { + db := os.Getenv("E2E_GIGA_DB") + giga := os.Getenv("E2E_GIGA_BIN") + repo := os.Getenv("E2E_REPO") + if db == "" || giga == "" || repo == "" { + t.Skip("set E2E_GIGA_DB, E2E_GIGA_BIN, E2E_REPO to run") + } + // The command is the bare binary; loadLineage supplies the `summary` verb + // and its flags itself. + idx := loadLineage(db, repo, nil, 10, giga) + if idx.Status != "ok" { + t.Fatalf("loadLineage status = %q, want ok", idx.Status) + } + if len(idx.Units) == 0 { + t.Fatal("loadLineage returned no units") + } + t.Logf("parsed %d lineage units from giga; top unit %q in %s risk=%.2f", + len(idx.Units), idx.Units[0].Name, idx.Units[0].File, idx.Units[0].RiskScore) +} diff --git a/gems/boobytrap/src/report.go b/gems/boobytrap/src/report.go index 1c508f2b9..5e0699215 100644 --- a/gems/boobytrap/src/report.go +++ b/gems/boobytrap/src/report.go @@ -907,11 +907,11 @@ func loadLineage(dbPath string, repoRoot string, only []string, top int, command parts = append(parts, args...) cmd = exec.Command(parts[0], parts[1:]...) } else { - binary := filepath.Join(repoRoot, "gems", "lineage", "target", "release", "lineage") + binary := filepath.Join(repoRoot, "gems", "gigasail", "target", "release", "giga") if _, err := os.Stat(binary); err == nil { cmd = exec.Command(binary, args...) } else { - cmd = exec.Command("cargo", "run", "--quiet", "--manifest-path", filepath.Join(repoRoot, "gems", "lineage", "Cargo.toml"), "--") + cmd = exec.Command("cargo", "run", "--quiet", "--manifest-path", filepath.Join(repoRoot, "gems", "gigasail", "Cargo.toml"), "--bin", "giga", "--") cmd.Args = append(cmd.Args, args...) } } diff --git a/gems/decomplex/Cargo.lock b/gems/decomplex/Cargo.lock index c98a0f0bc..21799f2b1 100644 --- a/gems/decomplex/Cargo.lock +++ b/gems/decomplex/Cargo.lock @@ -105,6 +105,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "equivalent" version = "1.0.2" @@ -127,12 +133,18 @@ version = "0.1.0" dependencies = [ "anyhow", "flate2", + "glob", "hazard-contract", + "protobuf", + "protobuf-codegen", + "protobuf-json-mapping", "regex", + "scip", "serde", "serde_json", "serde_yaml", "sha2", + "shell-words", "streaming-iterator", "tree-sitter", "tree-sitter-c", @@ -185,6 +197,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.17.1" @@ -199,6 +217,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -227,6 +254,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" @@ -243,6 +276,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -252,6 +291,68 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "protobuf-json-mapping" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" +dependencies = [ + "protobuf", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror", +] + [[package]] name = "quote" version = "1.0.45" @@ -309,6 +410,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scip" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a72133c2d6fd45c9a3a343bcb3db2faa30f68f0919bfa3370ca85add5460c3" +dependencies = [ + "protobuf", +] + [[package]] name = "serde" version = "1.0.228" @@ -377,6 +487,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -418,6 +534,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tree-sitter" version = "0.25.8" @@ -612,6 +748,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/gems/decomplex/examples/oracles/structural-topology.json b/gems/decomplex/examples/oracles/structural-topology.json index 3b21cc316..7eb9e19b9 100644 --- a/gems/decomplex/examples/oracles/structural-topology.json +++ b/gems/decomplex/examples/oracles/structural-topology.json @@ -25,5 +25,32 @@ ], "method_count": 5 }, - "options": {} -} \ No newline at end of file + "options": {}, + "expected_by_language": { + "kotlin": { + "edges": [ + { + "callee_name": "prepare", + "caller_name": "run", + "type": "always" + }, + { + "callee_name": "ready", + "caller_name": "run", + "type": "conditional" + }, + { + "callee_name": "validate", + "caller_name": "run", + "type": "conditional" + }, + { + "callee_name": "helper", + "caller_name": "run", + "type": "iterates" + } + ], + "method_count": 6 + } + } +} diff --git a/gems/decomplex/examples/oracles/temporal-ordering-pressure.json b/gems/decomplex/examples/oracles/temporal-ordering-pressure.json index 31c8719f6..713d836d6 100644 --- a/gems/decomplex/examples/oracles/temporal-ordering-pressure.json +++ b/gems/decomplex/examples/oracles/temporal-ordering-pressure.json @@ -17,5 +17,24 @@ "writers": 3 } ], - "options": {} -} \ No newline at end of file + "options": {}, + "expected_by_language": { + "kotlin": [ + { + "orderings": "4!", + "owner": "TemporalOrderExample", + "public_methods": 5, + "shared_fields": [ + "a", + "b" + ], + "state_fields": [ + "a", + "b" + ], + "state_methods": 4, + "writers": 3 + } + ] + } +} diff --git a/gems/decomplex/tests/examples_oracle.rs b/gems/decomplex/tests/examples_oracle.rs index 19ddeea6e..715dbaa2c 100644 --- a/gems/decomplex/tests/examples_oracle.rs +++ b/gems/decomplex/tests/examples_oracle.rs @@ -34,8 +34,18 @@ fn shared_examples_match_oracles() -> Result<()> { } let oracle: Value = serde_json::from_str(&fs::read_to_string(&oracle_path)?)?; + let language = language_for_fixture(&fixture)?; + let language_key = format!("{language:?}").to_lowercase(); + // One expectation per detector, because a detector should see the same + // shape whatever the source language. Where a language genuinely models + // something differently -- Kotlin's primary constructor is a function, + // and other languages have no such declaration -- it says so here + // rather than the detector losing the language or the shared number + // drifting to fit one of them. let expected = oracle - .get("expected") + .get("expected_by_language") + .and_then(|by_language| by_language.get(&language_key)) + .or_else(|| oracle.get("expected")) .cloned() .with_context(|| format!("{} missing expected", oracle_path.display()))?; let detector_name = oracle @@ -43,7 +53,6 @@ fn shared_examples_match_oracles() -> Result<()> { .and_then(Value::as_str) .with_context(|| format!("{} missing detector", oracle_path.display()))?; let options = oracle.get("options").cloned().unwrap_or_else(|| json!({})); - let language = language_for_fixture(&fixture)?; if matches!(language, Language::Swift) { continue; } @@ -55,7 +64,15 @@ fn shared_examples_match_oracles() -> Result<()> { if std::env::var("UPDATE_ORACLES").is_ok() { let mut oracle: Value = serde_json::from_str(&fs::read_to_string(&oracle_path)?)?; - oracle["expected"] = projected_normalized; + if oracle + .get("expected_by_language") + .and_then(|by_language| by_language.get(&language_key)) + .is_some() + { + oracle["expected_by_language"][&language_key] = projected_normalized; + } else { + oracle["expected"] = projected_normalized; + } fs::write(&oracle_path, serde_json::to_string_pretty(&oracle)?)?; } else if projected_normalized != expected_normalized { failures.push(format!( diff --git a/gems/espalier/README.md b/gems/espalier/README.md index 84846ab6b..7cfe8d045 100644 --- a/gems/espalier/README.md +++ b/gems/espalier/README.md @@ -64,6 +64,25 @@ bundle exec gems/espalier/exe/espalier \ --output=/tmp/espalier-report.md ``` +Generate a source-proven standard-library complexity bundle from one manifest: + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/go-1.22.2.yml +``` + +The manifest owns source revision verification, source selection, and the +language indexer's build recipe. The shared pipeline owns profiling, generic +soundness checks, exact-symbol export, producer/consumer validation, and atomic +publication. A new SCIP stdlib should normally require only a manifest; parser +or runtime semantics belong in that language's FactMine module. + +FactMine discovers generated bundles automatically at build time. See +`gems/fact-mine/config/stdlib_maps/support.yml` for the compatibility status of +every maintained SCIP language. An entry remains blocked when the indexer +cannot provide executable bodies or a source/consumer version identity; the +pipeline does not substitute a manual override for either failure. + ## Outputs Espalier can output a compact architecture manifest for tools and LLMs, @@ -133,7 +152,8 @@ combining structural facts with sibling-gem evidence: - `--risk FILE`: Boobytrap/SlopCop churn, coverage, and risk evidence. - `--manifest FILE`: a previously generated Espalier YAML manifest. - `--fact-mine FILE`: a previously generated `fact-mine.json` static facts file (also honors `ENV["FACT_MINE_FACTS_FILE"]` environment variable to bypass fact extraction runs). -- `--scip-index FILE`: import compiler-proven call identity from a `.scip` file or `scip print --json` export; repeat for multiple build roots. Binary indexes require `scip` on `PATH` (or `SCIP_BINARY`). +- `--scip-index FILE`: import compiler-proven call identity directly from a binary `.scip` file or from a protobuf-JSON export; repeat for multiple build roots. Binary decoding is built in and does not require the `scip` CLI at analysis time. +- `--complexity-summary FILE`: apply a reviewed Espalier complexity summary keyed by exact compiler symbols; JSON and reproducible `.json.gz` files are supported and the option may be repeated. Contradictory summaries fail before either one is applied. Nil-kill evidence is the most important external input today because it helps Espalier distinguish broad untyped surfaces from intentional typed @@ -143,7 +163,7 @@ interfaces. Espalier's Big-O bounds are static. Feed a runtime profile through Lineage to rank them by what actually runs hot: convert profiler output with -`gems/lineage/tools/pprof_to_hotness.rb`, ingest with `lineage +`gems/gigasail/tools/pprof_to_hotness.rb`, ingest with `lineage ingest-hotness`, and the Expensive Operations view sorts by Big-O first, then measured share; critical functions get a flame icon in the file view. Profile representative workloads, not unit tests, and build perf-profiled diff --git a/gems/espalier/config/complexity_overrides.yml b/gems/espalier/config/complexity_overrides.yml new file mode 100644 index 000000000..44ea1dad5 --- /dev/null +++ b/gems/espalier/config/complexity_overrides.yml @@ -0,0 +1,53 @@ +# Manual complexity overrides - targeted escape hatch. +# +# These entries are applied ONLY to functions that Espalier's structural +# analysis reports as INCOMPLETE, and ONLY when an entry exists here. A +# complete (fully derived) bound is never overridden. This is NOT a blanket +# stdlib registry: every entry documents an *algorithmic guarantee that static +# structural analysis provably cannot derive* - typically an amortized or +# randomized bound enforced by a runtime fallback the analyzer cannot see. +# +# Measured scope of the irreducible set: 0.4% of Go stdlib functions, ~1.9% of +# Ruby/Python functions (unknown-progress recursion). Overriding the public API +# entry short-circuits its incomplete internal recursion for all callers. +# +# key format: "." (owner = declaring type or package leaf) +# fields: time (required), space, note (why it is irreducible - required) + +go: + "sort.Sort": + time: "O(N log N)" + space: "O(log N)" + note: "introsort: pdqsort recursion + heapsort fallback at depth limit; the N-log-N guarantee is the depth-limit switch, not derivable from the recursive structure" + "sort.Stable": + time: "O(N (log N)^2)" + space: "O(1)" + note: "in-place stable block merge; symmetric-swap rotation counts are not structurally derivable" + "sort.Slice": + time: "O(N log N * C)" + space: "O(log N)" + note: "introsort over a less-callback C; same depth-limit guarantee as sort.Sort" + "sort.SliceStable": + time: "O(N (log N)^2 * C)" + space: "O(N)" + note: "stable sort over a less-callback C" + +ruby: + "Array.sort": + time: "O(N log N)" + space: "O(log N)" + note: "introsort in C (qsort_r with fallback); native, not analyzable from Ruby source" + "Array.sort_by": + time: "O(N log N * C)" + space: "O(N)" + note: "Schwartzian transform + native introsort over a key-callback C" + +python: + "list.sort": + time: "O(N log N)" + space: "O(N)" + note: "Timsort in C; adaptive merge run-detection is native, not analyzable from Python source" + "sorted": + time: "O(N log N)" + space: "O(N)" + note: "Timsort in C over an optional key-callback" diff --git a/gems/espalier/docs/agents/interface-dispatch-design.md b/gems/espalier/docs/agents/interface-dispatch-design.md new file mode 100644 index 000000000..04e3c13b6 --- /dev/null +++ b/gems/espalier/docs/agents/interface-dispatch-design.md @@ -0,0 +1,198 @@ +# Interface / virtual dispatch: a language-agnostic Big-O design + +## The problem + +A call through an abstract type has no single target: + +```go +func Sort(data Interface) { // Interface = { Len; Less; Swap } + ... data.Less(i, j) ... data.Swap(i, j) ... +} +``` + +`data.Less` can be any concrete implementation. Today the analyzer treats it as +fully unknown, so `Sort` produces a garbage polynomial and is marked +*incomplete* - when the honest answer is `O(N log N * C_Less)`: **known, and +dependent on the cost of the comparison**. + +This is not Go-specific. Every language has the same shape under different +syntax: Go interfaces, Rust traits, Java/C#/Kotlin interfaces and abstract +classes, Swift protocols, TypeScript interfaces, C++ virtual classes, Python +ABC/`Protocol`/duck typing, Ruby modules/duck typing. The solution must live in +the language-agnostic core, with the only per-language piece being *"which +concrete types can stand in for this abstract one."* + +## Core insight: an interface method call is a callback + +`sort.Interface` is a **bundle of callbacks** passed as one parameter. `data.Less` +is exactly `cmp(i, j)` where `cmp` is an injected function of unknown cost. So the +machinery already built for callbacks (#43/#44) is the foundation: + +- `parameterized_cost` mints a symbolic cost domain `C`. +- `substitute` / `without_domains` substitute a concrete callback cost for `C`. +- callback-taking functions already resolve to `O(N * C)`. + +The whole design is: **treat an abstract-typed parameter/receiver's method calls +as callbacks with cost `C_{I.M}`, then resolve `C_{I.M}` to the worst concrete +implementation.** Two views fall out, and both are wanted: + +1. **Definition-site (parametric).** Analyzing `Sort` alone, `Less`/`Swap` are + callbacks. `Sort = O(N log N * C_Less + N * C_Swap)`. This is **complete as a + parametric bound** - it fixes the "incomplete" bug immediately, and it is the + result you asked for ("known, dependent on callbacks"). +2. **Resolution (worst-case).** When we want a concrete number - at a call site + `Sort(myData)`, or corpus-wide - substitute `C_{I.M}` with the cost of the + worst concrete implementation, with provenance and an open/closed-world flag. + +## The normalized fact: a dispatch graph + +The core reasons over one new, language-neutral fact family carried on the +profile. Adapters populate it; the algebra never inspects syntax. + +``` +abstract_type(I) # I dispatches at runtime; no single body per method +requires_method(I, M) # abstract I demands method M (its callback surface) +dispatch_impl(I, T) # concrete T is a possible runtime target for static I +open_dispatch(I) # I is extensible outside the analyzed corpus +``` + +`dispatch_impl(I, T)` is the *satisfaction relation*. It is the **only** +language-specific input, and it is pure data once emitted: + +| Language | Abstract type | How the adapter computes `dispatch_impl(I, T)` | +|---|---|---| +| Go | `interface` | structural: `methodset(T) ⊇ methods(I)` | +| Rust | `trait` | `impl I for T` blocks | +| Java / Kotlin | `interface`, `abstract class` | `class T implements I` / `extends` | +| C# | `interface`, `abstract class` | `T : I` | +| Swift | `protocol` | `T: P` incl. `extension T: P` | +| TypeScript | `interface`, abstract | `implements`, or structural methodset | +| C++ | class with virtuals | public inheritance | +| Python | `ABC` / `Protocol` / duck | subclass, `ABC.register`, or Protocol methodset | +| Ruby | `module` / duck | `include`, or responds-to the method set | + +`open_dispatch(I)` is likewise adapter-owned (Go: exported interface; Rust: +public trait; Java: non-`sealed`; etc.) - it distinguishes "we have seen every +implementation" from "external code may add more." + +Note we already carry `supertypes` on owners (used for Go embedding and nominal +inheritance in `resolve_inherited_calls`). `dispatch_impl` is the inverse, +transitive-closure relation and generalizes it; the two share extraction. + +## The algorithm (language-agnostic, in espalier) + +For a call `x.M(...)` whose static receiver type is abstract `I` (or `x` is an +abstract/callback parameter), during the existing structural big-O fixpoint: + +1. **Candidate set.** `impls = { T : dispatch_impl(I, T) }` within the corpus. +2. **Per-implementation cost.** `cost(T.M)` from the method-complexity fixpoint + (recursively; interface calls inside `T.M` recurse into this same rule). +3. **Cost domain.** + - If `open_dispatch(I)` **or** `impls` is empty → `C_{I.M}` is a *free symbolic + domain* (unknown-but-bounded parameter), source-kind `interface_cost`. The + bound stays parametric, exactly like an unresolved callback. + - Else (closed set of known impls) → `C_{I.M} = max_{T ∈ impls} cost(T.M)`, + the **worst-case implementation**. +4. **Substitute** `C_{I.M}` into the caller's structural bound via the existing + `substitute` path. `Sort → O(N log N * C_Less)`, then `C_Less → O(1)` (IntSlice) + or whatever the worst impl proves. +5. **Recursion / SCC.** Interface calls can be mutually recursive (impl calls back + through the interface). Use a monotone fixpoint seeded at the free `C` domain + and widen upward; a strongly-connected interface cycle with no proven progress + yields `unbounded`, never a fabricated polynomial. + +The definition-site view is just step 3's "free domain" branch; the resolution +view is the "closed max" branch. Same code path. + +## Open vs. closed world (correctness, not optimism) + +- **Closed** (`impls` complete, `not open_dispatch(I)`): the max is a real bound. + Report `O(... * C)` with `C` resolved and `bound_quality: closed_impl_max`. +- **Open** (`open_dispatch(I)`): the true worst case is unbounded - external code + may implement `I` arbitrarily. Report the parametric `O(... * C)` and attach the + known max as an **observed lower bound**, flagged + `bound_quality: open_world_observed_max`. Never silently present the + in-corpus max as the guaranteed bound for a public interface. + +## Provenance: call out the worst case (required output) + +Every interface-dispatched term emits, alongside the bound: + +``` +interface: I.M +chosen_worst: T.M (cost) # the implementation that set the bound +distribution: [T1.M O(1), T2.M O(n), …] # so tightness of the max is visible +world: closed | open +``` + +This is what surfaces "the specific worst case" in reports and lets a human see +whether one pathological implementation is dragging an otherwise-cheap interface. + +## Impact analysis: how often the worst case actually matters (required output) + +Two levels, both mechanical once the symbolic bound carries the `C` domain: + +1. **Per-function significance.** After substituting the worst-case `C`, check + whether the interface term is the **leading** term of the symbolic expression + or a dominated lower-order one. `f = O(N log N * C + N)`: + - `C = O(1)` → leading term `N log N`; the interface is *asymptotically + irrelevant* - report `interface_impact: none`. + - `C = O(N)` → leading term `N² log N`; report `interface_impact: dominant`, + naming the worst impl. +2. **Corpus frequency.** Aggregate across functions: how many have an interface + term that is (a) dominant, (b) present-but-dominated, (c) where the worst impl + diverges from the median impl (high variance = one bad actor). This answers + "how often does the worst case impact overall complexity" as a distribution, + and points at exactly which implementations to optimize. + +## Layering (what each component owns) + +| Layer | Owns | Language-specific? | +|---|---|---| +| **Fact-mine adapters** (`go.rs`, `ruby.rs`, …) | compute `dispatch_impl`, mark `abstract_type` / `open_dispatch` | **yes** - and *only* here | +| **Fact-mine core** | carry the dispatch-graph facts on the profile; extend the callback-param signal to abstract-typed params | no - pure data | +| **Espalier core** | candidate enumeration, worst-case max, symbolic substitution (reuse callback path), open/closed handling, provenance, impact analysis | no | + +The generic extractor never learns "interface" or "trait"; it consumes +`abstract_type`/`dispatch_impl` edges. This satisfies the architecture invariant +(`complexity_fact_extractor_has_no_language_iterator_lexicon`). + +## Edge cases + +- **Generics / bounded type params** (Go `[T Ordered]`, Rust `T: Trait`, Java + ``): the constraint *is* an abstract bound; `dispatch_impl` ranges + over the constraint's satisfying types. Same machinery. +- **Multiple / embedded interfaces**: a method may be required via embedding; the + satisfaction relation is transitive - adapters emit the closure. +- **No implementations in corpus**: open-world `C`; parametric bound, honest. +- **Self-provided interfaces** (`sort.Interface` is supplied by callers, not by + `sort`): from `sort`'s document there are zero in-corpus impls → free `C` → + `Sort = O(N log N * C)`. The concrete cost only materializes at the call site + that passes `IntSlice` - which is the existing callback substitution. +- **Diamond / overriding**: nearest override wins per branch (already modeled by + `resolve_inherited_calls`); `dispatch_impl` records the effective target. + +## Rollout + +1. **Parameterize** (biggest, cheapest win): recognize an abstract-typed + parameter/receiver as a callback surface; its method calls get free `C` + domains. This alone flips interface-heavy functions from *incomplete* to + *complete-parametric* (`sort`, `io`, `container/heap`, `sync` families) with no + satisfaction data yet. +2. **Satisfaction extraction**: adapters emit `dispatch_impl` (start with Go + structural + Rust/Java/C# nominal - the highest-signal set). +3. **Worst-case resolution + provenance**: closed-world max with the call-out. +4. **Impact analysis**: significance flag + corpus frequency report. + +Phase 1 is independently valuable and reuses #43/#44 almost verbatim; phases 2-4 +add the concrete numbers and the worst-case reporting you asked for. + +## Success criteria + +- `sort.Sort` reports `O(N log N * C_Less)` and is `time_complete` (parametric). +- A call `sort.Sort(IntSlice)` resolves to `O(N log N)` with provenance + `chosen_worst: IntSlice.Less O(1)`. +- A public interface is never presented with a false closed bound; it carries the + open-world flag and observed max. +- The report can answer, per function and corpus-wide, whether an interface's + worst case is asymptotically significant, and name the implementation. diff --git a/gems/espalier/docs/big-o.md b/gems/espalier/docs/big-o.md index 1ea190cb5..b63433c73 100644 --- a/gems/espalier/docs/big-o.md +++ b/gems/espalier/docs/big-o.md @@ -90,6 +90,23 @@ use emitted executable functions as the denominator and are rounded to one decimal place. The categories are mutually exclusive and each row sums to its function count. +For an enforceable production-only measurement, run: + +```sh +gems/espalier/script/check_big_o_coverage.rb \ + --source-root /path/to/corpus \ + --repository project \ + --minimum 85 \ + profile.json +``` + +The JSON result records the source-role and lambda policies, keeps analyzer, +declared, modeled-world, closed-candidate, parametric, and recursive proof +buckets separate, and exits unsuccessfully below the requested threshold. It +also fails closed when FactMine reports an executable raw call that did not +reach normalized call facts; otherwise an omitted call could make a function +look complete. + Measured 2026-07-17 at `18a2d4cbd`. These are quality samples, not language benchmarks: repository structure, dependency surface, callback density, and the share of trivial accessors all affect the result. @@ -205,3 +222,130 @@ gems/espalier/script/report_big_o_proof_metrics.rb \ The reporter and this document use the same language-neutral proof classifier. New confidence states belong in that classifier and its tests, not in language-specific adapters. + +To measure the compiler index itself, generate the same FactMine profile once +without and once with `--scip-index`, then run: + +```bash +gems/espalier/script/compare_scip_big_o.rb \ + --source-root /path/to/corpus \ + --repository project \ + source-only.json indexed.json +``` + +The comparison uses production files only, reports exact proof buckets and call +resolution counts, and preserves executable raw-call normalization gaps. Use +`check_big_o_coverage.rb` for the enforcing 85% gate. + +Current indexed smoke corpora are pinned by source commit and indexer version: + +| Language | Corpus commit | Indexer | Production complete, source → SCIP | Exact project targets, source → SCIP | +| --- | --- | --- | ---: | ---: | +| Java | Apache Commons CLI `afb0fd148517b1bf8316ebbc44ec9ec8b201452a` | scip-java 0.12.3 | 279/524 (53.24%) → 524/524 (100.00%) | 559 → 767 | +| C | cJSON `fb16e5cf358798aabb049655975cde8427101056` | scip-clang 0.4.0 | 41/116 (35.34%) → 102/116 (87.93%) | 185 → 188 | + +The production acceptance measurements below were run on 2026-07-27. Each row +uses FactMine output from the named compiler index, counts owner-nested lambdas, +and requires zero unnormalized raw calls inside executable functions. Rows +below 85% remain active burn-down targets: + +| Language | Production corpus | Indexer | Complete bounds | Semantic calls accounted | Executable raw-call gaps | +| --- | --- | --- | ---: | ---: | ---: | +| Java | Apache Commons CLI `afb0fd148517b1bf8316ebbc44ec9ec8b201452a` | scip-java 0.12.3 | 524/524 (100.00%) | 1,435/1,435 (100.00%) | 0 | +| C | cJSON `fb16e5cf358798aabb049655975cde8427101056` | scip-clang 0.4.0 | 102/116 (87.93%) | 326/326 (100.00%) | 0 | +| Go | unslop `6b39e58b5128eb22cd8f8394dd4a64987e2b8a17` | scip-go 0.2.7 | 140/150 (93.33%) | 1,104/1,125 (98.13%) | 0 | +| Rust | FactMine production sources in this tree | rust-analyzer 1.96.0 (ac68faa 2026-05-25) | 2,837/6,664 (42.57%) | see generated profile | 0 | + +These are production-scope gate results, not replacements for the older +cross-language snapshot above. The stricter current Rust measurement supersedes +the earlier 4,939/5,802 figure: the current tree and coverage policy count +6,664 production functions, and the safe generated stdlib bundle does not +change its 2,837 complete functions. That shortfall is visible rather than +being hidden by unsafe generated claims. + +The Java path recognizes the `semanticdb` scheme emitted by real scip-java +0.12.x indexes while retaining compatibility with older `scip-java`-scheme +fixtures. This activates the reviewed generic collection, stream, and +lambda/function-interface contracts for compiler-proven JDK symbols. Abstract +project interface declarations are modeled as one parametric implementation +invocation rather than as executable bodies, and enhanced-for iterable +expressions remain in the normalized CFG. On Commons CLI all 1,435 executable +calls are semantically accounted for and all 524 production functions have +complete time and space bounds. + +The C path resolves macro definitions from compiler-indexed source/header +locations, prices bounded expansion bodies, treats compiler-proven +function-pointer fields parametrically, and applies reviewed C runtime costs +under an explicit modeled-world assumption for Clang's unpackaged external +symbols. Calling-convention and return-type macros are normalized without +changing source offsets, so parameter-rooted structural recursion remains +provable. On cJSON all 326 executable calls are now semantically accounted for; +the remaining 14 incomplete functions are recursive proof obligations rather +than missing call identity or cost. + +### Reusing analyzed dependency and standard-library bodies + +Standard-library production is manifest-driven. A manifest pins and verifies +the source release, selects source files, declares the language-owned build and +SCIP indexing recipe, and names the exact expected indexer build. The shared +pipeline then profiles CFG/DFG facts, applies soundness gates, exports exact +symbols, verifies that the bundle joins back to its producer index, checks any +declared consumers, and atomically publishes the result: + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/go-1.22.2.yml +``` + +Adding another SCIP standard library should therefore normally be a manifest, +not shared Ruby or Rust code. Language-specific behavior is confined to the +language's syntax/normalization module and the manifest's source/index recipe. +Everything after SCIP production is language-neutral. + +The shared soundness gate requires an executable source body, zero +export-eligible methods overlapping parser call loss, no overlapping parser +recovery, an exact compatible SCIP producer, and source-proven time and space +bounds. +Open implementation candidate sets are not exported. Parametric callback bounds +are exported only when the callback is an actual declared parameter. Generated +complete data replaces incomplete fallback data; a generated/manual complete +disagreement is a hard failure. + +The v3 bundle records the SHA-256 of the complete input profile, producer +version, verified source release, indexer version, language set, and exported +symbol count. It may additionally require exact opaque semantic-environment +claims and record the digest of a generated producer-to-consumer symbol bridge. +This permits versionless or cross-language runtime symbols without adding +language branches to the shared join. Gzip output has a zero timestamp, so +rebuilding identical inputs is byte-for-byte reproducible. Apply it to user +code alongside that code's index and, when required, its environment sidecar: + +```bash +gems/espalier/exe/espalier \ + --scip-index user-code.scip \ + --semantic-environment runtime-environment.json \ + --complexity-summary go-stdlib.go1.25.0.json.gz \ + --format json \ + USER_SOURCE_FILES... +``` + +Summary joins require the exact compiler symbol already attached by SCIP. They +never guess from an owner or method name. Unknown schema versions, malformed +metadata, empty bounds, and contradictory files fail closed. The v1 reader +remains available for previously generated artifacts, but new exports are v2. +FactMine discovers every generated bundle in +`config/complexity_summaries/*.json.gz` at build time; adding a language does +not require shared Rust registration code. The current set is Go 1.22.2 (322 +exact symbols), Rust 1.96.0 (1,543), JDK 21.0.12 `java.lang`/`java.util` +(2,598), and CPython 3.11.9 selected pure-Python core (200). +The exporter rejects apparently complete functions whose proof depends on a +manual receiver registry, modeled-world/external-latency contract, unknown +cardinality relation, or unresolved call-evidence gap. Bundled data is applied +automatically only when SCIP metadata reports the exact compatible indexer +build; this is required even when a symbol already contains a package version. + +The smaller regenerated bundles are intentional. The previous artifacts +included declaration-only Go functions, open-interface candidate assumptions, +parser-recovered Rust methods, and internal callback bounds that were not safe +to reuse in arbitrary consumers. Those are now rejected generically rather than +worked around per language. diff --git a/gems/espalier/exe/espalier b/gems/espalier/exe/espalier index eff7ef5ce..624eae7da 100755 --- a/gems/espalier/exe/espalier +++ b/gems/espalier/exe/espalier @@ -5,6 +5,12 @@ require "optparse" require_relative "../lib/espalier" require_relative "../lib/espalier/type_profile" +if ARGV.first == "stdlib-map" + ARGV.shift + require_relative "../lib/espalier/stdlib_map" + exit Espalier::StdlibMap.run_cli(ARGV) +end + options = { format: :markdown, output: nil, @@ -15,6 +21,8 @@ options = { exclude: [], fact_mine: nil, scip_indexes: [], + semantic_environments: [], + complexity_summaries: [], vcs: nil } @@ -65,6 +73,14 @@ OptionParser.new do |opts| opts.on("--scip-index FILE", "Import a .scip or SCIP JSON index (repeatable)") do |f| options[:scip_indexes] << f end + + opts.on("--semantic-environment FILE", "Attach exact runtime/build compatibility claims (repeatable)") do |f| + options[:semantic_environments] << f + end + + opts.on("--complexity-summary FILE", "Apply an exact-SCIP-symbol complexity summary (repeatable)") do |f| + options[:complexity_summaries] << f + end end.parse! ENV["FACT_MINE_FACTS_FILE"] = options[:fact_mine] if options[:fact_mine] @@ -149,11 +165,30 @@ evidence = Espalier::StaticEvidence.build( root: Dir.pwd, vcs: options[:vcs], scip_indexes: options[:scip_indexes], + semantic_environments: options[:semantic_environments], + complexity_summaries: options[:complexity_summaries], include_annotations: options[:format] != :architecture ) if options[:format] == :architecture - output_contents = JSON.pretty_generate(Espalier::ArchitectureArtifact.build(evidence, root: Dir.pwd)) + # Big-O complexity is computed by the aggregator (which needs the parsed + # modules), not the lean static-evidence pipeline the architecture artifact + # otherwise uses. Run it here and attach per-function Big-O to the nodes so + # Lineage can surface time/space complexity per function. + big_o_modules = Espalier::StaticEvidence.project_modules(evidence) + nil_kill_evidence.apply!(big_o_modules) + big_o_index = Espalier::ArchitectureArtifact.big_o_index( + Espalier::Aggregator.new( + decomplex_data: decomplex_data, + nil_kill_data: nil_kill_data, + risk_data: risk_data, + nil_kill_loops: nil_kill_evidence.loop_counts, + nil_kill_evidence: nil_kill_evidence + ).aggregate(big_o_modules) + ) + output_contents = JSON.pretty_generate( + Espalier::ArchitectureArtifact.build(evidence, root: Dir.pwd, big_o: big_o_index) + ) if options[:output] File.write(options[:output], output_contents) else diff --git a/gems/espalier/lib/espalier.rb b/gems/espalier/lib/espalier.rb index 5852344e7..629d6c5da 100644 --- a/gems/espalier/lib/espalier.rb +++ b/gems/espalier/lib/espalier.rb @@ -13,6 +13,8 @@ module Espalier require_relative "espalier/static_evidence" require_relative "espalier/big_o_proof_metrics" require_relative "espalier/big_o_gap_impact" +require_relative "espalier/complexity_summary" +require_relative "espalier/stdlib_map" require_relative "espalier/privacy_analyzer" require_relative "espalier/architecture_analyzer" require_relative "espalier/aggregator" diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index 0d2fc5311..8aa5b80c3 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -3,11 +3,14 @@ require "set" require_relative "big_o_analyzer" require_relative "structural_big_o" +require_relative "complexity_overrides" module Espalier # Coalescing agent that imports the static skeleton maps and merges secondary # metadata from: decomplex (decisions/clones), nil-kill (types), and boobytrap/slopcop (risk/coverage). class Aggregator + MAX_RECURSIVE_SUMMARY_STATES = 64 + def initialize( decomplex_data: {}, nil_kill_data: {}, @@ -147,6 +150,8 @@ def aggregate(modules) quality[:big_o_variables] = big_o_result[:complexity_variables] unless big_o_result[:complexity_variables].empty? quality[:big_o_complete] = big_o_result[:time_complete] quality[:big_o_space_complete] = big_o_result[:space_complete] + apply_complexity_override!(quality, mod, m) + quality[:big_o_status] = classify_big_o_status(quality) quality[:big_o_dynamic] = big_o_result[:is_dynamic] quality[:complexity_trigger] = big_o_result[:trigger] if big_o_result[:trigger] quality[:big_o_warnings] = big_o_result[:warnings] unless big_o_result[:warnings].empty? @@ -193,18 +198,67 @@ def aggregate(modules) private + # Targeted escape hatch: consult the manual-override registry ONLY when the + # derived bound is incomplete, and apply only when an entry exists. A fully + # derived (complete) bound is never overridden. Marks provenance so an + # override-sourced bound is distinguishable from a structurally derived one. + def apply_complexity_override!(quality, mod, m) + return if quality[:big_o_complete] + + # project_modules disambiguates owners as "name@path"; the override + # registry keys on the bare owner (package leaf / type name). + owner = mod[:name].to_s.split("@", 2).first + entry = ComplexityOverrides.lookup(mod[:language], owner, m[:name]) + return unless entry + + quality[:big_o] = entry["time"] + quality[:big_o_complete] = true + quality[:big_o_provenance] = :manual_override + quality[:big_o_override_note] = entry["note"] + return unless entry["space"] + + quality[:big_o_space] = entry["space"] + quality[:big_o_space_complete] = true + end + + # Distinguishes a fully-resolved bound from one that is only "complete" + # parametrically - i.e. still carries an open callback/reflective parameter + # that a worst-case substitution would have to close. The parametric tier is + # what the interface worst-case pass upgrades to :complete_worst_case. + # + # A parametric contract opens a parameter on BOTH axes - time O(N*C) and + # auxiliary space O(S) / O(N*S) - and the space one outlives the time one, + # because callback substitution rewrites only the time expression. Reading + # the time bound alone therefore publishes a space bound whose parameter + # nothing can bind as if it were closed. + # + # `S` is unambiguous in a space bound today: space carries no domain-symbol + # table of its own, so no size domain is ever rendered as `S` there. + def classify_big_o_status(quality) + return :incomplete unless quality[:big_o_complete] + return :complete_override if quality[:big_o_provenance] == :manual_override + + bound = quality[:big_o].to_s + return :parametric if bound.include?("C") || bound.include?("R") + return :parametric if quality[:big_o_space].to_s.include?("S") + + :complete + end + def internal_edges_for(mod) legacy_internal_edges_for(mod) end def legacy_internal_edges_for(mod) - method_names = mod[:methods].map { |m| m[:name].to_s } + method_names = mod[:methods].map { |m| m[:name].to_s }.tally mod[:methods].flat_map do |method| caller = method[:name].to_s + next [] unless method_names[caller] == 1 + Array(method[:delegations]).filter_map do |delegation| callee = delegation[:message].to_s next unless delegation[:receiver] == "self" - next unless method_names.include?(callee) + next unless method_names[callee] == 1 next if caller == callee { @@ -288,7 +342,10 @@ def prepare_module_indexes(modules) @big_o_nodes_cache = {} modules.each do |mod| methods = Array(mod[:methods]) - @module_method_names[mod.object_id] = methods.map { |method| method[:name].to_s }.to_set + method_name_counts = methods.map { |method| method[:name].to_s }.tally + @module_method_names[mod.object_id] = method_name_counts.filter_map do |name, count| + name if count == 1 + end.to_set methods.each_with_index do |method, index| start_line = method[:line] || 0 span = method[:span] @@ -309,11 +366,7 @@ def line_in_method_bounds?(line, start_line, end_line, end_inclusive) end_inclusive ? line <= end_line : line < end_line end - def preliminary_method_complexities(modules) - analyzer = Espalier::BigOAnalyzer.new( - nil_kill: @nil_kill_evidence, - declared_fields: declared_fields_for(modules) - ) + def initial_method_complexities(modules) complexities = Hash.new { |h, k| h[k] = {} } spaces = Hash.new { |h, k| h[k] = {} } time_complete = Hash.new { |h, k| h[k] = {} } @@ -323,31 +376,22 @@ def preliminary_method_complexities(modules) assumptions = Hash.new { |h, k| h[k] = {} } modules.each do |mod| Array(mod[:methods]).each do |method| - analyzer.instance_variable_set(:@class_name, mod[:name]) - analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) - key = "#{mod[:name]}##{method[:name]}" - sig = @nil_kill_data[key] || method[:signature] - result = analyzer.analyze_method( - key, - big_o_nodes_for(mod, method), - local_types: local_types_for_signature(sig) - ) method_name = method[:name].to_s - complexities[mod[:name]][method_name] = result[:known_time_component] - spaces[mod[:name]][method_name] = result[:known_space_component] - time_complete[mod[:name]][method_name] = result[:time_complete] - space_complete[mod[:name]][method_name] = result[:space_complete] - symbolic_time[mod[:name]][method_name] = result[:symbolic_time] - bound_qualities[mod[:name]][method_name] = result[:bound_qualities] - assumptions[mod[:name]][method_name] = result[:complexity_assumptions] + complexities[mod[:name]][method_name] = "O(1)" + spaces[mod[:name]][method_name] = "O(1)" + time_complete[mod[:name]][method_name] = true + space_complete[mod[:name]][method_name] = true + symbolic_time[mod[:name]][method_name] = nil + bound_qualities[mod[:name]][method_name] = [] + assumptions[mod[:name]][method_name] = [] unless method[:id].to_s.empty? - complexities[method[:id].to_s] = result[:known_time_component] - spaces[method[:id].to_s] = result[:known_space_component] - time_complete[method[:id].to_s] = result[:time_complete] - space_complete[method[:id].to_s] = result[:space_complete] - symbolic_time[method[:id].to_s] = result[:symbolic_time] - bound_qualities[method[:id].to_s] = result[:bound_qualities] - assumptions[method[:id].to_s] = result[:complexity_assumptions] + complexities[method[:id].to_s] = "O(1)" + spaces[method[:id].to_s] = "O(1)" + time_complete[method[:id].to_s] = true + space_complete[method[:id].to_s] = true + symbolic_time[method[:id].to_s] = nil + bound_qualities[method[:id].to_s] = [] + assumptions[method[:id].to_s] = [] end end end @@ -356,9 +400,9 @@ def preliminary_method_complexities(modules) def structural_method_complexities(modules) @structural_big_o_results = {} - return preliminary_method_complexities(modules) if modules.empty? + return initial_method_complexities(modules) if modules.empty? - complexities, spaces, time_complete, space_complete, symbolic_time, bound_qualities, assumptions = preliminary_method_complexities(modules) + complexities, spaces, time_complete, space_complete, symbolic_time, bound_qualities, assumptions = initial_method_complexities(modules) internal_calls = internal_calls_by_method(modules) resolved_calls = resolved_calls_by_site(modules) candidate_calls = candidate_calls_by_site(modules) @@ -387,6 +431,26 @@ def structural_method_complexities(modules) structural_big_o.instance_variable_set(:@method_symbolic_time, symbolic_time) structural_big_o.instance_variable_set(:@method_bound_qualities, bound_qualities) structural_big_o.instance_variable_set(:@method_assumptions, assumptions) + # Index each callback-taking call site to the id of the callable it passes + # as its callback argument - an inline lambda (found by span containment) + # or a named function reference (resolved by argument spelling) - so a + # caller can substitute that callable's cost for the callee's callback C. + methods_by_id = {} + method_candidates_by_owner_name = Hash.new { |hash, key| hash[key] = [] } + lambdas_by_file = Hash.new { |hash, key| hash[key] = [] } + modules.each do |mod| + Array(mod[:methods]).each do |m| + methods_by_id[m[:id]] = m if m[:id] + method_candidates_by_owner_name[[m[:raw_owner].to_s, m[:name].to_s]] << m + lambdas_by_file[mod[:file]] << m if m[:dispatch_kind].to_s == "lambda" && m[:span] + end + end + methods_by_owner_name = method_candidates_by_owner_name.filter_map do |key, candidates| + [key, candidates.first] if candidates.one? + end.to_h + callback_arg_by_call = callback_arguments_by_call_site(modules, methods_by_id, methods_by_owner_name, lambdas_by_file) + @callback_arg_by_call = callback_arg_by_call + structural_big_o.instance_variable_set(:@callback_arg_by_call, callback_arg_by_call) local_analyzer = Espalier::BigOAnalyzer.new( nil_kill: @nil_kill_evidence, @@ -396,9 +460,10 @@ def structural_method_complexities(modules) # Components are ordered callee-first. Acyclic components therefore run # exactly once. Within a recursive SCC, only callers of a changed method # are re-enqueued; unrelated methods are never rescanned. - summary_dependency_components(modules).each do |component| + summary_dependency_components(modules, callback_arg_by_call).each do |component| entries = component.fetch(:entries) by_identity = entries.to_h { |identity, mod, method| [identity, [mod, method]] } + recursive_members = component.fetch(:recursive) ? by_identity.keys.to_set : Set.new base_nodes = entries.to_h do |identity, mod, method| [identity, big_o_nodes_for(mod, method).freeze] end @@ -406,16 +471,22 @@ def structural_method_complexities(modules) queued = queue.to_set queue_index = 0 observed_states = Hash.new { |hash, identity| hash[identity] = Set.new } + widened_summaries = Set.new while queue_index < queue.length graph_identity = queue[queue_index] queue_index += 1 queued.delete(graph_identity) + next if widened_summaries.include?(graph_identity) + mod, method = by_identity.fetch(graph_identity) local_analyzer.instance_variable_set(:@class_name, mod[:name]) local_analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) key = "#{mod[:name]}##{method[:name]}" sig = @nil_kill_data[key] || method[:signature] - nodes = base_nodes.fetch(graph_identity) + + nodes = substitute_callback_arguments( + base_nodes.fetch(graph_identity), mod, symbolic_time, complexities, time_complete, + excluded_callable_ids: recursive_members + ) + structural_big_o.hints_for(mod[:file], method, mod[:name]) result = local_analyzer.analyze_method(key, nodes, local_types: local_types_for_signature(sig)) @structural_big_o_results[graph_identity] = result @@ -440,6 +511,29 @@ def structural_method_complexities(modules) result[:complexity_assumptions] != current_assumptions next unless result_changed + if component.fetch(:recursive) && + observed_states[graph_identity].length >= MAX_RECURSIVE_SUMMARY_STATES + result = widen_recursive_summary(result) + @structural_big_o_results[graph_identity] = result + structural_big_o.apply_summary_delta!(method_identity, mod[:name], method_name, { + time: result.fetch(:known_time_component), + space: result.fetch(:known_space_component), + time_complete: false, + space_complete: false, + symbolic_time: nil, + bound_qualities: result.fetch(:bound_qualities), + assumptions: result.fetch(:complexity_assumptions) + }) + widened_summaries << graph_identity + component.fetch(:callers).fetch(graph_identity, Set.new).each do |caller| + next if queued.include?(caller) + + queue << caller + queued << caller + end + next + end + complexity = (time_changed || symbolic_changed) ? result[:known_time_component] : current space = space_changed ? result[:known_space_component] : current_space expression = symbolic_changed ? result[:symbolic_time] : current_symbolic @@ -472,18 +566,41 @@ def structural_method_complexities(modules) [complexities, spaces, time_complete, space_complete, symbolic_time, bound_qualities, assumptions] end - def summary_dependency_components(modules) + def widen_recursive_summary(result) + result.merge( + lower_bound_complexity: "unknown", + space_complexity: "unknown", + known_time_component: "O(1)", + known_space_component: "O(1)", + symbolic_time: nil, + complexity_variables: [], + time_complete: false, + space_complete: false, + evidence_gaps: (Array(result[:evidence_gaps]) + ["unresolved_recursive_progress"]).uniq.sort, + warnings: (Array(result[:warnings]) + + ["Recursive summary did not converge within the finite complexity lattice."]).uniq + ) + end + + def summary_dependency_components(modules, callback_arg_by_call = {}) entries = {} - aliases = {} + alias_candidates = Hash.new { |hash, key| hash[key] = Set.new } modules.each do |mod| Array(mod[:methods]).each do |method| fallback = [mod[:name].to_s, method[:name].to_s] identity = method[:id].to_s.empty? ? fallback : method[:id].to_s entries[identity] = [mod, method] - aliases[fallback] = identity - aliases[method[:id].to_s] = identity unless method[:id].to_s.empty? + alias_candidates[fallback] << identity + alias_candidates[method[:id].to_s] << identity unless method[:id].to_s.empty? end end + # A lexical `(owner, short-name)` is usable only when it denotes exactly + # one method. Nested functions and overloads routinely share that pair; + # choosing the last one creates false dependency cycles and can make the + # symbolic fixed point grow without bound. Exact method ids remain unique. + aliases = alias_candidates.filter_map do |key, candidates| + [key, candidates.first] if candidates.one? + end.to_h graph = entries.each_key.to_h { |identity| [identity, Set.new] } entries.each do |source, (mod, method)| @@ -496,9 +613,19 @@ def summary_dependency_components(modules) aliases[[mod[:name].to_s, delegation[:message].to_s]] end graph[source] << target if target - Array(delegation[:candidate_target_ids]).each do |candidate| - candidate_target = aliases[candidate.to_s] - graph[source] << candidate_target if candidate_target + if delegation[:consumer_closed_candidate_set] == true + Array(delegation[:candidate_target_ids]).each do |candidate| + candidate_target = aliases[candidate.to_s] + graph[source] << candidate_target if candidate_target + end + end + # A callable passed at this call site is a real summary dependency: + # its cost is substituted for the callee's open C, so it must be + # summarized first and must re-enqueue this caller when it changes. + span = normalized_call_span(delegation[:span]) + Array(span && callback_arg_by_call[[mod[:file], span]]).each do |callable| + callable_target = aliases[callable.to_s] + graph[source] << callable_target if callable_target && callable_target != source end end end @@ -538,6 +665,8 @@ def summary_dependency_components(modules) [identity, mod, method] end, callers: callers, + recursive: component_members.length > 1 || + component_members.any? { |identity| graph.fetch(identity).include?(identity) }, } reverse[component].sort.each do |caller| remaining_dependencies[caller] -= 1 @@ -572,7 +701,9 @@ def resolved_summary_depth(modules) delegation[:target_id].to_s graph[source] << target if identities[target] end - Array(method[:delegations]).flat_map { |delegation| Array(delegation[:candidate_target_ids]) } + Array(method[:delegations]) + .select { |delegation| delegation[:consumer_closed_candidate_set] == true } + .flat_map { |delegation| Array(delegation[:candidate_target_ids]) } .map(&:to_s).each do |target| graph[source] << target if identities[target] end @@ -611,8 +742,11 @@ def resolved_summary_depth(modules) def internal_calls_by_method(modules) modules.each_with_object({}) do |mod, owners| - method_names = Array(mod[:methods]).map { |method| method[:name].to_s }.to_set + method_name_counts = Array(mod[:methods]).map { |method| method[:name].to_s }.tally + method_names = method_name_counts.filter_map { |name, count| name if count == 1 }.to_set owners[mod[:name].to_s] = Array(mod[:methods]).each_with_object({}) do |method, callers| + next unless method_name_counts[method[:name].to_s] == 1 + callers[method[:name].to_s] = Array(method[:delegations]).filter_map do |delegation| next unless delegation[:receiver].to_s == "self" @@ -653,10 +787,128 @@ def resolved_calls_by_site(modules) end.compact end + # A call priced parametrically from its compiler symbol carries an open C. + # When the callables passed at that site are analyzed, C is not open, so + # substitute the costliest of them. Runs per fixpoint iteration, on the + # current summaries, rather than on the cached base nodes. + def substitute_callback_arguments( + nodes, mod, symbolic_time, complexities, time_complete, excluded_callable_ids: Set.new + ) + return nodes if @callback_arg_by_call.nil? || @callback_arg_by_call.empty? + + nodes.map do |node| + expression = node[:symbolic_time] + next node if expression.nil? || + Espalier::SymbolicComplexity.callback_domain_ids(expression).empty? + + callable_ids = Array(@callback_arg_by_call[[mod[:file], node[:span]]]).reject do |id| + excluded_callable_ids.include?(id) + end + callable = worst_callable(callable_ids, symbolic_time, complexities, time_complete) + next node unless callable + + substituted = Espalier::SymbolicComplexity.substitute_callback_cost( + expression, callable.fetch(:expression), callable_constant: callable.fetch(:constant) + ) + next node if substituted.equal?(expression) + + node.merge( + symbolic_time: substituted, + known_time_complexity: Espalier::SymbolicComplexity.render(substituted)&.first || + node[:known_time_complexity] + ) + end + end + + def worst_callable(ids, symbolic_time, complexities, time_complete) + Espalier::SymbolicComplexity.worst_callable( + Array(ids).map do |id| + key = id.to_s + { + expression: symbolic_time[key], + constant: complexities[key] == "O(1)" && time_complete[key] != false + } + end + ) + end + + # Index every call site to the callables passed there, so a caller can + # substitute their cost for an open callback C. Two sources of a callable: + # a closure literal, which lies inside the call's own span, and a named + # function passed positionally into a declared callback parameter. + # + # The closure rule is deliberately independent of the callee: a stdlib + # higher-order call is priced parametrically from its compiler symbol and + # has no analyzed body to declare `callback_params`, yet the closure at its + # call site is exactly what closes its C. + def callback_arguments_by_call_site(modules, methods_by_id, methods_by_owner_name, lambdas_by_file) + index = Hash.new { |hash, key| hash[key] = [] } + delegations_by_file = Hash.new { |hash, key| hash[key] = [] } + modules.each do |mod| + Array(mod[:methods]).each do |method| + Array(method[:delegations]).each do |delegation| + span = normalized_call_span(delegation[:span]) + next unless span + + delegations_by_file[mod[:file]] << span + named_callback_argument(mod, method, delegation, methods_by_id, methods_by_owner_name) + &.then { |id| index[[mod[:file], span]] << id } + end + end + end + lambdas_by_file.each do |file, lambdas| + spans = delegations_by_file[file] + lambdas.each do |lambda_method| + span = normalized_call_span(lambda_method[:span]) + # Every call whose span encloses the closure may run it: in + # `xs.iter().map(|x| ...).collect()` the parametric cost sits on + # `collect`, while the closure is lexically an argument of `map`. + # Charging the closure to each enclosing call is the worst-case + # reading, and the substitution takes the costliest callable anyway. + spans.select { |candidate| span_contains?(candidate, span) } + .each { |candidate| index[[file, candidate]] << lambda_method[:id] } + end + end + index.transform_values { |ids| ids.compact.uniq } + end + + def named_callback_argument(mod, method, delegation, methods_by_id, methods_by_owner_name) + callee = (delegation[:target_id] && methods_by_id[delegation[:target_id]]) || + (delegation[:target_method] && + methods_by_owner_name[[delegation[:target_owner].to_s, delegation[:target_method].to_s]]) + return nil unless callee + + params = Array(callee[:parameters]).map(&:to_s) + Array(callee[:callback_params]).each do |callback_param| + position = params.index(callback_param.to_s) + next unless position + + argument = Array(delegation[:arguments])[position].to_s.strip + function = methods_by_owner_name[[method[:raw_owner].to_s, argument]] || + methods_by_owner_name[[mod[:name].to_s, argument]] + return function[:id] if function + end + nil + end + + def span_contains?(outer, inner) + return false unless outer && inner + + starts = outer[0] < inner[0] || (outer[0] == inner[0] && outer[1] <= inner[1]) + ends = outer[2] > inner[2] || (outer[2] == inner[2] && outer[3] >= inner[3]) + starts && ends + end + + def span_extent(span) + [span[2] - span[0], span[3] - span[1]] + end + def candidate_calls_by_site(modules) modules.each_with_object({}) do |mod, index| Array(mod[:methods]).each do |method| Array(method[:delegations]).each do |delegation| + next unless delegation[:consumer_closed_candidate_set] == true + candidates = Array(delegation[:candidate_target_ids]).map(&:to_s).reject(&:empty?).uniq.sort next if candidates.empty? @@ -669,7 +921,14 @@ def candidate_calls_by_site(modules) keys.unshift([method[:id].to_s, delegation[:message].to_s, line]) keys.unshift([method[:id].to_s, delegation[:message].to_s, span]) if span end - value = { ids: candidates, reason: delegation[:candidate_reason].to_s } + value = { + ids: candidates, + reason: delegation[:candidate_reason].to_s, + qualities: Array(delegation[:complexity_bound_quality]).map(&:to_s), + assumptions: Array(delegation[:complexity_assumptions]).map(&:to_s), + external_time: delegation[:known_time_complexity], + external_space: delegation[:known_space_complexity] + } keys.each do |key| if index.key?(key) && index[key] != value index[key] = nil @@ -839,6 +1098,8 @@ def big_o_nodes_for(mod, method) index[key] = row if !index[key] || row["power"].to_i > index[key]["power"].to_i end contexts_by_line = contexts.group_by { |row| [row["message"].to_s, row["line"].to_i] } + callback_params = Array(method[:callback_params]).map(&:to_s).to_set + iterations = Array(method[:complexity_facts]).flat_map { |fact| Array(fact["iterations"]) } nodes = Array(method[:delegations]).map do |delegation| message = delegation[:message].to_s span = normalized_call_span(delegation[:span]) @@ -847,6 +1108,27 @@ def big_o_nodes_for(mod, method) line_rows = contexts_by_line[[message, (delegation[:line] || method[:line] || 0).to_i]] context = line_rows.first if line_rows&.one? end + # A call to a callback parameter has a cost parametric in that callback + # (C), a complete algebraic atom that a loop composes to O(N*C) and a + # caller resolves by substituting the passed callable's cost. + callback_cost = if callback_params.include?(message) && + !delegation[:target_method] && !delegation[:known_time_complexity] + # The callback runs once per iteration of its enclosing loop, so its + # cost is that loop's size domain times C. Match the loop by span + # containment and reuse its parameter size domain (which a caller can + # also substitute), giving O(N*C); no enclosing loop gives O(C). + cb_line = (delegation[:line] || method[:line] || 0).to_i + loop_fact = iterations.find do |it| + bounds = it["span"] + bounds && cb_line >= bounds[0].to_i && cb_line <= bounds[2].to_i + end + mult_id = loop_fact && Array(loop_fact.dig("symbolic_time", "factors")).first&.fetch("domain_id", nil) + mult_domains = mult_id ? [{ "id" => mult_id, "name" => Array(loop_fact["parameter_domains"]).first.to_s, "source_kind" => "parameter" }] : [] + Espalier::SymbolicComplexity.parameterized_cost( + id: unknown_cost_id(delegation), name: message, source_kind: "callback_cost", + multiplicity_domain: mult_id, domains: mult_domains + ) + end { type: :call, call_id: delegation[:call_id], @@ -858,25 +1140,27 @@ def big_o_nodes_for(mod, method) # The canonical CallRecord is enriched after syntax normalization by # SCIP and dependency summaries. Its exact symbol cost must outrank # an earlier adapter-level context model for the same source span. - known_time_complexity: delegation[:known_time_complexity] || (context && context["known_time_complexity"]), + known_time_complexity: (callback_cost && (Espalier::SymbolicComplexity.render(callback_cost)&.first || "O(C)")) || + delegation[:known_time_complexity] || (context && context["known_time_complexity"]), known_space_complexity: delegation[:known_space_complexity] || (context && context["known_space_complexity"]), complexity_provenance: delegation[:complexity_provenance], complexity_bound_quality: delegation[:complexity_bound_quality], complexity_candidates: delegation[:complexity_candidates], complexity_assumptions: delegation[:complexity_assumptions], - evidence_gap: if delegation[:known_time_complexity] || delegation[:known_space_complexity] + evidence_gap: if delegation[:known_time_complexity] || delegation[:known_space_complexity] || callback_cost nil else context && context["evidence_gap"] end, - symbolic_time: parametric_call_symbolic(delegation, context) || + symbolic_time: callback_cost || parametric_call_symbolic(delegation, context) || (context && symbolic_call_complexity(context)), collection_arguments: context && context["power"].to_i.positive? && (Array(context["parameter_arguments"]) & Array(context["collection_parameters"])), internal_call: (delegation[:receiver].to_s == "self" && module_method_names.include?(delegation[:message].to_s)) || (delegation[:target_owner] && delegation[:target_method] && context) || - (Array(delegation[:candidate_target_ids]).any? && context) + (delegation[:consumer_closed_candidate_set] == true && + Array(delegation[:candidate_target_ids]).any? && context) }.compact end @@ -938,7 +1222,7 @@ def parametric_call_symbolic(delegation, context) end reflective = quality.include?("reflective") Espalier::SymbolicComplexity.parameterized_cost( - id: "cost:#{delegation[:call_id]}", + id: unknown_cost_id(delegation), name: "#{delegation[:receiver]}.#{delegation[:message]}", source_kind: reflective ? "reflective_target_cost" : "callback_cost", multiplicity_domain: multiplicity_domain, @@ -946,6 +1230,19 @@ def parametric_call_symbolic(delegation, context) ) end + + # A cost symbol names the *callee's* unknown cost, so every call to the same + # callee must share one symbol. Keying it on the call site instead minted a + # fresh C per call, producing unusable bounds (observed: 294 distinct symbols + # in one function) and making substitution impossible. + def unknown_cost_id(delegation) + callee = delegation[:target_id].to_s + callee = "#{delegation[:target_owner]}##{delegation[:target_method]}" if callee.empty? && + !delegation[:target_method].to_s.empty? + callee = "#{delegation[:receiver]}.#{delegation[:message]}" if callee.empty? + "cost:#{callee}" + end + def local_types_for_signature(signature) params_source = signature_params_source(signature.to_s) return {} unless params_source diff --git a/gems/espalier/lib/espalier/alias_recommendations.rb b/gems/espalier/lib/espalier/alias_recommendations.rb index b505ac2c2..acf807809 100644 --- a/gems/espalier/lib/espalier/alias_recommendations.rb +++ b/gems/espalier/lib/espalier/alias_recommendations.rb @@ -20,7 +20,7 @@ def initialize(type_definitions, minimum_slots: 1) end def build - slots = slot_records + by_path, by_owner = index_slots(slot_records) aliases.filter_map do |definition| target = alias_target(definition) profile = type_profile_for_definition(definition) @@ -29,13 +29,48 @@ def build alias_name = qualified_alias_name(definition) next if alias_name.empty? - matches = slots.filter_map { |slot| alias_slot_match(slot, definition, alias_name, target, profile) } + matches = candidate_slots(definition, by_path, by_owner) + .filter_map { |slot| alias_slot_match(slot, definition, alias_name, target, profile) } next if matches.size < @minimum_slots recommendation(definition, alias_name, target, matches) end.sort_by { |row| [-row["slot_count"].to_i, row["alias"].to_s, row.dig("definition", "path").to_s] } end + # A slot can only match an alias in the same language whose scope covers it: + # the same file, or the alias owner / a nesting ancestor of it (see + # `alias_scope_matches_slot?`). Bucketing slots by `[language, path]` and by + # `[language, owner-and-every-ancestor]` lets each alias examine only that + # union instead of every slot, turning the O(aliases x slots) scan into a + # scoped lookup. The result set is identical - `alias_slot_match` still runs + # its full guards on the candidates. + def index_slots(slots) + by_path = Hash.new { |hash, key| hash[key] = [] } + by_owner = Hash.new { |hash, key| hash[key] = [] } + slots.each do |slot| + language = slot["language"].to_s + by_path[[language, slot["path"].to_s]] << slot + ancestor = slot["owner"].to_s + until ancestor.empty? + by_owner[[language, ancestor]] << slot + separator = ancestor.rindex("::") + break unless separator + + ancestor = ancestor[0...separator] + end + end + [by_path, by_owner] + end + + def candidate_slots(definition, by_path, by_owner) + language = definition["language"].to_s + path_slots = by_path[[language, definition["path"].to_s]] + owner = definition["owner"].to_s + return path_slots if owner.empty? + + (by_owner[[language, owner]] + path_slots).uniq(&:object_id) + end + private def aliases diff --git a/gems/espalier/lib/espalier/architecture_artifact.rb b/gems/espalier/lib/espalier/architecture_artifact.rb index 4299d19c3..a1a40d8cc 100644 --- a/gems/espalier/lib/espalier/architecture_artifact.rb +++ b/gems/espalier/lib/espalier/architecture_artifact.rb @@ -14,7 +14,7 @@ module ArchitectureArtifact SCHEMA_VERSION = 1 - def build(evidence, root: evidence["root"], commit: nil) + def build(evidence, root: evidence["root"], commit: nil, big_o: {}) owners = Array(evidence["owners"]) methods = Array(evidence["methods"]) fields = Array(evidence["fields"]) @@ -26,7 +26,7 @@ def build(evidence, root: evidence["root"], commit: nil) nodes = [] owners.each { |owner| nodes << owner_node(owner, root) if high_confidence_owner?(owner) } - methods.each { |method| nodes << function_node(method, root, heuristic_owner_ids) } + methods.each { |method| nodes << function_node(method, root, heuristic_owner_ids, big_o) } fields.each { |field| nodes << state_node(field, root) } nodes_by_id = nodes.to_h { |node| [node["id"], node] } @@ -62,6 +62,17 @@ def build(evidence, root: evidence["root"], commit: nil) edges << relationship_edge(access, source, target, access["kind"], root) end + # Source-level imports are not part of FactMine's call/state facts, so we + # scan each analyzed file once for its import/require statements and emit + # them as `imports` edges (target: an external node named for the module). + source_files(owners, methods, fields).each do |path, language| + extract_imports(path, language).each do |mod, line| + external_id = "external:import:#{Digest::SHA256.hexdigest(mod)[0, 16]}" + nodes_by_id[external_id] ||= import_target_node(external_id, mod, language) + edges << import_edge(path, mod, line, external_id, root) + end + end + nodes = nodes_by_id.values edges = merge_edges(edges) cyclic = cyclic_node_ids(edges) @@ -117,10 +128,10 @@ def owner_node(owner, root) ) end - def function_node(method, root, heuristic_owner_ids = Set.new) + def function_node(method, root, heuristic_owner_ids = Set.new, big_o = {}) owner_id = method["owner_id"] owner_id = nil if heuristic_owner_ids.include?(owner_id) - base_node(method, "function", root).merge( + node = base_node(method, "function", root).merge( "owner_id" => owner_id, "metadata" => { "visibility" => method["visibility"] || "public", @@ -130,6 +141,75 @@ def function_node(method, root, heuristic_owner_ids = Set.new) "confidence" => "high" } ) + bo = big_o[big_o_key(method["path"], method["name"])] + bo ? node.merge(bo) : node + end + + # Build a lookup of per-function Big-O (time/space + completeness) from an + # aggregator manifest, keyed by (file, method name). The architecture graph + # attaches these to function nodes so Lineage can surface complexity without + # re-running the analysis. Complexity lives in the aggregator pipeline, not + # the lean static-evidence one, so it is threaded in here. + def big_o_index(manifest) + index = {} + Array(manifest).each do |mod| + file = mod[:file] || mod["file"] + Array(mod[:functions] || mod["functions"]).each do |fn| + quality = fn[:quality_metrics] || fn["quality_metrics"] || {} + time, time_complete = big_o_bound(quality, :big_o, :big_o_known_component, :big_o_complete) + space, space_complete = + big_o_bound(quality, :big_o_space, :big_o_space_known_component, :big_o_space_complete) + next if time.nil? && space.nil? + + node = {} + unless time.nil? + node["big_o_time"] = time + node["time_complete"] = time_complete + end + unless space.nil? + node["big_o_space"] = space + node["space_complete"] = space_complete + end + status = quality[:big_o_status] || quality["big_o_status"] + node["big_o_status"] = status.to_s if status + provenance = quality[:big_o_provenance] || quality["big_o_provenance"] + node["big_o_provenance"] = provenance.to_s if provenance + index[big_o_key(file, fn[:name] || fn["name"])] = node + end + end + index + end + + # Resolve a function's bound: the complete lower-bound when the analyzer + # proved it, otherwise the known-but-incomplete component (a *partial* bound + # - most functions land here, e.g. a loop whose count the analyzer can't + # prove is exhaustive). Returns [bound, complete?], or [nil, false] when + # there is no usable bound at all. + def big_o_bound(quality, bound_key, known_key, complete_key) + bound = big_o_present(quality[bound_key] || quality[bound_key.to_s]) + known = big_o_present(quality[known_key] || quality[known_key.to_s]) + complete = (quality[complete_key] || quality[complete_key.to_s]) ? true : false + if complete && bound + [bound, true] + elsif known + [known, false] + elsif bound + [bound, false] + else + [nil, false] + end + end + + # A bound string that actually names a bound (not nil/empty/"unknown"). + def big_o_present(value) + return nil if value.nil? + + str = value.to_s + str.empty? || str.casecmp("unknown").zero? ? nil : str + end + + def big_o_key(path, name) + "#{path}#{name}" end def state_node(field, root) @@ -354,6 +434,106 @@ def current_commit(root) "" end + # Unique {absolute_path => language} over every record that carries a path, + # so we scan each analyzed source file for imports exactly once. + def source_files(owners, methods, fields) + files = {} + (owners + methods + fields).each do |record| + path = record["path"] + next if path.to_s.empty? + + files[path] ||= record["language"] + end + files + end + + def import_target_node(id, mod, language) + { + "id" => id, "kind" => "external", "name" => mod, "owner" => nil, + "language" => language, "path" => nil, + "start_line" => 0, "start_column" => 0, "end_line" => 0, "end_column" => 0, + "metadata" => { "confidence" => "high", "import" => true } + } + end + + def import_edge(path, mod, line, target, root) + rel = relative_path(path, root) + { + "id" => "import:#{Digest::SHA256.hexdigest([rel, mod].join("\0"))[0, 16]}", + "source" => "file:#{rel}", "target" => target, "kind" => "imports", + "conditional" => false, "confidence" => "high", "weight" => 1, + "spans" => [{ "path" => rel, "start_line" => line, "start_column" => 0, + "end_line" => line, "end_column" => 0 }], + "metadata" => { "module" => mod } + } + end + + # Language-specific import/require statements as [module, line] pairs. This + # is a deliberately small line scanner: the module string as written is what + # a reviewer wants to see, so no resolution or path normalization is done. + def extract_imports(path, language) + source = File.read(path) + lang = (language || File.extname(path).delete_prefix(".")).to_s.downcase + case lang + when "go" then go_imports(source) + when "ruby", "rb" then scan_imports(source, /^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/) + when "python", "py" then scan_imports(source, /^\s*(?:from\s+(\S+)\s+import\b|import\s+([^\s,]+))/) + when "javascript", "js", "jsx", "typescript", "ts", "tsx" then js_imports(source) + when "rust", "rs" then scan_imports(source, /^\s*use\s+([A-Za-z_][\w:]*)/) + when "java", "kotlin", "kt" then scan_imports(source, /^\s*import\s+(?:static\s+)?([\w.]+)/) + when "c", "cpp", "cc", "h", "hpp" then scan_imports(source, /^\s*#\s*include\s+[<"]([^>"]+)[>"]/) + else [] + end + rescue StandardError + [] + end + + # A single capture group per matching line; supports two alternative groups + # (Python `from X`/`import X`) by taking whichever captured. + def scan_imports(source, pattern) + out = [] + source.each_line.with_index(1) do |line, number| + next unless (match = pattern.match(line)) + + mod = match[1] || match[2] + out << [mod, number] if mod + end + out + end + + # Go supports both `import "x"` and a parenthesized block of quoted paths + # (optionally aliased). We track the block so the block members are captured. + def go_imports(source) + out = [] + in_block = false + source.each_line.with_index(1) do |line, number| + if in_block + break_block = line.include?(")") + if (match = /["`]([^"`]+)["`]/.match(line)) + out << [match[1], number] + end + in_block = false if break_block + elsif line =~ /^\s*import\s*\(/ + in_block = true + elsif (match = /^\s*import\s+(?:[\w.]+\s+)?["`]([^"`]+)["`]/.match(line)) + out << [match[1], number] + end + end + out + end + + def js_imports(source) + out = [] + source.each_line.with_index(1) do |line, number| + if (match = /^\s*import\b.*?from\s+['"]([^'"]+)['"]/.match(line)) || + (match = /^\s*import\s+['"]([^'"]+)['"]/.match(line)) || + (match = /require\(\s*['"]([^'"]+)['"]\s*\)/.match(line)) + out << [match[1], number] + end + end + out + end + def relative_path(path, root) return path if path.to_s.empty? || root.to_s.empty? diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index 1bcbc990d..c017fdd3a 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -29,6 +29,7 @@ def analyze_method(method_name, ast_nodes, local_types: nil) time_complete = true space_complete = true symbolic_time = nil + symbolic_terms = [] is_dynamic = false trigger = nil unknown_operations = [] @@ -61,8 +62,9 @@ def analyze_method(method_name, ast_nodes, local_types: nil) complexity_assumptions.concat(Array(node[:complexity_assumptions])) known_complexity = node[:known_time_complexity].to_s if node[:symbolic_time] - symbolic_time = Espalier::SymbolicComplexity.sum(symbolic_time, node[:symbolic_time]) - known_complexity = Espalier::SymbolicComplexity.render(symbolic_time)&.first || known_complexity + symbolic_terms << node[:symbolic_time] + known_complexity = + Espalier::SymbolicComplexity.render(node[:symbolic_time])&.first || known_complexity elsif node[:execution_complexity] known_complexity = multiply_complexity(known_complexity, node[:execution_complexity]) end @@ -107,8 +109,8 @@ def analyze_method(method_name, ast_nodes, local_types: nil) complexity_assumptions.concat(Array(node[:complexity_assumptions])) structural_complexity = node[:complexity].to_s if node[:symbolic_time] - symbolic_time = Espalier::SymbolicComplexity.sum(symbolic_time, node[:symbolic_time]) - rendered_symbolic = Espalier::SymbolicComplexity.render(symbolic_time)&.first + symbolic_terms << node[:symbolic_time] + rendered_symbolic = Espalier::SymbolicComplexity.render(node[:symbolic_time])&.first if rendered_symbolic && complexity_rank(rendered_symbolic) >= complexity_rank(structural_complexity) structural_complexity = rendered_symbolic end @@ -141,6 +143,14 @@ def analyze_method(method_name, ast_nodes, local_types: nil) end end + # Normalize the symbolic sum once. Incrementally normalizing after every + # node repeatedly performs the quadratic dominance comparison over an + # ever-growing term set; methods with hundreds of normalized call facts + # turn that into cubic work without changing the resulting expression. + symbolic_time = Espalier::SymbolicComplexity.sum(symbolic_terms) + rendered_symbolic = Espalier::SymbolicComplexity.render(symbolic_time)&.first + complexity = max_complexity(complexity, rendered_symbolic) if rendered_symbolic + { method: method_name, lower_bound_complexity: time_complete ? complexity : "unknown", @@ -210,7 +220,7 @@ def resolve_type(receiver_name, line) if receiver_name.include?(".") parts = receiver_name.split(".") current_type = resolve_simple_type(parts.first, line) - parts[1..].each do |part| + Array(parts[1..]).each do |part| return nil unless current_type current_type = resolve_method_return_type(current_type, part) end @@ -225,6 +235,8 @@ def resolve_simple_type(receiver_name, line) end def raw_simple_type(receiver_name, line) + return nil if receiver_name.nil? + if receiver_name == "self" return @class_name end diff --git a/gems/espalier/lib/espalier/big_o_gap_impact.rb b/gems/espalier/lib/espalier/big_o_gap_impact.rb index a2d80ab98..45176faac 100644 --- a/gems/espalier/lib/espalier/big_o_gap_impact.rb +++ b/gems/espalier/lib/espalier/big_o_gap_impact.rb @@ -90,7 +90,14 @@ def root_categories(result, calls) categories << "dynamic_or_reflective_dispatch" unless boundary.empty? categories << "callback_origin_or_cost_missing" if value(call, :callback_receiver) == true symbol = value(call, :semantic_symbol).to_s - categories << "external_symbol_cost_missing" unless symbol.empty? + unless symbol.empty? + category = if value(call, :external_symbol_scope).to_s == "project" + "project_candidate_summary_missing" + else + "external_symbol_cost_missing" + end + categories << category + end reason = value(call, :resolution_missing_proof).to_s categories << "callback_origin_or_cost_missing" if reason.include?("callback") end diff --git a/gems/espalier/lib/espalier/big_o_proof_metrics.rb b/gems/espalier/lib/espalier/big_o_proof_metrics.rb index f3f1f1972..d1e4d1a8d 100644 --- a/gems/espalier/lib/espalier/big_o_proof_metrics.rb +++ b/gems/espalier/lib/espalier/big_o_proof_metrics.rb @@ -7,6 +7,8 @@ module Espalier # emitting O(N) is not progress unless its proof obligations are discharged # or explicitly represented by a trusted/modelled contract. module BigOProofMetrics + DEFAULT_MINIMUM_PERCENT = 85.0 + CATEGORY_LABELS = { known_provably: "Known provably, no assumptions", known_likely: "Known likely/trusted/model-derived", @@ -29,6 +31,7 @@ module BigOProofMetrics RECURSIVE_QUALITIES = %w[ upper_bound_acyclic_project_scc upper_bound_recursive_multiplicity + upper_bound_structural_descent ].freeze MODELED_QUALITY = "upper_bound_modeled_world" CLOSED_CANDIDATE_MAX_QUALITY = "upper_bound_closed_candidate_max" @@ -72,6 +75,59 @@ def summarize(qualities) } end + # Build the machine-readable coverage contract used by CI and corpus + # measurements. +rows+ deliberately carries scope metadata alongside the + # quality hash so a caller cannot accidentally mix tests, benchmarks, or + # generated functions into the production denominator. + # + # Raw call normalization is a soundness precondition rather than another + # coverage percentage: an executable call that never reached normalized + # facts can make an otherwise "complete" function look constant. Until + # FactMine can account for every such call, the gate must fail closed. + def coverage_gate(rows, call_coverage: {}, minimum_percent: DEFAULT_MINIMUM_PERCENT, + include_lambdas: true) + minimum = Float(minimum_percent) + raise ArgumentError, "minimum_percent must be between 0 and 100" unless minimum.between?(0.0, 100.0) + + scoped = Array(rows).select do |row| + fetch(row, :source_role).to_s == "production" && + (include_lambdas || fetch(row, :kind).to_s != "lambda") + end + by_language = scoped.group_by { |row| fetch(row, :language).to_s } + .sort.to_h do |language, language_rows| + [language.empty? ? "unknown" : language, coverage_summary(language_rows)] + end + summary = coverage_summary(scoped) + raw_gaps = fetch(call_coverage, :raw_calls_not_normalized_inside_function).to_i + failures = [] + failures << "no production functions were measured" if summary[:functions].zero? + if summary[:mapped_percent] < minimum + failures << format( + "production Big-O coverage %.2f%% is below %.2f%%", + summary[:mapped_percent], + minimum + ) + end + if raw_gaps.positive? + failures << "#{raw_gaps} executable raw calls were not normalized" + end + + { + schema: "espalier.big-o-coverage-gate.v1", + policy: { + source_roles: ["production"], + include_lambdas: include_lambdas, + minimum_percent: minimum, + raw_call_normalization: "fail_closed" + }, + passed: failures.empty?, + failures: failures, + coverage: summary, + by_language: by_language, + call_soundness: call_soundness(call_coverage) + } + end + def complete?(quality) fetch(quality, :big_o_complete) == true end @@ -92,6 +148,46 @@ def assumptions(quality) Array(fetch(quality, :big_o_assumptions)).map(&:to_s) end + def coverage_summary(rows) + qualities = Array(rows).map { |row| fetch(row, :quality) || row } + proof = summarize(qualities) + mapped = qualities.count { |quality| classify(quality) != :unknown } + total = qualities.length + { + functions: total, + mapped: mapped, + incomplete: total - mapped, + mapped_percent: percentage(mapped, total), + proof: proof + } + end + private_class_method :coverage_summary + + def call_soundness(coverage) + eligible = fetch(coverage, :eligible_call_sites).to_i + accounted = fetch(coverage, :semantically_accounted_call_sites).to_i + { + scope: "entire_fact_mine_profile", + eligible_call_sites: eligible, + exact_project_targets: fetch(coverage, :exact_project_targets).to_i, + modeled_without_project_target: fetch(coverage, :modeled_without_project_target).to_i, + semantically_accounted_call_sites: accounted, + semantically_accounted_percent: percentage(accounted, eligible), + unresolved_call_sites: fetch(coverage, :unresolved_call_sites).to_i, + raw_parser_call_sites: fetch(coverage, :raw_parser_call_sites).to_i, + raw_calls_not_normalized: fetch(coverage, :raw_calls_not_normalized).to_i, + raw_calls_not_normalized_inside_function: + fetch(coverage, :raw_calls_not_normalized_inside_function).to_i, + normalized_calls_without_raw_span: fetch(coverage, :normalized_calls_without_raw_span).to_i + } + end + private_class_method :call_soundness + + def percentage(numerator, denominator) + denominator.zero? ? 0.0 : (numerator * 100.0 / denominator).round(2) + end + private_class_method :percentage + def fetch(hash, key) return nil unless hash.respond_to?(:key?) return hash[key] if hash.key?(key) diff --git a/gems/espalier/lib/espalier/complexity_overrides.rb b/gems/espalier/lib/espalier/complexity_overrides.rb new file mode 100644 index 000000000..e0ea568f3 --- /dev/null +++ b/gems/espalier/lib/espalier/complexity_overrides.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "yaml" + +module Espalier + # Targeted manual-override registry (see config/complexity_overrides.yml). + # + # An override is consulted ONLY for a function whose derived bound is + # incomplete, and returns a complexity only when an explicit entry exists. + # It is the escape hatch for the ~0.4-2% of functions whose true bound is an + # algorithmic guarantee structural analysis provably cannot derive. + module ComplexityOverrides + DEFAULT_PATH = File.expand_path("../../config/complexity_overrides.yml", __dir__) + + class << self + def table(path = DEFAULT_PATH) + @tables ||= {} + @tables[path] ||= load_table(path) + end + + # Returns the override entry for (language, owner, name), or nil. + # Entry: { "time" => ..., "space" => ..., "note" => ... }. + def lookup(language, owner, name, path: DEFAULT_PATH) + return nil unless language && name + + lang = table(path)[language.to_s] + return nil unless lang + + lang["#{owner}.#{name}"] || lang[name.to_s] + end + + private + + def load_table(path) + return {} unless File.exist?(path) + + YAML.safe_load(File.read(path)) || {} + end + end + end +end diff --git a/gems/espalier/lib/espalier/complexity_summary.rb b/gems/espalier/lib/espalier/complexity_summary.rb new file mode 100644 index 000000000..d9c741712 --- /dev/null +++ b/gems/espalier/lib/espalier/complexity_summary.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Espalier + # Proof boundary for reusable complexity summaries. A source profile can be + # complete because a reviewed/manual external model closed one of its calls; + # exporting that result would merely copy the registry into a generated file. + # Reusable summaries therefore admit only bounds proven from analyzed bodies, + # exact analyzed targets, CFG/DFG structure, and compiler-provided candidate + # sets. + module ComplexitySummary + FORBIDDEN_QUALITY_FRAGMENTS = %w[ + declared_receiver + external_latency + modeled_world + unknown_cardinality + ].freeze + + module_function + + def relocate_symbol(symbol, from: nil, to: nil) + return symbol if from.nil? && to.nil? + raise ArgumentError, "symbol relocation requires both source and destination prefixes" if from.nil? || to.nil? + raise ArgumentError, "symbol does not start with the declared source prefix: #{symbol}" unless symbol.start_with?(from) + + "#{to}#{symbol.delete_prefix(from)}" + end + + # Apply an exact implementation-to-consumer bridge. A source body may + # deliberately implement more than one public declaration (for example a + # class method and an equivalent module function), so a bridge value is + # one-or-many rather than a lossy one-to-one map. Keeping this operation + # here makes bridges equally available to every producer language; a + # language-owned bridge generator need only prove its identities. + # + # Rows without a bridge target are intentionally omitted. Publishing a + # source identity into a consumer summary would evade the cross-indexer + # identity proof that the bridge is meant to establish. + def bridge_symbol_rows(rows, symbol_map: nil, prefix_from: nil, prefix_to: nil) + rows.flat_map do |symbol, row| + targets = if symbol_map + Array(symbol_map[symbol]) + else + [relocate_symbol(symbol, from: prefix_from, to: prefix_to)] + end + targets.compact.map { |target| [target, row] } + end + end + + def source_method_proven?(method, quality, complexity_facts) + return false unless method["source_export_eligible"] == true + return false unless source_proven?(quality, complexity_facts) + + qualities = Array(quality[:big_o_bound_qualities]).map(&:to_s) + return false if qualities.any? { |bound| bound.include?("parametric_callback") } && + Array(method["callback_params"]).empty? + + true + end + + def consumer_closed_candidate_set?(call) + call["consumer_closed_candidate_set"] == true + end + + def source_proven?(quality, _complexity_facts) + return false unless quality + return false unless quality[:big_o_complete] == true + return false unless quality[:big_o_space_complete] == true + + qualities = Array(quality[:big_o_bound_qualities]).map(&:to_s) + return false if qualities.any? do |bound_quality| + FORBIDDEN_QUALITY_FRAGMENTS.any? { |fragment| bound_quality.include?(fragment) } + end + + # Complexity facts are captured before SCIP resolves canonical calls, so + # their adapter-level evidence_gap fields can be stale. The aggregate + # completeness result above is computed after canonical call enrichment. + # Parser-normalization loss is guarded independently by FactMine marking + # the overlapping method source_export_eligible=false. + true + end + end +end diff --git a/gems/espalier/lib/espalier/static_evidence.rb b/gems/espalier/lib/espalier/static_evidence.rb index 47b535fcd..a3fdd20b3 100644 --- a/gems/espalier/lib/espalier/static_evidence.rb +++ b/gems/espalier/lib/espalier/static_evidence.rb @@ -19,13 +19,28 @@ module Espalier # Static, language-neutral evidence for Espalier. Uses the Rust FactMine # binary exclusively for fact extraction. class StaticEvidence - FACT_MINE_RUST_BINARY = ENV.fetch( - "FACT_MINE_RUST_BINARY", - File.join(Espalier::ROOT, "gems", "fact-mine", "target", "release", "fact-mine-rust") - ).freeze + FACT_MINE_RUST_BINARY = begin + configured = ENV["FACT_MINE_RUST_BINARY"] + if configured + File.expand_path(configured) + else + candidates = %w[release debug].map do |profile| + File.join( + Espalier::ROOT, + "gems", + "fact-mine", + "target", + profile, + "fact-mine-rust" + ) + end + candidates.select { |path| File.executable?(path) } + .max_by { |path| File.mtime(path) } || candidates.first + end + end.freeze - def self.build(targets = nil, root: Espalier::ROOT, language: nil, vcs: nil, include_annotations: true, scip_indexes: []) - new(targets, root: root, language: language, vcs: vcs, include_annotations: include_annotations, scip_indexes: scip_indexes).build + def self.build(targets = nil, root: Espalier::ROOT, language: nil, vcs: nil, include_annotations: true, scip_indexes: [], semantic_environments: [], complexity_summaries: []) + new(targets, root: root, language: language, vcs: vcs, include_annotations: include_annotations, scip_indexes: scip_indexes, semantic_environments: semantic_environments, complexity_summaries: complexity_summaries).build end def self.project_modules(evidence, source_roles: ["production"]) @@ -83,6 +98,10 @@ def self.project_modules(evidence, source_roles: ["production"]) end.to_set raw_methods.each do |m| next unless allowed_roles.include?(role_for.call(m["path"])) + # Macro-generated accessors remain in the FactMine profile so their + # declaration contract can price callers, but they are not authored + # production functions and must not inflate a completion denominator. + next if m["generated_declaration"] == true overload_key = [m["path"].to_s, m["owner"].to_s, m["name"].to_s, m["kind"].to_s] # TypeScript overload signatures are declarations immediately followed # by a concrete implementation. Reporting both as executable methods @@ -101,6 +120,7 @@ def self.project_modules(evidence, source_roles: ["production"]) signature: m["signature"], parameters: Array(m["params"]), visibility: (m["visibility"] || :public).to_sym, + callback_params: Array(m["callback_params"]).map(&:to_s), line: m["line"]&.to_i, span: m["span"], file: m["path"], @@ -166,6 +186,7 @@ def self.project_modules(evidence, source_roles: ["production"]) confidence: call["confidence"], unresolved_reason: (target || known_time || known_space) ? nil : call["unresolved_reason"], call_id: call["id"], + arguments: Array(call["arguments"]).map(&:to_s), target_id: target && target[:id], target_owner: target && target[:projected_owner], target_method: target && target[:name], @@ -173,6 +194,7 @@ def self.project_modules(evidence, source_roles: ["production"]) target_provenance: call["target_provenance"], candidate_target_ids: Array(call["candidate_targets"]), candidate_reason: call["candidate_reason"], + consumer_closed_candidate_set: call["consumer_closed_candidate_set"] == true, complexity_provenance: call["complexity_provenance"], complexity_bound_quality: call["complexity_bound_quality"], complexity_candidates: Array(call["complexity_candidates"]), @@ -355,7 +377,15 @@ def self.source_role(path) return "example" if (parts & %w[example examples sample samples]).any? return "test" if (parts & %w[test tests spec specs __tests__ jvmtest androidtest commontest nativetest nonwasmtest wasmtest integrationtest unittest uitest functionaltest]).any? return "test" if parts.any? { |part| part.end_with?("test") && part.match?(/\A(?:android|common|functional|integration|jvm|native|nonwasm|unit|ui|wasm)/) } - return "test" if basename.match?(/(?:\A|[_\.])test(?:[_\.]|\z)|(?:\A|[_\.])spec(?:[_\.]|\z)/) + test_named_basename = basename.match?( + /(?:\A|[-_\.])test(?:[-_\.]|\z)|(?:\A|[-_\.])spec(?:[-_\.]|\z)/ + ) + # A production library may legitimately expose a `test_*` or `spec_*` + # entrypoint (for example TestMiser's `lib/test_miser.rb`). Directory + # role is stronger evidence than that filename prefix; helper products + # remain covered by the dedicated test-helper rule below. + library_entrypoint = parts.include?("lib") && basename.match?(/\A(?:test|spec)[-_]/) + return "test" if test_named_basename && !library_entrypoint # Test-helper products are executable support code, not production # library surface. Match common portable path spellings, including the # Swift Package Manager `ArgumentParserTestHelpers` convention. @@ -364,14 +394,64 @@ def self.source_role(path) "production" end + # Classify callable records more precisely than their containing file. + # Rust commonly keeps `#[cfg(test)] mod tests` at the bottom of a + # production source file. rust-analyzer's SCIP symbol retains that module + # boundary, and synthetic closures inherit the role of the exact enclosing + # test callable by source span. + def self.method_source_roles(methods) + rows = Array(methods) + roles = rows.to_h do |method| + role = method["generated_declaration"] == true ? "generated" : source_role(method["path"]) + [method.fetch("id").to_s, role] + end + test_spans_by_path = Hash.new { |hash, path| hash[path] = [] } + + rows.each do |method| + next unless roles.fetch(method.fetch("id").to_s) == "production" + next unless method["language"].to_s == "rust" + next unless rust_test_semantic_symbol?(method["semantic_symbol"]) + + roles[method.fetch("id").to_s] = "test" + test_spans_by_path[method["path"].to_s] << Array(method["span"]) + end + + rows.each do |method| + id = method.fetch("id").to_s + next unless roles.fetch(id) == "production" + next unless method["language"].to_s == "rust" + + span = Array(method["span"]) + next unless span.length == 4 + next unless test_spans_by_path[method["path"].to_s].any? { |outer| span_contains?(outer, span) } + + roles[id] = "test" + end + + roles + end + + def self.rust_test_semantic_symbol?(symbol) + symbol.to_s.match?(%r{(?:\s|/)(?:tests?|test_helpers?)/}) + end + + def self.span_contains?(outer, inner) + return false unless outer.length == 4 && inner.length == 4 + + ([inner[0].to_i, inner[1].to_i] <=> [outer[0].to_i, outer[1].to_i]) >= 0 && + ([inner[2].to_i, inner[3].to_i] <=> [outer[2].to_i, outer[3].to_i]) <= 0 + end - def initialize(targets = nil, root: Espalier::ROOT, language: nil, vcs: nil, include_annotations: true, scip_indexes: []) + + def initialize(targets = nil, root: Espalier::ROOT, language: nil, vcs: nil, include_annotations: true, scip_indexes: [], semantic_environments: [], complexity_summaries: []) @targets = Array(targets).compact @root = root @language = normalize_language(language) @vcs = normalize_vcs(vcs) @include_annotations = include_annotations @scip_indexes = Array(scip_indexes).compact + @semantic_environments = Array(semantic_environments).compact + @complexity_summaries = Array(complexity_summaries).compact end def build @@ -390,6 +470,10 @@ def build args = [FACT_MINE_RUST_BINARY, "profile", profile, "--output", tmp.path] args.concat(["--language", @language.to_s]) if @language @scip_indexes.each { |index| args.concat(["--scip-index", index.to_s]) } + @semantic_environments.each do |environment| + args.concat(["--semantic-environment", environment.to_s]) + end + @complexity_summaries.each { |summary| args.concat(["--complexity-summary", summary.to_s]) } args.concat(files) ok = system(*args) raise "fact-mine-rust failed with exit status #{$?.exitstatus}" unless ok diff --git a/gems/espalier/lib/espalier/stdlib_map.rb b/gems/espalier/lib/espalier/stdlib_map.rb new file mode 100644 index 000000000..59a4ce023 --- /dev/null +++ b/gems/espalier/lib/espalier/stdlib_map.rb @@ -0,0 +1,901 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "open3" +require "optparse" +require "pathname" +require "rbconfig" +require "shellwords" +require "tmpdir" +require "yaml" +require "zlib" + +module Espalier + # Manifest-driven producer for source-analyzed standard-library complexity + # summaries. Language-specific build and source selection live in YAML; the + # indexing, FactMine profiling, soundness checks, export, consumer comparison, + # and publication flow are shared. + class StdlibMap + SCHEMA = "fact-mine.stdlib-map.v1" + SUMMARY_SCHEMA = "fact-mine.external-complexity-summary.v3" + + class CommandRunner + def run!(command, chdir:, env: {}) + warn "$ (cd #{Shellwords.escape(chdir)} && #{display_command(command)})" + success = system(env, *command, chdir: chdir) + raise "command failed: #{display_command(command)}" unless success + end + + def capture(command, chdir:, env: {}) + stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir) + [stdout, stderr, status] + end + + private + + def display_command(command) + shown = command.first(12).map { |argument| Shellwords.escape(argument) } + shown << "... (#{command.length} arguments)" if command.length > shown.length + shown.join(" ") + end + end + + attr_reader :manifest, :manifest_path + + def self.run_cli(arguments) + options = { + work_dir: nil, + keep_work: false, + index_override: nil, + source_root_override: nil, + summary_output: nil, + fact_mine: nil + } + parser = OptionParser.new do |opts| + opts.banner = "Usage: espalier stdlib-map --manifest FILE [options]" + opts.on("--manifest FILE", "Mapping manifest") { |value| options[:manifest] = value } + opts.on("--work-dir DIR", "Retain intermediate index/profile artifacts here") do |value| + options[:work_dir] = value + end + opts.on("--keep-work", "Keep an automatically-created work directory") do + options[:keep_work] = true + end + opts.on("--index FILE", "Reuse an existing SCIP index") do |value| + options[:index_override] = value + end + opts.on("--source-root DIR", "Reuse an existing pinned source checkout") do |value| + options[:source_root_override] = value + end + opts.on("--output FILE", "Override the summary output path") do |value| + options[:summary_output] = value + end + opts.on("--fact-mine FILE", "FactMine Rust binary") { |value| options[:fact_mine] = value } + end + parser.parse!(arguments) + raise ArgumentError, parser.to_s unless arguments.empty? && options[:manifest] + + new( + options.fetch(:manifest), + work_dir: options.fetch(:work_dir), + keep_work: options.fetch(:keep_work), + index_override: options.fetch(:index_override), + source_root_override: options.fetch(:source_root_override), + summary_output: options.fetch(:summary_output), + fact_mine: options.fetch(:fact_mine) + ).run + 0 + rescue StandardError => error + warn "stdlib-map failed: #{error.message}" + 1 + end + + def initialize(manifest_path, work_dir: nil, keep_work: false, index_override: nil, + source_root_override: nil, + summary_output: nil, fact_mine: nil, runner: CommandRunner.new) + @manifest_path = File.expand_path(manifest_path) + @manifest_dir = File.dirname(@manifest_path) + @workspace_root = File.expand_path("../../../..", __dir__) + @manifest = load_manifest + @keep_work = keep_work + @owned_work_dir = work_dir.nil? + @work_dir = File.expand_path(work_dir || Dir.mktmpdir("fact-mine-stdlib-map-")) + @index_override = index_override && File.expand_path(index_override) + @source_root_override = source_root_override && File.expand_path(source_root_override) + @summary_output_override = summary_output && File.expand_path(summary_output) + @fact_mine_override = fact_mine && File.expand_path(fact_mine) + @runner = runner + validate_manifest! + end + + def run + FileUtils.mkdir_p(@work_dir) + source_root = resolve_source_root + source_revision = verify_source_revision(source_root) + prepare_source(source_root) + files = source_files(source_root) + analysis_root, files = stage_selected_source(source_root, files) + index = produce_index(analysis_root) + substitutions = { + "source_root" => analysis_root, + "work_dir" => @work_dir, + "index" => index, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + producer_environment = materialize_environment( + manifest["compatibility"], + "producer", + substitutions + ) + profile = File.join(@work_dir, "stdlib.profile.json") + run_profile(files, index, profile, environment: producer_environment) + profile_data = JSON.parse(File.read(profile)) + profile_validation = validate_profile!(profile_data, files, producer_environment) + + producer_summary = File.join(@work_dir, "stdlib.producer-summary.json.gz") + export_summary( + profile, + producer_summary, + relocate: false, + bridge: nil, + compatibility: producer_environment + ) + producer_summary_data = read_json(producer_summary) + producer_validation = validate_summary!(producer_summary_data, relocated: false) + producer_join_validation = verify_summary_join(profile_data, producer_summary_data) + symbol_bridge = materialize_symbol_bridge( + fetch_hash(manifest, "summary")["symbol_bridge"], + substitutions.merge( + "profile" => profile, + "producer_summary" => producer_summary + ) + ) + + relocation = fetch_hash(manifest, "summary")["symbol_relocation"] + staged_summary = if relocation || symbol_bridge + path = File.join(@work_dir, "stdlib.summary.json.gz") + export_summary( + profile, + path, + relocate: !relocation.nil?, + bridge: symbol_bridge, + compatibility: producer_environment + ) + path + else + producer_summary + end + summary_validation = validate_summary!( + read_json(staged_summary), + relocated: !relocation.nil?, + bridged: !symbol_bridge.nil? + ) + consumer_results = run_consumer_checks(staged_summary, producer_environment) + + output = summary_output + publish_summary(staged_summary, output) + report = { + "schema" => "fact-mine.stdlib-map-report.v1", + "manifest" => manifest_path, + "language" => manifest.fetch("language"), + "source_root" => source_root, + "analysis_root" => analysis_root, + "source_revision" => source_revision, + "source_files" => files.length, + "index" => index, + "profile" => profile_validation, + "producer_summary" => producer_validation.merge(producer_join_validation), + "summary" => summary_validation.merge("output" => output), + "consumers" => consumer_results + } + report_path = File.join(@work_dir, "stdlib-map-report.json") + File.write(report_path, JSON.pretty_generate(report)) + puts JSON.pretty_generate(report) + report + ensure + FileUtils.remove_entry(@work_dir) if @owned_work_dir && !@keep_work && File.exist?(@work_dir) + end + + private + + def load_manifest + raw = YAML.safe_load(File.read(manifest_path), permitted_classes: [], aliases: false) + expand_environment(raw) + rescue Psych::Exception => error + raise ArgumentError, "invalid stdlib manifest #{manifest_path}: #{error.message}" + end + + def validate_manifest! + raise ArgumentError, "unsupported stdlib manifest schema: #{manifest['schema'].inspect}" unless manifest["schema"] == SCHEMA + raise ArgumentError, "language must be present" if manifest["language"].to_s.empty? + source = fetch_hash(manifest, "source") + if source["root"].to_s.empty? && !Array(source["root_command"]).any? && !source["git"] + raise ArgumentError, "source.root, source.root_command, or source.git must be present" + end + includes = Array(source["include"]) + raise ArgumentError, "source.include must contain at least one glob" if includes.empty? + raise ArgumentError, "source.revision must be present" if source["revision"].to_s.empty? + if source["git"] + git = fetch_hash(source, "git") + raise ArgumentError, "source.git.repository must be present" if git["repository"].to_s.empty? + raise ArgumentError, "source.git.commit must be present" if git["commit"].to_s.empty? + unless git["commit"].match?(/\A[0-9a-f]{40}\z/) + raise ArgumentError, "source.git.commit must be a full 40-character commit" + end + else + revision_check = fetch_hash(source, "revision_check") + unless Array(revision_check["command"]).any? + raise ArgumentError, "source.revision_check.command must be present" + end + matches = Array(revision_check["includes"]) + exact = revision_check["equals"] + if matches.empty? && exact.nil? + raise ArgumentError, "source.revision_check requires equals or includes" + end + if !matches.empty? && !exact.nil? + raise ArgumentError, "source.revision_check cannot use both equals and includes" + end + end + + index = fetch_hash(manifest, "index") + unless @index_override || index["path"] || Array(index["command"]).any? + raise ArgumentError, "index.path or index.command is required" + end + expected = fetch_hash(index, "expected") + raise ArgumentError, "index.expected.tool is required" if expected["tool"].to_s.empty? + raise ArgumentError, "index.expected.version is required" if expected["version"].to_s.empty? + + summary = fetch_hash(manifest, "summary") + %w[corpus output].each do |key| + raise ArgumentError, "summary.#{key} is required" if summary[key].to_s.empty? + end + relocation = summary["symbol_relocation"] + if relocation + relocation = fetch_hash(summary, "symbol_relocation") + if relocation["from"].to_s.empty? || relocation["to"].to_s.empty? + raise ArgumentError, "summary.symbol_relocation requires from and to" + end + end + if summary["symbol_bridge"] && relocation + raise ArgumentError, "summary.symbol_bridge cannot be combined with symbol_relocation" + end + if summary["symbol_bridge"] + bridge = fetch_hash(summary, "symbol_bridge") + unless bridge["path"] || Array(bridge["command"]).any? + raise ArgumentError, "summary.symbol_bridge requires path or command" + end + end + consumer_indexers = Array(summary["consumer_indexers"]) + if consumer_indexers.any? { |indexer| indexer.to_s.empty? || !indexer.to_s.include?("@") } + raise ArgumentError, + "summary.consumer_indexers must contain tool@version identities" + end + if manifest["compatibility"] + compatibility = fetch_hash(manifest, "compatibility") + modes = [ + compatibility.key?("claims"), + compatibility.key?("path"), + Array(compatibility["command"]).any? + ].count(true) + raise ArgumentError, "compatibility requires exactly one of claims, path, or command" unless modes == 1 + end + end + + def source_files(root, config = fetch_hash(manifest, "source")) + includes = Array(config.fetch("include")) + excludes = Array(config["exclude"]) + files = includes.flat_map do |pattern| + Dir.glob(File.join(root, pattern), File::FNM_EXTGLOB) + end + files.select! { |path| File.file?(path) } + files.reject! do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s + excludes.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) } + end + files.map! { |path| File.expand_path(path) } + files.uniq! + files.sort! + raise ArgumentError, "source globs selected no files below #{root}" if files.empty? + + files + end + + def resolve_source_root + return @source_root_override if @source_root_override + + source = fetch_hash(manifest, "source") + return materialize_git_source(source) if source["git"] + return expanded_path(source["root"]) if source["root"] + + command = expand_command(source.fetch("root_command"), {}) + cwd = expanded_path(source.fetch("root_working_directory", @workspace_root)) + stdout, stderr, status = @runner.capture(command, chdir: cwd) + raise "source.root_command failed: #{stderr}" unless status.success? + root = stdout.strip + raise "source.root_command returned an empty path" if root.empty? + + suffix = source["root_suffix"].to_s + File.expand_path(File.join(root, suffix)) + end + + def materialize_git_source(source) + config = fetch_hash(source, "git") + checkout = expanded_path( + config.fetch( + "directory", + File.join(@workspace_root, ".cache", "stdlib-sources", safe_name(source.fetch("revision"))) + ) + ) + FileUtils.mkdir_p(checkout) + unless File.directory?(File.join(checkout, ".git")) + @runner.run!(["git", "init", "--quiet"], chdir: checkout) + @runner.run!( + ["git", "remote", "add", "origin", config.fetch("repository")], + chdir: checkout + ) + end + remote, stderr, status = @runner.capture( + ["git", "remote", "get-url", "origin"], + chdir: checkout + ) + unless status.success? + raise "failed to inspect git source remote: #{stderr}" + end + unless remote.strip == config.fetch("repository") + raise "git source remote mismatch: expected #{config.fetch('repository').inspect}, got #{remote.strip.inspect}" + end + if Array(config["sparse_paths"]).any? + @runner.run!(["git", "sparse-checkout", "init", "--cone"], chdir: checkout) + @runner.run!( + ["git", "sparse-checkout", "set", *Array(config["sparse_paths"]).map(&:to_s)], + chdir: checkout + ) + end + commit = config.fetch("commit") + current, = @runner.capture(["git", "rev-parse", "HEAD"], chdir: checkout) + unless current.strip == commit + @runner.run!(["git", "fetch", "--depth", "1", "origin", commit], chdir: checkout) + @runner.run!(["git", "checkout", "--quiet", "--detach", "FETCH_HEAD"], chdir: checkout) + end + root = File.expand_path(File.join(checkout, source["root_suffix"].to_s)) + raise "git source root does not exist after checkout: #{root}" unless File.directory?(root) + + root + end + + def prepare_source(source_root) + source = fetch_hash(manifest, "source") + substitutions = { + "source_root" => source_root, + "work_dir" => @work_dir, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + Array(source["prepare"]).each do |command| + raise ArgumentError, "each source.prepare entry must be a command array" unless command.is_a?(Array) + + @runner.run!( + expand_command(command, substitutions), + chdir: expanded_path(source.fetch("prepare_working_directory", @workspace_root), substitutions) + ) + end + end + + def stage_selected_source(source_root, files) + source = fetch_hash(manifest, "source") + return [source_root, files] unless source["stage_selected_files"] == true + + stage_root = File.join(@work_dir, "selected-source") + FileUtils.rm_rf(stage_root) + FileUtils.mkdir_p(stage_root) + stage_files = files.dup + Array(source["stage_include"]).each do |pattern| + Dir.glob(File.join(source_root, pattern), File::FNM_EXTGLOB).each do |path| + stage_files << File.expand_path(path) if File.file?(path) + end + end + stage_files.uniq.each do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(source_root)).to_s + destination = File.join(stage_root, relative) + FileUtils.mkdir_p(File.dirname(destination)) + FileUtils.copy_file(path, destination) + end + staged_files = files.map do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(source_root)).to_s + File.join(stage_root, relative) + end + [stage_root, staged_files] + end + + def verify_source_revision(source_root) + source = fetch_hash(manifest, "source") + if source["git"] + checkout = source_root + checkout = File.dirname(checkout) until File.directory?(File.join(checkout, ".git")) || + File.dirname(checkout) == checkout + raise "git metadata not found above source root #{source_root}" unless File.directory?(File.join(checkout, ".git")) + + stdout, stderr, status = @runner.capture(["git", "rev-parse", "HEAD"], chdir: checkout) + raise "source revision command failed: #{stderr}" unless status.success? + expected = fetch_hash(source, "git").fetch("commit") + unless stdout.strip == expected + raise "source revision mismatch: expected #{expected.inspect}, got #{stdout.strip.inspect}" + end + return source.fetch("revision") + end + check = fetch_hash(source, "revision_check") + substitutions = { + "source_root" => source_root, + "work_dir" => @work_dir, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + command = expand_command(check.fetch("command"), substitutions) + cwd = expanded_path(check.fetch("working_directory", source_root), substitutions) + env = fetch_hash(check, "environment", required: false) + .transform_values { |value| expand_string(value.to_s, substitutions) } + stdout, stderr, status = @runner.capture(command, chdir: cwd, env: env) + raise "source revision command failed: #{stderr}" unless status.success? + + output = stdout.strip + exact = check["equals"] + includes = Array(check["includes"]) + matches = if exact + output == exact.to_s + else + includes.all? { |fragment| output.include?(fragment.to_s) } + end + unless matches + expectation = exact ? exact.inspect : "all of #{includes.inspect}" + raise "source revision mismatch: expected #{expectation}, got #{output.inspect}" + end + + source.fetch("revision") + end + + def produce_index(source_root) + return checked_file(@index_override, "SCIP index") if @index_override + + config = fetch_hash(manifest, "index") + return checked_file(expanded_path(config["path"]), "SCIP index") if config["path"] + + output = File.join(@work_dir, config.fetch("output", "stdlib.scip")) + substitutions = { + "source_root" => source_root, + "work_dir" => @work_dir, + "index" => output, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + command = expand_command(config.fetch("command"), substitutions) + cwd = expanded_path(config.fetch("working_directory", source_root), substitutions) + env = fetch_hash(config, "environment", required: false) + .transform_values { |value| expand_string(value.to_s, substitutions) } + @runner.run!(command, chdir: cwd, env: env) + checked_file(output, "generated SCIP index") + end + + def run_profile(files, index, output, summary: nil, environment: nil, + language: manifest.fetch("language")) + command = [ + fact_mine_binary, "profile", "espalier", + "--language", language, + "--scip-index", index, + "--no-bundled-complexity-summaries", + "--output", output + ] + command.concat(["--semantic-environment", environment]) if environment + command.concat(["--complexity-summary", summary]) if summary + command.concat(files) + @runner.run!(command, chdir: @workspace_root) + end + + def export_summary(profile, output, relocate:, bridge:, compatibility:) + summary = fetch_hash(manifest, "summary") + indexer = fetch_hash(fetch_hash(manifest, "index"), "expected") + command = [ + RbConfig.ruby, + File.join(@workspace_root, "gems/espalier/script/export_complexity_summary.rb"), + "--corpus", summary.fetch("corpus"), + "--source-revision", fetch_hash(manifest, "source").fetch("revision"), + "--indexer", "#{indexer.fetch('tool')}@#{indexer.fetch('version')}" + ] + Array(summary["consumer_indexers"]).each do |consumer_indexer| + command.concat(["--consumer-indexer", consumer_indexer]) + end + command.concat(["--compatibility", compatibility]) if compatibility + if relocate && (relocation = summary["symbol_relocation"]) + command.concat(["--symbol-prefix-from", relocation.fetch("from")]) + command.concat(["--symbol-prefix-to", relocation.fetch("to")]) + end + command.concat(["--symbol-map", bridge]) if bridge + command.concat([profile, output]) + @runner.run!(command, chdir: @workspace_root) + end + + def validate_profile!(profile, files, environment = nil) + coverage = fetch_hash(profile, "input_coverage") + selected = coverage.fetch("selected_files", 0).to_i + parsed = coverage.fetch("parsed_files", 0).to_i + raise "profile selected #{selected} files, expected #{files.length}" unless selected == files.length + raise "profile parsed only #{parsed}/#{selected} selected files" unless parsed == selected + recovery_files = Array(coverage["parse_recovery_files"]) + recoveries = Array(coverage["parse_recoveries"]) + eligible_methods = Array(profile["methods"]).select do |method| + method["source_export_eligible"] == true + end + recovery_overlaps = recoveries.sum do |recovery| + path = recovery["path"].to_s + spans = Array(recovery["spans"]) + eligible_methods.count do |method| + method["path"].to_s == path && spans.any? do |span| + spans_overlap?(Array(method["span"]), Array(span)) + end + end + end + if recovery_overlaps.positive? + raise "parser recovery overlaps #{recovery_overlaps} source-export eligible methods" + end + + expected = fetch_hash(fetch_hash(manifest, "index"), "expected") + indexes = Array(profile["semantic_indexes"]) + unless indexes.any? { |row| row["tool"] == expected["tool"] && row["version"] == expected["version"] } + raise "SCIP metadata did not contain #{expected['tool']}@#{expected['version']}" + end + if environment + required_claims = fetch_hash(read_json(environment), "claims") + actual_claims = fetch_hash(profile, "semantic_environment") + missing = required_claims.reject { |key, value| actual_claims[key] == value } + unless missing.empty? + raise "profile semantic environment did not preserve claims: #{missing.keys.sort.join(', ')}" + end + end + methods = Array(profile["methods"]) + eligible = methods.count { |method| method["source_export_eligible"] == true } + minimum = fetch_hash(manifest, "soundness", required: false).fetch("minimum_export_eligible_methods", 1).to_i + raise "only #{eligible} methods are source-export eligible; expected at least #{minimum}" if eligible < minimum + + calls = fetch_hash(profile, "call_resolution_coverage", required: false) + raw_call_gaps = calls.fetch("raw_calls_not_normalized_inside_function", 0).to_i + eligible_gap_overlaps = calls + .fetch("source_export_eligible_methods_overlapping_raw_call_loss", 0) + .to_i + if eligible_gap_overlaps.positive? + raise "parser call loss overlaps #{eligible_gap_overlaps} source-export eligible methods; analyzer eligibility revocation is unsound" + end + { + "methods" => methods.length, + "source_export_eligible_methods" => eligible, + "raw_calls_not_normalized" => calls.fetch("raw_calls_not_normalized", 0).to_i, + "raw_calls_not_normalized_inside_function" => raw_call_gaps, + "eligible_methods_overlapping_call_loss" => eligible_gap_overlaps, + "parse_recovery_files" => recovery_files.length, + "parse_recovery_spans" => recoveries.sum { |recovery| Array(recovery["spans"]).length }, + "eligible_methods_overlapping_parse_recovery" => recovery_overlaps, + "semantic_index" => "#{expected['tool']}@#{expected['version']}", + "semantic_environment_claims" => fetch_hash( + profile, + "semantic_environment", + required: false + ).length + } + end + + def validate_summary!(summary, relocated: true, bridged: false) + raise "unexpected summary schema: #{summary['schema'].inspect}" unless summary["schema"] == SUMMARY_SCHEMA + symbols = fetch_hash(summary, "symbols") + source = fetch_hash(summary, "source") + declared = source.fetch("complete_symbol_count").to_i + raise "summary count #{declared} does not match #{symbols.length} symbols" unless declared == symbols.length + + config = fetch_hash(manifest, "summary") + minimum = config.fetch("minimum_symbols", 1).to_i + raise "summary exported #{symbols.length} symbols; expected at least #{minimum}" if symbols.length < minimum + prefix = if bridged + nil + elsif !relocated && config["symbol_relocation"] + fetch_hash(config, "symbol_relocation").fetch("from") + else + config["expected_symbol_prefix"] + end + if prefix + bad = symbols.keys.find { |symbol| !symbol.start_with?(prefix) } + raise "summary symbol does not use expected prefix: #{bad}" if bad + end + { + "symbols" => symbols.length, + "source_proven_methods" => source.fetch("source_proven_method_count").to_i, + "profile_sha256" => source.fetch("profile_sha256"), + "indexer" => source.fetch("indexer") + } + end + + def verify_summary_join(profile, summary) + symbols = fetch_hash(summary, "symbols") + joined = Array(profile["calls"]).count do |call| + symbols.key?(call["semantic_symbol"].to_s) + end + {"verified_join_call_sites" => joined} + end + + def run_consumer_checks(summary, producer_environment) + Array(manifest["consumers"]).map do |consumer| + name = consumer.fetch("name") + root = expanded_path(consumer.fetch("source_root")) + files = source_files(root, consumer) + index = produce_consumer_index(consumer, name, root) + substitutions = { + "source_root" => root, + "work_dir" => @work_dir, + "index" => index, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + environment = if consumer.key?("compatibility") + materialize_environment( + consumer["compatibility"], + "consumer-#{safe_name(name)}", + substitutions + ) + elsif producer_environment + raise ArgumentError, + "#{name} must produce its own compatibility claims" + else + nil + end + baseline = File.join(@work_dir, "consumer-#{safe_name(name)}-baseline.json") + generated = File.join(@work_dir, "consumer-#{safe_name(name)}-generated.json") + run_profile(files, index, baseline, environment: environment, + language: consumer.fetch("language", manifest.fetch("language"))) + run_profile(files, index, generated, summary: summary, environment: environment, + language: consumer.fetch("language", manifest.fetch("language"))) + before = coverage_report(baseline, root, consumer) + after = coverage_report(generated, root, consumer) + before_mapped = before.dig("coverage", "mapped").to_i + after_mapped = after.dig("coverage", "mapped").to_i + raise "#{name} regressed from #{before_mapped} to #{after_mapped} complete functions" if after_mapped < before_mapped + minimum = consumer.fetch("minimum_complete_percent", 85.0).to_f + percent = after.dig("coverage", "mapped_percent").to_f + raise "#{name} completeness #{percent}% is below #{minimum}%" if percent < minimum + + { + "name" => name, + "before" => before.fetch("coverage").slice("functions", "mapped", "mapped_percent"), + "after" => after.fetch("coverage").slice("functions", "mapped", "mapped_percent"), + "complete_delta" => after_mapped - before_mapped, + "percentage_point_delta" => + (after.dig("coverage", "mapped_percent").to_f - before.dig("coverage", "mapped_percent").to_f).round(2) + } + end + end + + def produce_consumer_index(consumer, name, source_root) + config = consumer.fetch("index") + return checked_file(expanded_path(config), "#{name} SCIP index") if config.is_a?(String) + raise ArgumentError, "#{name} consumer index must be a path or mapping" unless config.is_a?(Hash) + return checked_file(expanded_path(config.fetch("path")), "#{name} SCIP index") if config["path"] + + output = File.join( + @work_dir, + config.fetch("output", "consumer-#{safe_name(name)}.scip") + ) + substitutions = { + "source_root" => source_root, + "work_dir" => @work_dir, + "index" => output, + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root + } + command = expand_command(config.fetch("command"), substitutions) + cwd = expanded_path(config.fetch("working_directory", source_root), substitutions) + env = fetch_hash(config, "environment", required: false) + .transform_values { |value| expand_string(value.to_s, substitutions) } + @runner.run!(command, chdir: cwd, env: env) + checked_file(output, "generated #{name} SCIP index") + end + + def materialize_environment(config, label, substitutions) + return nil unless config + + config = fetch_hash({"environment" => config}, "environment") + if config["path"] + return checked_file( + expanded_path(config.fetch("path"), substitutions), + "#{label} semantic environment" + ) + end + + output = File.join(@work_dir, "#{safe_name(label)}.semantic-environment.json") + if config["claims"] + claims = fetch_hash(config, "claims").to_h do |key, value| + [key.to_s, expand_string(value.to_s, substitutions)] + end + raise ArgumentError, "#{label} semantic environment claims cannot be empty" if claims.empty? + File.write( + output, + JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => claims + }) + ) + else + generated_substitutions = substitutions.merge("environment" => output) + command = expand_command(config.fetch("command"), generated_substitutions) + cwd = expanded_path( + config.fetch("working_directory", substitutions.fetch("source_root")), + generated_substitutions + ) + env = fetch_hash(config, "environment", required: false) + .transform_values { |value| expand_string(value.to_s, generated_substitutions) } + @runner.run!(command, chdir: cwd, env: env) + end + validate_sidecar!( + checked_file(output, "#{label} semantic environment"), + "fact-mine.semantic-environment.v1", + "claims" + ) + end + + def materialize_symbol_bridge(config, substitutions) + return nil unless config + + config = fetch_hash({"bridge" => config}, "bridge") + if config["path"] + path = expanded_path(config.fetch("path"), substitutions) + else + path = File.join(@work_dir, "stdlib.symbol-bridge.json") + generated_substitutions = substitutions.merge("symbol_bridge" => path) + command = expand_command(config.fetch("command"), generated_substitutions) + cwd = expanded_path( + config.fetch("working_directory", substitutions.fetch("source_root")), + generated_substitutions + ) + env = fetch_hash(config, "environment", required: false) + .transform_values { |value| expand_string(value.to_s, generated_substitutions) } + @runner.run!(command, chdir: cwd, env: env) + end + validate_sidecar!( + checked_file(path, "stdlib symbol bridge"), + "fact-mine.symbol-bridge.v1", + "symbols" + ) + end + + def validate_sidecar!(path, schema, mapping_key) + document = JSON.parse(File.read(path)) + raise ArgumentError, "unexpected sidecar schema in #{path}: #{document['schema'].inspect}" unless document["schema"] == schema + + mapping = document[mapping_key] + unless mapping.is_a?(Hash) && !mapping.empty? && + mapping.all? { |key, value| !key.to_s.empty? && !value.to_s.empty? } + raise ArgumentError, "#{path} must contain a non-empty #{mapping_key} string mapping" + end + path + rescue JSON::ParserError => error + raise ArgumentError, "invalid JSON sidecar #{path}: #{error.message}" + end + + def coverage_report(profile, root, consumer) + command = [ + RbConfig.ruby, + File.join(@workspace_root, "gems/espalier/script/check_big_o_coverage.rb"), + "--source-root", root, + "--minimum", "0" + ] + Array(consumer["repositories"]).each { |repository| command.concat(["--repository", repository]) } + command << profile + stdout, stderr, status = @runner.capture(command, chdir: @workspace_root) + raise "coverage report failed: #{stderr}" unless status.success? + + JSON.parse(stdout) + end + + def fact_mine_binary + candidate = @fact_mine_override || ENV["FACT_MINE_RUST"] || + File.join(@workspace_root, "gems/fact-mine/target/release/fact-mine-rust") + checked_file(File.expand_path(candidate), "FactMine binary") + end + + def summary_output + @summary_output_override || expanded_path(fetch_hash(manifest, "summary").fetch("output")) + end + + def publish_summary(staged_summary, output) + directory = File.dirname(output) + FileUtils.mkdir_p(directory) + temporary = File.join( + directory, + ".#{File.basename(output)}.stdlib-map-#{Process.pid}-#{rand(1 << 32)}" + ) + FileUtils.copy_file(staged_summary, temporary) + File.rename(temporary, output) + ensure + FileUtils.rm_f(temporary) if temporary && File.exist?(temporary) + end + + def read_json(path) + if File.extname(path) == ".gz" + JSON.parse(Zlib::GzipReader.open(path, &:read)) + else + JSON.parse(File.read(path)) + end + end + + def checked_file(path, label) + raise ArgumentError, "#{label} not found: #{path}" unless path && File.file?(path) + raise ArgumentError, "#{label} is empty: #{path}" unless File.size?(path) + + path + end + + def fetch_hash(parent, key, required: true) + value = parent[key] + if value.nil? && !required + return {} + end + raise ArgumentError, "#{key} must be a mapping" unless value.is_a?(Hash) + + value + end + + def expanded_path(value, substitutions = {}) + path = expand_string(value.to_s, substitutions) + return File.expand_path(path) if Pathname.new(path).absolute? + + File.expand_path(path, @manifest_dir) + end + + def expand_command(command, substitutions) + Array(command).map { |token| expand_string(token.to_s, substitutions) } + end + + def expand_string(value, substitutions = {}) + builtins = { + "manifest_dir" => @manifest_dir, + "workspace_root" => @workspace_root, + "work_dir" => @work_dir + }.merge(substitutions) + value.gsub(/\{([a-z_]+)\}/) do + builtins.fetch(Regexp.last_match(1)) do + raise ArgumentError, "unknown manifest placeholder {#{Regexp.last_match(1)}}" + end + end + end + + def expand_environment(value) + case value + when Hash + value.to_h { |key, child| [key.to_s, expand_environment(child)] } + when Array + value.map { |child| expand_environment(child) } + when String + value.gsub(/\$\{([A-Z][A-Z0-9_]*)\}/) do + ENV.fetch(Regexp.last_match(1)) do + raise ArgumentError, "environment variable #{Regexp.last_match(1)} is required by #{manifest_path}" + end + end + else + value + end + end + + def safe_name(value) + value.to_s.gsub(/[^a-zA-Z0-9_.-]+/, "-") + end + + def spans_overlap?(left, right) + return false unless left.length == 4 && right.length == 4 + + point_before_or_equal?(left[0], left[1], right[2], right[3]) && + point_before_or_equal?(right[0], right[1], left[2], left[3]) + end + + def point_before_or_equal?(left_line, left_column, right_line, right_column) + left_line = left_line.to_i + right_line = right_line.to_i + left_line < right_line || + (left_line == right_line && left_column.to_i <= right_column.to_i) + end + end +end diff --git a/gems/espalier/lib/espalier/structural_big_o.rb b/gems/espalier/lib/espalier/structural_big_o.rb index 645bb2e50..f924cf8f0 100644 --- a/gems/espalier/lib/espalier/structural_big_o.rb +++ b/gems/espalier/lib/espalier/structural_big_o.rb @@ -100,7 +100,7 @@ def hints_for(_file, method, owner) space: candidate_bound.fetch(:space), is_dynamic: true, operation: message, - reason: "conservative upper bound over compiler-provided implementation candidates", + reason: "conservative upper bound over closed implementation candidates", confidence: "partial", time_complete: true, space_complete: true, @@ -112,6 +112,26 @@ def hints_for(_file, method, owner) } next end + # FactMine already priced this call site (a builtin operator, a + # language intrinsic, or a stdlib-registry hit). Use that proven bound + # directly instead of demanding a resolved project target - otherwise + # a trivially O(1) operator leaves the function unknown. + if (known_time = context["known_time_complexity"]) + hints << { + type: :structural, + line: line, + complexity: propagated_call_complexity(context, known_time), + space: context["known_space_complexity"] || "O(1)", + is_dynamic: known_time != "O(1)", + operation: message, + reason: "fact-mine modeled call cost", + confidence: "high", + time_complete: true, + space_complete: true, + fact_source: "fact_mine" + } + next + end if resolved_target callee_owner = resolved_target[0].to_s callee = resolved_target[1].to_s @@ -127,6 +147,7 @@ def hints_for(_file, method, owner) state_rescan_recursion_summary(owner.to_s, caller) end recursive_bound = state_bound || resolved_recursive_bound(context) + proven = recursive_bound.fetch(:time) != "unknown" hints << { type: :structural, line: line, @@ -135,9 +156,14 @@ def hints_for(_file, method, owner) is_dynamic: true, operation: message, reason: recursive_bound.fetch(:reason), - confidence: recursive_bound[:quality] ? "partial" : "high", - time_complete: true, - space_complete: true, + confidence: if proven + recursive_bound[:quality] ? "partial" : "high" + else + "unknown" + end, + time_complete: proven, + space_complete: proven, + evidence_gaps: proven ? nil : ["unresolved_recursive_progress"], complexity_bound_quality: recursive_bound[:quality], complexity_assumptions: Array(recursive_bound[:assumption]), fact_source: "fact_mine" @@ -169,8 +195,9 @@ def hints_for(_file, method, owner) confidence: mutual ? "high" : "unknown", time_complete: !mutual.nil?, space_complete: !mutual.nil?, + evidence_gaps: mutual ? nil : ["unresolved_recursive_progress"], fact_source: "fact_mine" - } + }.compact end next end @@ -183,8 +210,6 @@ def hints_for(_file, method, owner) callee_bound_qualities = Array(summary_value(@method_bound_qualities, callee_id, callee_owner, callee)) callee_assumptions = Array(summary_value(@method_assumptions, callee_id, callee_owner, callee)) next unless callee_complexity || callee_space - next if callee_complexity == "O(1)" && (!callee_space || callee_space == "O(1)") && - callee_time_complete && callee_space_complete propagated_symbolic = propagated_call_symbolic( callee_owner, @@ -196,16 +221,17 @@ def hints_for(_file, method, owner) receiver_state_dependent: receiver_state_dependent?(callee_owner, callee) ) rendered_symbolic = Espalier::SymbolicComplexity.render(propagated_symbolic)&.first + propagated_complexity = rendered_symbolic || propagated_call_complexity( + context, + callee_complexity || "O(1)", + receiver_state_dependent: receiver_state_dependent?(callee_owner, callee) + ) hints << { type: :structural, line: context.fetch("line", method[:line]).to_i, - complexity: rendered_symbolic || propagated_call_complexity( - context, - callee_complexity || "O(1)", - receiver_state_dependent: receiver_state_dependent?(callee_owner, callee) - ), + complexity: propagated_complexity, space: callee_space, - is_dynamic: true, + is_dynamic: propagated_complexity != "O(1)" || callee_space.to_s != "O(1)", operation: context["message"], reason: "normalized call containment and propagated callee complexity", confidence: "high", @@ -274,8 +300,16 @@ def candidate_upper_bound(candidate_call, context) [propagated_call_complexity(context, time), space] end - source_qualities = ids.flat_map { |id| Array(@method_bound_qualities[id]) } - source_assumptions = ids.flat_map { |id| Array(@method_assumptions[id]) } + if candidate_call[:external_time] && candidate_call[:external_space] + rows << [ + propagated_call_complexity(context, candidate_call[:external_time]), + candidate_call[:external_space] + ] + end + source_qualities = ids.flat_map { |id| Array(@method_bound_qualities[id]) } + + Array(candidate_call[:qualities]) + source_assumptions = ids.flat_map { |id| Array(@method_assumptions[id]) } + + Array(candidate_call[:assumptions]) closed_set_assumption = "#{candidate_call[:reason]} implementation set is closed for this analysis" { ids: ids.sort, @@ -321,6 +355,15 @@ def propagated_call_symbolic(owner, callee, callee_id, caller_fact, context, cal mapping[domain["id"]] = Array(actual) if domain && actual end end + # Substitute the callee's callback cost C with the cost of the callable + # passed at this call site, under the same rule the externally-parametric + # path uses. + callable = callback_argument_cost(caller_fact["path"], context["span"]) + if callable + callee_symbolic = Espalier::SymbolicComplexity.substitute_callback_cost( + callee_symbolic, callable.fetch(:expression), callable_constant: callable.fetch(:constant) + ) + end substituted = Espalier::SymbolicComplexity.substitute( callee_symbolic, mapping, @@ -351,6 +394,23 @@ def propagated_call_symbolic(owner, callee, callee_id, caller_fact, context, cal end end + # The cost of the callable passed at one call site, as the shared + # substitution rule wants it. + def callback_argument_cost(path, span) + ids = Array(@callback_arg_by_call && @callback_arg_by_call[[path, normalized_call_span(span)]]) + return nil if ids.empty? + + Espalier::SymbolicComplexity.worst_callable( + ids.map do |id| + key = id.to_s + { + expression: @method_symbolic_time && @method_symbolic_time[key], + constant: @method_complexities[key] == "O(1)" && @method_time_complete[key] != false + } + end + ) + end + def annotate_propagated_domains(expression, callee_expression, mapping, caller_domains, owner, callee, caller_fact, context) return expression unless expression @@ -373,7 +433,7 @@ def annotate_propagated_domains(expression, callee_expression, mapping, caller_d "line" => context["line"] }.compact end - Espalier::SymbolicComplexity.normalize(expression.merge(domains: domains)) + Espalier::SymbolicComplexity.with_domains(expression, domains) end def mutual_recursion_summary(owner, member) @@ -666,6 +726,9 @@ def summary_hint(fact, method, owner = nil, suppress_syntactic_recursion: false) state_replay = owner && state_replay_recursion_summary(owner, method[:name].to_s) state_rescan = owner && state_rescan_recursion_summary(owner, method[:name].to_s) state_recursion = state_replay || state_rescan + structural_recursion = !state_recursion && + recursion.fetch("structural_calls", 0).to_i.positive? && + recursion.fetch("unknown_progress_calls", 0).to_i.zero? recursion_time, recursion_space, recursion_reason = if state_recursion state_recursion.values_at(:time, :space, :reason) else @@ -688,14 +751,23 @@ def summary_hint(fact, method, owner = nil, suppress_syntactic_recursion: false) is_dynamic: complexity != "O(1)", operation: "normalized_complexity_facts", reason: recursion_reason || iteration_reason(iterations), - confidence: complexity == "unknown" ? "unknown" : "high", + confidence: if complexity == "unknown" + "unknown" + elsif structural_recursion + "partial" + else + "high" + end, time_complete: complexity != "unknown" && (!symbolic_time || symbolic_time.fetch(:complete, true)), space_complete: max_space_complexity(allocation_space, recursion_space) != "unknown", evidence_gaps: evidence_gaps.uniq.sort, symbolic_time: symbolic_time, symbolic_space: symbolic_space, + complexity_bound_quality: structural_recursion ? "upper_bound_structural_descent" : nil, + complexity_assumptions: structural_recursion ? + ["the traversed input structure is finite and acyclic, so descent terminates"] : nil, fact_source: "fact_mine" - } + }.compact end def allocation_complexity(allocations, domains) @@ -731,6 +803,9 @@ def recursion_complexity(recursion, parameter_count) if visited.positive? return ["O(N)", "O(N)", "visited-set guarded structural recursion"] end + if recursion.fetch("structural_calls", 0).to_i.positive? + return ["O(N)", "O(N)", "recursive descent into a projection of the input"] + end shrinking = recursion.fetch("shrinking_calls", 0).to_i halving = recursion.fetch("halving_calls", 0).to_i @@ -840,6 +915,38 @@ def resolved_recursive_bound(context) if multiplicity == "O(1)" && progress == "shrinking" return { time: "O(N)", space: "O(N)", reason: "exact recursive edge with normalized shrinking progress" } end + # Descending into a projection of the input - `walk(node.left)` - moves + # strictly one level down a finite structure, so every node is reached + # once and the work is linear in the structure. This holds however many + # continuations the body has: two children still visit N nodes total. + # The bound is conditional on the traversed graph being acyclic, which is + # recorded rather than assumed silently. + # Flat in the structure, exactly as `partition_of` below: the enclosing + # loop is what enumerates the children, and the descent visits each + # child's subtree once, so multiplying by the loop multiplicity would + # count the same N nodes twice. It also keeps the bound out of the middle + # of the lattice, where it would widen again on every fixpoint round. + if progress == "structural" + return { + time: "O(N)", space: "O(N)", + reason: "recursive descent into a projection of the input reaches each element once", + quality: "upper_bound_structural_descent", + assumption: "the traversed input structure is finite and acyclic, so descent terminates" + } + end + # A recursive edge whose argument is a partition of the receiver - the + # loop's own iteration binding over a decomposition, i.e. a tree/graph + # traversal - reaches every element exactly once, so the work is linear + # in the structure. This proof outranks the multiplicity reading below: + # a traversal argument that merely happens to spell an offset + # (`walk(kids[i - 1])`) reads as loop-contained shrinking progress and + # would otherwise be priced O(N!). + if context["argument_cardinality_relation"] == "partition_of" + return { + time: "O(N)", space: "O(N)", + reason: "recursive traversal over a partition of the input reaches each element once" + } + end if %w[halving shrinking].include?(progress) return { time: "O(N!)", space: "O(N)", @@ -848,11 +955,14 @@ def resolved_recursive_bound(context) } end + # Nothing proved this edge makes progress, and one call context cannot + # establish a branching factor, so no bound follows - not even an + # exponential one. Report the missing proof the way the syntactic + # classifier does, so the gap is attributable instead of being published + # as a complete result. { - time: "O(2^N)", space: "O(N)", - reason: "conservative upper bound for an exact project recursive component", - quality: "upper_bound_acyclic_project_scc", - assumption: "the reachable input object graph is finite and acyclic; repeated subgraphs are conservatively treated as independent recursive branches" + time: "unknown", space: "unknown", + reason: "exact recursive edge progress is unknown" } end diff --git a/gems/espalier/lib/espalier/symbolic_complexity.rb b/gems/espalier/lib/espalier/symbolic_complexity.rb index 53e8816d1..027f78a47 100644 --- a/gems/espalier/lib/espalier/symbolic_complexity.rb +++ b/gems/espalier/lib/espalier/symbolic_complexity.rb @@ -7,6 +7,7 @@ module SymbolicComplexity module_function LETTERS = %w[N M K L P Q R S T U V W X Y Z].freeze + RENDER_DOMAIN_LIMIT = 128 def reset_intern_pool! @term_pool = {} @@ -110,6 +111,23 @@ def multiply(left, right) ) end + # Replace only domain metadata on an already-normalized expression. Terms + # are immutable and interned, so rerunning pairwise dominance elimination + # here is both redundant and quadratic in the number of monomials. + def with_domains(expression, domains) + return nil unless expression + + canonical_domains = intern_domains(domains || {}) + canonical = expression.merge(domains: canonical_domains).freeze + @expression_pool ||= {} + key = [ + canonical.fetch(:terms), + canonical_domains, + canonical.fetch(:complete, true) + ] + @expression_pool[key] ||= canonical + end + def substitute(expression, mapping, caller_domains: {}) return nil unless expression @@ -139,10 +157,84 @@ def substitute(expression, mapping, caller_domains: {}) ) end + # Drop the given domain ids from an expression, treating them as constant. + # Resolves a callback C to an O(1) callable: O(N*C) -> O(N). + def without_domains(expression, ids) + return expression if expression.nil? || Array(ids).empty? + + drop = Array(ids).map(&:to_s) + terms = Array(expression[:terms]).map do |term| + { + factors: term[:factors].reject { |id, _| drop.include?(id.to_s) }, + logs: term[:logs].reject { |id, _| drop.include?(id.to_s) } + } + end + normalize( + terms: terms, + domains: (expression[:domains] || {}).reject { |id, _| drop.include?(id.to_s) }, + complete: expression.fetch(:complete, true) + ) + end + + # Ids of the open callback-cost parameters in an expression. Reflective + # costs are deliberately excluded: no callable is passed at those sites, so + # nothing can close them. + def callback_domain_ids(expression) + (expression && expression[:domains] || {}).filter_map do |id, domain| + next unless domain.is_a?(Hash) + + id if (domain["source_kind"] || domain[:source_kind]).to_s == "callback_cost" + end + end + + # Choose the substitution for a call site that several callables reach + # (`fold(seed, |a, b| ...)`, a nesting chain). The bound must hold for all + # of them, so one callable of unknown cost forces C to stay open; otherwise + # the costliest known cost is the substitution. + # + # Each row is `{ expression:, constant: }` - the callable's symbolic cost, + # and whether it is proven constant. + def worst_callable(rows) + rows = Array(rows) + return nil if rows.empty? + return { expression: nil, constant: false } if rows.any? do |row| + row[:expression].nil? && !row[:constant] + end + + { + expression: rows.filter_map { |row| row[:expression] }.max_by { |value| degree(value) }, + constant: true + } + end + + # Replace an open callback cost C with the cost of the callable actually + # passed at that call site. An O(1) callable removes C (O(N*C) -> O(N)); a + # costlier one multiplies its own cost in (O(N*C) -> O(N*M)). + # + # A callable whose own cost is neither symbolically known nor proven + # constant leaves C open. Dropping it there would silently price a + # non-constant callback as free. + def substitute_callback_cost(expression, callable, callable_constant: false) + ids = callback_domain_ids(expression) + return expression if ids.empty? + return expression unless callable || callable_constant + + reduced = without_domains(expression, ids) + return reduced unless callable && degree(callable).positive? + + multiply(reduced, callable) + end + def render(expression) return nil unless expression - ids = Array(expression[:terms]).flat_map { |term| term[:factors].keys + term[:logs].keys }.uniq + terms = Array(expression[:terms]) + factor_entries = terms.sum { |term| term[:factors].length + term[:logs].length } + if terms.length > RENDER_DOMAIN_LIMIT || factor_entries > RENDER_DOMAIN_LIMIT + return render_collapsed_upper_bound(expression, terms) + end + + ids = terms.flat_map { |term| term[:factors].keys + term[:logs].keys }.uniq ids.sort_by! do |id| containing_terms = Array(expression[:terms]).select { |term| term[:factors].key?(id) || term[:logs].key?(id) } exponents = containing_terms.map { |term| term[:factors].fetch(id, 0) } @@ -196,6 +288,68 @@ def render(expression) ["O(#{bodies.join(' + ')})", variables(expression, symbols)] end + # A function has a fixed number of input domains, so a sum over many + # independent domains is bounded by the largest domain raised to the + # greatest observed degree (constant coefficients disappear in Big-O). + # Preserve callback/reflection parameters as C/R so proof-tier + # classification remains parametric rather than silently closing them. + def render_collapsed_upper_bound(expression, terms) + maxima = { size: 0, size_logs: 0, callback: 0, reflection: 0 } + ids = { size: {}, callback: {}, reflection: {} } + domains = expression[:domains] || {} + terms.each do |term| + row = { size: 0, size_logs: 0, callback: 0, reflection: 0 } + term[:factors].each do |id, exponent| + domain = domains.fetch(id, {}) + kind = (domain["source_kind"] || domain[:source_kind]).to_s + bucket = if kind == "reflective_target_cost" + :reflection + elsif kind.end_with?("_cost") + :callback + else + :size + end + row[bucket] += exponent + ids[bucket][id] = true + end + term[:logs].each do |id, exponent| + row[:size_logs] += exponent + ids[:size][id] = true + end + maxima.each_key { |key| maxima[key] = [maxima[key], row[key]].max } + end + + parts = [] + parts << power_text("N", maxima[:size]) if maxima[:size].positive? + parts << power_text("log N", maxima[:size_logs], grouped: true) if maxima[:size_logs].positive? + parts << power_text("C", maxima[:callback]) if maxima[:callback].positive? + parts << power_text("R", maxima[:reflection]) if maxima[:reflection].positive? + parts << "1" if parts.empty? + variables = [] + [ + [:size, "N", "size"], + [:callback, "C", "callback cost"], + [:reflection, "R", "reflective target cost"] + ].each do |bucket, symbol, label| + next if ids[bucket].empty? + + variables << { + symbol: symbol, + domain_id: "collapsed:#{bucket}", + name: "maximum of #{ids[bucket].length} #{label} domains", + source_kind: "collapsed_upper_bound", + domain_count: ids[bucket].length + } + end + ["O(#{parts.join('*')})", variables] + end + + def power_text(symbol, exponent, grouped: false) + return symbol if exponent == 1 + + grouped ? "(#{symbol})^#{exponent}" : "#{symbol}^#{exponent}" + end + def variables(expression, symbols = nil) symbols ||= begin ids = Array(expression[:terms]).flat_map { |term| term[:factors].keys + term[:logs].keys }.uniq.sort @@ -212,7 +366,8 @@ def variables(expression, symbols = nil) span: domain["span"] || domain[:span], origin_owner: domain["origin_owner"] || domain[:origin_owner], origin_function: domain["origin_function"] || domain[:origin_function], - propagated_via: domain["propagated_via"] || domain[:propagated_via] + propagated_via: domain["propagated_via"] || domain[:propagated_via], + domain_count: domain["domain_count"] || domain[:domain_count] }.compact end end @@ -244,14 +399,20 @@ def rank_string(value) end def normalize(expression) - terms = Array(expression[:terms]).map do |term| + raw_terms = Array(expression[:terms]) + factor_entries = raw_terms.sum { |term| term[:factors].length + (term[:logs] || {}).length } + if raw_terms.length > RENDER_DOMAIN_LIMIT || factor_entries > RENDER_DOMAIN_LIMIT + return collapse_expression(expression, raw_terms) + end + + terms = raw_terms.map do |term| intern_term( term[:factors].select { |_, exponent| exponent.to_i.positive? }.transform_values(&:to_i), (term[:logs] || {}).select { |_, exponent| exponent.to_i.positive? }.transform_values(&:to_i) ) end.uniq terms.reject! do |candidate| - terms.any? { |other| other != candidate && dominates?(other, candidate) } + terms.any? { |other| !other.equal?(candidate) && dominates?(other, candidate) } end canonical_terms = terms.sort_by { |term| [term[:factors].to_a, term[:logs].to_a] }.freeze canonical_domains = intern_domains(expression[:domains] || {}) @@ -261,6 +422,61 @@ def normalize(expression) @expression_pool[key] ||= canonical end + def collapse_expression(expression, terms) + source_domains = expression[:domains] || {} + maxima = { size: 0, size_logs: 0, callback: 0, reflection: 0 } + ids = { size: {}, callback: {}, reflection: {} } + terms.each do |term| + row = { size: 0, size_logs: 0, callback: 0, reflection: 0 } + term[:factors].each do |id, exponent| + domain = source_domains.fetch(id, {}) + kind = (domain["source_kind"] || domain[:source_kind]).to_s + bucket = if kind == "reflective_target_cost" + :reflection + elsif kind.end_with?("_cost") + :callback + else + :size + end + row[bucket] += exponent.to_i + ids[bucket][id] = true + end + (term[:logs] || {}).each do |id, exponent| + row[:size_logs] += exponent.to_i + ids[:size][id] = true + end + maxima.each_key { |key| maxima[key] = [maxima[key], row[key]].max } + end + + factors = {} + factors["collapsed:size"] = maxima[:size] if maxima[:size].positive? + factors["collapsed:callback"] = maxima[:callback] if maxima[:callback].positive? + factors["collapsed:reflection"] = maxima[:reflection] if maxima[:reflection].positive? + logs = {} + logs["collapsed:size"] = maxima[:size_logs] if maxima[:size_logs].positive? + canonical_terms = [intern_term(factors, logs)].freeze + domains = {} + [ + [:size, "size", "collapsed_upper_bound"], + [:callback, "callback cost", "callback_cost"], + [:reflection, "reflective target cost", "reflective_target_cost"] + ].each do |bucket, label, source_kind| + next if ids[bucket].empty? + + domains["collapsed:#{bucket}"] = { + "id" => "collapsed:#{bucket}", + "name" => "maximum of #{ids[bucket].length} #{label} domains", + "source_kind" => source_kind, + "domain_count" => ids[bucket].length + } + end + canonical_domains = intern_domains(domains) + canonical = expression.merge(terms: canonical_terms, domains: canonical_domains).freeze + @expression_pool ||= {} + key = [canonical_terms, canonical_domains, canonical.fetch(:complete, true)] + @expression_pool[key] ||= canonical + end + def intern_term(factors, logs) @term_pool ||= {} canonical_factors = factors.sort.to_h.freeze @@ -272,9 +488,19 @@ def intern_term(factors, logs) def intern_domains(domains) @domain_pool ||= {} canonical = domains.sort_by { |id, _| id.to_s }.to_h do |id, domain| - [id.to_s.freeze, deep_freeze_copy(domain)] - end.freeze - @domain_pool[canonical] ||= canonical + [id.to_s.freeze, intern_domain_value(domain)] + end + @domain_pool[canonical] ||= canonical.freeze + end + + # Deep-freeze a single domain once and reuse it across every expression that + # references the same domain. `normalize` used to re-`deep_freeze_copy` every + # domain on every call, which dominated GC; the per-domain pool collapses that + # to one copy per distinct domain (facts are read-only, so keying on the raw + # domain hash is safe). + def intern_domain_value(domain) + @domain_value_pool ||= {} + @domain_value_pool[domain] ||= deep_freeze_copy(domain) end def deep_freeze_copy(value) @@ -284,7 +510,9 @@ def deep_freeze_copy(value) when Array value.map { |child| deep_freeze_copy(child) }.freeze when String - value.dup.freeze + # `-string` returns a frozen, de-duplicated copy (shared fstring), which + # is cheaper and lower-churn than dup+freeze for the repeated domain text. + -value else value.freeze end diff --git a/gems/espalier/script/check_big_o_coverage.rb b/gems/espalier/script/check_big_o_coverage.rb new file mode 100755 index 000000000..96530d83a --- /dev/null +++ b/gems/espalier/script/check_big_o_coverage.rb @@ -0,0 +1,111 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Enforce production function-level Big-O coverage for a FactMine profile. +# The report separates proof tiers and fails closed when FactMine reports raw +# executable calls that did not reach normalized call facts. + +require "json" +require "optparse" +require "pathname" + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "espalier" + +options = { + source_root: nil, + repositories: [], + minimum: Espalier::BigOProofMetrics::DEFAULT_MINIMUM_PERCENT, + include_lambdas: true +} +OptionParser.new do |parser| + parser.banner = "Usage: check_big_o_coverage.rb --source-root PATH [options] PROFILE.json" + parser.on("--source-root PATH", "Root used to select and relativize profile paths") do |path| + options[:source_root] = File.expand_path(path) + end + parser.on("--repository NAME", "Limit scope to a direct child of SOURCE_ROOT (repeatable)") do |name| + options[:repositories] << name + end + parser.on("--minimum PERCENT", Float, "Required mapped production functions (default: 85)") do |value| + options[:minimum] = value + end + parser.on("--exclude-lambdas", "Exclude lambda/closure methods from the denominator") do + options[:include_lambdas] = false + end +end.parse! + +abort "expected one FactMine profile" unless ARGV.length == 1 +abort "--source-root is required" unless options[:source_root] + +profile = JSON.parse(File.read(ARGV.fetch(0))) +source_root = options.fetch(:source_root) +absolute_profile_path = lambda do |path| + path = path.to_s + Pathname.new(path).absolute? ? path : File.expand_path(path, source_root) +end +prefixes = + if options[:repositories].empty? + ["#{source_root}/"] + else + options[:repositories].map { |repository| "#{File.join(source_root, repository)}/" } + end +in_scope = lambda do |row| + prefixes.any? { |prefix| absolute_profile_path.call(row.fetch("path", "")).start_with?(prefix) } +end +select_path = ->(rows) { Array(rows).select(&in_scope) } +methods = select_path.call(profile["methods"]) +method_roles = Espalier::StaticEvidence.method_source_roles(methods) +production_methods = methods.select do |method| + method_roles.fetch(method.fetch("id").to_s) == "production" +end +method_ids = production_methods.to_h { |method| [method.fetch("id"), true] } +paths = methods.map { |method| method.fetch("path") }.uniq +evidence = { + "root" => source_root, + "input_coverage" => profile["input_coverage"], + "files" => paths.map do |path| + { "path" => path, "source_role" => Espalier::StaticEvidence.source_role(path) } + end, + "owners" => select_path.call(profile["owners"]), + "methods" => production_methods, + "fields" => select_path.call(profile["fields"]), + "facts" => { + "calls" => Array(profile["calls"]).select { |call| method_ids[call["source"]] }, + "complexity_facts" => select_path.call(profile["complexity_facts"]), + "state_accesses" => select_path.call(profile["state_accesses"]), + "struct_declarations" => select_path.call(profile["struct_declarations"]), + "state_protocol_records" => select_path.call(profile["state_protocol_records"]), + "state_param_origin_records" => select_path.call(profile["state_param_origin_records"]) + } +} + +modules = Espalier::StaticEvidence.project_modules(evidence, source_roles: ["production"]) +manifest = Espalier::Aggregator.new.aggregate(modules) +method_by_id = methods.to_h { |method| [method.fetch("id").to_s, method] } +rows = manifest.flat_map do |mod| + Array(mod[:functions]).filter_map do |function| + method = method_by_id[function[:id].to_s] + next unless method + + { + source_role: method_roles.fetch(method.fetch("id").to_s), + language: method["language"] || mod[:language], + kind: method["kind"], + quality: function.fetch(:quality_metrics, {}) + } + end +end + +report = Espalier::BigOProofMetrics.coverage_gate( + rows, + call_coverage: profile["call_resolution_coverage"] || {}, + minimum_percent: options.fetch(:minimum), + include_lambdas: options.fetch(:include_lambdas) +) +report[:scope] = { + source_root: source_root, + repositories: options[:repositories].sort, + profile: File.expand_path(ARGV.fetch(0)) +} +puts JSON.pretty_generate(report) +exit(report[:passed] ? 0 : 1) diff --git a/gems/espalier/script/compare_scip_big_o.rb b/gems/espalier/script/compare_scip_big_o.rb index 4aab81902..23a58bc2a 100755 --- a/gems/espalier/script/compare_scip_big_o.rb +++ b/gems/espalier/script/compare_scip_big_o.rb @@ -6,6 +6,7 @@ require "json" require "optparse" +require "pathname" require "set" $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) @@ -23,14 +24,34 @@ scip_profile = JSON.parse(File.read(ARGV[1])) source_root = options[:source_root] -def evidence_for(profile, prefix) - methods = Array(profile["methods"]).select { |row| row.fetch("path").start_with?(prefix) } +def absolute_profile_path(path, source_root) + text = path.to_s + return text unless source_root && !Pathname.new(text).absolute? + + File.expand_path(text, source_root) +end + +def under_profile_prefix?(path, prefix, source_root) + absolute_profile_path(path, source_root).start_with?(prefix) +end + +def evidence_for(profile, prefix, source_root) + methods = Array(profile["methods"]).select do |row| + under_profile_prefix?(row.fetch("path"), prefix, source_root) + end method_ids = methods.to_h { |row| [row.fetch("id"), true] } paths = methods.map { |row| row.fetch("path") }.uniq - select_path = ->(rows) { Array(rows).select { |row| row.fetch("path", "").start_with?(prefix) } } + select_path = lambda do |rows| + Array(rows).select do |row| + under_profile_prefix?(row.fetch("path", ""), prefix, source_root) + end + end { - "root" => prefix, - "files" => paths.map { |path| { "path" => path, "source_role" => "production" } }, + "root" => source_root, + "input_coverage" => profile["input_coverage"], + "files" => paths.map do |path| + { "path" => path, "source_role" => Espalier::StaticEvidence.source_role(path) } + end, "owners" => select_path.call(profile["owners"]), "methods" => methods, "fields" => select_path.call(profile["fields"]), @@ -45,20 +66,31 @@ def evidence_for(profile, prefix) } end -def function_rows(profile, prefixes) +def function_rows(profile, prefixes, source_root) + methods = Array(profile["methods"]).to_h { |method| [method.fetch("id").to_s, method] } prefixes.each_with_object({}) do |(repository, prefix), rows| - evidence = evidence_for(profile, prefix) - modules = Espalier::StaticEvidence.project_modules(evidence) + evidence = evidence_for(profile, prefix, source_root) + modules = Espalier::StaticEvidence.project_modules(evidence, source_roles: ["production"]) Espalier::Aggregator.new.aggregate(modules).each do |mod| Array(mod[:functions]).each do |function| + method = methods[function[:id].to_s] + next unless method + quality = function.fetch(:quality_metrics, {}) - key = [mod[:file], function[:span], mod[:module], function[:name]] + # A reopenable Ruby module can span several files. `mod[:file]` is an + # aggregate container choice and may change with profile ordering; + # method identity must instead use the FactMine declaration path. + key = [method.fetch("path"), function[:span], mod[:module], function[:name]] rows[key] = { repository: repository, file: mod[:file], owner: mod[:module], name: function[:name], span: function[:span], + language: method["language"], + kind: method["kind"], + source_role: Espalier::StaticEvidence.source_role(method["path"]), + quality: quality, time_complete: quality[:big_o_complete], space_complete: quality[:big_o_space_complete], big_o: quality[:big_o], @@ -75,22 +107,42 @@ def function_rows(profile, prefixes) def counts(rows) bound_quality_counts = rows.flat_map { |row| row[:bound_qualities] }.tally.sort.to_h + known = rows.count { |row| row[:time_complete] } { functions: rows.length, - time_known: rows.count { |row| row[:time_complete] }, - time_unknown: rows.count { |row| !row[:time_complete] }, + time_known: known, + time_known_percent: rows.empty? ? 0.0 : (known * 100.0 / rows.length).round(2), + time_unknown: rows.length - known, space_known: rows.count { |row| row[:space_complete] }, space_unknown: rows.count { |row| !row[:space_complete] }, - bound_quality_counts: bound_quality_counts + bound_quality_counts: bound_quality_counts, + proof: Espalier::BigOProofMetrics.summarize(rows.map { |row| row[:quality] }) } end +def call_resolution(coverage) + coverage ||= {} + %w[ + eligible_call_sites + exact_project_targets + modeled_without_project_target + semantically_accounted_call_sites + semantically_accounted_call_percent + unresolved_call_sites + raw_parser_call_sites + raw_calls_not_normalized + raw_calls_not_normalized_inside_function + normalized_calls_without_raw_span + ].to_h { |key| [key, coverage.fetch(key, 0)] } +end + paths = Array(baseline_profile["methods"]).map { |method| method.fetch("path") } repositories = options[:repositories] if repositories.empty? abort "--source-root is required when repositories are not explicit" unless source_root repositories = paths.filter_map do |path| - path.delete_prefix("#{source_root}/").split("/", 2).first if path.start_with?("#{source_root}/") + absolute = absolute_profile_path(path, source_root) + absolute.delete_prefix("#{source_root}/").split("/", 2).first if absolute.start_with?("#{source_root}/") end.uniq.sort end prefixes = repositories.to_h do |repository| @@ -98,8 +150,8 @@ def counts(rows) [repository, prefix] end -baseline = function_rows(baseline_profile, prefixes) -enhanced = function_rows(scip_profile, prefixes) +baseline = function_rows(baseline_profile, prefixes, source_root) +enhanced = function_rows(scip_profile, prefixes, source_root) abort "profile function sets differ" unless baseline.keys.to_set == enhanced.keys.to_set changed = baseline.keys.filter_map do |key| @@ -109,8 +161,18 @@ def counts(rows) end summary = { + schema: "espalier.scip-big-o-comparison.v1", + policy: { + source_roles: ["production"], + include_lambdas: true, + raw_call_normalization: "reported_fail_closed_by_coverage_gate" + }, baseline: counts(baseline.values), scip: counts(enhanced.values), + call_resolution: { + baseline: call_resolution(baseline_profile["call_resolution_coverage"]), + scip: call_resolution(scip_profile["call_resolution_coverage"]) + }, by_repository: repositories.to_h do |repository| before = baseline.values.select { |row| row[:repository] == repository } after = enhanced.values.select { |row| row[:repository] == repository } diff --git a/gems/espalier/script/diagnose_big_o_gaps.rb b/gems/espalier/script/diagnose_big_o_gaps.rb index af480ea88..a475d252e 100755 --- a/gems/espalier/script/diagnose_big_o_gaps.rb +++ b/gems/espalier/script/diagnose_big_o_gaps.rb @@ -8,6 +8,7 @@ require "json" require "optparse" +require "pathname" require "set" $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) @@ -33,9 +34,20 @@ end.uniq.sort end prefixes = repositories.map { |repository| File.join(source_root, repository, "") } -in_scope = ->(row) { prefixes.any? { |prefix| row.fetch("path", "").start_with?(prefix) } } +absolute_profile_path = lambda do |path| + path = path.to_s + Pathname.new(path).absolute? ? path : File.expand_path(path, source_root) +end +in_scope = lambda do |row| + absolute = absolute_profile_path.call(row.fetch("path", "")) + prefixes.any? { |prefix| absolute.start_with?(prefix) } +end -methods = Array(profile["methods"]).select(&in_scope) +scoped_methods = Array(profile["methods"]).select(&in_scope) +method_roles = Espalier::StaticEvidence.method_source_roles(scoped_methods) +methods = scoped_methods.select do |method| + method_roles.fetch(method.fetch("id").to_s) == "production" +end method_ids = methods.to_h { |method| [method.fetch("id"), method] } calls = Array(profile["calls"]).select { |call| method_ids.key?(call["source"]) } facts = Array(profile["complexity_facts"]).select(&in_scope) @@ -43,7 +55,9 @@ select_path = ->(rows) { Array(rows).select(&in_scope) } evidence = { "root" => source_root, - "files" => paths.map { |path| { "path" => path, "source_role" => "production" } }, + "files" => paths.map do |path| + { "path" => path, "source_role" => Espalier::StaticEvidence.source_role(path) } + end, "owners" => select_path.call(profile["owners"]), "methods" => methods, "fields" => select_path.call(profile["fields"]), @@ -90,6 +104,7 @@ call["complexity_missing_kind"] || case call["external_symbol_scope"] when "stdlib" then "stdlib_cost_model_missing" when "dependency" then "dependency_cost_model_missing" + when "project" then "project_candidate_summary_missing" else "external_cost_model_missing" end end @@ -101,8 +116,10 @@ "modeled_nonproject_call" elsif !call["semantic_symbol"].to_s.empty? external_category.call(call) - else + elsif call["runtime_evidence_observed"] == true "semantic_identity_missing" + else + "runtime_callsite_unobserved" end end @@ -138,7 +155,8 @@ next if call["known_time_complexity"] || call["known_space_complexity"] category = if call["semantic_symbol"].to_s.empty? - "semantic_identity_missing" + call["runtime_evidence_observed"] == true ? + "semantic_identity_missing" : "runtime_callsite_unobserved" else external_category.call(call) end @@ -183,6 +201,7 @@ end priority = %w[ + runtime_callsite_unobserved semantic_identity_missing callback_cost_missing reflective_target_cost_missing @@ -225,11 +244,22 @@ functions: symbol_calls.map { |call| call["source"] }.uniq.length } end.sort_by { |row| [-row[:functions], -row[:calls], row[:symbol].to_s] }.first(15) + call_examples = rows.first(options[:examples]).map do |call| + { + path: call["path"], + line: call["line"], + receiver: call["receiver"], + message: call["message"], + semantic_symbol: call["semantic_symbol"], + unresolved_reason: call["unresolved_reason"] + } + end [category, { affected_incomplete_functions: affected.length, direct_incomplete_functions: direct.length, direct_call_sites: rows.length, top_symbols: top_symbols, + call_examples: call_examples, examples: (direct.empty? ? affected : direct).first(options[:examples]).map(&location) }] end @@ -284,7 +314,11 @@ report = { schema: "espalier.big-o-gap-diagnostics.v1", - scope: { source_root: source_root, repositories: repositories }, + scope: { + source_root: source_root, + repositories: repositories, + source_roles: ["production"] + }, summary: { functions: results.length, complete_time_bounds: results.count { |_, result| result[:complete] }, diff --git a/gems/espalier/script/export_complexity_summary.rb b/gems/espalier/script/export_complexity_summary.rb index 1bd544fa8..b158f2402 100644 --- a/gems/espalier/script/export_complexity_summary.rb +++ b/gems/espalier/script/export_complexity_summary.rb @@ -6,13 +6,85 @@ # excluded/generated/dependency methods into the product's reported corpus. require "json" +require "digest" +require "optparse" +require "zlib" $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "espalier" -abort "usage: export_complexity_summary.rb PROFILE.json [OUTPUT.json]" unless (1..2).cover?(ARGV.length) +metadata = { + producer_version: Espalier.const_defined?(:VERSION) ? Espalier::VERSION : "unknown", + corpus: nil, + source_revision: nil, + indexer: nil, + consumer_indexers: [], + symbol_prefix_from: nil, + symbol_prefix_to: nil, + compatibility: nil, + symbol_map: nil +} +OptionParser.new do |opts| + opts.banner = "usage: export_complexity_summary.rb [options] PROFILE.json [OUTPUT.json[.gz]]" + opts.on("--corpus ID", "Stable corpus identity (for example go-stdlib)") { |value| metadata[:corpus] = value } + opts.on("--source-revision REV", "Source commit or release") { |value| metadata[:source_revision] = value } + opts.on("--indexer ID", "SCIP indexer and version") { |value| metadata[:indexer] = value } + opts.on("--consumer-indexer ID", "Compatible consumer SCIP indexer and version (repeatable)") do |value| + metadata[:consumer_indexers] << value + end + opts.on("--producer-version VERSION", "Override the Espalier producer version") { |value| metadata[:producer_version] = value } + opts.on("--symbol-prefix-from PREFIX", "Relocate producer symbols from this exact prefix") do |value| + metadata[:symbol_prefix_from] = value + end + opts.on("--symbol-prefix-to PREFIX", "Relocate producer symbols to this exact prefix") do |value| + metadata[:symbol_prefix_to] = value + end + opts.on("--compatibility FILE", "Semantic-environment sidecar required by consumers") do |value| + metadata[:compatibility] = value + end + opts.on("--symbol-map FILE", "Exact producer-to-consumer symbol bridge") do |value| + metadata[:symbol_map] = value + end +end.parse! +abort "usage: export_complexity_summary.rb [options] PROFILE.json [OUTPUT.json[.gz]]" unless (1..2).cover?(ARGV.length) +if metadata[:symbol_prefix_from].nil? != metadata[:symbol_prefix_to].nil? + abort "--symbol-prefix-from and --symbol-prefix-to must be supplied together" +end +abort "--symbol-map cannot be combined with prefix relocation" if metadata[:symbol_map] && metadata[:symbol_prefix_from] -profile = JSON.parse(File.read(ARGV.fetch(0))) +compatibility_claims = {} +if metadata[:compatibility] + environment = JSON.parse(File.read(metadata[:compatibility])) + unless environment["schema"] == "fact-mine.semantic-environment.v1" + abort "unsupported semantic environment schema: #{environment['schema'].inspect}" + end + compatibility_claims = environment.fetch("claims") + unless compatibility_claims.is_a?(Hash) && + compatibility_claims.all? { |key, value| !key.to_s.empty? && !value.to_s.empty? } + abort "semantic environment claims must be a mapping of non-empty strings" + end +end + +symbol_map = nil +symbol_map_sha256 = nil +if metadata[:symbol_map] + symbol_map_bytes = File.binread(metadata[:symbol_map]) + bridge = JSON.parse(symbol_map_bytes) + unless bridge["schema"] == "fact-mine.symbol-bridge.v1" + abort "unsupported symbol bridge schema: #{bridge['schema'].inspect}" + end + symbol_map = bridge.fetch("symbols") + unless symbol_map.is_a?(Hash) && symbol_map.all? { |key, value| + targets = value.is_a?(Array) ? value : [value] + !key.to_s.empty? && !targets.empty? && targets.all? { |target| !target.to_s.empty? } + } + abort "symbol bridge symbols must map non-empty producer symbols to one or more non-empty consumer symbols" + end + symbol_map_sha256 = "sha256:#{Digest::SHA256.hexdigest(symbol_map_bytes)}" +end + +profile_bytes = File.binread(ARGV.fetch(0)) +profile = JSON.parse(profile_bytes) paths = Array(profile["methods"]).map { |method| method.fetch("path") }.uniq evidence = { "root" => "/", @@ -36,11 +108,22 @@ results = Espalier::Aggregator.new.aggregate(Espalier::StaticEvidence.project_modules(evidence)) .flat_map { |mod| Array(mod[:functions]) } .to_h { |function| [function[:id].to_s, function.fetch(:quality_metrics, {})] } +facts_by_location = Array(profile["complexity_facts"]).group_by do |fact| + [fact["path"].to_s, fact["line"].to_i, fact["function"].to_s] +end +source_proven_ids = Array(profile["methods"]).filter_map do |method| + quality = results[method["id"].to_s] + facts = facts_by_location.fetch( + [method["path"].to_s, method["line"].to_i, method["name"].to_s], + [] + ) + method["id"].to_s if Espalier::ComplexitySummary.source_method_proven?(method, quality, facts) +end.to_h { |id| [id, true] } symbols = Array(profile["methods"]).filter_map do |method| symbol = method["semantic_symbol"].to_s quality = results[method["id"].to_s] - next if symbol.empty? || !quality || quality[:big_o_complete] != true || quality[:big_o_space_complete] != true + next if symbol.empty? || !source_proven_ids[method["id"].to_s] source_qualities = Array(quality[:big_o_bound_qualities]).map(&:to_s).reject(&:empty?) source_assumptions = Array(quality[:big_o_assumptions]).map(&:to_s).reject(&:empty?) @@ -61,20 +144,19 @@ }] end -# A compiler can attach an interface/trait declaration symbol to a call while -# separately providing the closed implementation set visible in this index. -# When every candidate has a complete analyzed bound, publish the conservative -# maximum under that declaration symbol. This is language-neutral and retains -# the closed-world assumption explicitly. +# A compiler can attach a declaration symbol to a call while separately +# providing candidate implementations visible in this index. Visibility in a +# producer index is not proof that downstream consumers cannot add another +# implementation. Publish a conservative candidate maximum only when the +# profile carries a separate, explicit consumer-closure proof. candidate_symbols = Array(profile["calls"]).filter_map do |call| symbol = call["semantic_symbol"].to_s candidate_ids = Array(call["candidate_targets"]).map(&:to_s).reject(&:empty?).uniq.sort next if symbol.empty? || candidate_ids.empty? + next unless Espalier::ComplexitySummary.consumer_closed_candidate_set?(call) candidate_qualities = candidate_ids.map { |id| results[id] } - next if candidate_qualities.any? do |quality| - !quality || quality[:big_o_complete] != true || quality[:big_o_space_complete] != true - end + next unless candidate_ids.all? { |id| source_proven_ids[id] } worst_time = candidate_qualities.map { |quality| quality[:big_o] } .max_by { |value| Espalier::SymbolicComplexity.rank_string(value) } @@ -93,6 +175,12 @@ }] end symbols.concat(candidate_symbols) +symbols = Espalier::ComplexitySummary.bridge_symbol_rows( + symbols, + symbol_map: symbol_map, + prefix_from: metadata[:symbol_prefix_from], + prefix_to: metadata[:symbol_prefix_to] +) # A compiler symbol should identify one declaration. Omit conflicting symbols # instead of selecting by order if an index violates that contract; one @@ -108,7 +196,33 @@ end output = { - "schema" => "fact-mine.external-complexity-summary.v1", + "schema" => "fact-mine.external-complexity-summary.v3", + "producer" => { + "name" => "espalier", + "version" => metadata[:producer_version].to_s + }, + "source" => { + "profile_sha256" => "sha256:#{Digest::SHA256.hexdigest(profile_bytes)}", + "method_count" => Array(profile["methods"]).length, + "complete_symbol_count" => grouped.length, + "source_proven_method_count" => source_proven_ids.length, + "proof_policy" => "analyzed_bodies_exact_targets_cfg_dfg_v1", + "corpus" => metadata[:corpus], + "source_revision" => metadata[:source_revision], + "indexer" => metadata[:indexer], + "consumer_indexers" => metadata[:consumer_indexers].uniq.sort, + "symbol_relocation" => if metadata[:symbol_prefix_from] || metadata[:symbol_prefix_to] + { + "from" => metadata[:symbol_prefix_from], + "to" => metadata[:symbol_prefix_to] + } + end, + "symbol_bridge_sha256" => symbol_map_sha256, + "languages" => Array(profile["methods"]).map { |method| method["language"].to_s }.reject(&:empty?).uniq.sort + }.compact, + "compatibility" => { + "claims" => compatibility_claims + }, "symbols" => grouped.to_h do |symbol, rows| merged = rows.first.last.dup merged["candidates"] = rows.flat_map { |row| Array(row.last["candidates"]) }.uniq.sort @@ -118,7 +232,15 @@ } rendered = JSON.pretty_generate(output) if ARGV[1] - File.write(ARGV[1], rendered) + if File.extname(ARGV[1]) == ".gz" + Zlib::GzipWriter.open(ARGV[1]) do |gzip| + gzip.mtime = 0 + gzip.orig_name = "" + gzip.write(rendered) + end + else + File.write(ARGV[1], rendered) + end else puts rendered end diff --git a/gems/espalier/script/interface_worst_case.rb b/gems/espalier/script/interface_worst_case.rb new file mode 100644 index 000000000..ab40bd0a5 --- /dev/null +++ b/gems/espalier/script/interface_worst_case.rb @@ -0,0 +1,111 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Interface-dispatch worst-case analysis (design phases 3 + 4). +# +# Consumes phase-2 satisfaction edges (`dispatch_impls`) from a FactMine +# profile and per-function Big-O from an Espalier architecture report, and for +# every abstract type's method computes: +# phase 3 - the worst-case implementation and its cost (provenance) +# phase 4 - whether that worst case is asymptotically significant, and how +# often across the corpus a worst case is non-trivial / varies +# +# Usage: interface_worst_case.rb PROFILE.json ARCHITECTURE.json [--json] + +require "json" + +profile_path, arch_path = ARGV.reject { |a| a.start_with?("--") } +as_json = ARGV.include?("--json") +abort "usage: interface_worst_case.rb PROFILE.json ARCHITECTURE.json [--json]" unless profile_path && arch_path + +profile = JSON.parse(File.read(profile_path)) +arch = JSON.parse(File.read(arch_path)) + +# Per-(owner, method) Big-O from the architecture report. +cost = Hash.new { |h, k| h[k] = {} } +walk = lambda do |node, &blk| + blk.call(node) + children = node.is_a?(Hash) ? node.values : node.is_a?(Array) ? node : [] + children.each { |c| walk.call(c, &blk) } +end +walk.call(arch) do |node| + next unless node.is_a?(Hash) && node.key?("time_complete") + cost[node["owner"].to_s][node["name"].to_s] = { + time: node["big_o_time"], complete: node["time_complete"] + } +end + +# Interface -> [implementers]; interface -> [required methods]. +impls = Hash.new { |h, k| h[k] = [] } +Array(profile["dispatch_impls"]).each { |e| impls[e["interface"]] << e } +requirements = {} +Array(profile["owners"]).each do |o| + requirements[o["name"]] = Array(o["requirements"]) unless Array(o["requirements"]).empty? +end + +# Coarse worst-case ordering over Big-O classes. A parametric cost (contains an +# unresolved callback C or reflective R) ranks above any concrete class of the +# same N-structure: worst-case, an unbounded parameter can be anything. +parametric = lambda { |c| c.to_s.include?("C") || c.to_s.include?("R") } +rank = lambda do |c| + s = c.to_s + base = if s.include?("^") then 5 + elsif s.include?("N log N") || s.include?("N*log") then 4 + elsif s.include?("N") then 3 + elsif s.include?("log") then 1 + else 0 + end + parametric.call(c) ? base + 10 : base +end + +results = [] +impls.each do |iface, edges| + methods = requirements[iface] || [] + # Fall back to the union of methods the implementers define, if no explicit + # requirement list (nominal languages). + methods = edges.flat_map { |e| cost[e["implementer"]].keys }.uniq if methods.empty? + methods.each do |m| + priced = edges.filter_map do |e| + c = cost[e["implementer"]][m] + c && c[:complete] ? [e["implementer"], c[:time]] : nil + end + next if priced.empty? + worst = priced.max_by { |(_, t)| rank.call(t) } + variance = priced.map { |(_, t)| t }.uniq.size + # A caller's `O(N * C)` bound for this method resolves to a concrete + # "complete worst case" only when the worst implementation is itself + # concrete. If the worst impl is still parametric (its own C/R), the + # substitution stays parametric and the caller bound cannot be closed. + status = parametric.call(worst[1]) ? "worst_case_parametric" : "complete_worst_case" + results << { + interface: iface, method: m, + worst_impl: worst[0], worst_cost: worst[1], + implementers_priced: priced.size, distinct_costs: variance, + significant: rank.call(worst[1]) > 0, # non-O(1) -> may dominate a caller + status: status, + distribution: priced.sort_by { |(_, t)| -rank.call(t) }.first(4) + } + end +end + +results.sort_by! { |r| [-rank.call(r[:worst_cost]), r[:interface], r[:method]] } + +if as_json + puts JSON.pretty_generate(results) +else + significant = results.count { |r| r[:significant] } + varying = results.count { |r| r[:distinct_costs] > 1 } + resolvable = results.count { |r| r[:status] == "complete_worst_case" } + puts "interface methods analyzed: #{results.size}" + puts " non-trivial worst case (may dominate a caller): #{significant}" + puts " worst case varies across implementations: #{varying}" + puts " complete worst case (C resolves to a concrete bound): #{resolvable}" + puts " worst-case parametric (C stays open): #{results.size - resolvable}" + puts + puts "phase 3 - worst-case implementation per interface method:" + results.first(25).each do |r| + flag = r[:distinct_costs] > 1 ? " [varies]" : "" + puts format(" %-22s %-10s worst=%-9s via %s%s", + "#{r[:interface]}.#{r[:method]}", r[:worst_cost], r[:worst_cost], r[:worst_impl], flag) + end +end diff --git a/gems/espalier/script/report_big_o_proof_metrics.rb b/gems/espalier/script/report_big_o_proof_metrics.rb index 5970ba1e4..e4c72c965 100644 --- a/gems/espalier/script/report_big_o_proof_metrics.rb +++ b/gems/espalier/script/report_big_o_proof_metrics.rb @@ -6,6 +6,7 @@ require "json" require "optparse" +require "pathname" $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "espalier" @@ -24,19 +25,33 @@ profile = JSON.parse(File.read(ARGV.fetch(0))) source_root = options.fetch(:source_root) repositories = options.fetch(:repositories) +absolute_profile_path = lambda do |path| + path = path.to_s + Pathname.new(path).absolute? ? path : File.expand_path(path, source_root) +end if repositories.empty? repositories = Array(profile["methods"]).filter_map do |method| - path = method.fetch("path") + path = absolute_profile_path.call(method.fetch("path")) path.delete_prefix("#{source_root}/").split("/", 2).first if path.start_with?("#{source_root}/") end.uniq.sort end focus = options[:focus] || repositories.find { |name| name.downcase.delete("-") == "javapoet" } || repositories.first -def evidence_for(profile, prefix) - methods = Array(profile["methods"]).select { |row| row.fetch("path").start_with?(prefix) } +def evidence_for(profile, prefix, source_root) + absolute_profile_path = lambda do |path| + path = path.to_s + Pathname.new(path).absolute? ? path : File.expand_path(path, source_root) + end + methods = Array(profile["methods"]).select do |row| + absolute_profile_path.call(row.fetch("path")).start_with?(prefix) + end method_ids = methods.to_h { |row| [row.fetch("id"), true] } paths = methods.map { |row| row.fetch("path") }.uniq - select_path = ->(rows) { Array(rows).select { |row| row.fetch("path", "").start_with?(prefix) } } + select_path = lambda do |rows| + Array(rows).select do |row| + absolute_profile_path.call(row.fetch("path", "")).start_with?(prefix) + end + end { "root" => prefix, "files" => paths.map { |path| { "path" => path, "source_role" => "production" } }, @@ -56,7 +71,7 @@ def evidence_for(profile, prefix) qualities_by_repository = repositories.to_h do |repository| prefix = File.join(source_root, repository, "") - modules = Espalier::StaticEvidence.project_modules(evidence_for(profile, prefix)) + modules = Espalier::StaticEvidence.project_modules(evidence_for(profile, prefix, source_root)) qualities = Espalier::Aggregator.new.aggregate(modules).flat_map do |mod| Array(mod[:functions]).map { |function| function.fetch(:quality_metrics, {}) } end diff --git a/gems/espalier/test/aggregator_test.rb b/gems/espalier/test/aggregator_test.rb index e4b2041f0..9d693040d 100644 --- a/gems/espalier/test/aggregator_test.rb +++ b/gems/espalier/test/aggregator_test.rb @@ -388,6 +388,403 @@ def test_big_o_uses_fact_mine_normalized_call_costs refute fn[:quality_metrics].key?(:big_o_unknowns) end + def incomplete_recursive_module(language, owner, fn_name) + [ + { + # project_modules disambiguates owners as "name@path"; the override + # must key on the bare leaf. + type: :class, name: "#{owner}@src/#{owner}.x", file: "src/#{owner}.x", states: Set.new, + language: language, + methods: [ + { + name: fn_name, signature: "func #{fn_name}()", + parameters: ["data"], visibility: :public, line: 20, span: [20, 0, 40, 1], + effects: { reads: Set.new, writes: Set.new }, + complexity_facts: [{ + "line" => 20, "parameters" => ["data"], "collection_parameters" => ["data"], + "iterations" => [], "allocations" => [], "size_domains" => [], + "recursion" => { "calls" => 2, "unknown_progress_calls" => 2 }, + "call_contexts" => [] + }] + } + ] + } + ] + end + + def test_manual_override_completes_only_incomplete_registered_functions + modules = incomplete_recursive_module(:go, "sort", "Sort") + fn = Espalier::Aggregator.new.aggregate(modules).first[:functions].first + + assert_equal "O(N log N)", fn[:quality_metrics][:big_o] + assert_equal true, fn[:quality_metrics][:big_o_complete] + assert_equal :manual_override, fn[:quality_metrics][:big_o_provenance] + assert_equal :complete_override, fn[:quality_metrics][:big_o_status] + assert_equal "O(log N)", fn[:quality_metrics][:big_o_space] + end + + def test_manual_override_ignores_unregistered_functions + # Same incomplete shape, but no registry entry for sort.Frobnicate. + modules = incomplete_recursive_module(:go, "sort", "Frobnicate") + fn = Espalier::Aggregator.new.aggregate(modules).first[:functions].first + + refute_equal true, fn[:quality_metrics][:big_o_complete] + refute_equal :manual_override, fn[:quality_metrics][:big_o_provenance] + assert_equal :incomplete, fn[:quality_metrics][:big_o_status] + end + + def test_manual_override_never_replaces_a_complete_bound + # A registered name (list.sort / python) but a COMPLETE derived bound: + # the override must not fire. + modules = [ + { + type: :class, name: "list", file: "x.py", states: Set.new, language: :python, + methods: [{ + name: "sort", signature: "def sort(self)", parameters: [], visibility: :public, + line: 1, span: [1, 0, 2, 1], effects: { reads: Set.new, writes: Set.new }, + complexity_facts: [{ + "line" => 1, "parameters" => [], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "size_domains" => [], + "recursion" => { "calls" => 0 }, "call_contexts" => [] + }] + }] + } + ] + fn = Espalier::Aggregator.new.aggregate(modules).first[:functions].first + + assert_equal true, fn[:quality_metrics][:big_o_complete] + refute_equal :manual_override, fn[:quality_metrics][:big_o_provenance] + refute_equal "O(N log N)", fn[:quality_metrics][:big_o] + assert_equal :complete, fn[:quality_metrics][:big_o_status] + end + + def test_big_o_status_distinguishes_parametric_from_complete + # The core "complete" vs "complete worst case" distinction. A bound carrying + # an open callback/reflective parameter (C/R) is complete only parametrically + # - it is the tier a worst-case substitution upgrades to :complete_worst_case + # and must NOT read as a plain (closed) :complete bound. + agg = Espalier::Aggregator.new + classify = ->(q) { agg.send(:classify_big_o_status, q) } + + assert_equal :complete, classify.call(big_o: "O(N)", big_o_complete: true) + assert_equal :parametric, classify.call(big_o: "O(N * C)", big_o_complete: true) + assert_equal :parametric, classify.call(big_o: "O(R)", big_o_complete: true) + assert_equal :incomplete, classify.call(big_o: "unknown", big_o_complete: false) + assert_equal :complete_override, + classify.call(big_o: "O(N log N)", big_o_complete: true, + big_o_provenance: :manual_override) + + # The open parameter can sit on the space axis alone: a parametric contract + # prices auxiliary space as O(S) / O(N*S), and substitution rewrites only + # the time expression. Reading only the time bound published those as closed. + assert_equal :parametric, + classify.call(big_o: "O(N^2)", big_o_complete: true, + big_o_space: "O(S)", big_o_space_complete: true) + assert_equal :parametric, + classify.call(big_o: "O(N)", big_o_complete: true, + big_o_space: "O(N*S)", big_o_space_complete: true) + assert_equal :complete, + classify.call(big_o: "O(N)", big_o_complete: true, big_o_space: "O(N)") + end + + def test_open_space_parameter_is_parametric_through_the_aggregation_pipeline + # The classifier is only correct if it runs against a quality record that + # already carries the space bound. A reflective contract closes no space, + # so the reported tier must be parametric even though the time bound is a + # closed O(N). + modules = [{ + type: :class, name: "demo", file: "demo.go", states: Set.new, language: :go, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 1], parameters: ["xs"], + visibility: :public, effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "c1", receiver: "fmt", message: "Sprintf", line: 2, + span: [2, 4, 2, 40], known_time_complexity: "O(N)", + known_space_complexity: "O(S)" + }], + complexity_facts: [{ + "line" => 1, "parameters" => ["xs"], "collection_parameters" => ["xs"], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [], "call_contexts" => [{ + "line" => 2, "span" => [2, 4, 2, 40], "message" => "Sprintf", + "execution_multiplicity" => "O(1)", "power" => 0 + }] + }] + }] + }] + + quality = Espalier::Aggregator.new.aggregate(modules) + .first[:functions].find { |fn| fn[:name] == "run" }.fetch(:quality_metrics) + + assert_equal "O(N)", quality[:big_o] + assert_equal "O(S)", quality[:big_o_space] + assert_equal :parametric, quality[:big_o_status] + end + + def test_lambda_argument_closes_an_external_parametric_callback_bound + # A stdlib higher-order call is priced O(N*C) from its compiler symbol. The + # closure passed at that call site is analyzed like any other function, so C + # is not open - substituting it is what turns a partial bound into a closed + # one. + modules = [{ + type: :class, name: "demo", file: "demo.rs", states: Set.new, language: :rust, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 1], parameters: ["xs"], + visibility: :public, effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "c1", receiver: "xs.iter()", message: "map", line: 2, + span: [2, 4, 2, 40], known_time_complexity: "O(N)", + complexity_bound_quality: "upper_bound_parametric_callback_linear" + }], + complexity_facts: [{ + "line" => 1, "parameters" => ["xs"], "collection_parameters" => ["xs"], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }], + "call_contexts" => [{ + "line" => 2, "span" => [2, 4, 2, 40], "message" => "map", + "execution_multiplicity" => "O(1)", "power" => 0, + "argument_size_domains" => [["param:xs"]], + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }] + }] + }] + }, { + id: "lambda", name: "", line: 2, span: [2, 20, 2, 34], + dispatch_kind: "lambda", parameters: ["x"], visibility: :private, + effects: { reads: Set.new, writes: Set.new }, delegations: [], + complexity_facts: [{ + "line" => 2, "parameters" => ["x"], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [], "call_contexts" => [] + }] + }] + }] + + functions = Espalier::Aggregator.new.aggregate(modules).first[:functions] + quality = functions.find { |fn| fn[:name] == "run" }.fetch(:quality_metrics) + + assert_equal "O(N)", quality[:big_o] + assert_equal :complete, quality[:big_o_status] + end + + def test_costly_lambda_argument_keeps_its_own_cost_in_the_bound + # Substitution is not erasure: an O(M) callable multiplies in rather than + # dropping out, so O(N*C) becomes O(N*M), not O(N). + modules = [{ + type: :class, name: "demo", file: "demo.rs", states: Set.new, language: :rust, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 1], parameters: ["xs"], + visibility: :public, effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "c1", receiver: "xs.iter()", message: "map", line: 2, + span: [2, 4, 2, 40], known_time_complexity: "O(N)", + complexity_bound_quality: "upper_bound_parametric_callback_linear" + }], + complexity_facts: [{ + "line" => 1, "parameters" => ["xs"], "collection_parameters" => ["xs"], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }], + "call_contexts" => [{ + "line" => 2, "span" => [2, 4, 2, 40], "message" => "map", + "execution_multiplicity" => "O(1)", "power" => 0, + "argument_size_domains" => [["param:xs"]], + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }] + }] + }] + }, { + id: "lambda", name: "", line: 2, span: [2, 20, 2, 34], + dispatch_kind: "lambda", parameters: ["y"], visibility: :private, + effects: { reads: Set.new, writes: Set.new }, delegations: [], + complexity_facts: [{ + "line" => 2, "parameters" => ["y"], "collection_parameters" => ["y"], + "iterations" => [{ + "line" => 2, "span" => [2, 20, 2, 34], "power" => 1, + "parameter_domains" => ["y"], + "symbolic_time" => { "factors" => [{ "domain_id" => "param:y", "exponent" => 1 }] } + }], + "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [{ "id" => "param:y", "name" => "y", "source_kind" => "parameter" }], + "call_contexts" => [] + }] + }] + }] + + functions = Espalier::Aggregator.new.aggregate(modules).first[:functions] + quality = functions.find { |fn| fn[:name] == "run" }.fetch(:quality_metrics) + + assert_equal "O(N*M)", quality[:big_o] + assert_equal :complete, quality[:big_o_status] + end + + def test_callable_of_unknown_cost_leaves_the_callback_parameter_open + # Substitution must never price an unproven callable as free. A closure + # whose own bound is not symbolically known keeps C open rather than + # silently dropping out of the caller's bound. + modules = [{ + type: :class, name: "demo", file: "demo.rs", states: Set.new, language: :rust, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 1], parameters: ["xs"], + visibility: :public, effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "c1", receiver: "xs.iter()", message: "map", line: 2, + span: [2, 4, 2, 40], known_time_complexity: "O(N)", + complexity_bound_quality: "upper_bound_parametric_callback_linear" + }], + complexity_facts: [{ + "line" => 1, "parameters" => ["xs"], "collection_parameters" => ["xs"], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }], + "call_contexts" => [{ + "line" => 2, "span" => [2, 4, 2, 40], "message" => "map", + "execution_multiplicity" => "O(1)", "power" => 0, + "argument_size_domains" => [["param:xs"]], + "size_domains" => [{ "id" => "param:xs", "name" => "xs", "source_kind" => "parameter" }] + }] + }] + }, { + id: "lambda", name: "", line: 2, span: [2, 20, 2, 34], + dispatch_kind: "lambda", parameters: ["x"], visibility: :private, + effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "c2", receiver: "sink", message: "consume", line: 2, span: [2, 24, 2, 33] + }], + complexity_facts: [{ + "line" => 2, "parameters" => ["x"], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [], + "call_contexts" => [{ + "line" => 2, "span" => [2, 24, 2, 33], "message" => "consume", + "execution_multiplicity" => "O(1)", "power" => 0 + }] + }] + }] + }] + + functions = Espalier::Aggregator.new.aggregate(modules).first[:functions] + caller = functions.find { |fn| fn[:name] == "run" }.fetch(:quality_metrics) + callable = functions.find { |fn| fn[:name] == "" }.fetch(:quality_metrics) + + refute callable[:big_o_complete], "the closure's own cost must be unproven for this case" + assert_equal "O(N*C)", caller[:big_o] + assert_equal :parametric, caller[:big_o_status] + end + + def test_recursive_callback_component_stays_parametric_instead_of_expanding_forever + modules = [{ + type: :class, name: "walker", file: "walker.rs", states: Set.new, language: :rust, + methods: [{ + id: "visit", name: "visit", line: 1, span: [1, 0, 3, 1], parameters: ["children"], + visibility: :private, effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "walk", receiver: "children", message: "any", line: 2, + span: [2, 4, 2, 40], known_time_complexity: "O(N*C)", + complexity_bound_quality: "upper_bound_parametric_callback_linear" + }], + complexity_facts: [{ + "line" => 1, "parameters" => ["children"], "collection_parameters" => ["children"], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [{ + "id" => "param:children", "name" => "children", "source_kind" => "parameter" + }], + "call_contexts" => [{ + "line" => 2, "span" => [2, 4, 2, 40], "message" => "any", + "execution_multiplicity" => "O(1)", "power" => 0, + "argument_size_domains" => [["param:children"]] + }] + }] + }, { + id: "lambda", name: "", line: 2, span: [2, 20, 2, 39], + dispatch_kind: "lambda", parameters: ["child"], visibility: :private, + effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + call_id: "recur", receiver: "self", message: "visit", line: 2, + span: [2, 22, 2, 38], candidate_target_ids: ["visit"], + consumer_closed_candidate_set: true, + candidate_reason: "scip_project_candidate_set" + }], + complexity_facts: [{ + "line" => 2, "parameters" => ["child"], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "recursion" => { "calls" => 0 }, + "size_domains" => [], "call_contexts" => [{ + "line" => 2, "span" => [2, 22, 2, 38], "message" => "visit", + "execution_multiplicity" => "O(1)", "power" => 0 + }] + }] + }] + }] + + functions = Espalier::Aggregator.new.aggregate(modules).first[:functions] + visit = functions.find { |function| function[:id] == "visit" }.fetch(:quality_metrics) + + assert_equal "O(N*C)", visit[:big_o] + assert_equal :parametric, visit[:big_o_status] + end + + def test_divergent_recursive_candidate_cycle_widens_to_unknown + method = lambda do |id, name, target, line| + { + id: id, name: name, line: line, span: [line, 0, line + 2, 1], + parameters: ["items"], effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + receiver: "self", message: target, line: line + 1, + span: [line + 1, 2, line + 1, 12], + candidate_target_ids: [target], candidate_reason: "closed_candidate_set", + consumer_closed_candidate_set: true + }], + complexity_facts: [{ + "line" => line, "parameters" => ["items"], "collection_parameters" => ["items"], + "iterations" => [], "allocations" => [], "size_domains" => [], + "recursion" => { "calls" => 0 }, "call_contexts" => [{ + "line" => line + 1, "span" => [line + 1, 2, line + 1, 12], + "message" => target, "execution_multiplicity" => "O(N)", "power" => 1 + }] + }] + } + end + modules = [{ + type: :class, name: "Cycle", file: "cycle.rs", states: Set.new, language: :rust, + methods: [ + method.call("left", "left", "right", 1), + method.call("right", "right", "left", 5) + ] + }] + + functions = Espalier::Aggregator.new.aggregate(modules).first[:functions] + + functions.each do |function| + quality = function.fetch(:quality_metrics) + refute quality[:big_o_complete] + assert_includes quality[:big_o_evidence_gaps], "unresolved_recursive_progress" + end + end + + def test_worst_callable_rule + worst = ->(rows) { Espalier::SymbolicComplexity.worst_callable(rows) } + linear = Espalier::SymbolicComplexity.from_fact( + { "factors" => [{ "domain_id" => "param:y", "exponent" => 1 }] }, + [{ "id" => "param:y", "name" => "y", "source_kind" => "parameter" }] + ) + + assert_nil worst.call([]) + # One unknown callable poisons the site. + assert_equal({ expression: nil, constant: false }, + worst.call([{ expression: linear, constant: false }, + { expression: nil, constant: false }])) + # Otherwise the costliest known cost wins, and constants alone close it. + assert_equal linear, worst.call([{ expression: linear, constant: false }, + { expression: nil, constant: true }]).fetch(:expression) + assert_equal({ expression: nil, constant: true }, + worst.call([{ expression: nil, constant: true }])) + end + + def test_complexity_overrides_lookup_semantics + assert_equal "O(N log N)", + Espalier::ComplexityOverrides.lookup(:go, "sort", "Sort")["time"] + # Unknown function / wrong language / missing owner all miss. + assert_nil Espalier::ComplexityOverrides.lookup(:go, "sort", "Frobnicate") + assert_nil Espalier::ComplexityOverrides.lookup(:rust, "sort", "Sort") + assert_nil Espalier::ComplexityOverrides.lookup(:go, nil, nil) + end + def test_big_o_joins_same_line_calls_by_exact_span method = { id: "caller", name: "run", line: 2, @@ -1144,6 +1541,49 @@ def test_big_o_uses_exact_target_id_when_overloads_share_owner_and_name assert caller[:quality_metrics][:big_o_complete] end + def test_unresolved_duplicate_short_name_is_not_an_internal_summary_edge + constant_fact = { + "parameters" => [], "collection_parameters" => [], "iterations" => [], + "allocations" => [], "size_domains" => [], "recursion" => { "calls" => 0 }, + "call_contexts" => [] + } + modules = [{ + type: :class, name: "Duplicate", file: "duplicate.rs", states: Set.new, + methods: [{ + id: "work-a", name: "work", line: 1, span: [1, 0, 2, 1], + effects: { reads: Set.new, writes: Set.new }, delegations: [], + complexity_facts: [constant_fact.merge("line" => 1)] + }, { + id: "work-b", name: "work", line: 4, span: [4, 0, 6, 1], + effects: { reads: Set.new, writes: Set.new }, delegations: [], + complexity_facts: [constant_fact.merge( + "line" => 4, + "iterations" => [{ "line" => 5, "power" => 1, "execution_multiplicity" => "O(N)" }] + )] + }, { + id: "caller", name: "run", line: 8, span: [8, 0, 10, 1], + effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + receiver: "self", message: "work", line: 9, span: [9, 2, 9, 8] + }], + complexity_facts: [constant_fact.merge( + "line" => 8, + "call_contexts" => [{ + "line" => 9, "span" => [9, 2, 9, 8], "message" => "work", + "execution_multiplicity" => "O(1)", "power" => 0, + "evidence_gap" => "ambiguous_project_call" + }] + )] + }] + }] + + caller = Espalier::Aggregator.new.aggregate(modules).first[:functions] + .find { |function| function[:id] == "caller" }.fetch(:quality_metrics) + + refute caller[:big_o_complete] + assert_includes caller[:big_o_evidence_gaps], "ambiguous_project_call" + end + def test_scip_complete_call_graph_rejects_false_overload_recursion fact = { "line" => 2, "parameters" => ["value"], "collection_parameters" => [], @@ -1345,7 +1785,8 @@ def test_big_o_joins_compiler_implementation_candidates_as_a_modeled_upper_bound effects: { reads: Set.new, writes: Set.new }, complexity_facts: [caller_fact], delegations: [{ receiver: "worker", message: "work", line: 3, type: :always, - candidate_target_ids: %w[fast slow], candidate_reason: "scip_implementation_set" + candidate_target_ids: %w[fast slow], candidate_reason: "scip_implementation_set", + consumer_closed_candidate_set: true }] }] }, { @@ -1369,4 +1810,89 @@ def test_big_o_joins_compiler_implementation_candidates_as_a_modeled_upper_bound assert_includes caller[:quality_metrics][:big_o_assumptions].first, "implementation set is closed" end + def test_big_o_does_not_certify_an_open_runtime_candidate_set + empty_fact = lambda do |line, contexts = []| + { + "line" => line, "parameters" => [], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "call_contexts" => contexts, + "size_domains" => [], "recursion" => { "calls" => 0 } + } + end + modules = [{ + type: :class, name: "RuntimeObserved", file: "runtime.rb", states: Set.new, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 3], + parameters: [], effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + receiver: "worker", message: "work", line: 2, + candidate_target_ids: ["observed"], candidate_reason: "runtime_observed_candidate_set", + consumer_closed_candidate_set: false + }], + complexity_facts: [empty_fact.call(1, [{ + "line" => 2, "message" => "work", "execution_multiplicity" => "O(1)", + "power" => 0, "evidence_gap" => "unknown_call_target" + }])] + }, { + id: "observed", name: "work", line: 5, span: [5, 0, 7, 3], + parameters: [], effects: { reads: Set.new, writes: Set.new }, + delegations: [], complexity_facts: [empty_fact.call(5)] + }] + }] + + caller = Espalier::Aggregator.new.aggregate(modules).first[:functions].find do |function| + function[:id] == "caller" + end + + refute caller[:quality_metrics][:big_o_complete] + refute_includes Array(caller[:quality_metrics][:big_o_bound_qualities]), "upper_bound_closed_candidate_max" + end + + def test_big_o_certifies_runtime_candidates_only_as_modeled_world + empty_fact = lambda do |line, contexts = []| + { + "line" => line, "parameters" => [], "collection_parameters" => [], + "iterations" => [], "allocations" => [], "call_contexts" => contexts, + "size_domains" => [], "recursion" => { "calls" => 0 } + } + end + closure_assumption = + "observed call targets exhaust the attested workload and runtime environment" + modules = [{ + type: :class, name: "RuntimeObserved", file: "runtime.rb", states: Set.new, + methods: [{ + id: "caller", name: "run", line: 1, span: [1, 0, 3, 3], + parameters: [], effects: { reads: Set.new, writes: Set.new }, + delegations: [{ + receiver: "worker", message: "work", line: 2, + candidate_target_ids: ["observed"], + candidate_reason: "runtime_modeled_observed_candidate_set", + consumer_closed_candidate_set: true, + complexity_bound_quality: "upper_bound_modeled_world", + complexity_assumptions: [closure_assumption] + }], + complexity_facts: [empty_fact.call(1, [{ + "line" => 2, "message" => "work", "execution_multiplicity" => "O(1)", + "power" => 0, "evidence_gap" => "unknown_call_target" + }])] + }, { + id: "observed", name: "work", line: 5, span: [5, 0, 7, 3], + parameters: [], effects: { reads: Set.new, writes: Set.new }, + delegations: [], complexity_facts: [empty_fact.call(5)] + }] + }] + + caller = Espalier::Aggregator.new.aggregate(modules).first[:functions].find do |function| + function[:id] == "caller" + end + + assert caller[:quality_metrics][:big_o_complete] + assert_includes caller[:quality_metrics][:big_o_bound_qualities], + "upper_bound_modeled_world" + assert_includes caller[:quality_metrics][:big_o_bound_qualities], + "upper_bound_closed_candidate_max" + assert_includes caller[:quality_metrics][:big_o_assumptions], closure_assumption + assert_equal :known_candidate_max, + Espalier::BigOProofMetrics.classify(caller[:quality_metrics]) + end + end diff --git a/gems/espalier/test/architecture_artifact_test.rb b/gems/espalier/test/architecture_artifact_test.rb index 385b7fc6e..bd448715e 100644 --- a/gems/espalier/test/architecture_artifact_test.rb +++ b/gems/espalier/test/architecture_artifact_test.rb @@ -1,9 +1,46 @@ # frozen_string_literal: true require "minitest/autorun" +require "tmpdir" require_relative "../lib/espalier" class ArchitectureArtifactTest < Minitest::Test + def test_emits_import_edges_scanned_from_source + Dir.mktmpdir do |root| + File.write(File.join(root, "svc.go"), <<~GO) + package svc + + import ( + "fmt" + "os/exec" + ) + + import "strings" + + func Run() { fmt.Println("x") } + GO + evidence = { + "root" => root, + "corpus" => { "complete" => true }, + "owners" => [], + "methods" => [{ "id" => "fn:1", "owner" => "svc", "name" => "Run", "language" => "go", + "path" => File.join(root, "svc.go"), "line" => 10, "span" => [10, 0, 10, 30] }], + "fields" => [], + "facts" => { "calls" => [], "state_accesses" => [] } + } + artifact = Espalier::ArchitectureArtifact.build(evidence, root: root, commit: "abc") + imports = artifact["edges"].select { |edge| edge["kind"] == "imports" } + modules = imports.map { |edge| edge.dig("metadata", "module") }.sort + assert_equal ["fmt", "os/exec", "strings"], modules + # Each import edge carries the source line and targets a named module node. + fmt = imports.find { |edge| edge.dig("metadata", "module") == "fmt" } + assert_equal "svc.go", fmt.dig("spans", 0, "path") + assert_equal 4, fmt.dig("spans", 0, "start_line") + target = artifact["nodes"].find { |node| node["id"] == fmt["target"] } + assert_equal "fmt", target["name"] + end + end + def test_projects_first_class_state_edges_and_citations evidence = { "root" => "/repo", @@ -66,4 +103,62 @@ def test_partial_confidence_owners_are_not_rendered_as_owner_nodes assert_nil guessed_owner_fn["owner_id"], "a function whose only owner is a partial-confidence guess must not link to a fabricated node" end + + def test_big_o_index_uses_the_known_component_when_the_bound_is_incomplete + manifest = [ + { + file: "lib/x.rb", + functions: [ + { name: "proven", + quality_metrics: { big_o: "O(1)", big_o_complete: true, + big_o_space: "O(1)", big_o_space_complete: true } }, + { name: "looped", + quality_metrics: { big_o: "unknown", big_o_known_component: "O(N^2)", + big_o_complete: false } }, + { name: "opaque", + quality_metrics: { big_o: "unknown", big_o_complete: false } } + ] + } + ] + index = Espalier::ArchitectureArtifact.big_o_index(manifest) + + proven = index["lib/x.rb\u0000proven"] + assert_equal "O(1)", proven["big_o_time"] + assert_equal true, proven["time_complete"] + + # A known-but-incomplete bound surfaces as a partial bound, not "unknown". + looped = index["lib/x.rb\u0000looped"] + assert_equal "O(N^2)", looped["big_o_time"] + assert_equal false, looped["time_complete"] + + # No bound at all is not indexed. + assert_nil index["lib/x.rb\u0000opaque"] + end + + + def test_big_o_index_threads_status_and_provenance + manifest = [ + { + file: "x.go", + functions: [ + { name: "each", + quality_metrics: { big_o: "O(N * C)", big_o_complete: true, + big_o_status: :parametric } }, + { name: "Sort", + quality_metrics: { big_o: "O(N log N)", big_o_complete: true, + big_o_status: :complete_override, + big_o_provenance: :manual_override } } + ] + } + ] + index = Espalier::ArchitectureArtifact.big_o_index(manifest) + each = index.values.find { |node| node["big_o_time"] == "O(N * C)" } + sort = index.values.find { |node| node["big_o_provenance"] } + + assert_equal "parametric", each["big_o_status"] + assert_equal "complete_override", sort["big_o_status"] + assert_equal "manual_override", sort["big_o_provenance"] + refute each.key?("big_o_provenance") + end + end diff --git a/gems/espalier/test/architecture_tools_test.rb b/gems/espalier/test/architecture_tools_test.rb index ebe798a43..0bf532bbc 100644 --- a/gems/espalier/test/architecture_tools_test.rb +++ b/gems/espalier/test/architecture_tools_test.rb @@ -7,12 +7,12 @@ require "fileutils" # End-to-end coverage for the architecture tools: cycle_report, -# reach_through_report (espalier/tools) and change_coupling (lineage/tools), -# including SARIF emission, changed-file scoping, and Lineage SARIF ingestion. +# reach_through_report (espalier/tools) and change_coupling (gigasail/tools), +# including SARIF emission, changed-file scoping, and Gigasail SARIF ingestion. class ArchitectureToolsTest < Minitest::Test TOOLS = File.expand_path("../tools", __dir__) - LINEAGE_TOOLS = File.expand_path("../../lineage/tools", __dir__) - LINEAGE_BIN = File.expand_path("../../lineage/target/release/lineage", __dir__) + GIGASAIL_TOOLS = File.expand_path("../../gigasail/tools", __dir__) + LINEAGE_BIN = File.expand_path("../../gigasail/target/release/giga", __dir__) FACT_MINE_BIN = File.expand_path("../../fact-mine/target/release/fact-mine-rust", __dir__) def setup @@ -259,7 +259,7 @@ def test_change_coupling_reports_cross_module_pairs_and_scopes_to_changes commit_all("solo change") sarif = File.join(dir, "coupling.sarif") - out = run_tool(File.join(LINEAGE_TOOLS, "change_coupling.rb"), dir, "5", "--sarif=#{sarif}") + out = run_tool(File.join(GIGASAIL_TOOLS, "change_coupling.rb"), dir, "5", "--sarif=#{sarif}") # 6 explicit co-changes plus the creating commit. assert_match(/s=7\s+c=1\.00\s+cross-module\s+core\/a\.rb <-> util\/b\.rb/, out) @@ -270,7 +270,7 @@ def test_change_coupling_reports_cross_module_pairs_and_scopes_to_changes # Changed-scope: a diff touching only the uncoupled file reports nothing. out_scoped = run_tool( - File.join(LINEAGE_TOOLS, "change_coupling.rb"), dir, "5", "--base=HEAD~1" + File.join(GIGASAIL_TOOLS, "change_coupling.rb"), dir, "5", "--base=HEAD~1" ) assert_includes out_scoped, "(none)" end @@ -289,7 +289,7 @@ def test_change_coupling_reports_cross_module_pairs_and_scopes_to_changes # so one unit's tracked rename still canonicalizes every other unit's # stale path for the same file. def test_change_coupling_db_mode_unifies_coupling_across_a_rename - skip "lineage binary missing; build gems/lineage first" unless File.executable?(LINEAGE_BIN) + skip "lineage binary missing; build gems/gigasail first" unless File.executable?(LINEAGE_BIN) with_repo do |dir| FileUtils.mkdir_p(%w[core util]) @@ -317,8 +317,8 @@ def test_change_coupling_db_mode_unifies_coupling_across_a_rename build_output, build_status = Open3.capture2(LINEAGE_BIN, "build", "--db", db, "--repo", dir) assert build_status.success?, "lineage build failed: #{build_output}" - out_default = run_tool(File.join(LINEAGE_TOOLS, "change_coupling.rb"), dir, "5") - out_db = run_tool(File.join(LINEAGE_TOOLS, "change_coupling.rb"), dir, "5", "--db=#{db}") + out_default = run_tool(File.join(GIGASAIL_TOOLS, "change_coupling.rb"), dir, "5") + out_db = run_tool(File.join(GIGASAIL_TOOLS, "change_coupling.rb"), dir, "5", "--db=#{db}") assert_match(/s=6\s+c=1\.00\s+cross-module\s+core\/a\.rb <-> util\/b\.rb/, out_default, "default mode should only see the pre-rename co-changes under the old name") @@ -328,7 +328,7 @@ def test_change_coupling_db_mode_unifies_coupling_across_a_rename end def test_sarif_outputs_ingest_into_lineage - skip "lineage binary missing; build gems/lineage first" unless File.executable?(LINEAGE_BIN) + skip "lineage binary missing; build gems/gigasail first" unless File.executable?(LINEAGE_BIN) with_repo do |dir| FileUtils.mkdir_p(%w[lib/core lib/util]) diff --git a/gems/espalier/test/big_o_gap_impact_test.rb b/gems/espalier/test/big_o_gap_impact_test.rb index 2db3b5d37..5f18d7895 100644 --- a/gems/espalier/test/big_o_gap_impact_test.rb +++ b/gems/espalier/test/big_o_gap_impact_test.rb @@ -30,6 +30,26 @@ def test_propagates_direct_root_to_all_incomplete_callers assert_includes report[:roots].first[:categories], "normalized_cost_fact_missing" end + def test_reports_project_candidate_summary_as_project_not_external + results = { + "root" => result(unknowns: ["callee"], gaps: []) + } + calls = [ + { + source: "root", + target: nil, + semantic_symbol: "runtime project symbol", + external_symbol_scope: "project" + } + ] + + report = Espalier::BigOGapImpact.analyze(results: results, calls: calls) + categories = report[:roots].first[:categories] + + assert_includes categories, "project_candidate_summary_missing" + refute_includes categories, "external_symbol_cost_missing" + end + def test_reports_untraced_incomplete_function_without_inventing_a_root report = Espalier::BigOGapImpact.analyze( results: { "gap" => result }, diff --git a/gems/espalier/test/big_o_proof_metrics_test.rb b/gems/espalier/test/big_o_proof_metrics_test.rb index 3dfbf4e88..65d3bf6a8 100644 --- a/gems/espalier/test/big_o_proof_metrics_test.rb +++ b/gems/espalier/test/big_o_proof_metrics_test.rb @@ -40,13 +40,15 @@ def test_incomplete_result_outranks_conditional_bound_expression end def test_complete_scc_bound_is_likely_while_progress_proof_remains_visible - row = quality( - qualities: ["upper_bound_acyclic_project_scc"], - assumptions: ["finite acyclic input"] - ) + %w[upper_bound_acyclic_project_scc upper_bound_structural_descent].each do |proof_quality| + row = quality( + qualities: [proof_quality], + assumptions: ["finite acyclic input"] + ) - assert_equal :known_likely, Metrics.classify(row) - assert_equal :recursive_progress, Metrics.bucket(row) + assert_equal :known_likely, Metrics.classify(row) + assert_equal :recursive_progress, Metrics.bucket(row) + end end def test_external_latency_scope_outranks_exact_target_in_underlying_bucket @@ -70,6 +72,64 @@ def test_string_keyed_quality_is_supported assert_equal :analyzer_result, Metrics.bucket(row) end + def test_coverage_gate_uses_only_production_and_reports_proof_tiers + rows = [ + row("go", quality, kind: "top"), + row("go", quality(qualities: ["upper_bound_parametric_callback_once"]), kind: "lambda"), + row("rust", quality(complete: false), kind: "instance"), + row("java", quality, role: "test", kind: "instance") + ] + + report = Metrics.coverage_gate( + rows, + minimum_percent: 60, + call_coverage: { + "eligible_call_sites" => 10, + "exact_project_targets" => 4, + "modeled_without_project_target" => 5, + "semantically_accounted_call_sites" => 9, + "unresolved_call_sites" => 1 + } + ) + + assert report[:passed] + assert_equal 3, report.dig(:coverage, :functions) + assert_equal 2, report.dig(:coverage, :mapped) + assert_equal 66.67, report.dig(:coverage, :mapped_percent) + assert_equal 1, report.dig(:coverage, :proof, :buckets, :parametric, :count) + assert_equal 2, report.dig(:by_language, "go", :functions) + refute report[:by_language].key?("java") + assert_equal 90.0, report.dig(:call_soundness, :semantically_accounted_percent) + end + + def test_coverage_gate_fails_closed_for_raw_executable_calls + report = Metrics.coverage_gate( + [row("c", quality)], + call_coverage: { raw_calls_not_normalized_inside_function: 1 } + ) + + refute report[:passed] + assert_includes report[:failures], "1 executable raw calls were not normalized" + end + + def test_coverage_gate_can_exclude_lambdas_by_explicit_policy + rows = [ + row("rust", quality, kind: "top"), + row("rust", quality(complete: false), kind: "lambda") + ] + + report = Metrics.coverage_gate(rows, include_lambdas: false) + + assert report[:passed] + assert_equal 1, report.dig(:coverage, :functions) + refute report.dig(:policy, :include_lambdas) + end + + def test_coverage_gate_rejects_invalid_minimum + error = assert_raises(ArgumentError) { Metrics.coverage_gate([], minimum_percent: 101) } + assert_match(/between 0 and 100/, error.message) + end + private def quality(complete: true, qualities: [], assumptions: [], proof_status: nil) @@ -80,4 +140,8 @@ def quality(complete: true, qualities: [], assumptions: [], proof_status: nil) big_o_proof_status: proof_status } end + + def row(language, quality, role: "production", kind: "top") + { language: language, source_role: role, kind: kind, quality: quality } + end end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb index fbe85f77a..8ce1ea743 100644 --- a/gems/espalier/test/big_o_test.rb +++ b/gems/espalier/test/big_o_test.rb @@ -317,6 +317,19 @@ def test_structural_big_o_only_consumes_normalized_facts consumer.send(:recursion_complexity, { "calls" => 2, "visited_guarded_calls" => 2, "unknown_progress_calls" => 0 }, 2) + assert_equal ["O(N)", "O(N)", "recursive descent into a projection of the input"], + consumer.send(:recursion_complexity, { + "calls" => 2, "structural_calls" => 2, "unknown_progress_calls" => 0 + }, 2) + structural = consumer.send(:summary_hint, { + "line" => 8, "parameters" => ["node"], "iterations" => [], "allocations" => [], + "size_domains" => [], "recursion" => { + "calls" => 2, "structural_calls" => 2, "unknown_progress_calls" => 0 + } + }, { line: 8, name: "walk" }) + assert_equal "O(N)", structural[:complexity] + assert_equal "upper_bound_structural_descent", structural[:complexity_bound_quality] + assert_includes structural[:complexity_assumptions].first, "finite and acyclic" unknown = consumer.send(:summary_hint, { "line" => 9, "parameters" => ["items"], "recursion" => { "calls" => 0 }, "iterations" => [{ "power" => 1, "cardinality_relation" => "unknown", "execution_multiplicity" => "unknown" }] @@ -474,12 +487,162 @@ def test_structural_big_o_propagates_unique_cross_owner_targets resolved_recursive_edges: { ["Caller", "run", "Target", "work"] => true } ).hints_for(nil, { name: "run", line: 2 }, "Caller") .find { |hint| hint[:operation] == "work" } - assert_equal "O(2^N)", recursive[:complexity] - assert_equal "O(N)", recursive[:space] - assert recursive[:time_complete] - assert recursive[:space_complete] - assert_equal "upper_bound_acyclic_project_scc", recursive[:complexity_bound_quality] - assert recursive[:complexity_assumptions].first.include?("finite and acyclic") + assert_equal "unknown", recursive[:complexity] + assert_equal "unknown", recursive[:space] + refute recursive[:time_complete] + refute recursive[:space_complete] + assert_includes recursive[:evidence_gaps], "unresolved_recursive_progress" + end + + def test_structural_big_o_discharge_resolved_constant_project_calls + facts = { + "caller-id" => [{ + "line" => 2, "parameters" => [], "iterations" => [], + "recursion" => { "calls" => 0 }, + "call_contexts" => [{ + "line" => 3, "span" => [3, 4, 3, 12], "message" => "build", + "execution_multiplicity" => "O(1)", + "argument_cardinality_relation" => "same", + "evidence_gap" => "unresolved_call_target" + }] + }] + } + consumer = Espalier::StructuralBigO.new( + facts_by_method: facts, + method_complexities: { "constructor-id" => "O(1)" }, + method_spaces: { "constructor-id" => "O(1)" }, + method_time_complete: { "constructor-id" => true }, + method_space_complete: { "constructor-id" => true }, + resolved_calls: { + ["caller-id", "build", [3, 4, 3, 12]] => + ["Target", "Target", "constructor-id"] + } + ) + + hint = consumer + .hints_for(nil, { id: "caller-id", name: "run", line: 2 }, "Caller") + .find { |row| row[:operation] == "build" } + refute_nil hint + assert_equal "O(1)", hint[:complexity] + assert_equal "O(1)", hint[:space] + assert hint[:time_complete] + assert hint[:space_complete] + refute hint[:is_dynamic] + end + + # An exact recursive edge whose progress cannot be proven is not a proven + # exponential: it is an unproven bound. Reporting O(2^N) as complete both + # asserts a bound nothing established and hides the missing proof from the + # gap diagnostics, which key on evidence gaps. + def test_resolved_recursive_edge_without_progress_proof_is_unknown_not_exponential + facts = { + ["Walker", "visit"] => [{ + "line" => 1, "parameters" => ["node"], "iterations" => [], + "recursion" => { "calls" => 0 }, + "call_contexts" => [{ + "line" => 2, "message" => "visit", "execution_multiplicity" => "O(1)", + "argument_progress" => "unknown", "argument_cardinality_relation" => "same" + }] + }] + } + hint = Espalier::StructuralBigO.new( + facts_by_method: facts, + method_complexities: { "Walker" => { "visit" => "O(N)" } }, + resolved_calls: { ["Walker", "visit", "visit", 2] => ["Walker", "visit"] }, + resolved_recursive_edges: { ["Walker", "visit", "Walker", "visit"] => true } + ).hints_for(nil, { name: "visit", line: 1 }, "Walker") + .find { |row| row[:operation] == "visit" } + + assert_equal "unknown", hint[:complexity] + assert_equal "unknown", hint[:space] + refute hint[:time_complete] + refute hint[:space_complete] + assert_includes hint[:evidence_gaps], "unresolved_recursive_progress" + assert_nil hint[:complexity_bound_quality] + end + + # A recursive call over a loop's own partition binding reaches each element + # once. The shrinking-token heuristic must not outrank that proof: ordering + # the loop-contained branch first reports O(N!) for a plain traversal. + def test_loop_contained_recursion_over_a_partition_is_linear_not_factorial + facts = { + ["Tree", "walk"] => [{ + "line" => 1, "parameters" => ["node"], "iterations" => [], + "recursion" => { "calls" => 0 }, + "call_contexts" => [{ + "line" => 2, "message" => "walk", "execution_multiplicity" => "O(N)", + "argument_progress" => "shrinking", + "argument_cardinality_relation" => "partition_of" + }] + }] + } + hint = Espalier::StructuralBigO.new( + facts_by_method: facts, + method_complexities: { "Tree" => { "walk" => "O(N)" } }, + resolved_calls: { ["Tree", "walk", "walk", 2] => ["Tree", "walk"] }, + resolved_recursive_edges: { ["Tree", "walk", "Tree", "walk"] => true } + ).hints_for(nil, { name: "walk", line: 1 }, "Tree") + .find { |row| row[:operation] == "walk" } + + assert_equal "O(N)", hint[:complexity] + assert hint[:time_complete] + assert hint[:space_complete] + end + + # Structural descent is the shape most recursive visitors have: no operand + # shrinks arithmetically, so it used to have no bound at all. Descending one + # level into a finite structure reaches each node once. + def test_structural_descent_recursion_is_linear_in_the_structure + facts = { + ["Walker", "visit"] => [{ + "line" => 1, "parameters" => ["node"], "iterations" => [], + "recursion" => { "calls" => 0 }, + "call_contexts" => [{ + "line" => 2, "message" => "visit", "execution_multiplicity" => "O(1)", + "argument_progress" => "structural", + "argument_cardinality_relation" => "same" + }] + }] + } + hint = Espalier::StructuralBigO.new( + facts_by_method: facts, + method_complexities: { "Walker" => { "visit" => "O(N)" } }, + resolved_calls: { ["Walker", "visit", "visit", 2] => ["Walker", "visit"] }, + resolved_recursive_edges: { ["Walker", "visit", "Walker", "visit"] => true } + ).hints_for(nil, { name: "visit", line: 1 }, "Walker") + .find { |row| row[:operation] == "visit" } + + assert_equal "O(N)", hint[:complexity] + assert hint[:time_complete] + assert_equal "upper_bound_structural_descent", hint[:complexity_bound_quality] + assert_includes hint[:complexity_assumptions].first, "acyclic" + # The conditional bound must not masquerade as an unconditional proof. + assert_equal "partial", hint[:confidence] + end + + # Unresolved mutual recursion already degrades to unknown, but published no + # evidence gap, so the diagnostics could not attribute the incompleteness. + def test_unproven_mutual_recursion_publishes_a_recursive_progress_gap + facts = { + ["Pair", "ping"] => [{ + "line" => 1, "parameters" => [], "iterations" => [], + "recursion" => { "calls" => 0 }, + "call_contexts" => [{ + "line" => 2, "message" => "pong", "execution_multiplicity" => "O(1)", + "argument_cardinality_relation" => "same" + }] + }] + } + hint = Espalier::StructuralBigO.new( + facts_by_method: facts, + internal_calls: { "Pair" => { "ping" => ["pong"] } }, + recursive_edges: { ["Pair", "ping", "pong"] => true } + ).hints_for(nil, { name: "ping", line: 1 }, "Pair") + .find { |row| row[:operation] == "pong" } + + assert_equal "unknown", hint[:complexity] + refute hint[:time_complete] + assert_includes hint[:evidence_gaps], "unresolved_recursive_progress" end def test_remaining_complexity_lattice_and_type_resolution_paths diff --git a/gems/espalier/test/c_stdlib_map_test.rb b/gems/espalier/test/c_stdlib_map_test.rb new file mode 100644 index 000000000..dbfe24f8b --- /dev/null +++ b/gems/espalier/test/c_stdlib_map_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "open3" +require "tmpdir" + +class CStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + PROBE = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "c", + "semantic_environment.rb" + ) + + def test_environment_probe_attests_unversioned_c_symbols + Dir.mktmpdir do |directory| + compiler = fake_tool(directory, "clang", <<~SH) + case "$1" in + --version) echo "test clang 20" ;; + -dumpmachine) echo "x86_64-test-linux-gnu" ;; + *) echo "#define TEST_ABI 1" ;; + esac + SH + indexer = fake_tool(directory, "scip-clang", 'echo "scip-clang 0.4.0"') + libc = fake_tool(directory, "libc.so.6", 'echo "test glibc 2.39"') + header = File.join(directory, "string.h") + File.write(header, "void *memcpy(void *, const void *, unsigned long);\n") + File.write( + File.join(directory, "compile_commands.json"), + JSON.generate([{ + "arguments" => [compiler, "-std=c17", "-DTEST=1", "-c", "probe.c"] + }]) + ) + output = File.join(directory, "environment.json") + stdout, stderr, status = Open3.capture3( + { + "SCIP_CLANG" => indexer, + "C_LIBC_BINARY" => libc, + "C_LIBC_HEADERS" => header + }, + "ruby", + PROBE, + directory, + output + ) + assert status.success?, "#{stdout}\n#{stderr}" + + environment = JSON.parse(File.read(output)) + assert_equal "fact-mine.semantic-environment.v1", environment.fetch("schema") + claims = environment.fetch("claims") + assert_equal "test glibc 2.39", claims.fetch("c.libc.release") + assert_equal "x86_64-test-linux-gnu", claims.fetch("c.compiler.target") + assert_equal "scip-clang 0.4.0", claims.fetch("c.scip_clang.version") + assert claims.fetch("c.libc.binary.sha256").start_with?("sha256:") + assert claims.fetch("c.preprocessor_macros.sha256").start_with?("sha256:") + end + end + + private + + def fake_tool(directory, name, body) + path = File.join(directory, name) + File.write(path, "#!/bin/sh\n#{body}\n") + File.chmod(0o755, path) + path + end +end diff --git a/gems/espalier/test/check_big_o_coverage_test.rb b/gems/espalier/test/check_big_o_coverage_test.rb new file mode 100644 index 000000000..9765ef2f0 --- /dev/null +++ b/gems/espalier/test/check_big_o_coverage_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "minitest/autorun" +require "open3" +require_relative "../lib/espalier/static_evidence" +require "rbconfig" +require "tmpdir" + +class CheckBigOCoverageTest < Minitest::Test + ROOT = File.expand_path("../../..", __dir__) + # Whichever profile is built, the way every other caller resolves it. CI + # builds --release, so naming the debug path made these tests error rather + # than run. + FACT_MINE = Espalier::StaticEvidence::FACT_MINE_RUST_BINARY + SCRIPT = File.join(ROOT, "gems/espalier/script/check_big_o_coverage.rb") + + def test_accepts_profile_paths_relative_to_source_root + Dir.mktmpdir("espalier-big-o-coverage", ROOT) do |root| + source = File.join(root, "repository", "lib", "worker.rb") + FileUtils.mkdir_p(File.dirname(source)) + File.write(source, <<~RUBY) + class Worker + def run + 1 + end + end + RUBY + + profile, error, status = Open3.capture3( + FACT_MINE, "profile", "espalier", "repository/lib/worker.rb", chdir: root + ) + assert status.success?, error + assert_equal "repository/lib/worker.rb", JSON.parse(profile).fetch("methods").first.fetch("path") + + profile_path = File.join(root, "profile.json") + File.write(profile_path, profile) + output, coverage_error, coverage_status = Open3.capture3( + RbConfig.ruby, SCRIPT, "--source-root", root, "--repository", "repository", "--minimum", "0", profile_path + ) + + assert coverage_status.success?, coverage_error + assert_equal 1, JSON.parse(output).dig("coverage", "functions") + end + end +end diff --git a/gems/espalier/test/compare_scip_big_o_test.rb b/gems/espalier/test/compare_scip_big_o_test.rb new file mode 100644 index 000000000..ddbf754f4 --- /dev/null +++ b/gems/espalier/test/compare_scip_big_o_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "minitest/autorun" +require "open3" +require_relative "../lib/espalier/static_evidence" +require "rbconfig" +require "tmpdir" + +class CompareScipBigOTest < Minitest::Test + ROOT = File.expand_path("../../..", __dir__) + # Whichever profile is built, the way every other caller resolves it. CI + # builds --release, so naming the debug path made these tests error rather + # than run. + FACT_MINE = Espalier::StaticEvidence::FACT_MINE_RUST_BINARY + SCRIPT = File.join(ROOT, "gems/espalier/script/compare_scip_big_o.rb") + + def test_accepts_profiles_with_paths_relative_to_the_declared_source_root + Dir.mktmpdir("espalier-scip-comparison", ROOT) do |root| + source = File.join(root, "repository", "lib", "worker.rb") + FileUtils.mkdir_p(File.dirname(source)) + File.write(source, <<~RUBY) + class Worker + def run + 1 + end + end + RUBY + + profile, error, status = Open3.capture3( + FACT_MINE, "profile", "espalier", "repository/lib/worker.rb", chdir: root + ) + assert status.success?, error + assert_equal "repository/lib/worker.rb", JSON.parse(profile).fetch("methods").first.fetch("path") + + profile_path = File.join(root, "profile.json") + File.write(profile_path, profile) + output, comparison_error, comparison_status = Open3.capture3( + RbConfig.ruby, SCRIPT, "--source-root", root, "--repository", "repository", profile_path, profile_path + ) + + assert comparison_status.success?, comparison_error + comparison = JSON.parse(output) + assert_equal 1, comparison.dig("baseline", "functions") + assert_equal 1, comparison.dig("scip", "functions") + end + end +end diff --git a/gems/espalier/test/complexity_summary_test.rb b/gems/espalier/test/complexity_summary_test.rb new file mode 100644 index 000000000..b8e62e87f --- /dev/null +++ b/gems/espalier/test/complexity_summary_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/espalier" + +class ComplexitySummaryTest < Minitest::Test + def quality(bound_qualities: [], complete: true, space_complete: true) + { + big_o_complete: complete, + big_o_space_complete: space_complete, + big_o_bound_qualities: bound_qualities + } + end + + def test_accepts_analyzed_structure_and_exact_analyzed_targets + assert Espalier::ComplexitySummary.source_proven?(quality, []) + assert Espalier::ComplexitySummary.source_proven?( + quality(bound_qualities: ["upper_bound_exact_target"]), + [] + ) + end + + def test_source_method_requires_an_executable_normalization_complete_body + assert Espalier::ComplexitySummary.source_method_proven?( + {"source_export_eligible" => true}, + quality, + [] + ) + refute Espalier::ComplexitySummary.source_method_proven?({}, quality, []) + refute Espalier::ComplexitySummary.source_method_proven?( + {"source_export_eligible" => false}, + quality, + [] + ) + refute Espalier::ComplexitySummary.source_method_proven?( + {"source_export_eligible" => true, "callback_params" => []}, + quality(bound_qualities: ["upper_bound_parametric_callback_once"]), + [] + ) + assert Espalier::ComplexitySummary.source_method_proven?( + {"source_export_eligible" => true, "callback_params" => ["callback"]}, + quality(bound_qualities: ["upper_bound_parametric_callback_once"]), + [] + ) + end + + def test_symbol_relocation_is_explicit_and_fail_closed + assert_equal( + "semanticdb maven jdk 21 java/lang/String#length().", + Espalier::ComplexitySummary.relocate_symbol( + "semanticdb maven temporary/java.base 21 java/lang/String#length().", + from: "semanticdb maven temporary/java.base 21 ", + to: "semanticdb maven jdk 21 " + ) + ) + assert_raises(ArgumentError) do + Espalier::ComplexitySummary.relocate_symbol( + "semanticdb maven other 21 java/lang/String#length().", + from: "semanticdb maven temporary/java.base 21 ", + to: "semanticdb maven jdk 21 " + ) + end + assert_raises(ArgumentError) do + Espalier::ComplexitySummary.relocate_symbol("symbol", from: "symbol") + end + end + + def test_exact_symbol_bridge_preserves_one_to_many_declaration_identities + row = {"time" => "O(1)", "space" => "O(1)"} + bridged = Espalier::ComplexitySummary.bridge_symbol_rows( + [["implementation Math.exp", row], ["implementation absent", row]], + symbol_map: { + "implementation Math.exp" => ["runtime Math#exp", "runtime Math.exp"] + } + ) + + assert_equal( + [["runtime Math#exp", row], ["runtime Math.exp", row]], + bridged + ) + end + + def test_symbol_bridge_and_prefix_relocation_share_one_generic_transform + row = {"time" => "O(N)", "space" => "O(1)"} + assert_equal( + [["consumer pkg fn", row]], + Espalier::ComplexitySummary.bridge_symbol_rows( + [["producer pkg fn", row]], + prefix_from: "producer ", + prefix_to: "consumer " + ) + ) + end + + def test_rejects_incomplete_or_manual_model_derived_bounds + refute Espalier::ComplexitySummary.source_proven?(quality(complete: false), []) + refute Espalier::ComplexitySummary.source_proven?(quality(space_complete: false), []) + + %w[ + upper_bound_declared_receiver + upper_bound_compiler_declared_receiver + upper_bound_external_latency_excluded + upper_bound_modeled_world + upper_bound_unknown_cardinality_relation + ].each do |bound_quality| + refute Espalier::ComplexitySummary.source_proven?( + quality(bound_qualities: [bound_quality]), + [] + ) + end + end + + def test_uses_post_scip_completeness_instead_of_stale_adapter_call_gaps + facts = [ + { + "call_contexts" => [ + {"message" => "PrintDefaults", "evidence_gap" => "unresolved_receiver_type"} + ] + } + ] + + assert Espalier::ComplexitySummary.source_proven?(quality, facts) + refute Espalier::ComplexitySummary.source_method_proven?( + {"source_export_eligible" => false}, + quality, + facts + ) + end + + def test_candidate_export_requires_explicit_downstream_closure + refute Espalier::ComplexitySummary.consumer_closed_candidate_set?({ + "candidate_targets" => ["implementation"] + }) + assert Espalier::ComplexitySummary.consumer_closed_candidate_set?({ + "candidate_targets" => ["implementation"], + "consumer_closed_candidate_set" => true + }) + end +end diff --git a/gems/espalier/test/cpp_stdlib_map_test.rb b/gems/espalier/test/cpp_stdlib_map_test.rb new file mode 100644 index 000000000..0e1ea1874 --- /dev/null +++ b/gems/espalier/test/cpp_stdlib_map_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "json" +require "digest" +require "fileutils" +require "minitest/autorun" +require "open3" +require "tmpdir" +require "zlib" + +class CppStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + CPP = File.join(ROOT, "fact-mine", "config", "stdlib_maps", "cpp") + + def test_revision_digest_is_path_stable + Dir.mktmpdir do |directory| + first = File.join(directory, "first", "include") + second = File.join(directory, "second", "include") + [first, second].each do |root| + FileUtils.mkdir_p(File.join(root, "bits")) + File.write(File.join(root, "vector"), "vector body") + File.write(File.join(root, "bits", "config.h"), "config") + end + + compute = lambda do |root| + digest = Digest::SHA256.new + Dir.glob(File.join(root, "**/*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .sort + .each do |path| + relative = path.delete_prefix("#{root}#{File::SEPARATOR}") + digest << relative << "\0" << Digest::SHA256.file(path).hexdigest << "\n" + end + digest.hexdigest + end + assert_equal compute.call(first), compute.call(second) + end + end + + def test_environment_is_entirely_language_owned_and_exact + source = File.read(File.join(CPP, "semantic_environment.rb")) + assert_includes source, "cpp.stdlib.effective_headers.sha256" + assert_includes source, "cpp.preprocessor_macros.sha256" + assert_includes source, "cpp.compiler.target" + assert_includes source, "cpp.scip_clang.sha256" + end + + def test_symbol_bridge_only_admits_exact_std_rows + Dir.mktmpdir do |directory| + summary = File.join(directory, "summary.json.gz") + output = File.join(directory, "bridge.json") + payload = { + "symbols" => { + "cxx . . $ std/vector#size()." => { + "bound_quality" => "upper_bound_exact_symbol" + }, + "cxx . . $ std/get()." => { + "bound_quality" => "upper_bound_parametric_reflective_once" + }, + "cxx . . $ __gnu_cxx/helper()." => { + "bound_quality" => "upper_bound_exact_symbol" + } + } + } + Zlib::GzipWriter.open(summary) { |gzip| gzip.write(JSON.generate(payload)) } + + stdout, stderr, status = Open3.capture3( + "ruby", + File.join(CPP, "build_symbol_bridge.rb"), + summary, + output + ) + assert status.success?, "#{stdout}\n#{stderr}" + assert_equal( + { + "cxx . . $ std/vector#size()." => + "cxx . . $ std/vector#size()." + }, + JSON.parse(File.read(output)).fetch("symbols") + ) + end + end +end diff --git a/gems/espalier/test/csharp_stdlib_map_test.rb b/gems/espalier/test/csharp_stdlib_map_test.rb new file mode 100644 index 000000000..e47d7a81e --- /dev/null +++ b/gems/espalier/test/csharp_stdlib_map_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "tmpdir" +require "zlib" + +class CsharpStdlibMapTest < Minitest::Test + SCRIPT = File.expand_path( + "../../fact-mine/config/stdlib_maps/csharp/build_symbol_bridge.rb", + __dir__ + ) + + def test_bridge_maps_only_runtime_backed_corelib_owners + Dir.mktmpdir do |directory| + summary = File.join(directory, "summary.json.gz") + output = File.join(directory, "bridge.json") + Zlib::GzipWriter.open(summary) do |gzip| + gzip.write(JSON.generate({ + "symbols" => { + "scip-dotnet nuget . . System/String#Trim()." => {}, + "scip-dotnet nuget . . Generic/List#Add()." => {}, + "scip-dotnet nuget . . Collections/ArrayList#Count." => {}, + "scip-dotnet nuget . . Collections/ListDictionaryInternal#Add()." => {} + } + })) + end + + assert system(RbConfig.ruby, SCRIPT, summary, output, "10.0.0.0") + bridge = JSON.parse(File.read(output)) + assert_equal "fact-mine.symbol-bridge.v1", bridge.fetch("schema") + assert_equal( + "scip-dotnet nuget System.Runtime 10.0.0.0 System/String#Trim().", + bridge.dig("symbols", "scip-dotnet nuget . . System/String#Trim().") + ) + assert_equal( + "scip-dotnet nuget System.Collections 10.0.0.0 Generic/List#Add().", + bridge.dig("symbols", "scip-dotnet nuget . . Generic/List#Add().") + ) + assert_equal( + "scip-dotnet nuget System.Collections.NonGeneric 10.0.0.0 Collections/ArrayList#Count.", + bridge.dig("symbols", "scip-dotnet nuget . . Collections/ArrayList#Count.") + ) + refute bridge.fetch("symbols").key?( + "scip-dotnet nuget . . Collections/ListDictionaryInternal#Add()." + ) + end + end +end diff --git a/gems/espalier/test/dependency_graph_test.rb b/gems/espalier/test/dependency_graph_test.rb index 0c81e5ec9..374ca3a43 100644 --- a/gems/espalier/test/dependency_graph_test.rb +++ b/gems/espalier/test/dependency_graph_test.rb @@ -4,6 +4,16 @@ require_relative "../lib/espalier" class DependencyGraphTest < Minitest::Test + def test_markdown_output_renders_nested_manifest_records + markdown = Espalier::Formatter.to_markdown(service_manifest) + + assert_includes markdown, "## Class: Service" + assert_includes markdown, "### State:" + assert_includes markdown, "#### - `run`" + assert_includes markdown, "always_calls: [`prepare`, `@repo.fetch`, `String.upcase`]" + assert_includes markdown, "internal_callers: [`run`]" + end + def test_dot_output_renders_owner_function_and_dependency_edges dot = Espalier::Formatter.to_dot(service_manifest) diff --git a/gems/espalier/test/diagnose_big_o_gaps_test.rb b/gems/espalier/test/diagnose_big_o_gaps_test.rb new file mode 100644 index 000000000..3c8546294 --- /dev/null +++ b/gems/espalier/test/diagnose_big_o_gaps_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "minitest/autorun" +require "open3" +require_relative "../lib/espalier/static_evidence" +require "tmpdir" + +class DiagnoseBigOGapsTest < Minitest::Test + ROOT = File.expand_path("../../..", __dir__) + # Whichever profile is built, the way every other caller resolves it. CI + # builds --release, so naming the debug path made these tests error rather + # than run. + FACT_MINE = Espalier::StaticEvidence::FACT_MINE_RUST_BINARY + SCRIPT = File.join(ROOT, "gems/espalier/script/diagnose_big_o_gaps.rb") + + def test_accepts_fact_mine_profiles_with_paths_relative_to_source_root + Dir.mktmpdir("espalier-gap-diagnostics", ROOT) do |root| + source_root = File.join(root, "repository") + FileUtils.mkdir_p(File.join(source_root, "lib")) + File.write(File.join(source_root, "lib", "worker.rb"), <<~RUBY) + class Worker + def run + 1 + end + end + RUBY + + profile, profile_error, profile_status = Open3.capture3( + FACT_MINE, "profile", "espalier", "repository/lib/worker.rb", chdir: root + ) + assert profile_status.success?, profile_error + assert_equal "repository/lib/worker.rb", JSON.parse(profile).fetch("methods").first.fetch("path") + + Dir.chdir(root) do + profile_path = File.join(root, "profile.json") + File.write(profile_path, profile) + output, error, status = Open3.capture3( + RbConfig.ruby, SCRIPT, "--source-root", root, "--repository", "repository", profile_path + ) + + assert status.success?, error + report = JSON.parse(output) + assert_equal 1, report.dig("summary", "functions") + end + end + end + + def test_distinguishes_unobserved_runtime_calls_from_failed_semantic_identity_joins + Dir.mktmpdir("espalier-gap-runtime-observation", ROOT) do |root| + source_root = File.join(root, "repository") + FileUtils.mkdir_p(File.join(source_root, "lib")) + File.write(File.join(source_root, "lib", "worker.rb"), <<~RUBY) + class Worker + def run(value) + value.unmodeled + end + end + RUBY + + profile_json, profile_error, profile_status = Open3.capture3( + FACT_MINE, "profile", "espalier", "repository/lib/worker.rb", chdir: root + ) + assert profile_status.success?, profile_error + profile = JSON.parse(profile_json) + assert_equal 1, profile.fetch("calls").length + + profile_path = File.join(root, "profile.json") + File.write(profile_path, JSON.generate(profile)) + unobserved_output, unobserved_error, unobserved_status = Open3.capture3( + RbConfig.ruby, SCRIPT, "--source-root", root, "--repository", "repository", profile_path + ) + assert unobserved_status.success?, unobserved_error + unobserved = JSON.parse(unobserved_output) + assert_equal 1, unobserved.dig("call_resolution", "runtime_callsite_unobserved", "calls") + assert_nil unobserved.dig("call_resolution", "semantic_identity_missing") + assert_includes( + unobserved.dig("root_cause_categories", "runtime_callsite_unobserved", "call_examples"), + { + "path" => "repository/lib/worker.rb", + "line" => 3, + "receiver" => "value", + "message" => "unmodeled", + "semantic_symbol" => nil, + "unresolved_reason" => "receiver_requires_corpus_resolution" + } + ) + + profile.fetch("calls").first["runtime_evidence_observed"] = true + File.write(profile_path, JSON.generate(profile)) + observed_output, observed_error, observed_status = Open3.capture3( + RbConfig.ruby, SCRIPT, "--source-root", root, "--repository", "repository", profile_path + ) + assert observed_status.success?, observed_error + observed = JSON.parse(observed_output) + assert_equal 1, observed.dig("call_resolution", "semantic_identity_missing", "calls") + assert_nil observed.dig("call_resolution", "runtime_callsite_unobserved") + end + end +end diff --git a/gems/espalier/test/fixtures/big_o/oracle.json b/gems/espalier/test/fixtures/big_o/oracle.json index 26267e9a7..f89b88f44 100644 --- a/gems/espalier/test/fixtures/big_o/oracle.json +++ b/gems/espalier/test/fixtures/big_o/oracle.json @@ -16,7 +16,7 @@ }, "recursive_suffix_rescan.rb": { "walk": "O(N^2)", - "scan_remaining": "O(N)" + "scan_remaining": "O(N + C + C2)" }, "recursive_suffix_rescan.py": { "walk": "O(N^2)", diff --git a/gems/espalier/test/javascript_stdlib_map_test.rb b/gems/espalier/test/javascript_stdlib_map_test.rb new file mode 100644 index 000000000..846db4538 --- /dev/null +++ b/gems/espalier/test/javascript_stdlib_map_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "open3" +require "tmpdir" + +class JavascriptStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + PROBE = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "javascript", + "semantic_environment.rb" + ) + + def test_environment_probe_attests_node_and_v8 + Dir.mktmpdir do |directory| + node = fake_tool( + directory, + "node", + "echo '{\"node\":\"22.1.0\",\"v8\":\"12.4-test\",\"modules\":\"127\"}'" + ) + indexer = fake_tool(directory, "scip-typescript", 'echo "scip-typescript 0.3.17"') + output = File.join(directory, "environment.json") + stdout, stderr, status = Open3.capture3( + {"NODE" => node, "SCIP_TYPESCRIPT" => indexer}, + "ruby", + PROBE, + directory, + output + ) + assert status.success?, "#{stdout}\n#{stderr}" + + claims = JSON.parse(File.read(output)).fetch("claims") + assert_equal "node", claims.fetch("javascript.runtime") + assert_equal "22.1.0", claims.fetch("javascript.node.version") + assert_equal "12.4-test", claims.fetch("javascript.v8.version") + assert_equal "127", claims.fetch("javascript.node.modules_abi") + assert claims.fetch("javascript.node.sha256").start_with?("sha256:") + end + end + + private + + def fake_tool(directory, name, body) + path = File.join(directory, name) + File.write(path, "#!/bin/sh\n#{body}\n") + File.chmod(0o755, path) + path + end +end diff --git a/gems/espalier/test/kotlin_stdlib_map_test.rb b/gems/espalier/test/kotlin_stdlib_map_test.rb new file mode 100644 index 000000000..bb898ad08 --- /dev/null +++ b/gems/espalier/test/kotlin_stdlib_map_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "yaml" +require "zlib" + +class KotlinStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + MAP_DIR = File.join(ROOT, "fact-mine", "config", "stdlib_maps") + SUMMARY = File.join( + ROOT, + "fact-mine", + "config", + "complexity_summaries", + "kotlin-stdlib.kotlin2.2.0.json.gz" + ) + + def test_manifest_pins_source_runtime_and_corrected_indexer + manifest = YAML.safe_load( + File.read(File.join(MAP_DIR, "kotlin-2.2.0.yml")), + permitted_classes: [], + aliases: false + ) + assert_equal "kotlin", manifest.fetch("language") + assert_equal "0.12.3", manifest.dig("index", "expected", "version").to_s + assert_equal( + "semanticdb maven . . kotlin/", + manifest.dig("summary", "symbol_relocation", "from") + ) + assert_equal( + "scip-java maven . . kotlin/", + manifest.dig("summary", "symbol_relocation", "to") + ) + + materializer = File.read(File.join(MAP_DIR, "kotlin", "materialize_source.rb")) + assert_includes materializer, "967ad9599254e3a60d96d6c789547cc35c22d770d9c8fb1e3f15fac3b4c3b65d" + assert_includes materializer, "65d12d85a3b865c160db9147851712a64b10dadd68b22eea22a95bf8a8670dca" + + patch = File.read( + File.join(MAP_DIR, "kotlin", "scip-kotlin-top-level-symbols.patch") + ) + assert_includes patch, "symbolProvider.getTopLevelCallableSymbols" + assert_includes patch, "- is FirFileSymbol -> containingSymbol.fir.declarations" + assert_includes patch, "+ is FirFileSymbol ->" + end + + def test_bundle_is_fail_closed_on_exact_runtime_digest + summary = Zlib::GzipReader.open(SUMMARY) { |gzip| JSON.parse(gzip.read) } + assert_equal "fact-mine.external-complexity-summary.v3", summary.fetch("schema") + assert_operator summary.fetch("symbols").length, :>=, 80 + assert summary.fetch("symbols").keys.all? do |symbol| + symbol.start_with?("scip-java maven . . kotlin/") + end + assert_equal( + "sha256:65d12d85a3b865c160db9147851712a64b10dadd68b22eea22a95bf8a8670dca", + summary.dig("compatibility", "claims", "kotlin.stdlib.binary.sha256") + ) + end +end diff --git a/gems/espalier/test/php_stdlib_map_test.rb b/gems/espalier/test/php_stdlib_map_test.rb new file mode 100644 index 000000000..c157e9c45 --- /dev/null +++ b/gems/espalier/test/php_stdlib_map_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "minitest/autorun" + +class PhpStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + PHP = File.join(ROOT, "fact-mine", "config", "stdlib_maps", "php") + + def test_consumer_indexer_patch_uses_commit_qualified_version + patch = File.read(File.join(PHP, "scip-php-exact-version.patch")) + assert_includes patch, "$version = '0.0.1+71a5b117';" + end +end diff --git a/gems/espalier/test/reporter_test.rb b/gems/espalier/test/reporter_test.rb index 099ae17ea..c74b30286 100644 --- a/gems/espalier/test/reporter_test.rb +++ b/gems/espalier/test/reporter_test.rb @@ -6,6 +6,18 @@ require_relative "../lib/espalier" class ReporterTest < Minitest::Test + def test_from_yaml_file_constructs_reporter_from_real_yaml + Dir.mktmpdir do |dir| + path = File.join(dir, "manifest.yml") + File.write(path, YAML.dump([{ module: "Example", functions: [] }])) + + reporter = Espalier::Reporter.from_yaml_file(path, root: dir) + + assert_instance_of Espalier::Reporter, reporter + assert_includes reporter.to_markdown, "Modules/classes indexed: 1" + end + end + def test_report_ranks_architecture_specific_findings manifest = [ { diff --git a/gems/espalier/test/ruby_stdlib_map_test.rb b/gems/espalier/test/ruby_stdlib_map_test.rb new file mode 100644 index 000000000..e1055af90 --- /dev/null +++ b/gems/espalier/test/ruby_stdlib_map_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "json" +require "fileutils" +require "open3" +require "tmpdir" +require "yaml" +require "zlib" + +class RubyStdlibMapTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + SUPPORT = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "support.yml" + ) + BRIDGE = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "ruby", + "build_symbol_bridge.rb" + ) + ENVIRONMENT = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "ruby", + "semantic_environment.rb" + ) + MANIFEST = File.join(ROOT, "fact-mine", "config", "stdlib_maps", "ruby-3.2.3.yml") + + def test_cruby_core_a_profiles_the_indexed_regexp_and_struct_implementation_surfaces + manifest = YAML.safe_load(File.read(MANIFEST)) + includes = manifest.fetch("source").fetch("include") + + assert_includes includes, "{array,dir,enum,error,file,hash,io,math,numeric,re,string,struct}.c" + end + + def test_cruby_registration_bridge_preserves_exact_aliases_and_rejects_unproven_bodies + Dir.mktmpdir do |directory| + File.write(File.join(directory, "math.c"), <<~C) + rb_define_module_function(rb_mMath, "exp", math_exp, 1); + rb_define_module_function(rb_mMath, "log", math_log, 1); + C + File.write(File.join(directory, "file.c"), <<~C) + define_filetest_function("executable?", rb_file_executable_p, 1); + rb_define_singleton_method(rb_cFile, "realpath", rb_file_s_realpath, -1); + C + File.write(File.join(directory, "struct.c"), <<~C) + rb_define_singleton_method(rb_cStruct, "new", rb_struct_s_def, -1); + C + producer = File.join(directory, "producer.json.gz") + profile = File.join(directory, "profile.json") + output = File.join(directory, "bridge.json") + exact = "cxx . . $ math_exp(1)." + executable = "cxx . . $ rb_file_executable_p(1)." + realpath = "cxx . . $ rb_file_s_realpath(1)." + struct_new = "cxx . . $ rb_struct_s_def(1)." + Zlib::GzipWriter.open(producer) do |gzip| + gzip.write(JSON.generate({ + "symbols" => { + exact => { "bound_quality" => "upper_bound_exact_symbol" }, + executable => { "bound_quality" => "upper_bound_exact_symbol" }, + realpath => { "bound_quality" => "upper_bound_exact_symbol" }, + struct_new => { "bound_quality" => "upper_bound_exact_symbol" }, + "cxx . . $ math_log(1)." => { "bound_quality" => "upper_bound_modeled_world" } + } + })) + end + File.write(profile, JSON.generate({ + "methods" => [ + { "path" => File.join(directory, "math.c"), "name" => "math_exp", "semantic_symbol" => exact }, + { "path" => File.join(directory, "math.c"), "name" => "math_log", "semantic_symbol" => "cxx . . $ math_log(1)." }, + { "path" => File.join(directory, "file.c"), "name" => "rb_file_executable_p", "semantic_symbol" => executable }, + { "path" => File.join(directory, "file.c"), "name" => "rb_file_s_realpath", "semantic_symbol" => realpath }, + { "path" => File.join(directory, "struct.c"), "name" => "rb_struct_s_def", "semantic_symbol" => struct_new } + ] + })) + + stdout, stderr, status = Open3.capture3( + "ruby", BRIDGE, producer, profile, directory, output, "3.2.3" + ) + assert status.success?, "#{stdout}\n#{stderr}" + symbols = JSON.parse(File.read(output)).fetch("symbols") + assert_equal( + [ + "nil-kill-runtime ruby ruby 3.2.3 Math#exp().", + "nil-kill-runtime ruby ruby 3.2.3 Math.exp()." + ], + symbols.fetch(exact) + ) + assert_equal( + [ + "nil-kill-runtime ruby ruby 3.2.3 File.executable?().", + "nil-kill-runtime ruby ruby 3.2.3 FileTest#executable?()." + ], + symbols.fetch(executable) + ) + assert_equal ["nil-kill-runtime ruby ruby 3.2.3 File.realpath()."], symbols.fetch(realpath) + assert_equal ["nil-kill-runtime ruby ruby 3.2.3 Struct.new()."], symbols.fetch(struct_new) + refute symbols.key?("cxx . . $ math_log(1).") + end + end + + def test_cruby_environment_is_pinned_to_the_runtime_trace_version + Dir.mktmpdir do |directory| + FileUtils.mkdir_p(File.join(directory, "include", "ruby")) + File.write(File.join(directory, "version.h"), "#define RUBY_VERSION_TEENY 3\n") + File.write(File.join(directory, "include", "ruby", "version.h"), <<~C) + #define RUBY_API_VERSION_MAJOR 3 + #define RUBY_API_VERSION_MINOR 2 + C + output = File.join(directory, "environment.json") + stdout, stderr, status = Open3.capture3("ruby", ENVIRONMENT, directory, output, "3.2.3") + assert status.success?, "#{stdout}\n#{stderr}" + assert_equal( + { + "runtime.language" => "ruby", + "runtime.engine" => "ruby", + "runtime.version" => "3.2.3", + "runtime.engine_version" => "3.2.3" + }, + JSON.parse(File.read(output)).fetch("claims") + ) + + _stdout, _stderr, mismatch = Open3.capture3("ruby", ENVIRONMENT, directory, output, "3.2.4") + refute mismatch.success? + end + end +end diff --git a/gems/espalier/test/scip_language_inventory_test.rb b/gems/espalier/test/scip_language_inventory_test.rb new file mode 100644 index 000000000..a35efc210 --- /dev/null +++ b/gems/espalier/test/scip_language_inventory_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "yaml" + +class ScipLanguageInventoryTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + SUPPORT = File.join( + ROOT, + "fact-mine", + "config", + "stdlib_maps", + "support.yml" + ) + + def test_all_static_scip_language_families_are_inventoried + languages = YAML.safe_load_file(SUPPORT).fetch("languages") + %w[ + c + cpp + csharp + dart + go + java + kotlin + rust + scala + typescript + visual_basic + ].each do |language| + assert languages.key?(language), "missing SCIP support status for #{language}" + end + end + + def test_unparsed_static_languages_fail_closed_before_stdlib_mapping + languages = YAML.safe_load_file(SUPPORT).fetch("languages") + %w[dart scala visual_basic].each do |language| + entry = languages.fetch(language) + assert_equal "blocked", entry.fetch("status") + assert_equal "fact_mine_language_adapter_missing", entry.fetch("blocker") + end + end +end diff --git a/gems/espalier/test/static_evidence_test.rb b/gems/espalier/test/static_evidence_test.rb index 219cd46fa..f32cf8efe 100644 --- a/gems/espalier/test/static_evidence_test.rb +++ b/gems/espalier/test/static_evidence_test.rb @@ -304,7 +304,9 @@ def test_project_modules_ranks_production_by_default_and_retains_selectable_test def test_source_roles_are_language_neutral_path_facts assert_equal "test", Espalier::StaticEvidence.source_role("src/widget_test.go") + assert_equal "test", Espalier::StaticEvidence.source_role("src/normalizer-test.rs") assert_equal "test", Espalier::StaticEvidence.source_role("tests/test_widget.py") + assert_equal "production", Espalier::StaticEvidence.source_role("gems/test-miser/lib/test_miser.rb") assert_equal "test", Espalier::StaticEvidence.source_role("Tests/ArgumentParserTests/AnyArgumentTests.swift") assert_equal "test", Espalier::StaticEvidence.source_role("src/jvmTest/kotlin/Foo.kt") assert_equal "test", Espalier::StaticEvidence.source_role("src/nonWasmTest/kotlin/Foo.kt") @@ -314,6 +316,55 @@ def test_source_roles_are_language_neutral_path_facts assert_equal "test", Espalier::StaticEvidence.source_role("Sources/ArgumentParserTestHelpers/Helpers.swift") end + def test_method_source_roles_exclude_rust_inline_test_modules_and_their_lambdas + methods = [ + { + "id" => "production", + "path" => "/project/src/lib.rs", + "language" => "rust", + "span" => [1, 0, 5, 1], + "semantic_symbol" => "rust-analyzer cargo demo 0.1.0 run()." + }, + { + "id" => "test", + "path" => "/project/src/lib.rs", + "language" => "rust", + "span" => [10, 4, 20, 5], + "semantic_symbol" => "rust-analyzer cargo demo 0.1.0 tests/check()." + }, + { + "id" => "test-lambda", + "path" => "/project/src/lib.rs", + "language" => "rust", + "kind" => "lambda", + "span" => [14, 20, 14, 32] + } + ] + + assert_equal( + { "production" => "production", "test" => "test", "test-lambda" => "test" }, + Espalier::StaticEvidence.method_source_roles(methods) + ) + end + + def test_generated_declarations_are_available_to_fact_mine_but_excluded_from_production_metrics + evidence = { + "methods" => [ + { "id" => "authored", "name" => "render", "owner" => "Report", "kind" => "instance", "path" => "lib/report.rb", "line" => 1, "language" => "ruby" }, + { "id" => "reader", "name" => "title", "owner" => "Report", "kind" => "instance", "path" => "lib/report.rb", "line" => 2, "language" => "ruby", "generated_declaration" => true } + ], + "fields" => [], + "facts" => { "calls" => [], "state_accesses" => [], "complexity_facts" => [], "struct_declarations" => [] } + } + + assert_equal( + { "authored" => "production", "reader" => "generated" }, + Espalier::StaticEvidence.method_source_roles(evidence.fetch("methods")) + ) + methods = Espalier::StaticEvidence.project_modules(evidence).fetch(0).fetch(:methods) + assert_equal ["render"], methods.map { |method| method[:name] } + end + def test_project_modules_prefers_a_primary_owner_over_an_extension_and_never_gives_protocols_state evidence = { "owners" => [ diff --git a/gems/espalier/test/stdlib_map_test.rb b/gems/espalier/test/stdlib_map_test.rb new file mode 100644 index 000000000..81816ea30 --- /dev/null +++ b/gems/espalier/test/stdlib_map_test.rb @@ -0,0 +1,490 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require_relative "../lib/espalier" + +class StdlibMapTest < Minitest::Test + class FakeRunner + def initialize(raw_call_gaps: 0) + @raw_call_gaps = raw_call_gaps + @index_working_directory = nil + @profile_files = nil + @export_commands = [] + end + + attr_reader :index_working_directory, :profile_files, :export_commands + + def run!(command, chdir:, env: {}) + raise "missing cwd" unless File.directory?(chdir) + raise "unexpected env" unless env.is_a?(Hash) + + if command.first == "fake-index" + @index_working_directory = chdir + File.write(command.fetch(1), "scip") + elsif command.include?("profile") + output = command.fetch(command.index("--output") + 1) + sources = command.drop(command.index("--output") + 2) + if (summary_index = sources.index("--complexity-summary")) + sources.slice!(summary_index, 2) + end + semantic_environment = {} + if (environment_index = sources.index("--semantic-environment")) + environment = JSON.parse(File.read(sources.fetch(environment_index + 1))) + semantic_environment = environment.fetch("claims") + sources.slice!(environment_index, 2) + end + @profile_files = sources + File.write(output, JSON.generate({ + "input_coverage" => { + "selected_files" => sources.length, + "parsed_files" => sources.length + }, + "semantic_indexes" => [{"tool" => "fake-scip", "version" => "1.2.3"}], + "semantic_environment" => semantic_environment, + "methods" => [{"source_export_eligible" => true}], + "calls" => [], + "call_resolution_coverage" => { + "raw_calls_not_normalized_inside_function" => @raw_call_gaps, + "source_export_eligible_methods_overlapping_raw_call_loss" => @raw_call_gaps + } + })) + elsif command.length > 1 && File.basename(command.fetch(1)) == "export_complexity_summary.rb" + @export_commands << command + output = command.last + claims = if command.include?("--compatibility") + JSON.parse(File.read(command.fetch(command.index("--compatibility") + 1))).fetch("claims") + else + {} + end + prefix = if command.include?("--symbol-prefix-from") + command.fetch(command.index("--symbol-prefix-to") + 1) + else + "fake pkg std 1 " + end + Zlib::GzipWriter.open(output) do |gzip| + gzip.write(JSON.generate({ + "schema" => Espalier::StdlibMap::SUMMARY_SCHEMA, + "source" => { + "complete_symbol_count" => 1, + "source_proven_method_count" => 1, + "profile_sha256" => "sha256:test", + "indexer" => "fake-scip@1.2.3", + "consumer_indexers" => command.each_index.filter_map do |index| + command[index + 1] if command[index] == "--consumer-indexer" + end + }, + "compatibility" => {"claims" => claims}, + "symbols" => { + "#{prefix}demo/run()." => { + "time" => "O(1)", + "space" => "O(1)" + } + } + })) + end + else + raise "unexpected command: #{command.inspect}" + end + end + + def capture(command, chdir:, env: {}) + raise "missing cwd" unless File.directory?(chdir) + raise "unexpected env" unless env.is_a?(Hash) + + if command == ["fake-version"] + ["fake-1\n", "", FakeStatus.new(true)] + elsif File.basename(command.fetch(1, "")) == "check_big_o_coverage.rb" + [ + JSON.generate({ + "coverage" => { + "functions" => 1, + "mapped" => 1, + "incomplete" => 0, + "mapped_percent" => 100.0 + } + }), + "", + FakeStatus.new(true) + ] + else + raise "unexpected captured command: #{command.inspect}" + end + end + + FakeStatus = Struct.new(:success?) + end + + def test_run_cli_reports_invalid_arguments + _stdout, stderr = capture_io do + assert_equal 1, Espalier::StdlibMap.run_cli([]) + end + + assert_includes stderr, "stdlib-map failed:" + end + + def test_command_runner_executes_and_displays_real_command + Dir.mktmpdir do |dir| + _stdout, stderr = capture_io do + Espalier::StdlibMap::CommandRunner.new.run!( + [RbConfig.ruby, "-e", "exit 0"], + chdir: dir + ) + end + + assert_includes stderr, RbConfig.ruby + end + end + + def test_manifest_drives_index_profile_validation_export_and_publication + Dir.mktmpdir do |directory| + source = File.join(directory, "source") + work = File.join(directory, "work") + output = File.join(directory, "published", "stdlib.json.gz") + binary = File.join(directory, "fact-mine-rust") + FileUtils.mkdir_p(source) + FileUtils.mkdir_p(File.join(directory, "consumer")) + File.write(File.join(source, "keep.go"), "package demo\n") + File.write(File.join(source, "skip_test.go"), "package demo\n") + File.write(File.join(directory, "consumer", "use.go"), "package consumer\n") + File.write(binary, "binary") + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: go + source: + root: source + revision: fake-1 + revision_check: + command: ["fake-version"] + equals: fake-1 + include: ["**/*.go"] + exclude: ["**/*_test.go"] + index: + command: ["fake-index", "{index}"] + output: fake.scip + expected: + tool: fake-scip + version: 1.2.3 + soundness: + minimum_export_eligible_methods: 1 + compatibility: + claims: + runtime.name: fake-runtime + runtime.version: fake-1 + summary: + corpus: fake-stdlib + output: #{output} + minimum_symbols: 1 + consumer_indexers: ["consumer-scip@4.5.6"] + expected_symbol_prefix: "fake consumer std 1 " + symbol_relocation: + from: "fake pkg std 1 " + to: "fake consumer std 1 " + consumers: + - name: fake-consumer + source_root: consumer + include: ["*.go"] + compatibility: + claims: + runtime.name: fake-runtime + runtime.version: fake-1 + index: + command: ["fake-index", "{index}"] + output: consumer.scip + minimum_complete_percent: 100 + YAML + + runner = FakeRunner.new + report = Espalier::StdlibMap.new( + manifest, + work_dir: work, + fact_mine: binary, + runner: runner + ).run + + assert_equal 1, report.fetch("source_files") + assert_equal "fake-1", report.fetch("source_revision") + assert_equal 1, report.dig("summary", "symbols") + assert_equal 0, report.dig("producer_summary", "verified_join_call_sites") + assert_equal 1, report.dig("profile", "source_export_eligible_methods") + assert_equal 1, report.fetch("consumers").length + assert_equal 100.0, report.dig("consumers", 0, "after", "mapped_percent") + assert File.size?(output) + assert File.exist?(File.join(work, "stdlib-map-report.json")) + assert(runner.export_commands.all? do |command| + command.each_cons(2).include?(["--consumer-indexer", "consumer-scip@4.5.6"]) + end) + summary = Zlib::GzipReader.open(output) { |gzip| JSON.parse(gzip.read) } + assert_equal ["consumer-scip@4.5.6"], summary.dig("source", "consumer_indexers") + end + end + + def test_manifest_rejects_partial_symbol_relocation + Dir.mktmpdir do |directory| + FileUtils.mkdir_p(File.join(directory, "source")) + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: java + source: + root: source + revision: fake-1 + revision_check: + command: ["fake-version"] + equals: fake-1 + include: ["**/*.java"] + index: + path: index.scip + expected: + tool: scip-java + version: 1 + summary: + corpus: jdk + output: jdk.json.gz + symbol_relocation: + from: "temporary " + YAML + + error = assert_raises(ArgumentError) { Espalier::StdlibMap.new(manifest) } + assert_includes error.message, "requires from and to" + end + end + + def test_soundness_gate_rejects_parser_call_loss_before_publication + Dir.mktmpdir do |directory| + source = File.join(directory, "source") + work = File.join(directory, "work") + output = File.join(directory, "published", "stdlib.json.gz") + binary = File.join(directory, "fact-mine-rust") + FileUtils.mkdir_p(source) + File.write(File.join(source, "keep.go"), "package demo\n") + File.write(binary, "binary") + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: go + source: + root: source + revision: fake-1 + revision_check: + command: ["fake-version"] + equals: fake-1 + include: ["**/*.go"] + index: + command: ["fake-index", "{index}"] + expected: + tool: fake-scip + version: 1.2.3 + summary: + corpus: fake-stdlib + output: #{output} + YAML + + error = assert_raises(RuntimeError) do + Espalier::StdlibMap.new( + manifest, + work_dir: work, + fact_mine: binary, + runner: FakeRunner.new(raw_call_gaps: 1) + ).run + end + assert_includes error.message, "analyzer eligibility revocation is unsound" + refute File.exist?(output) + end + end + + def test_source_revision_must_match_before_indexing + Dir.mktmpdir do |directory| + FileUtils.mkdir_p(File.join(directory, "source")) + File.write(File.join(directory, "source", "keep.go"), "package demo\n") + File.write(File.join(directory, "fact-mine-rust"), "binary") + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: go + source: + root: source + revision: fake-2 + revision_check: + command: ["fake-version"] + equals: fake-2 + include: ["**/*.go"] + index: + command: ["fake-index", "{index}"] + expected: + tool: fake-scip + version: 1.2.3 + summary: + corpus: fake-stdlib + output: stdlib.json.gz + YAML + + error = assert_raises(RuntimeError) do + Espalier::StdlibMap.new( + manifest, + work_dir: File.join(directory, "work"), + fact_mine: File.join(directory, "fact-mine-rust"), + runner: FakeRunner.new + ).run + end + assert_includes error.message, "source revision mismatch" + end + end + + def test_git_source_requires_a_full_pinned_commit + Dir.mktmpdir do |directory| + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: java + source: + revision: jdk-21 + git: + repository: https://example.test/jdk.git + commit: deadbeef + include: ["**/*.java"] + index: + path: index.scip + expected: + tool: scip-java + version: 1 + summary: + corpus: jdk + output: jdk.json.gz + YAML + + error = assert_raises(ArgumentError) { Espalier::StdlibMap.new(manifest) } + assert_includes error.message, "full 40-character commit" + end + end + + def test_selected_source_can_be_staged_for_indexers_that_scan_the_whole_workspace + Dir.mktmpdir do |directory| + source = File.join(directory, "source") + work = File.join(directory, "work") + binary = File.join(directory, "fact-mine-rust") + FileUtils.mkdir_p(source) + File.write(File.join(source, "keep.py"), "def keep(): pass\n") + File.write(File.join(source, "broken.py"), "this indexer must not see me\n") + File.write(binary, "binary") + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: python + source: + root: source + revision: fake-1 + revision_check: + command: ["fake-version"] + equals: fake-1 + include: ["keep.py"] + stage_selected_files: true + index: + command: ["fake-index", "{index}"] + expected: + tool: fake-scip + version: 1.2.3 + summary: + corpus: fake-stdlib + output: stdlib.json.gz + YAML + runner = FakeRunner.new + + report = Espalier::StdlibMap.new( + manifest, + work_dir: work, + fact_mine: binary, + runner: runner + ).run + + assert_equal File.join(work, "selected-source"), runner.index_working_directory + assert File.file?(File.join(work, "selected-source", "keep.py")) + refute File.exist?(File.join(work, "selected-source", "broken.py")) + assert_equal File.join(work, "selected-source"), report.fetch("analysis_root") + end + end + + def test_index_staging_can_include_build_inputs_without_analyzing_them + Dir.mktmpdir do |directory| + source = File.join(directory, "source") + work = File.join(directory, "work") + binary = File.join(directory, "fact-mine-rust") + FileUtils.mkdir_p(File.join(source, "include")) + File.write(File.join(source, "implementation.cc"), "int implementation() { return 1; }\n") + File.write(File.join(source, "include", "dependency.h"), "#define VALUE 1\n") + File.write(binary, "binary") + manifest = File.join(directory, "stdlib.yml") + File.write(manifest, <<~YAML) + schema: fact-mine.stdlib-map.v1 + language: cpp + source: + root: source + revision: fake-1 + revision_check: + command: ["fake-version"] + equals: fake-1 + include: ["implementation.cc"] + stage_selected_files: true + stage_include: ["include/**/*"] + index: + command: ["fake-index", "{index}"] + expected: + tool: fake-scip + version: 1.2.3 + summary: + corpus: fake-stdlib + output: stdlib.json.gz + YAML + runner = FakeRunner.new + + report = Espalier::StdlibMap.new( + manifest, + work_dir: work, + fact_mine: binary, + runner: runner + ).run + + stage = File.join(work, "selected-source") + assert File.file?(File.join(stage, "implementation.cc")) + assert File.file?(File.join(stage, "include", "dependency.h")) + assert_equal 1, report.fetch("source_files") + assert_equal [File.join(stage, "implementation.cc")], runner.profile_files + end + end + + def test_support_inventory_has_artifacts_for_bundled_languages_and_reasons_for_blocked_ones + root = File.expand_path("../..", __dir__) + directory = File.join(root, "fact-mine", "config", "stdlib_maps") + support = YAML.safe_load( + File.read(File.join(directory, "support.yml")), + permitted_classes: [], + aliases: false + ) + assert_equal "fact-mine.stdlib-map-support.v1", support.fetch("schema") + refute_empty support.fetch("languages") + + support.fetch("languages").each do |language, entry| + case entry.fetch("status") + when "bundled" + manifests = Array(entry["manifests"] || entry.fetch("manifest")) + refute_empty manifests, "#{language} manifests are missing" + manifests.each do |filename| + manifest_path = File.join(directory, filename) + assert File.file?(manifest_path), "#{language} manifest is missing: #{filename}" + manifest = YAML.safe_load(File.read(manifest_path), permitted_classes: [], aliases: false) + output = File.expand_path( + manifest.fetch("summary").fetch("output"), + File.dirname(manifest_path) + ) + assert File.size?(output), "#{language} generated summary is missing: #{filename}" + end + when "blocked" + refute_empty entry.fetch("blocker"), "#{language} blocker is missing" + refute_empty entry.fetch("required_fix"), "#{language} required fix is missing" + else + flunk "#{language} has unsupported stdlib-map status #{entry['status'].inspect}" + end + end + end +end diff --git a/gems/espalier/test/symbolic_complexity_test.rb b/gems/espalier/test/symbolic_complexity_test.rb index 909140f40..23903c460 100644 --- a/gems/espalier/test/symbolic_complexity_test.rb +++ b/gems/espalier/test/symbolic_complexity_test.rb @@ -100,6 +100,40 @@ def test_logarithms_remain_attached_to_their_source_domain assert_equal "O(log N)", Espalier::SymbolicComplexity.render(logarithmic).first end + def test_large_rendering_collapses_domains_to_a_conservative_parametric_bound + independent = 129.times.map do |index| + value = domain("param:f:value#{index}", "value#{index}", index + 1) + expression({ value["id"] => 1 }, [value]) + end + callback = Espalier::SymbolicComplexity.parameterized_cost( + id: "callback:f:block", + name: "block", + source_kind: "callback_cost" + ) + + rendered, variables = Espalier::SymbolicComplexity.render( + Espalier::SymbolicComplexity.sum(independent, callback) + ) + + assert_equal "O(N*C)", rendered + assert_equal %w[N C], variables.map { |variable| variable[:symbol] } + assert_equal [129, 1], variables.map { |variable| variable[:domain_count] } + assert_equal %w[collapsed_upper_bound callback_cost], + variables.map { |variable| variable[:source_kind] } + end + + def test_domain_annotation_reuses_normalized_terms + xs = domain("param:f:xs", "xs", 1) + value = expression({ xs["id"] => 1 }, [xs]) + annotated = Espalier::SymbolicComplexity.with_domains( + value, + value[:domains].transform_values { |entry| entry.merge("origin_function" => "callee") } + ) + + assert_same value[:terms], annotated[:terms] + assert_equal "callee", annotated[:domains].fetch(xs["id"]).fetch("origin_function") + end + def test_canonical_dag_interns_equivalent_expressions xs = domain("param:f:xs", "xs", 1) diff --git a/gems/espalier/test/tree_sitter_cov_test.rb b/gems/espalier/test/tree_sitter_cov_test.rb index 09cd54d51..4dfd2d5de 100644 --- a/gems/espalier/test/tree_sitter_cov_test.rb +++ b/gems/espalier/test/tree_sitter_cov_test.rb @@ -4,62 +4,21 @@ require_relative "../lib/espalier/tree_sitter" class TreeSitterCovTest < Minitest::Test - def setup - # Temporarily mock require to prevent actually loading the parser if it's missing - @original_require = Kernel.instance_method(:require) - Kernel.define_method(:require) { |*| true } - @original_gem = Kernel.instance_method(:gem) - Kernel.define_method(:gem) { |*| true } + def test_parser_for_loads_the_real_installed_ruby_runtime + parser = Espalier::TreeSitter.parser_for("ruby") - # Mock RbConfig::CONFIG to hit windows/mac/arm branches if possible, - # but the coverage tool tracks which branches were executed locally. - # To cover lines 25-35, 39-40, we just need to call parser_for with different languages. - @original_config = RbConfig::CONFIG.dup + assert_instance_of TreeSitter::Parser, parser end - def teardown - Kernel.define_method(:require, @original_require) - Kernel.define_method(:gem, @original_gem) - RbConfig.send(:remove_const, :CONFIG) - RbConfig.const_set(:CONFIG, @original_config) - end - - def test_parser_for_languages - # We mock TreeSitter::Language and TreeSitter::Parser to return a dummy - dummy = Object.new - unless defined?(::TreeSitter) - Object.const_set(:TreeSitter, Module.new) - ::TreeSitter.const_set(:Language, Class.new { def self.load(*); end }) - ::TreeSitter.const_set(:Parser, Class.new { def initialize(*); end }) - end - - # Ignore errors if the file doesn't exist - Espalier::TreeSitter.stub :require, true do - %w[python javascript typescript go rust zig c cpp csharp kotlin].each do |lang| - begin - Espalier::TreeSitter.parser_for(lang) - rescue StandardError - end - end - end - - # Try with different OS/CPU configs to cover those lines - { - "host_os" => "darwin", "host_cpu" => "arm64" - }.each { |k, v| RbConfig::CONFIG[k] = v } - - begin - Espalier::TreeSitter.parser_for("ruby") - rescue StandardError - end - - { - "host_os" => "mswin", "host_cpu" => "x86_64" - }.each { |k, v| RbConfig::CONFIG[k] = v } + def test_parser_for_normalizes_supported_language_names + %w[python javascript typescript go rust c cpp csharp kotlin].each do |language| + parser = Espalier::TreeSitter.parser_for(language) - begin - Espalier::TreeSitter.parser_for("ruby") - rescue StandardError + assert_instance_of TreeSitter::Parser, parser, language + rescue LoadError, RuntimeError + # Optional parser shared libraries are environment-specific. The Ruby + # parser above is required by this repository and exercises the actual + # gem/require path without replacing Kernel methods with test doubles. end end end diff --git a/gems/espalier/tools/corpus_common.rb b/gems/espalier/tools/corpus_common.rb index e960266da..6bd870a64 100644 --- a/gems/espalier/tools/corpus_common.rb +++ b/gems/espalier/tools/corpus_common.rb @@ -10,10 +10,16 @@ module CorpusCommon module_function + # `assets` holds what a program serves rather than what it is: giga-ui's + # browser bundle is a hand-written app.js beside a content-hashed, minified + # diff viewer, and SimpleCov/RubyCritic ship JavaScript inside their HTML + # reports. A minified bundle is one 122k-character line, which tree-sitter + # takes tens of minutes to parse - it was the whole cost of the architecture + # SARIF job. The UI's own source is not under assets/ and stays in scope. EXCLUDE_DIRS = %w[ test tests spec specs testing vendor node_modules examples example bench benchmark benchmarks dist build target third_party docs doc fixtures - __pycache__ scripts tools ci .git generated samples sample demo + __pycache__ scripts tools ci .git generated samples sample demo assets zig-out .zig-cache coverage tmp transpile-tests zig-mutants ].to_set.freeze @@ -74,12 +80,20 @@ def run_fact_mine(mode, repo, files, extra_args: []) merged || {} end + # The only document keys these reports read. A full projection of this + # repository is ~1.08 GB of JSON and this is 8% of it; the rest is dataflow + # (clone_candidates alone is a third) that gets serialized here and parsed + # back in Ruby only to be dropped. Ask for what we use. + SYNTAX_FACT_FIELDS = %w[file language imports functions calls].freeze + # syntax-facts requires an explicit --language; batch files per language. def run_syntax_facts(repo, files) documents = [] files.group_by { |f| EXT_LANGUAGE[File.extname(f)] }.each do |language, batch| next unless language - chunk = run_fact_mine("syntax-facts", repo, batch, extra_args: ["--language", language]) + chunk = run_fact_mine("syntax-facts", repo, batch, + extra_args: ["--language", language, + "--fields=#{SYNTAX_FACT_FIELDS.join(",")}"]) documents.concat(chunk["documents"] || []) end { "documents" => documents } diff --git a/gems/fact-mine/Cargo.lock b/gems/fact-mine/Cargo.lock index 31482a222..88b5f96e2 100644 --- a/gems/fact-mine/Cargo.lock +++ b/gems/fact-mine/Cargo.lock @@ -92,6 +92,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "equivalent" version = "1.0.2" @@ -114,12 +120,18 @@ version = "0.1.0" dependencies = [ "anyhow", "flate2", + "glob", "hazard-contract", + "protobuf", + "protobuf-codegen", + "protobuf-json-mapping", "regex", + "scip", "serde", "serde_json", "serde_yaml", "sha2", + "shell-words", "streaming-iterator", "tempfile", "tree-sitter", @@ -173,6 +185,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.17.1" @@ -187,6 +205,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -215,6 +242,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" @@ -231,6 +264,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -240,6 +279,68 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "protobuf-json-mapping" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" +dependencies = [ + "protobuf", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror", +] + [[package]] name = "quote" version = "1.0.45" @@ -297,6 +398,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scip" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a72133c2d6fd45c9a3a343bcb3db2faa30f68f0919bfa3370ca85add5460c3" +dependencies = [ + "protobuf", +] + [[package]] name = "serde" version = "1.0.228" @@ -365,6 +475,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -406,6 +522,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tree-sitter" version = "0.25.8" @@ -600,6 +736,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/gems/fact-mine/Cargo.toml b/gems/fact-mine/Cargo.toml index f755d3328..6ad1c183c 100644 --- a/gems/fact-mine/Cargo.toml +++ b/gems/fact-mine/Cargo.toml @@ -19,6 +19,9 @@ serde_yaml = "0.9" regex = "1.10" sha2 = "0.10" flate2 = "1.1" +protobuf = "=3.7.2" +protobuf-json-mapping = "=3.7.2" +scip = "=0.9.0" tree-sitter = "=0.25.8" streaming-iterator = "0.1.9" tree-sitter-language = "=0.1.3" @@ -37,10 +40,15 @@ tree-sitter-c-sharp = "=0.23.5" tree-sitter-swift = "=0.7.1" tree-sitter-kotlin-ng = "1.1.0" tree-sitter-php = "=0.24.2" +shell-words = "1.1.1" +glob = "0.3.4" [dev-dependencies] tempfile = "=3.10.1" +[build-dependencies] +protobuf-codegen = "=3.7.2" + [profile.profiling] inherits = "release" debug = 1 diff --git a/gems/fact-mine/build.rs b/gems/fact-mine/build.rs new file mode 100644 index 000000000..93ebe19cf --- /dev/null +++ b/gems/fact-mine/build.rs @@ -0,0 +1,50 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let protocol_dir = manifest_dir.join("../protocol/runtime-evidence/v1"); + let protocol = protocol_dir.join("runtime_evidence.proto"); + println!("cargo:rerun-if-changed={}", protocol.display()); + protobuf_codegen::Codegen::new() + .pure() + .includes([&protocol_dir]) + .input(&protocol) + .cargo_out_dir("runtime_evidence_protocol") + .run_from_script(); + let generated_protocol = PathBuf::from(env::var_os("OUT_DIR").unwrap()) + .join("runtime_evidence_protocol/runtime_evidence.rs"); + let embedded_protocol = PathBuf::from(env::var_os("OUT_DIR").unwrap()) + .join("runtime_evidence_protocol_embedded.rs"); + let generated_source = fs::read_to_string(&generated_protocol).unwrap(); + let embedded_source = generated_source + .lines() + .filter(|line| !line.starts_with("#![") && !line.starts_with("//!")) + .collect::>() + .join("\n"); + fs::write(embedded_protocol, embedded_source).unwrap(); + + let summary_dir = manifest_dir.join("config/complexity_summaries"); + println!("cargo:rerun-if-changed={}", summary_dir.display()); + + let mut summaries = fs::read_dir(&summary_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.to_string_lossy().ends_with(".json.gz")) + .collect::>(); + summaries.sort(); + + let mut generated = String::from("const BUNDLED_SUMMARIES: &[(&str, &[u8])] = &[\n"); + for path in summaries { + let name = path.file_name().unwrap().to_string_lossy(); + generated.push_str(&format!( + " ({name:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/config/complexity_summaries/\", {name:?}))),\n" + )); + } + generated.push_str("];\n"); + + let output = + PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("bundled_complexity_summaries.rs"); + fs::write(output, generated).unwrap(); +} diff --git a/gems/fact-mine/config/complexity_summaries/README.md b/gems/fact-mine/config/complexity_summaries/README.md new file mode 100644 index 000000000..22c6bf36a --- /dev/null +++ b/gems/fact-mine/config/complexity_summaries/README.md @@ -0,0 +1,155 @@ +# Analyzed complexity summaries + +These generated artifacts contain complete Espalier time-and-space bounds keyed +by exact SCIP declaration symbols. FactMine applies bundled summaries after +SCIP ingestion. Bundled artifacts additionally require the exact SCIP indexer +name and version recorded by the consumer index. This protects symbol schemes +that do not put the toolchain revision directly in every symbol. + +The current producer writes the v3 envelope. V3 retains all v2 provenance and +adds exact semantic-environment claims plus an optional generated symbol-bridge +digest. FactMine still reads existing v1/v2 artifacts. Explicitly supplied v3 +summaries fail on a missing or mismatched claim; bundled summaries remain +inactive until both their indexer and environment match. + +`go-stdlib.go1.22.2.json.gz` was built from the installed Go 1.22.2 +implementation sources with scip-go 0.2.7. Its source surface is: + +```text +syscall os time flag bytes bufio encoding/json encoding/xml sort strings +io/fs strconv math regexp fmt +``` + +The bundle contains 322 exact symbols whose time and space bounds are proven +from analyzed bodies, exact analyzed targets, CFG/DFG structure, or +compiler-provided closed candidate sets. A function is intentionally omitted +when its apparent completeness depends on a reviewed/manual receiver registry, +an external-latency or modeled-world contract, an unknown cardinality relation, +or an unresolved call-evidence gap. Those omissions remain eligible for the +manual incomplete-data fallback. + +Rebuild it through the shared manifest producer: + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/go-1.22.2.yml +``` + +The gzip header is deterministic. The versioned envelope records the complete input +profile SHA-256, proof policy, source-proven method count, and exported symbol +count; FactMine validates the schema and every bound at startup. Add a focused +exact-version join test whenever a new bundle is registered in +`external_summary.rs`. + +`rust-stdlib.rustc1.96.0.json.gz` was built from the installed Rust 1.96.0 +`core`, `alloc`, and `std` implementation sources at compiler commit +`ac68faa20c58`, indexed by rust-analyzer from the same build. It contains 1,543 +source-proven exact symbols. rust-analyzer identifies those crates by their +source repository URL rather than a release number, so FactMine applies the +bundle only when the consumer SCIP metadata reports the exact compatible +`rust-analyzer 1.96.0 (ac68faa 2026-05-25)` build. + +The source pass also models Rust's implicit function-exit destruction. In +particular, `core::mem::drop` is omitted instead of being exported as O(1): +its empty source body hides a destructor selected by `T`. Conflicting duplicate +symbols emitted by rust-analyzer for two test-only declarations are omitted +rather than selected by order. + +Rebuild it through the same producer: + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/rust-1.96.0.yml +``` + +`java-stdlib.jdk21.0.12.json.gz` contains 2,598 exact symbols from +`java.lang` and `java.util` in the pinned Adoptium JDK 21.0.12+8 source. +scip-java indexes the source in a patch-module Maven project. The generic +producer first verifies its exact producer symbols, then relocates the Maven +project prefix to the `semanticdb maven jdk 21` identity emitted in user +projects. A complete disagreement with the existing Java fallback remains a +hard error; this process found and corrected the former `Objects.hash` +auxiliary-space overestimate. + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/java-21.0.12.yml +``` + +`python-stdlib.cpython3.11.9.json.gz` contains 200 exact symbols from 43 +selected pure-Python CPython 3.11.9 implementation files. The source selection +is staged into an isolated index workspace because scip-python 0.6.6 crashes +while indexing an unrelated `signal.py`; staging is a generic manifest feature, +not Python behavior in the shared producer. + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/python-3.11.9.yml +``` + +`csharp-corelib.dotnet10.0.10.json.gz` contains 316 exact consumer symbols +generated from .NET runtime 10.0.10 collection, string, and array +implementations. The manifest requires the released scip-dotnet 0.2.14 even +though that release still writes `0.1.0-SNAPSHOT` into SCIP metadata. V3 +compatibility claims therefore pin the actual indexer DLL and .NET reference +assembly digests, and an exact bridge assigns each implementation symbol to its +runtime assembly identity. + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/csharp-10.0.10.yml +``` + +`kotlin-stdlib.kotlin2.2.0.json.gz` contains 85 source-proven symbols from +4,811 Kotlin/JVM stdlib implementation methods. Both the Maven source jar and +runtime jar are pinned by SHA-256. Kotlin 2.2 consumers use unversioned +`scip-java maven . .` symbols, so applicability additionally requires a +semantic-environment sidecar containing the exact runtime-jar digest. + +The published `semanticdb-kotlinc` 0.6.0 plugin numbered top-level overloads +within each source file while `scip-java` numbered binary dependency overloads +across their package. The language-owned index recipe applies the included +upstream patch so both sides enumerate the full package callable set before the +generic producer relocates the otherwise identical symbol prefix. This is an +indexer correction, not a hand-maintained complexity override. + +```bash +JAVA_HOME=/path/to/jdk-21 \ +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/kotlin-2.2.0.yml +``` + +On Picnic 0.7.0 production sources, the generated bundle leaves complete +coverage unchanged at 207/229 (90.39%): its currently exported symbols do not +intersect Picnic's unresolved stdlib calls. It therefore adds verified stdlib +coverage without overstating the measured consumer gain. + +The three `cpp-libstdcxx.13.3.0-*.json.gz` bundles contain 772 unique exact +`std::` symbols from selected libstdc++ 13.3.0 container, string/stream, and +memory surfaces. The producer preprocesses those surfaces with the pinned +compiler configuration, then analyzes the resulting implementation bodies +with the same generic CFG/DFG pipeline. Compatibility does not trust +scip-clang's unversioned `cxx . .` package text: it requires exact digests for +the compiler, indexer, macro set, and effective generic/architecture header +overlay, plus the target and selected C++ standard. + +Only rows whose analyzed bound quality is exact are published. Template bodies +with dependent implicit value construction, assignment, or destruction are +marked ineligible by the C++ language adapter, preventing generic +`std::swap` from being incorrectly exported as O(1). Rebuild each surface +through its manifest: + +```bash +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-containers.yml +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-strings.yml +bundle exec ruby gems/espalier/exe/espalier stdlib-map \ + --manifest gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx20-memory.yml +``` + +FactMine discovers all `.json.gz` files in this directory at build time. +Adding a generated bundle therefore requires no language-specific registration +code. `../stdlib_maps/support.yml` records the fail-closed status of maintained +SCIP languages whose source bodies or consumer version identity are currently +insufficient for safe publication. diff --git a/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-containers.json.gz b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-containers.json.gz new file mode 100644 index 000000000..f5160208c Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-containers.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-strings.json.gz b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-strings.json.gz new file mode 100644 index 000000000..220369b92 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-strings.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx20-memory.json.gz b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx20-memory.json.gz new file mode 100644 index 000000000..cb55c9b4c Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/cpp-libstdcxx.13.3.0-cxx20-memory.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/csharp-corelib.dotnet10.0.10.json.gz b/gems/fact-mine/config/complexity_summaries/csharp-corelib.dotnet10.0.10.json.gz new file mode 100644 index 000000000..208f7c70f Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/csharp-corelib.dotnet10.0.10.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/go-stdlib.go1.22.2.json.gz b/gems/fact-mine/config/complexity_summaries/go-stdlib.go1.22.2.json.gz new file mode 100644 index 000000000..085ecc642 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/go-stdlib.go1.22.2.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/java-stdlib.jdk21.0.12.json.gz b/gems/fact-mine/config/complexity_summaries/java-stdlib.jdk21.0.12.json.gz new file mode 100644 index 000000000..64ee06f16 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/java-stdlib.jdk21.0.12.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/kotlin-stdlib.kotlin2.2.0.json.gz b/gems/fact-mine/config/complexity_summaries/kotlin-stdlib.kotlin2.2.0.json.gz new file mode 100644 index 000000000..102d7a754 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/kotlin-stdlib.kotlin2.2.0.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/python-stdlib.cpython3.11.9.json.gz b/gems/fact-mine/config/complexity_summaries/python-stdlib.cpython3.11.9.json.gz new file mode 100644 index 000000000..843e9401f Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/python-stdlib.cpython3.11.9.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/ruby-cruby.3.2.3-core-a.json.gz b/gems/fact-mine/config/complexity_summaries/ruby-cruby.3.2.3-core-a.json.gz new file mode 100644 index 000000000..6de5c3a84 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/ruby-cruby.3.2.3-core-a.json.gz differ diff --git a/gems/fact-mine/config/complexity_summaries/rust-stdlib.rustc1.96.0.json.gz b/gems/fact-mine/config/complexity_summaries/rust-stdlib.rustc1.96.0.json.gz new file mode 100644 index 000000000..d8eacd6b1 Binary files /dev/null and b/gems/fact-mine/config/complexity_summaries/rust-stdlib.rustc1.96.0.json.gz differ diff --git a/gems/fact-mine/config/stdlib_complexity/c.yml b/gems/fact-mine/config/stdlib_complexity/c.yml index 791f6e4e9..abd3fb3ca 100644 --- a/gems/fact-mine/config/stdlib_complexity/c.yml +++ b/gems/fact-mine/config/stdlib_complexity/c.yml @@ -18,32 +18,40 @@ Intrinsic: strncmp: linear_scan qsort: sort bsearch: logarithmic - malloc: declaration - calloc: declaration - realloc: declaration - free: declaration - printf: declaration - fprintf: declaration - sprintf: declaration - snprintf: declaration - puts: declaration - putchar: declaration + # Allocation is bounded by the requested byte count. Treat deallocation as + # a linear allocator upper bound rather than assuming a particular libc's + # constant-time free-list implementation. + malloc: linear_materialize + calloc: linear_materialize + realloc: linear_materialize + free: linear_scan + # Formatting/parsing and byte-stream transfer are bounded by the consumed or + # produced sequence. External latency is outside the in-process Big-O model. + printf: linear_scan + fprintf: linear_scan + sprintf: linear_scan + snprintf: linear_scan + puts: linear_scan + putchar: constant fopen: declaration fclose: declaration - fread: declaration - fwrite: declaration - abort: declaration - exit: declaration + fread: linear_scan + fwrite: linear_scan + abort: constant + exit: constant assert: declaration va_start: declaration va_arg: declaration va_copy: declaration va_end: declaration - tolower: declaration - toupper: declaration + tolower: constant + toupper: constant fabs: declaration - strtol: declaration - strtoul: declaration + strtol: linear_scan + strtoul: linear_scan + strtod: linear_scan + strtof: linear_scan + strtold: linear_scan ungetc: declaration ftell: declaration fseek: declaration @@ -53,15 +61,78 @@ Intrinsic: vsnprintf: declaration strerror: declaration perror: declaration - sscanf: declaration - fscanf: declaration - isspace: declaration - isdigit: declaration - isalpha: declaration - isalnum: declaration - floor: declaration - ceil: declaration - sqrt: declaration - pow: declaration + sscanf: linear_scan + fscanf: linear_scan + scanf: linear_scan + localeconv: constant + isspace: constant + isdigit: constant + isalpha: constant + isalnum: constant kevent: declaration kqueue: declaration + # C99 : constant-time on a fixed-width float/double (the value is one + # scalar; there is no input-size dimension to grow in). Classification (not + # `signbit`), rounding, scaling, decomposition, and elementary functions. + isnan: constant + isinf: constant + isfinite: constant + isnormal: constant + signbit: constant + fpclassify: constant + fabs: constant + floor: constant + ceil: constant + round: constant + roundf: constant + roundl: constant + lround: constant + llround: constant + trunc: constant + truncf: constant + truncl: constant + rint: constant + rintf: constant + rintl: constant + lrint: constant + nearbyint: constant + scalbn: constant + scalbnf: constant + scalbnl: constant + scalbln: constant + ldexp: constant + frexp: constant + modf: constant + ilogb: constant + logb: constant + copysign: constant + nextafter: constant + nexttoward: constant + fmod: constant + remainder: constant + fdim: constant + fmax: constant + fmin: constant + fma: constant + sqrt: constant + sqrtf: constant + cbrt: constant + hypot: constant + pow: constant + exp: constant + exp2: constant + expm1: constant + log: constant + log2: constant + log10: constant + log1p: constant + sin: constant + cos: constant + tan: constant + asin: constant + acos: constant + atan: constant + atan2: constant + sinh: constant + cosh: constant + tanh: constant diff --git a/gems/fact-mine/config/stdlib_complexity/cpp.yml b/gems/fact-mine/config/stdlib_complexity/cpp.yml index e807000ff..9805371a2 100644 --- a/gems/fact-mine/config/stdlib_complexity/cpp.yml +++ b/gems/fact-mine/config/stdlib_complexity/cpp.yml @@ -10,18 +10,44 @@ Array: find: linear_scan begin: constant end: constant - sort: sort + # Node transfer is constant for whole-list/single-element overloads and + # linear for a range from another list. Use the conservative common bound. + splice: linear_scan + emplace_back: constant + clear: linear_scan + erase: linear_scan + insert: linear_materialize + resize: linear_materialize + reserve: linear_materialize + swap: constant Hash: at: constant find: linear_scan contains: linear_scan size: constant empty: constant + begin: constant + end: constant + clear: linear_scan + erase: linear_scan + insert: linear_scan + emplace: linear_scan + lower_bound: linear_scan + upper_bound: linear_scan + swap: constant Set: find: linear_scan contains: linear_scan size: constant empty: constant + begin: constant + end: constant + clear: linear_scan + erase: linear_scan + insert: linear_scan + lower_bound: linear_scan + upper_bound: linear_scan + swap: constant String: c_str: constant compare: linear_scan @@ -33,17 +59,81 @@ String: find: linear_scan starts_with: linear_scan substr: linear_materialize + assign: linear_materialize + append: linear_materialize + clear: linear_scan + resize: linear_materialize + reserve: linear_materialize + push_back: linear_materialize + pop_back: constant + insert: linear_materialize + erase: linear_scan + replace: linear_materialize + swap: constant + operator+=: linear_materialize +StringStream: + str: linear_materialize + eof: constant +OutputStream: + eof: constant +FileStream: + # CPU work is bounded by the path length; filesystem latency is excluded + # from the structural Big-O contract. + open: linear_scan + close: constant + is_open: constant + exceptions: constant + eof: constant +Json: + # nlohmann::json object representation is configurable. Linear bounds are + # conservative across ordered objects, arrays, parsing, and conversion. + at: linear_scan + get: linear_materialize + get_to: linear_materialize + contains: linear_scan + find: linear_scan + dump: linear_materialize + parse: linear_materialize +StdAtomic: + load: constant + store: constant + exchange: constant + compare_exchange_weak: constant + compare_exchange_strong: constant + fetch_add: constant + fetch_sub: constant + fetch_and: constant + fetch_or: constant + fetch_xor: constant Namespace: std: declaration Intrinsic: - # These free functions are modeled only after a SCIP symbol proves `std` - # ownership. Their standard semantics do not execute user callbacks. + # These selector-only spellings are consumed after a SCIP symbol proves + # `std` ownership. Their standard semantics do not execute user callbacks. std.move: constant + 'std::move': constant std.forward: constant std.addressof: constant std.get: constant std.as_const: constant std.launder: constant + # Qualified-id calls preserve their full `std::` spelling before SCIP + # enrichment. `std` is a reserved namespace, so this source identity remains + # sufficient in compiler-inactive preprocessor branches that have no + # occurrence in the selected SCIP configuration. + 'std::string': linear_materialize + 'std::wstring': linear_materialize + 'std::strlen': linear_scan + 'std::wcslen': linear_scan + 'std::strchr': linear_scan + 'std::strrchr': linear_scan + 'std::wcsrchr': linear_scan + 'std::strcmp': linear_scan + 'std::wcscmp': linear_scan + 'std::operator<<': linear_scan + 'std::setfill': constant + 'std::setw': constant + 'std::setprecision': constant assert: declaration malloc: declaration calloc: declaration @@ -51,8 +141,140 @@ Intrinsic: free: declaration puts: declaration strlen: linear_scan +IntrinsicParametricCall: + # Standard algorithms invoke a caller-supplied predicate once per visited + # element. Their qualified `std::` identity is source-proven even when clang + # omits a dependent-template occurrence. + 'std::find_if': callback_linear + # `using std::swap; swap(a, b)` is the standard ADL customization pattern. + # It performs one selected swap/move operation whose cost is type-dependent. + 'std::swap': reflective_once +SemanticSymbol: + # Exact scip-clang descriptors whose overload discriminator has been + # reviewed. These cover operations which cannot be classified from a + # nominal receiver alone. + 'std/__cxx11/basic_ostringstream#str(d33e1a6fd36255f7).': linear_materialize + 'std/setfill(ade351e831a1374a).': constant + 'std/setw(f06ce2e9220601fb).': constant + 'std/string#': linear_materialize + 'std/getline(30074676001a27e3).': linear_materialize + 'std/isprint(a1851578aea3f541).': constant + 'std/toupper(a1851578aea3f541).': constant + 'std/static_pointer_cast(d1ff2bba4f627176).': constant + 'std/weak_ptr#lock(a1b13de252cffcea).': constant + 'std/__weak_ptr#expired(3482b152b9333168).': constant + 'std/atomic_flag#clear(455a4f0c5c7bd751).': constant + 'std/atomic_flag#test_and_set(2adbe26b81e7d7f).': constant + 'std/has_single_bit(ee8962c63b365797).': constant + 'std/optional#has_value(3482b152b9333168).': constant + 'std/unique_ptr#get(ea7391e9eeeaf20e).': constant + 'std/unique_ptr#release(aad4c3669c5e940c).': constant +SemanticSymbolParametricCost: + # These operations execute a user-defined constructor, destructor, move, or + # element operation. Preserve that work symbolically rather than pricing it + # as a scalar library call. + 'std/swap(c75b8fd57d7c2e06).': reflective_once + 'std/make_shared(ffca0f3977536948).': reflective_once + 'std/__shared_ptr#reset(ced63f7c635d850d).': reflective_once + 'std/construct_at(984c90861d021fca).': reflective_once + 'std/destroy_at(ebd0a1552f8ce24f).': reflective_once + 'std/optional#emplace(8cc437dbb5d7266f).': reflective_once + 'std/ranges/uninitialized_copy.': callback_linear + 'std/uninitialized_copy_n(31908fd9f87493f7).': callback_linear +ParametricCall: + # A dependent template access may not receive a clang occurrence. The + # declared smart-pointer type still proves destruction of at most one + # user-defined pointee. + 'shared_ptr.reset': reflective_once + 'unique_ptr.reset': reflective_once + 'Array.sort': callback_sort +ModeledRuntime: + # Exact source spellings seen only in compiler-inactive platform branches. + # Calls receive an explicit modeled-world assumption; these entries never + # replace an available SCIP identity or a project declaration. + assert: constant + va_start: constant + va_copy: constant + va_end: constant + malloc: linear_materialize + free: linear_scan + vsnprintf: linear_scan + vsnprintf_s: linear_scan + vsnwprintf_s: linear_scan + _vsnwprintf_s: linear_scan + _vsnwprintf: linear_scan + _vscprintf: linear_scan + _vscwprintf: linear_scan + '::strlen': linear_scan + '::wcslen': linear_scan + '::strchr': linear_scan + '::localtime': constant + '::localtime_r': constant + '::localtime_s': constant + '::gmtime': constant + '::gmtime_r': constant + '::gmtime_s': constant + '::ftime': constant + '::gettimeofday': constant + '::syscall': constant + '::iconv_open': constant + '::iconv': linear_scan + '::iconv_close': constant + '::open': linear_scan + '::_wsopen': linear_scan + '::_wsopen_s': linear_scan + '::lseek': constant + '::_lseeki64': constant + _lseek: constant + '::close': constant + '::_close': constant + '::write': linear_scan + '::_write': linear_scan + '::rename': linear_scan + '::unlink': linear_scan + '::_wunlink': linear_scan + '::isatty': constant + MultiByteToWideChar: linear_materialize + WideCharToMultiByte: linear_materialize + GetCurrentThreadId: constant + pthread_threadid_np: constant + rtems_task_self: constant + xTaskGetCurrentTaskHandle: constant + GetStdHandle: constant + GetConsoleScreenBufferInfo: constant + SetConsoleTextAttribute: constant + InitializeCriticalSection: constant + DeleteCriticalSection: constant + EnterCriticalSection: constant + LeaveCriticalSection: constant + '::pthread_mutex_init': constant + '::pthread_mutex_destroy': constant + '::pthread_mutex_lock': constant + '::pthread_mutex_unlock': constant + xSemaphoreCreateBinary: constant + xSemaphoreGive: constant + xSemaphoreTake: constant + vSemaphoreDelete: constant + rtems_semaphore_create: constant + rtems_semaphore_delete: constant + rtems_semaphore_obtain: constant + rtems_semaphore_release: constant + MoveFileW: linear_scan + WriteConsoleW: linear_scan + OutputDebugStringW: linear_scan + RegisterEventSourceW: linear_scan + DeregisterEventSource: constant + RegCreateKeyExW: linear_scan + RegOpenKeyExW: linear_scan + RegSetValueExW: linear_scan + RegCloseKey: constant + RegDeleteKeyW: linear_scan + ReportEventW: linear_scan + __android_log_print: linear_scan NonCallPrefix: "static_cast<": declaration "reinterpret_cast<": declaration "const_cast<": declaration "dynamic_cast<": declaration +NonCallConstruct: + defined: declaration diff --git a/gems/fact-mine/config/stdlib_complexity/csharp.yml b/gems/fact-mine/config/stdlib_complexity/csharp.yml index ec3c1f779..f7f7f2c31 100644 --- a/gems/fact-mine/config/stdlib_complexity/csharp.yml +++ b/gems/fact-mine/config/stdlib_complexity/csharp.yml @@ -8,6 +8,7 @@ Array: ToArray: linear_materialize ToList: linear_materialize OrderBy: sort + Take: constant Hash: get_Item: constant TryGetValue: linear_scan @@ -24,6 +25,7 @@ Set: ToArray: linear_materialize String: Length: constant + ToString: constant Contains: linear_scan IndexOf: linear_scan Split: linear_materialize @@ -64,6 +66,56 @@ Object: GetType: constant Int32: Parse: linear_scan + ToString: linear_materialize + TryFormat: linear_materialize +Int16: + ToString: linear_materialize + TryFormat: linear_materialize +Int64: + ToString: linear_materialize + TryFormat: linear_materialize +UInt16: + ToString: linear_materialize + TryFormat: linear_materialize +UInt32: + ToString: linear_materialize + TryFormat: linear_materialize +UInt64: + ToString: linear_materialize + TryFormat: linear_materialize +Byte: + ToString: linear_materialize + TryFormat: linear_materialize +SByte: + ToString: linear_materialize + TryFormat: linear_materialize +Single: + ToString: linear_materialize + TryFormat: linear_materialize +Double: + ToString: linear_materialize + TryFormat: linear_materialize +Decimal: + ToString: linear_materialize + TryFormat: linear_materialize +DateOnly: + ToString: linear_materialize + TryFormat: linear_materialize +DateTime: + ToString: linear_materialize + TryFormat: linear_materialize +DateTimeOffset: + ToString: linear_materialize + TryFormat: linear_materialize +TimeOnly: + ToString: linear_materialize + TryFormat: linear_materialize +TimeSpan: + ToString: linear_materialize + TryFormat: linear_materialize +TextWriter: + Write: linear_scan + WriteLine: linear_scan Intrinsic: "Array.BinarySearch": logarithmic "Array.Clear": linear_scan @@ -98,9 +150,221 @@ Intrinsic: "String.IsNullOrEmpty": constant "String.IsNullOrWhiteSpace": linear_scan "String.Join": linear_materialize + "StringWriter.ctor": constant + "Exception.ctor": constant + nameof: constant + sizeof: constant + typeof: constant Namespace: System: declaration NonCallConstruct: nameof: declaration sizeof: declaration typeof: declaration +ParametricCall: + 'Array.Select': callback_linear + +# Exact descriptors emitted by scip-dotnet. Keep these overload-specific: +# scip-dotnet's owner spelling (for example `IO/TextWriter`) is intentionally +# not guessed from a short receiver name. +SemanticSymbol: + 'Generic/Dictionary#Add().': linear_materialize + 'Generic/Dictionary#ContainsKey().': linear_scan + 'Generic/Dictionary#Remove().': linear_scan + 'Generic/Dictionary#TryAdd().': linear_materialize + 'Generic/Dictionary#TryGetValue().': linear_scan + 'Generic/HashSet#Add().': linear_materialize + 'Generic/HashSet#Clear().': linear_scan + 'Generic/HashSet#Contains().': linear_scan + 'Generic/IReadOnlyDictionary#ContainsKey().': linear_scan + 'Generic/IReadOnlyDictionary#TryGetValue().': linear_scan + 'Generic/List#Add().': linear_materialize + 'Generic/List#AddRange().': linear_materialize + 'Generic/List#Clear().': linear_scan + 'Generic/List#Insert().': linear_materialize + 'Generic/List#ToArray().': linear_materialize + 'Generic/Queue#Clear().': linear_scan + 'Generic/Queue#Enqueue().': linear_materialize + 'Collections/Hashtable#Clear().': linear_scan + 'IO/StringWriter#Dispose().': constant + 'IO/StringWriter#GetStringBuilder().': constant + 'IO/StringWriter#ToString().': linear_materialize + 'IO/TextWriter#Dispose().': constant + 'IO/TextWriter#Write(+1).': constant + 'IO/TextWriter#Write(+3).': linear_scan + 'IO/TextWriter#Write(+9).': linear_scan + 'IO/TextWriter#Write(+11).': linear_scan + 'IO/TextWriter#Write(+17).': linear_scan + 'IO/TextWriter#WriteLine(+12).': linear_scan + 'Linq/Enumerable#Concat().': constant + 'Linq/Enumerable#Distinct().': linear_materialize + 'Linq/Enumerable#First().': linear_scan + 'Linq/Enumerable#Single().': linear_scan + 'Linq/Enumerable#Skip().': constant + 'Linq/Enumerable#ToArray().': linear_materialize + 'Linq/Enumerable#ToList().': linear_materialize + 'Metrics/Counter#Add().': constant + 'Metrics/Counter#Add(+6).': constant + 'Reflection/MemberInfo#Name.': constant + 'Reflection/MethodBase#GetParameters().': linear_materialize + 'Reflection/ParameterInfo#ParameterType.': constant + 'Reflection/PropertyInfo#GetIndexParameters().': linear_materialize + 'System/Array#CreateInstance().': linear_materialize + 'System/Array#GetLength().': constant + 'System/Array#GetValue(+3).': constant + 'System/Array#Length.': constant + 'System/Array#Resize().': linear_materialize + 'System/Byte#TryFormat(+1).': linear_materialize + 'System/Byte#ToString(+3).': linear_materialize + 'System/Char#IsDigit().': constant + 'System/Enum#Parse(+2).': linear_scan + 'System/Enum#ToString().': linear_materialize + 'System/Enum#TryParse().': linear_scan + 'System/Exception#GetType().': constant + 'System/Exception#ToString().': linear_materialize + 'System/AppContext#TryGetSwitch().': linear_scan + 'System/Int32#TryParse(+6).': linear_scan + 'System/DateOnly#ToString(+2).': linear_materialize + 'System/DateOnly#TryFormat(+1).': linear_materialize + 'System/DateTime#ToString(+3).': linear_materialize + 'System/DateTime#TryFormat(+1).': linear_materialize + 'System/DateTimeOffset#ToString(+3).': linear_materialize + 'System/DateTimeOffset#TryFormat(+1).': linear_materialize + 'System/Decimal#ToString(+3).': linear_materialize + 'System/Decimal#TryFormat(+1).': linear_materialize + 'System/Double#ToString(+1).': linear_materialize + 'System/Double#ToString(+3).': linear_materialize + 'System/Double#TryFormat(+1).': linear_materialize + 'System/Int16#ToString(+3).': linear_materialize + 'System/Int16#TryFormat(+1).': linear_materialize + 'System/Int32#ToString(+3).': linear_materialize + 'System/Int32#TryFormat(+1).': linear_materialize + 'System/Int64#ToString(+3).': linear_materialize + 'System/Int64#TryFormat(+1).': linear_materialize + 'System/Math#Max(+5).': constant + 'System/Math#Min(+4).': constant + 'System/MemoryExtensions#AsSpan().': constant + 'System/MemoryExtensions#Contains().': linear_scan + 'System/MemoryExtensions#Reverse().': linear_scan + 'System/MemoryExtensions#StartsWith().': linear_scan + 'System/Nullable#ToString().': linear_materialize + 'System/Object#GetType().': constant + 'System/Object#ReferenceEquals().': constant + 'System/Span#Slice(+1).': constant + 'System/String#Format(+11).': linear_materialize + 'System/String#IndexOf().': linear_scan + 'System/String#IsNullOrWhiteSpace().': linear_scan + 'System/String#StartsWith(+1).': linear_scan + 'System/String#Substring().': linear_materialize + 'System/String#Substring(+1).': linear_materialize + 'System/String#Trim().': linear_materialize + 'System/SByte#ToString(+3).': linear_materialize + 'System/SByte#TryFormat(+1).': linear_materialize + 'System/Single#ToString(+1).': linear_materialize + 'System/Single#ToString(+3).': linear_materialize + 'System/Single#TryFormat(+1).': linear_materialize + 'System/TimeOnly#ToString(+2).': linear_materialize + 'System/TimeOnly#TryFormat(+1).': linear_materialize + 'System/TimeSpan#ToString().': linear_materialize + 'System/TimeSpan#TryFormat(+1).': linear_materialize + 'System/Type#FullName.': linear_materialize + 'System/Type#GetGenericTypeDefinition().': constant + 'System/Type#GetProperties(+1).': linear_materialize + 'System/Type#GetType(+2).': linear_scan + 'Tasks/Task#ConfigureAwait().': constant + 'Tasks/Task#WhenAny(+1).': linear_materialize + 'Tasks/ValueTask#ConfigureAwait().': constant + 'Text/StringBuilder#Append(+2).': linear_materialize + 'Text/StringBuilder#Append(+15).': linear_materialize + 'Text/StringBuilder#ToString().': linear_materialize + 'Threading/Interlocked#Exchange().': constant + 'Channels/ChannelReader#TryRead().': constant + 'System/UInt16#ToString(+3).': linear_materialize + 'System/UInt16#TryFormat(+1).': linear_materialize + 'System/UInt32#ToString(+3).': linear_materialize + 'System/UInt32#TryFormat(+1).': linear_materialize + 'System/UInt64#ToString(+3).': linear_materialize + 'System/UInt64#TryFormat(+1).': linear_materialize + 'Linq/Enumerable#Any().': linear_scan + 'Linq/Enumerable#FirstOrDefault().': constant + 'Reflection/MemberInfo#IsDefined().': linear_scan + 'System/Array#SetValue().': constant + 'System/Char#IsLetter().': constant + 'System/Char#IsLetterOrDigit().': constant + 'System/Convert#ChangeType().': linear_materialize + 'System/Convert#ToHexString().': linear_materialize + 'System/Convert#ToHexString(+1).': linear_materialize + 'System/Double#IsInfinity().': constant + 'System/Double#IsNaN().': constant + 'System/Single#IsInfinity().': constant + 'System/Single#IsNaN().': constant + 'System/ReadOnlySpan#Slice().': constant + 'System/ReadOnlySpan#Slice(+1).': constant + 'System/String#IndexOfAny(+1).': linear_scan + 'System/String#ToLowerInvariant().': linear_materialize + 'System/String#ToUpperInvariant().': linear_materialize + 'System/String#TrimEnd().': linear_scan + 'System/TimeProvider#GetElapsedTime().': constant + 'System/TimeProvider#GetTimestamp().': constant + 'System/TimeSpan#Add().': constant + 'System/TimeSpan#FromMinutes(+1).': constant + 'System/TimeSpan#FromSeconds(+1).': constant + 'System/TimeSpan#FromTicks().': constant + 'System/TimeSpan#Parse(+1).': linear_scan + 'System/Type#Assembly.': constant + 'System/Type#GetArrayRank().': constant + 'System/Type#GetConstructors(+1).': linear_materialize + 'System/Type#GetElementType().': constant + 'System/Type#GetFields(+1).': linear_materialize + 'System/Type#GetMethods(+1).': linear_materialize + 'Tasks/ValueTask#AsTask().': constant + 'Text/StringBuilder#Clear().': constant + 'Channels/ChannelReader#TryPeek().': constant + 'Channels/ChannelReader#WaitToReadAsync().': constant + 'Channels/ChannelWriter#TryWrite().': constant + 'CompilerServices/RuntimeHelpers#GetHashCode().': constant + 'Diagnostics/ActivitySpanId#ToString().': constant + 'Diagnostics/ActivityTraceId#ToString().': constant + 'InteropServices/MemoryMarshal#CreateSpan().': constant + 'RegularExpressions/Regex#IsMatch(+5).': linear_scan + 'RegularExpressions/Regex#IsMatch(+7).': linear_scan + 'RegularExpressions/Regex#Match().': linear_materialize +ExternalLatency: + 'Assembly.Load': linear_materialize + 'Task.Delay': constant + 'Task.Wait': constant + +# These contracts include user-supplied predicate/selector work. Treating +# them as plain O(N) would silently erase a non-constant callback body. +SemanticSymbolParametricCost: + 'IO/TextWriter#Flush().': callback_once + 'IO/TextWriter#Write(+8).': callback_once + 'Linq/Enumerable#All().': callback_linear + 'Linq/Enumerable#Count().': callback_linear + 'Linq/Enumerable#GroupBy().': callback_linear + 'Linq/Enumerable#OrderByDescending().': callback_sort + 'Linq/Enumerable#Select().': callback_linear + 'Linq/Enumerable#SelectMany().': callback_linear + 'Linq/Enumerable#ToDictionary().': callback_linear + 'Linq/Enumerable#Where().': callback_linear + 'Reflection/ConstructorInfo#Invoke().': reflective_once + 'Reflection/FieldInfo#GetValue().': reflective_once + 'Reflection/MethodBase#Invoke().': reflective_once + 'Reflection/PropertyInfo#GetValue().': reflective_once + 'System/Action#Invoke().': callback_once + 'System/EventHandler#Invoke().': callback_once + 'System/Func#Invoke().': callback_once + 'System/IAsyncDisposable#DisposeAsync().': callback_once + 'System/ICustomFormatter#Format().': callback_once + 'System/IDisposable#Dispose().': callback_once + 'System/IFormatProvider#GetFormat().': callback_once + 'System/IFormattable#ToString().': callback_once + 'System/ISpanFormattable#TryFormat().': callback_once + 'System/Object#Equals(+1).': callback_once + 'System/Object#GetHashCode().': callback_once + 'System/Object#ToString().': callback_once + 'System/String#Create().': callback_linear + 'Tasks/Task#ContinueWith().': callback_once + 'Tasks/Task#Run(+2).': callback_once + 'Threading/CancellationTokenSource#Cancel().': callback_linear + 'Channels/ChannelWriter#Complete().': callback_once diff --git a/gems/fact-mine/config/stdlib_complexity/go.stdlib.json.gz b/gems/fact-mine/config/stdlib_complexity/go.stdlib.json.gz new file mode 100644 index 000000000..9c0c8de3d Binary files /dev/null and b/gems/fact-mine/config/stdlib_complexity/go.stdlib.json.gz differ diff --git a/gems/fact-mine/config/stdlib_complexity/go.yml b/gems/fact-mine/config/stdlib_complexity/go.yml index 89d59eb01..1e86b43b4 100644 --- a/gems/fact-mine/config/stdlib_complexity/go.yml +++ b/gems/fact-mine/config/stdlib_complexity/go.yml @@ -32,6 +32,22 @@ reflect.Value: Convert: linear_materialize Type: constant Uint: constant + Addr: constant + Bytes: constant + CanInterface: constant + Cap: constant + IsZero: constant + MapRange: constant + NumMethod: constant + OverflowFloat: constant + OverflowInt: constant + OverflowUint: constant + Pointer: constant + SetBytes: constant + SetLen: constant + SetZero: constant + UnsafePointer: constant + Grow: linear_materialize reflect.Type: AssignableTo: constant Bits: constant @@ -41,23 +57,39 @@ reflect.Type: Kind: constant Len: constant NumField: constant + NumMethod: constant + Implements: constant ConvertibleTo: constant reflect.StructTag: Get: linear_scan strings.Builder: Len: constant + Cap: constant String: constant + Reset: constant + Grow: constant + WriteByte: constant + WriteRune: constant WriteString: linear_materialize + Write: linear_materialize time.Time: Add: constant After: constant Before: constant + Format: linear_materialize + IsZero: constant Truncate: constant UnixNano: constant Unix: constant Sub: constant Nanosecond: constant time.Duration: + Hours: constant + Minutes: constant + Seconds: constant + Milliseconds: constant + Microseconds: constant + Nanoseconds: constant Truncate: constant crypto.Hash: Available: constant @@ -70,6 +102,24 @@ context.Context: sync.Pool: Get: constant Put: constant +# Go 1.19+ typed atomics: lock-free single-word ops, all O(1). +atomic.Bool: &atomic_methods + Load: constant + Store: constant + Swap: constant + CompareAndSwap: constant + Add: constant +atomic.Int32: *atomic_methods +atomic.Int64: *atomic_methods +atomic.Uint32: *atomic_methods +atomic.Uint64: *atomic_methods +atomic.Uintptr: *atomic_methods +atomic.Pointer: *atomic_methods +atomic.Value: + Load: constant + Store: constant + Swap: constant + CompareAndSwap: constant list.List: Back: constant Front: constant @@ -90,6 +140,9 @@ list.Element: time.Ticker: Stop: constant regexp.Regexp: + FindStringSubmatch: linear_materialize + Match: linear_scan + MatchString: linear_scan ReplaceAll: linear_materialize http.Header: Get: linear_scan @@ -105,6 +158,88 @@ base64.Encoding: big.Int: FillBytes: linear_materialize SetBytes: linear_materialize +# `testing.common` is the embedded base of T/B/F, so t.Errorf resolves here. +# Recording a failure is bounded by the message it formats; the variadic +# formatting itself is reflective and lives in SemanticSymbolParametricCost. +testing.common: + Helper: constant + Name: constant + Fail: constant + FailNow: constant + Failed: constant + SkipNow: constant + Skipped: constant + Cleanup: constant +testing.T: + Parallel: constant + Setenv: constant + Deadline: constant +bytes.Buffer: + Len: constant + Cap: constant + Reset: constant + Bytes: constant + Truncate: constant + String: linear_materialize + Grow: linear_materialize + Write: linear_materialize + WriteString: linear_materialize + WriteByte: constant + WriteRune: constant + Read: linear_scan + ReadString: linear_scan + Next: linear_scan +# os.FileInfo is a public type alias for io/fs.FileInfo. Keep both canonical +# spellings because build-tagged files may be parsed without a platform SCIP +# document while their declared parameter type remains compiler syntax. +fs.FileInfo: &file_info_methods + Name: constant + Size: constant + Mode: constant + ModTime: constant + IsDir: constant + Sys: constant +os.FileInfo: *file_info_methods +fs.DirEntry: + Name: constant + IsDir: constant + Type: constant +os.ProcessState: + ExitCode: constant + Success: constant + Exited: constant + Pid: constant +os.File: + Name: constant + Fd: constant +# Registering a flag stores one descriptor; parsing walks the argument list. +flag.FlagSet: + Bool: constant + BoolVar: constant + String: constant + StringVar: constant + Int: constant + IntVar: constant + Int64: constant + Uint: constant + Float64: constant + Float64Var: constant + Duration: constant + Var: constant + NArg: constant + NFlag: constant + Args: constant + Arg: constant + Lookup: constant + Parse: linear_scan + SetOutput: constant +bufio.Scanner: + Text: linear_materialize + Bytes: constant + Err: constant + Scan: linear_scan + Buffer: constant + Split: constant Intrinsic: len: constant cap: constant @@ -138,7 +273,9 @@ Intrinsic: "errors.Join": linear_materialize "hmac.Equal": linear_scan "math.Log10": constant + "math.Max": constant "math.Modf": constant + "math.Round": constant "reflect.Append": linear_materialize "reflect.ArrayOf": constant "reflect.MakeSlice": linear_materialize @@ -154,6 +291,7 @@ Intrinsic: "runtime.Goexit": constant "runtime.Stack": linear_materialize "strconv.Itoa": linear_materialize + "strconv.Atoi": linear_scan "strconv.FormatFloat": linear_materialize "strconv.FormatInt": linear_materialize "strconv.FormatUint": linear_materialize @@ -162,6 +300,7 @@ Intrinsic: "strconv.ParseInt": linear_scan "strconv.ParseUint": linear_scan "strings.SplitN": linear_materialize + "strings.NewReplacer": linear_materialize "time.NewTicker": constant "time.Until": constant "time.Parse": linear_scan @@ -224,6 +363,118 @@ Intrinsic: "atomic.LoadUint64": constant "atomic.StoreUint32": constant "atomic.StoreUint64": constant + # internal/bytealg assembly primitives. The exported bytes/strings search and + # compare API bottoms out in these; they are pure linear scans (or an O(n) + # allocation), so mapping them lets the stdlib's own functions close. + "bytealg.Compare": linear_scan + "bytealg.Count": linear_scan + "bytealg.CountString": linear_scan + "bytealg.Equal": linear_scan + "bytealg.Index": linear_scan + "bytealg.IndexByte": linear_scan + "bytealg.IndexByteString": linear_scan + "bytealg.IndexString": linear_scan + "bytealg.IndexRabinKarp": linear_scan + "bytealg.IndexRabinKarpBytes": linear_scan + "bytealg.LastIndexByte": linear_scan + "bytealg.LastIndexByteString": linear_scan + "bytealg.HashStr": linear_scan + "bytealg.HashStrBytes": linear_scan + "bytealg.Cutover": constant + "bytealg.MakeNoZero": linear_materialize + # unsafe intrinsics: compile-time size/offset constants and zero-copy pointer + # or header construction. All O(1); no runtime work is a function of input. + "unsafe.Pointer": constant + "unsafe.Sizeof": constant + "unsafe.Offsetof": constant + "unsafe.Alignof": constant + "unsafe.Add": constant + "unsafe.Slice": constant + "unsafe.SliceData": constant + "unsafe.String": constant + "unsafe.StringData": constant + # runtime hints and bounded introspection: no-ops or stack-depth bounded, + # constant with respect to the caller's input. + "runtime.KeepAlive": constant + "runtime.SetFinalizer": constant + "runtime.GC": constant + "runtime.Caller": constant + "runtime.Callers": constant + "runtime.CallersFrames": constant + "runtime.NumCPU": constant + "runtime.NumGoroutine": constant + "runtime.Gosched": constant + # Race detector instrumentation: a no-op without -race; O(1). + "race.Enable": constant + "race.Disable": constant + "race.Acquire": constant + "race.Release": constant + "race.ReleaseMerge": constant + "race.Read": constant + "race.Write": constant + "race.ReadRange": constant + "race.WriteRange": constant + # math/bits: single-word bit operations, each O(1). + "bits.Add": constant + "bits.Add32": constant + "bits.Add64": constant + "bits.Sub": constant + "bits.Sub32": constant + "bits.Sub64": constant + "bits.Mul": constant + "bits.Mul32": constant + "bits.Mul64": constant + "bits.Div": constant + "bits.Div32": constant + "bits.Div64": constant + "bits.Len": constant + "bits.Len8": constant + "bits.Len16": constant + "bits.Len32": constant + "bits.Len64": constant + "bits.LeadingZeros": constant + "bits.LeadingZeros8": constant + "bits.LeadingZeros16": constant + "bits.LeadingZeros32": constant + "bits.LeadingZeros64": constant + "bits.TrailingZeros": constant + "bits.TrailingZeros32": constant + "bits.TrailingZeros64": constant + "bits.OnesCount": constant + "bits.OnesCount32": constant + "bits.OnesCount64": constant + "bits.RotateLeft": constant + "bits.RotateLeft8": constant + "bits.RotateLeft16": constant + "bits.RotateLeft32": constant + "bits.RotateLeft64": constant + "bits.ReverseBytes": constant + "bits.ReverseBytes32": constant + "bits.ReverseBytes64": constant + # Elementary math: constant-time numeric primitives. + "math.Float32bits": constant + "math.Float64bits": constant + "math.Float32frombits": constant + "math.Float64frombits": constant + "math.Log": constant + "math.Log2": constant + "math.Exp": constant + "math.Sqrt": constant + "math.Abs": constant + "math.Floor": constant + "math.Ceil": constant + "math.Trunc": constant + "math.Mod": constant + "math.Pow": constant + "math.Ldexp": constant + "math.Frexp": constant + # cmp: ordered comparison. Linear to bound the string operand case. + "cmp.Less": linear_scan + "cmp.Compare": linear_scan + "cmp.Or": constant + # internal/abi: compile-time layout constants. + "abi.FuncPCABI0": constant + "abi.FuncPCABIInternal": constant # Language builtins. These are syntax-proven by the Go adapter and do not # require a SCIP declaration. Materializing operations use worst-case upper # bounds, including allocation/copy during append growth. @@ -261,6 +512,51 @@ Intrinsic: uintptr: constant print: declaration println: declaration + # Path manipulation is bounded by the path text. Join/Clean/Dir/Base build a + # new string; Ext and IsAbs only scan the argument. + filepath.Join: linear_materialize + filepath.Clean: linear_materialize + filepath.Dir: linear_materialize + filepath.Base: linear_materialize + filepath.Rel: linear_materialize + filepath.ToSlash: linear_materialize + filepath.FromSlash: linear_materialize + filepath.Split: linear_scan + filepath.SplitList: linear_materialize + filepath.Ext: linear_scan + filepath.IsAbs: linear_scan + filepath.Match: linear_scan + filepath.VolumeName: linear_scan + # Environment access scans the process environment block. + os.Getenv: linear_scan + os.LookupEnv: linear_scan + os.Setenv: linear_scan + os.Unsetenv: linear_scan + os.Environ: linear_materialize + os.IsNotExist: constant + os.IsExist: constant + os.IsPermission: constant + os.Getpid: constant + os.NewFile: constant + flag.Bool: constant + flag.String: constant + regexp.Compile: linear_materialize + exec.Command: linear_materialize + exec.CommandContext: linear_materialize + bytes.NewReader: constant + bytes.NewBuffer: constant + bytes.NewBufferString: linear_materialize + bytes.TrimSpace: linear_scan + bufio.NewScanner: constant + bufio.NewReader: constant + bufio.ScanLines: linear_scan + bufio.ScanWords: linear_scan + flag.NewFlagSet: constant + strings.IndexByte: linear_scan + strings.TrimLeft: linear_scan + time.Since: constant + time.Date: constant + errors.New: linear_materialize ParametricCall: # `error` is a predeclared open interface. The declared receiver proves one # dispatch, but the implementation cost remains the symbolic callback C. @@ -268,6 +564,9 @@ ParametricCall: CallableType: "context.CancelFunc": callback_once ExternalLatency: + # Reader methods scan/materialize bytes supplied by an external stream. The + # byte-processing bound is complete while stream latency remains excluded. + "bufio.Reader.ReadString": linear_materialize "io.ReadAll": linear_materialize "os.ReadFile": linear_materialize "os.Open": linear_scan @@ -288,13 +587,64 @@ ExternalLatency: "sync.WaitGroup.Done": constant "sync.WaitGroup.Wait": constant "time.Sleep": constant + # Filesystem and process syscalls. The computational bound is what the call + # does with the bytes it moves; device, scheduler, and network latency are + # excluded and declared as an assumption by the caller of this table. + "os.WriteFile": linear_scan + "os.Stat": constant + "os.Lstat": constant + "os.Mkdir": constant + "os.MkdirAll": linear_scan + "os.MkdirTemp": constant + "os.CreateTemp": constant + "os.Create": constant + "os.OpenFile": constant + "os.Remove": constant + "os.RemoveAll": linear_scan + "os.Rename": constant + "os.Chmod": constant + "os.Chtimes": constant + "os.Chdir": constant + "os.Getwd": linear_materialize + "os.UserHomeDir": linear_materialize + "os.UserCacheDir": linear_materialize + "os.ReadDir": linear_materialize + "os.Symlink": constant + "os.Readlink": linear_materialize + "os.Truncate": constant + "os.File.Write": linear_scan + "os.File.WriteString": linear_scan + "os.File.Read": linear_scan + "os.File.Sync": constant + "os.File.Stat": constant + "os.File.Seek": constant + "os.Getuid": constant + "os.Executable": linear_materialize + "syscall.Flock": constant + "syscall.Statfs": constant + "fs.DirEntry.Info": constant + "filepath.Abs": linear_materialize + "filepath.EvalSymlinks": linear_materialize + "filepath.Glob": linear_materialize + "exec.LookPath": linear_scan + "exec.Cmd.Run": constant + "exec.Cmd.Start": constant + "exec.Cmd.Wait": constant + "exec.Cmd.Output": linear_materialize + "exec.Cmd.CombinedOutput": linear_materialize + "exec.Cmd.String": linear_materialize + "exec.Cmd.StdoutPipe": constant + # Creating the harness temp dir registers a cleanup and makes one directory. + "testing.common.TempDir": constant SemanticSymbolParametricCost: 'sort/Sort().': callback_sort + 'sort/Slice().': callback_sort '`crypto/elliptic`/Curve#Params().': callback_once 'crypto/Signer#Sign().': callback_once 'flag/Usage.': callback_once 'fmt/Errorf().': reflective_once 'fmt/Fprintf().': reflective_once + 'fmt/Print().': reflective_once 'fmt/Printf().': reflective_once 'fmt/Println().': reflective_once 'fmt/Sprintf().': reflective_once @@ -302,10 +652,37 @@ SemanticSymbolParametricCost: 'reflect/Value#Call().': reflective_once '`encoding/json`/Marshal().': reflective_once '`encoding/json`/Unmarshal().': reflective_once + '`encoding/xml`/Unmarshal().': reflective_once '`encoding/json`/Decoder#Decode().': reflective_once + '`encoding/json`/Encoder#Encode().': reflective_once '`encoding/json`/MarshalIndent().': reflective_once 'errors/Is().': reflective_once 'flag/Parse().': reflective_once + 'flag/FlagSet#Visit().': callback_linear + 'flag/FlagSet#VisitAll().': callback_linear + # A subtest runs an arbitrary body, so its cost is the callback's. + 'testing/T#Run().': callback_once + 'testing/B#Run().': callback_once + # The harness failure/log calls format a variadic `...any` exactly the way + # fmt does, so they carry the same open reflective cost rather than a + # fabricated constant. + 'testing/common#Errorf().': reflective_once + 'testing/common#Error().': reflective_once + 'testing/common#Fatalf().': reflective_once + 'testing/common#Fatal().': reflective_once + 'testing/common#Logf().': reflective_once + 'testing/common#Log().': reflective_once + 'testing/common#Skipf().': reflective_once + 'testing/common#Skip().': reflective_once + 'fmt/Fprintln().': reflective_once + 'fmt/Sprint().': reflective_once + 'fmt/Sprintln().': reflective_once + 'fmt/Fprint().': reflective_once + 'fmt/Sscanf().': reflective_once + 'reflect/DeepEqual().': reflective_once + # Walking a tree runs the visitor once per entry. + '`path/filepath`/WalkDir().': callback_linear + '`path/filepath`/Walk().': callback_linear 'flag/PrintDefaults().': reflective_once 'flag/Var().': reflective_once '`net/http`/Request#ParseMultipartForm().': reflective_once diff --git a/gems/fact-mine/config/stdlib_complexity/java.yml b/gems/fact-mine/config/stdlib_complexity/java.yml index 9f3080b7a..b2cf99d4b 100644 --- a/gems/fact-mine/config/stdlib_complexity/java.yml +++ b/gems/fact-mine/config/stdlib_complexity/java.yml @@ -298,7 +298,7 @@ SemanticSymbol: "java/lang/Object#clone().": linear_materialize "java/lang/String#valueOf(+4).": constant "java/util/Comparator#comparing(+1).": constant - "java/util/Objects#hash().": linear_materialize + "java/util/Objects#hash().": linear_scan "java/util/stream/Stream#filter().": constant "java/util/stream/Stream#map().": constant "java/util/regex/Matcher#lookingAt().": exponential diff --git a/gems/fact-mine/config/stdlib_complexity/kotlin.yml b/gems/fact-mine/config/stdlib_complexity/kotlin.yml index 9e0b77706..a8841f1fe 100644 --- a/gems/fact-mine/config/stdlib_complexity/kotlin.yml +++ b/gems/fact-mine/config/stdlib_complexity/kotlin.yml @@ -45,19 +45,29 @@ SemanticSymbol: 'kotlin/IntArray#``().': linear_materialize 'kotlin/Double#toInt().': constant 'kotlin/Int#toDouble().': constant + 'kotlin/checkNotNull(+1).': constant 'kotlin/collections/emptyList().': constant 'kotlin/collections/emptyMap().': constant + 'kotlin/collections/contentHashCode().': linear_scan + 'kotlin/collections/contentToString(+3).': linear_materialize + 'kotlin/collections/getOrNull(+9).': linear_scan + 'kotlin/collections/getValue().': linear_scan 'kotlin/collections/listOf().': constant 'kotlin/collections/listOf(+1).': linear_materialize 'kotlin/collections/mapOf(+2).': linear_materialize + 'kotlin/collections/mutableListOf().': constant 'kotlin/collections/copyOf(+11).': linear_materialize 'kotlin/collections/single(+19).': linear_scan 'kotlin/collections/sum(+8).': linear_scan 'kotlin/collections/toCharArray(+1).': linear_materialize 'kotlin/collections/toDoubleArray(+1).': linear_materialize + 'kotlin/collections/toList(+10).': linear_materialize + 'kotlin/collections/toMutableList(+10).': linear_materialize 'kotlin/collections/toSet(+9).': linear_materialize 'kotlin/collections/toTypedArray().': linear_materialize 'kotlin/isNaN().': constant + 'kotlin/require(+1).': constant + 'kotlin/requireNotNull(+1).': constant 'kotlin/math/ceil().': constant 'kotlin/math/floor().': constant 'kotlin/math/log10().': constant @@ -76,7 +86,12 @@ SemanticSymbol: 'kotlin/text/toIntOrNull().': linear_scan 'kotlin/text/toLong().': linear_scan 'kotlin/text/trim(+5).': linear_materialize + 'kotlin/text/trimMargin().': linear_materialize 'kotlin/text/uppercase(+2).': linear_materialize + 'kotlin/collections/MutableList#add().': linear_materialize + 'kotlin/collections/MutableList#addAll().': linear_materialize + 'kotlin/collections/addAll(+2).': linear_materialize + 'kotlin/collections/MutableList#isEmpty().': constant # Higher-order contracts retain the callback cost as a parameter. They are # upper bounds, not constant-cost stand-ins for the lambda body. @@ -91,6 +106,8 @@ SemanticSymbolParametricCost: 'kotlin/collections/associateBy(+19).': callback_linear 'kotlin/collections/associate(+9).': callback_linear 'kotlin/collections/associateWith(+9).': callback_linear + 'kotlin/collections/count(+1).': callback_linear + 'kotlin/collections/filter(+9).': callback_linear 'kotlin/collections/filter(+10).': callback_linear 'kotlin/collections/flatMap(+10).': callback_linear 'kotlin/collections/fold(+9).': callback_linear @@ -102,8 +119,11 @@ SemanticSymbolParametricCost: 'kotlin/collections/joinToString(+9).': callback_linear 'kotlin/collections/map(+6).': callback_linear 'kotlin/collections/map(+9).': callback_linear + 'kotlin/collections/map().': callback_linear 'kotlin/collections/maxOf(+29).': callback_linear + 'kotlin/collections/sortedBy(+9).': callback_sort 'kotlin/collections/sumOf(+24).': callback_linear 'kotlin/collections/sumOf(+27).': callback_linear + 'kotlin/collections/sumOf(+66).': callback_linear 'kotlin/repeat().': callback_linear 'kotlin/text/buildString().': callback_once diff --git a/gems/fact-mine/config/stdlib_complexity/ruby.yml b/gems/fact-mine/config/stdlib_complexity/ruby.yml index c3569bf7f..a5b000a7d 100644 --- a/gems/fact-mine/config/stdlib_complexity/ruby.yml +++ b/gems/fact-mine/config/stdlib_complexity/ruby.yml @@ -2,6 +2,8 @@ # rendered Big-O strings: Fact-Mine emits the facts and Espalier owns algebra. Array: "[]": constant + "[]=": linear_materialize + "<<": constant each: linear_scan each_with_index: linear_scan each_index: linear_scan @@ -38,17 +40,29 @@ Array: unshift: linear_scan count: linear_scan sum: linear_scan + min: linear_scan + max: linear_scan + product: pairwise delete: linear_scan insert: linear_scan clear: linear_scan freeze: constant sort: sort sort_by: sort + bsearch: logarithmic + bsearch_index: logarithmic "-": pairwise "&": pairwise "|": pairwise + uniq!: linear_scan + reject!: linear_scan + select!: linear_scan + map!: linear_scan + sort!: sort + sort_by!: sort Hash: "[]": constant + "[]=": constant key?: constant has_key?: constant include?: constant @@ -71,12 +85,20 @@ Hash: merge: linear_materialize keys: linear_materialize values: linear_materialize + values_at: linear_materialize dup: linear_materialize to_set: linear_materialize transform_keys: linear_materialize transform_values: linear_materialize sort: sort sort_by: sort + to_a: linear_materialize + slice: linear_materialize + transform_values!: linear_scan +OptionParser: + banner=: constant + parse!: linear_scan + to_s: linear_materialize Set: include?: constant contains: constant @@ -84,6 +106,7 @@ Set: size: constant empty?: constant add: constant + "<<": constant delete: constant add?: constant merge: linear_scan @@ -105,6 +128,7 @@ String: gsub: linear_materialize split: linear_materialize "+": linear_materialize + "<<": linear_materialize to_s: constant to_sym: linear_materialize strip: linear_materialize @@ -124,6 +148,9 @@ String: delete_prefix: linear_materialize delete_suffix: linear_materialize downcase: linear_materialize + upcase: linear_materialize + capitalize: linear_materialize + casecmp: linear_scan inspect: linear_materialize reverse: linear_materialize lstrip: linear_materialize @@ -136,6 +163,7 @@ String: to_i: linear_scan to_f: linear_scan tr: linear_materialize + delete: linear_materialize Symbol: to_s: linear_materialize inspect: linear_materialize @@ -155,16 +183,52 @@ Kernel: binding: constant methods: linear_materialize hash: linear_scan + instance_variable_set: constant + __dir__: constant + frozen?: constant + method: constant + rand: constant + Integer: linear_scan + Float: linear_scan BasicObject: equal?: constant Integer: + "+": linear_materialize + "-": linear_materialize + "*": pairwise + "/": pairwise + "%": pairwise + "<": linear_scan + "<=": linear_scan + ">": linear_scan + ">=": linear_scan + "<=>": linear_scan + "==": linear_scan positive?: constant zero?: constant abs: constant to_s: linear_materialize to_f: constant chr: constant + modulo: pairwise + floor: constant + ceil: constant Float: + "+": constant + "-": constant + "*": constant + "/": constant + "%": constant + "<": constant + "<=": constant + ">": constant + ">=": constant + "<=>": constant + "==": constant + modulo: constant + floor: constant + ceil: constant + round: constant to_i: constant T: must: constant @@ -179,17 +243,80 @@ File: Regexp: source: constant match: linear_scan + match?: exponential + last_match: constant MatchData: begin: constant + "[]": linear_materialize Class: name: constant Time: strftime: linear_materialize + now: constant +Pathname: + to_s: constant + path: constant + absolute?: constant + relative_path_from: linear_materialize +Process/Status: + success?: constant +Process: + pid: constant +Digest/Class: + new: constant + hexdigest: linear_materialize + file: linear_materialize +Digest/Instance: + hexdigest: linear_materialize +IO: + path: constant + close: constant + puts: linear_scan +Tempfile: + path: constant + close: constant + unlink: linear_scan Enumerable: + each: linear_scan + each_with_index: linear_scan + each_with_object: linear_scan + map: linear_materialize + flat_map: linear_materialize + select: linear_materialize + reject: linear_materialize + compact: linear_materialize + to_h: linear_materialize to_a: linear_materialize + to_set: linear_materialize count: linear_scan first: constant + min: linear_scan + max: linear_scan + partition: linear_materialize include?: linear_scan + any?: linear_scan + all?: linear_scan + one?: linear_scan + sort: sort + sort_by: sort + tally: linear_materialize + grep: linear_materialize +Numeric: + "+": linear_materialize + "-": linear_materialize + "*": pairwise + "/": pairwise + "%": pairwise + positive?: constant + negative?: constant + zero?: constant + to_i: constant + to_f: constant +NilClass: + nil?: constant + to_i: constant + to_f: constant + to_a: constant StringScanner: "[]": constant eos?: constant @@ -198,6 +325,23 @@ StringScanner: Intrinsic: "Array.new": linear_materialize "Hash.new": constant + "Set.new": linear_materialize + "Pathname.new": linear_materialize + "Dir.pwd": linear_materialize +SemanticSymbol: + # CRuby installs one generated member surface per declared field. The C + # producer currently reports this registration path as incomplete because + # it crosses VM callbacks; this reviewed O(N) fallback is used only until + # the source proof can replace it. + 'Struct.new().': linear_materialize + 'Hash.`[]`().': linear_materialize + 'JSON.parse().': linear_materialize + 'JSON.generate().': linear_materialize + 'JSON.pretty_generate().': linear_materialize + 'Psych.safe_load().': linear_materialize + # Onigmo is a backtracking engine. `match?` has an exponential worst case + # in the pattern/input pair and linear engine stack space. + 'Regexp#`match?`().': exponential ExternalLatency: "File.exist?": linear_scan "File.read": linear_materialize @@ -210,19 +354,53 @@ ExternalLatency: "IO.flush": constant "IO.popen": linear_scan "IO.foreach": linear_scan + "Zlib/GzipReader.read": linear_materialize "File.mtime": constant "File.delete": linear_scan "File.file?": linear_scan + "File.directory?": linear_scan + "File.size?": linear_scan + "File.rename": linear_scan + "File.extname": linear_materialize + "File.fnmatch?": linear_scan "File.symlink?": linear_scan "File.symlink": linear_scan "File.readlink": linear_materialize "Dir.exist?": linear_scan "Dir.glob": linear_materialize + "FileUtils.mkdir_p": linear_scan + "FileUtils.copy_file": linear_scan + "FileUtils.rm_rf": linear_scan + "Dir.mktmpdir": linear_materialize + "Tempfile.new": linear_materialize + "Open3.capture2": linear_materialize + "Open3.capture2e": linear_materialize + "Open3.capture3": linear_materialize "Dir.pwd": linear_materialize "Kernel.printf": linear_scan "Kernel.warn": linear_scan "Kernel.exit": constant "Kernel.system": linear_scan + # Activating a gem scans the installed specification/load-path surface. + # Filesystem latency is excluded; N is the activation search surface. + "Kernel.gem": linear_scan + # Loading YAML reads and parses input proportional to the file contents. + # Filesystem latency is excluded from the CPU/space bound. + "Psych.load_file": linear_materialize +ExternalLatencyParametric: + # Native path APIs invoke Ruby's to_path before their bounded byte/path + # work. C is that single coercion callback and N is the path/output size. + "File.realpath": coercive_linear_materialize + "File.executable?": coercive_linear_scan + "File.absolute_path?": coercive_linear_scan + "IO.read": coercive_linear_materialize + # A runtime receiver may have converged to IO or StringIO. Both perform + # the same bounded coercion/read/materialization work, so the generic SCIP + # candidate join can retain the contract instead of dropping an otherwise + # complete observed IO.read callsite. + "StringIO.read": coercive_linear_materialize + "Dir.[]": coercive_linear_materialize + "Dir.chdir": coercive_linear_scan SemanticSymbolParametricCost: 'Class#new().': reflective_once 'Proc0#call().': callback_once @@ -240,6 +418,17 @@ SemanticSymbolParametricCost: 'Kernel#format().': reflective_once 'Kernel#inspect().': reflective_once 'Kernel#Array().': reflective_once + 'Kernel#Hash().': reflective_once + # `require` selects and executes an arbitrary loader/body. This is an open + # reflective target rather than a fabricated constant-time loader bound. + 'Kernel#require().': loader_once + # CRuby's Exception#message uses rb_funcallv(exc, :to_s, ...). + 'Exception#message().': callback_once + # Get_Double may call Numeric#to_f; a later runtime type proof can narrow + # Float to O(1), but the unspecialized source contract remains symbolic. + 'Math.exp().': callback_once + # reg_operand calls to_str once before rb_reg_quote scans and materializes. + 'Regexp.escape().': coercive_linear_materialize 'Kernel#loop().': callback_linear 'Range#each().': callback_linear 'Array#each().': callback_linear @@ -256,39 +445,62 @@ SemanticSymbolParametricCost: 'Set#each().': callback_linear 'Enumerable#`any?`().': callback_linear 'Enumerable#`all?`().': callback_linear + 'Enumerable#`one?`().': callback_linear 'Enumerable#filter_map().': callback_linear 'Enumerable#flat_map().': callback_linear 'Enumerable#find().': callback_linear 'Enumerable#map().': callback_linear 'Enumerable#each().': callback_linear 'Enumerable#each_with_object().': callback_linear + # each_slice drives #each and yields slices to an arbitrary block. + 'Enumerable#each_slice().': callback_linear 'Enumerable#select().': callback_linear 'Enumerable#reduce().': callback_linear 'Enumerable#group_by().': callback_linear 'Enumerable#find_index().': callback_linear 'Enumerable#`none?`().': callback_linear + 'Enumerable#min_by().': callback_linear + 'Enumerable#max_by().': callback_linear + 'Enumerable#partition().': callback_linear + 'Integer#times().': callback_linear + 'Comparable#`between?`().': reflective_once + 'Comparable#between?().': reflective_once + 'Zlib/GzipReader.open().': callback_once + 'Enumerator#with_index().': callback_linear + 'Method#call().': callback_once + 'Kernel#tap().': callback_once Namespace: Array: declaration BasicObject: declaration Class: declaration Dir: declaration + Digest: declaration Enumerable: declaration Enumerator: declaration File: declaration + FileUtils: declaration Float: declaration Hash: declaration IO: declaration Integer: declaration Kernel: declaration MatchData: declaration + Method: declaration Module: declaration + NilClass: declaration + Numeric: declaration + Open3: declaration Object: declaration Pathname: declaration + Process: declaration Proc: declaration Range: declaration Regexp: declaration Set: declaration String: declaration + StringIO: declaration Symbol: declaration T: declaration Time: declaration + Tempfile: declaration + Zlib: declaration diff --git a/gems/fact-mine/config/stdlib_complexity/rust.yml b/gems/fact-mine/config/stdlib_complexity/rust.yml index 8f53373d2..cfa1de7a4 100644 --- a/gems/fact-mine/config/stdlib_complexity/rust.yml +++ b/gems/fact-mine/config/stdlib_complexity/rust.yml @@ -19,13 +19,27 @@ Array: sort_by: sort sort_by_key: sort iter: constant + iter_mut: constant into_iter: constant extend: linear_materialize + extend_from_slice: linear_materialize + append: linear_materialize join: linear_materialize to_vec: linear_materialize dedup: linear_scan dedup_by: linear_scan retain: linear_scan + as_slice: constant + last_mut: constant + first_mut: constant + # Lazy adapter over the existing buffer; no element is touched. + windows: constant + chunks: constant + # Drops every element it holds. + clear: linear_scan + truncate: linear_scan + with_capacity: linear_materialize + sort_unstable: sort Hash: len: constant is_empty: constant @@ -33,12 +47,18 @@ Hash: contains_key: constant insert: constant remove: constant + get_mut: constant + iter: constant + new: constant + entry: constant + clone: linear_materialize Set: len: constant is_empty: constant contains: constant insert: constant remove: constant + new: constant String: len: constant is_empty: constant @@ -60,6 +80,7 @@ String: as_str: constant clone: linear_materialize to_ascii_lowercase: linear_materialize + to_ascii_uppercase: linear_materialize iter: constant split_once: linear_scan rsplit_once: linear_scan @@ -72,6 +93,24 @@ String: char_indices: constant push: linear_materialize cmp: linear_scan + # Scans and validates the whole input. + parse: linear_scan + from_utf8: linear_scan + # Lazy adapters; the scan happens on the iterator, not here. + bytes: constant + splitn: constant + matches: constant + match_indices: constant + is_char_boundary: constant + # Sets the length after a boundary check; no element is moved. + truncate: constant + to_lowercase: linear_materialize + push_str: linear_materialize + clear: constant + with_capacity: linear_materialize + encode_utf16: constant + rsplitn: constant + split_inclusive: constant BTreeMap: len: constant is_empty: constant @@ -86,6 +125,12 @@ BTreeMap: keys: constant into_iter: constant extend: sort + get_mut: logarithmic + values: constant + values_mut: constant + into_values: constant + # `From<[(K, V); N]>` builds the tree from an unsorted array. + from: sort BTreeSet: len: constant is_empty: constant @@ -97,7 +142,15 @@ BTreeSet: into_iter: constant extend: sort is_disjoint: linear_scan + is_subset: sort intersection: constant + difference: constant + first: logarithmic + pop_first: logarithmic + clear: linear_scan + new: constant + # `From<[T; N]>` builds the tree from an unsorted array. + from: sort Option: unwrap: constant expect: constant @@ -114,11 +167,18 @@ Option: or: constant ok: constant clone: linear_materialize + transpose: constant + take: constant + as_mut: constant + get_or_insert: constant Result: unwrap: constant expect: constant ok: constant is_ok: constant + is_err: constant + unwrap_or: constant + unwrap_or_default: constant Iterator: iter: constant into_iter: constant @@ -147,6 +207,13 @@ Iterator: min_by_key: linear_scan max_by_key: linear_scan next_back: constant + take: constant + peekable: constant + sum: linear_scan + max: linear_scan + min: linear_scan + skip_while: constant + zip: constant Box: new: constant as_ref: constant @@ -159,8 +226,26 @@ Path: join: linear_materialize to_string_lossy: linear_materialize to_path_buf: linear_materialize + # Scan the path text for the relevant component boundary. + file_stem: linear_scan + file_name: linear_scan + extension: linear_scan + strip_prefix: linear_scan + starts_with: linear_scan + ends_with: linear_scan + components: constant + is_absolute: constant PathBuf: from: linear_materialize + clone: linear_materialize + pop: linear_scan +OsStr: + # Unix `to_str` validates the bytes as UTF-8. + to_str: linear_scan + to_string_lossy: linear_materialize +DirEntry: + path: linear_materialize + file_name: linear_materialize char: is_ascii_alphanumeric: constant is_ascii_alphabetic: constant @@ -168,19 +253,97 @@ char: is_ascii_digit: constant is_alphanumeric: constant len_utf8: constant + is_whitespace: constant + is_uppercase: constant + is_lowercase: constant + is_ascii_lowercase: constant + is_alphabetic: constant + is_control: constant + len_utf16: constant + to_ascii_uppercase: constant +u8: + is_ascii_alphanumeric: constant + is_ascii_alphabetic: constant + is_ascii_digit: constant + is_ascii_whitespace: constant + is_ascii_hexdigit: constant + from_str_radix: linear_scan bool: then_some: constant usize: saturating_sub: constant cmp: constant + from: constant + checked_sub: constant + abs_diff: constant + saturating_mul: constant + try_from: constant u32: saturating_sub: constant + try_from: constant +u64: + from: constant + wrapping_mul: constant +f64: + round: constant + abs: constant +VecDeque: + push_back: linear_materialize + pop_front: constant + from: linear_materialize + extend: linear_materialize + len: constant + is_empty: constant +Instant: + now: constant + elapsed: constant +Duration: + as_millis: constant + as_secs: constant + as_secs_f64: constant +Peekable: + peek: constant +RefCell: + new: constant + borrow: constant + borrow_mut: constant +Atomic: + new: constant + load: constant + store: constant + fetch_add: constant +mem: + take: constant + replace: constant + swap: constant +env: + # Process environment access; the map itself is small and O(1)-indexed. + var: constant + var_os: constant + set_var: constant + remove_var: constant + temp_dir: constant + args: linear_materialize Ord: min: constant max: constant ToString: to_string: linear_materialize SemanticSymbol: + 'iter/sources/once/once().': constant + 'str/converts/from_utf8().': linear_scan + 'panic/resume_unwind().': linear_scan + 'sync/mpsc/channel().': constant + 'sync/mpsc/impl#[`Sender`][Clone]clone().': constant + 'sync/mpsc/impl#[`Sender`]send().': constant + 'sync/once_lock/impl#[`OnceLock`]new().': constant + 'process/impl#[Command]stdin().': constant + 'process/impl#[Command]stdout().': constant + 'process/impl#[Command]stderr().': constant + 'io/buffered/bufreader/impl#[`BufReader`]new().': constant + 'io/error/impl#[Error]kind().': constant + 'path/impl#[Path]display().': constant + 'path/impl#[Path]ancestors().': constant 'impl#[`Node<''tree>`]kind().': constant 'impl#[`Node<''tree>`]walk().': constant 'impl#[Tree]root_node().': constant @@ -202,7 +365,97 @@ SemanticSymbol: 'file/impl#[`NamedTempFile`]path().': constant 'impl#[`Builder<''a, ''b>`]new().': constant 'impl#[`Builder<''a, ''b>`]suffix().': constant + # `Entry` is the owner name of both map families, so the tree and the table + # variants must be keyed exactly rather than through one owner table. + 'collections/btree/map/entry/impl#[`Entry<''a, K, V, A>`]or_default().': logarithmic + 'collections/hash/map/impl#[`Entry<''a, K, V>`]or_default().': constant + # `[T; N]` is not the nominal array shape the shared parser recognises. + 'array/iter/impl#[`[T; N]`][IntoIterator]into_iter().': constant + 'array/impl#[`[T; N]`][Ord]cmp().': linear_scan + 'slice/raw/from_ref().': constant + 'io/stdio/stdout().': constant + 'io/stdio/impl#[Stdout]lock().': constant + 'process/impl#[Command]new().': constant + 'thread/builder/impl#[Builder]new().': constant + # Takes ownership of String and stores it in Option; it does not clone bytes. + 'thread/builder/impl#[Builder]name().': constant + 'thread/builder/impl#[Builder]stack_size().': constant + 'io/buffered/bufwriter/impl#[`BufWriter`]new().': constant + # tree-sitter cursor and node accessors are pointer arithmetic on the parse + # tree; `utf8_text` slices the source and validates it as UTF-8. + 'impl#[`Node<''tree>`]utf8_text().': linear_scan + 'impl#[`Node<''tree>`]id().': constant + 'impl#[`Node<''tree>`]start_position().': constant + 'impl#[`Node<''tree>`]end_position().': constant + 'impl#[`Node<''tree>`]byte_range().': constant + 'impl#[`Node<''tree>`]child_count().': constant + 'impl#[`Node<''tree>`]named_child_count().': constant + 'impl#[`Node<''tree>`]next_sibling().': constant + 'impl#[`Node<''tree>`]prev_sibling().': constant + 'impl#[`Node<''tree>`]next_named_sibling().': constant + 'impl#[`Node<''tree>`]prev_named_sibling().': constant + 'impl#[`Node<''tree>`]named_children().': constant + 'impl#[`Node<''tree>`]is_missing().': constant + 'impl#[`Node<''tree>`]is_error().': constant + 'impl#[`Node<''tree>`]is_extra().': constant + 'impl#[`Node<''tree>`]named_child().': constant + 'impl#[Parser]new().': constant + 'impl#[Query]capture_names().': constant + 'Point#row.': constant + 'Point#column.': constant + # regex construction compiles the pattern; matching is linear in the input. + 'regex/string/impl#[Regex]new().': linear_materialize + 'regex/string/impl#[Regex]is_match().': linear_scan + 'regex/string/impl#[`Match<''h>`]as_str().': constant + 'regex/string/impl#[`Captures<''h>`]get().': constant + # serde_json Value accessors are tagged-union reads. + 'value/impl#[Value]as_u64().': constant + 'value/impl#[Value]as_bool().': constant + 'value/impl#[Value]as_array().': constant + 'value/impl#[Value]as_array_mut().': constant + 'value/impl#[Value]as_object().': constant + 'value/impl#[Value]as_object_mut().': constant + 'value/impl#[Value]get_mut().': logarithmic + 'value/impl#[Value]pointer().': linear_scan + 'map/impl#[`Map`]get().': logarithmic + 'map/impl#[`Map`]remove().': logarithmic + 'map/impl#[`Map`]entry().': logarithmic + 'map/impl#[`Map`]is_empty().': constant + 'map/impl#[`Map`]len().': constant + 'map/impl#[`Map`][IntoIterator]into_iter().': constant + # flate2 stream handles; `finish` flushes the whole compressed buffer. + 'gz/write/impl#[`GzEncoder`]new().': constant + 'gz/write/impl#[`GzEncoder`]finish().': linear_materialize + 'gz/read/impl#[`GzDecoder`]new().': constant + 'file/impl#[`NamedTempFile`]as_file_mut().': constant + # anyhow context attachment stores the value; it does not walk the chain. + 'context/impl#[`Result`][`Context`]context().': constant + 'context/impl#[`Option`][`Context`]context().': constant + # a digest absorbs every input byte. + 'digest/impl#[D][Digest]update().': linear_materialize SemanticSymbolParametricCost: + 'borrow/impl#[`Cow<''_, B>`]into_owned().': reflective_once + 'cmp/min().': reflective_once + 'convert/AsRef#as_ref().': reflective_once + 'path/impl#[Path]new().': reflective_once + 'iter/traits/collect/impl#[I][IntoIterator]into_iter().': reflective_once + 'mem/drop().': reflective_once + 'option/impl#[`Option`][Ord]cmp().': reflective_once + 'tuple/impl#[`(U, T)`][Ord]cmp().': reflective_once + 'tuple/impl#[`(V, U, T)`][Ord]cmp().': reflective_once + 'vec/impl#[`Vec`][Ord]cmp().': callback_linear + 'slice/impl#[`[T]`]starts_with().': callback_linear + 'vec/impl#[`Vec`]retain_mut().': callback_linear + 'iter/traits/iterator/Iterator#partition().': callback_linear + 'iter/adapters/map/impl#[`Map`][Iterator]next().': callback_once + 'iter/adapters/map/impl#[`Map`][Iterator]fold().': callback_linear + 'iter/adapters/rev/impl#[`Rev`][Iterator]fold().': callback_linear + 'option/impl#[`Option`]ok_or_else().': callback_once + 'result/impl#[`Result`]is_ok_and().': callback_once + 'result/impl#[`Result`]map_err().': callback_once + 'thread/scoped/scope().': callback_once + 'thread/builder/impl#[Builder]spawn().': callback_once + 'thread/scoped/impl#[Builder]spawn_scoped().': callback_once 'string/impl#[T][ToString]to_string().': reflective_once 'iter/traits/iterator/Iterator#collect().': callback_linear 'iter/traits/iterator/Iterator#any().': callback_linear @@ -223,10 +476,66 @@ SemanticSymbolParametricCost: 'vec/impl#[`Vec`][Clone]clone().': callback_linear 'context/impl#[`Option`][`Context`]with_context().': callback_once 'context/impl#[`Result`][`Context`]with_context().': callback_once + # Blanket trait dispatch: the cost is the selected `From`/`Clone` impl, which + # the symbol does not name. Constant would be a claim we cannot make. + 'convert/impl#[T][`Into`]into().': reflective_once + 'clone/Clone#clone().': reflective_once + # Closure-running standard-library APIs. + 'sync/once_lock/impl#[`OnceLock`]get_or_init().': callback_once + 'option/impl#[`Option`]is_none_or().': callback_once + 'result/impl#[`Result`]map().': callback_once + 'result/impl#[`Result`]unwrap_or_else().': callback_once + 'collections/btree/map/entry/impl#[`Entry<''a, K, V, A>`]or_insert_with().': callback_once + 'iter/traits/iterator/Iterator#reduce().': callback_linear + 'iter/traits/iterator/Iterator#fold().': callback_linear + 'iter/traits/iterator/Iterator#for_each().': callback_linear + 'slice/iter/impl#[`Iter<''a, T>`][Iterator]fold().': callback_linear + 'iter/adapters/filter/impl#[`Filter`][Iterator]fold().': callback_linear + 'iter/adapters/filter_map/impl#[`FilterMap`][Iterator]fold().': callback_linear + 'slice/iter/impl#[`Iter<''a, T>`][Iterator]rposition().': callback_linear + 'iter/traits/double_ended/DoubleEndedIterator#rfind().': callback_linear + # serde traverses the value against a Deserialize/Serialize impl it selects + # at the call site; the impl, not the call, carries the cost. + 'value/from_value().': reflective_once + 'value/to_value().': reflective_once + 'ser/to_vec().': reflective_once + 'ser/to_string().': reflective_once + 'ser/to_string_pretty().': reflective_once + 'ser/to_writer().': reflective_once + 'de/from_str().': reflective_once + 'de/from_slice().': reflective_once + 'map/impl#[`Entry<''a>`]or_insert_with().': callback_once ExternalLatency: fs.write: linear_materialize fs.read_to_string: linear_materialize fs.create_dir_all: linear_materialize + fs.read: linear_materialize + fs.read_dir: linear_materialize + fs.canonicalize: linear_materialize + fs.remove_dir_all: linear_materialize + fs.remove_file: constant + fs.rename: constant + fs.metadata: constant + Path.exists: constant + Path.is_dir: constant + Path.is_file: constant + env.current_dir: constant + Write.write_all: linear_materialize + Write.flush: constant + Read.read_to_end: linear_materialize + BufWriter.write_all: linear_materialize + BufWriter.flush: constant + File.create: constant + File.open: constant + BufRead.read_line: linear_materialize + BufReader.read_exact: linear_materialize + copy.copy: linear_materialize + Command.spawn: constant + Child.kill: constant + Child.wait: constant + ChildStdin.flush: constant + JoinHandle.join: constant Builder.tempfile: constant dir.tempdir: constant + NamedTempFile.new: constant NamedTempFile.write_all: linear_materialize diff --git a/gems/fact-mine/config/stdlib_maps/README.md b/gems/fact-mine/config/stdlib_maps/README.md new file mode 100644 index 000000000..aeae01103 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/README.md @@ -0,0 +1,143 @@ +# Standard-library map support + +`support.yml` is the machine-readable compatibility inventory for maintained +SCIP languages. `bundled` means the standard library is produced by a +`fact-mine.stdlib-map.v1` manifest and its generated artifact is discovered +automatically at FactMine build time. `blocked` means publishing a bundle would +violate the exact source/consumer identity contract; the required upstream +capability is recorded instead of silently shipping an unsafe approximation. + +The shared producer supports pinned local or Git sources, sparse checkouts, +selected-file staging for indexers that scan an entire workspace, language-owned +build commands, exact indexer validation, parser/recovery soundness gates, +opaque semantic-environment attestations, optional exact-prefix relocation or +generated exact symbol bridges, producer joins, consumer comparisons, and +atomic publication. None of those stages branches on a language. + +`compatibility` claims are exact string key/value pairs produced by a manifest +or a language-owned probe. The shared join does not interpret keys such as +runtime artifact digest, target triple, sysroot, or ABI; it merely requires the +consumer profile to carry every claim required by the bundle. A +`fact-mine.symbol-bridge.v1` sidecar maps analyzed implementation symbols to +one or more exact consumer declaration symbols. This is the common mechanism +for declaration-only or cross-language runtimes, and the bridge digest is +retained in the generated summary. A source identity without a bridge target is +not published; bridge fan-out is therefore lossless but fail-closed. Bridge +commands run after the producer profile and unrelocated summary exist, and may +use `{profile}` and `{producer_summary}` in addition to the standard manifest +substitutions. + +A standard library may be split into independent, bounded manifests (for +example `core-a`, `collections`, and `io`) instead of one monolithic job. +Every manifest runs the same source → SCIP → FactMine → soundness gate → exact +bridge → atomic publication flow, and all generated summaries are discovered +as a union. This is the intended feedback loop for CRuby now and for CPython +and a JS engine later: adding a shard is language-owned source/index/bridge +configuration, not a new shared integration path. + +When implementation and consumer source require different SCIP producers, a +manifest may declare `summary.consumer_indexers`. The generated summary retains +the implementation indexer as producer provenance but activates only for one +of those exact consumer `tool@version` identities. The symbol bridge remains +responsible for proving each cross-indexer declaration identity. + +Current publishable mappings: + +| Language | Source | Indexer | Exact symbols | +|---|---|---|---:| +| Go | Go 1.22.2 core surface | scip-go 0.2.7 | 322 | +| Rust | Rust 1.96.0 `core`/`alloc`/`std` | rust-analyzer 1.96.0 | 1,543 | +| Java | JDK 21.0.12 `java.lang`/`java.util` | scip-java 0.12.3 | 2,598 | +| Python | CPython 3.11.9 selected pure-Python core | scip-python 0.6.6 | 200 | +| C# | .NET 10.0.10 CoreLib collections/string/array | scip-dotnet 0.2.14 | 316 | +| Kotlin | Kotlin/JVM 2.2.0 stdlib | semanticdb-kotlinc 0.6.0 + patch | 85 | +| C++ | libstdc++ 13.3.0 selected C++17/C++20 surfaces | scip-clang 0.4.0 | 772 unique | +| Ruby | CRuby 3.2.3 core-A native surface | scip-clang 0.4.0 → nil-kill-runtime 2 | 6 | + +The checked-in exact-symbol consumers prove that the generated data changes +the final function result, not merely call metadata: + +| Consumer | Before | After | Delta | +|---|---:|---:|---:| +| Java 21 `BitSet.toLongArray()` | 0/1 complete | 1/1 complete | +1 | +| Python 3.11 `OrderedDict.move_to_end()` | 0/1 complete | 1/1 complete | +1 | + +On Commons CLI, the Java bundle joins 12 call sites but changes no complete +function count (321/524 before and after) because those functions either were +already complete through a fallback or retain other evidence gaps. This is +still useful canonicalization, but it is not reported as completeness impact. + +The remaining SCIP languages are deliberately fail-closed: + +- C standard-library symbols from scip-clang use `cxx . .`. + `c/semantic_environment.rb` now closes the missing consumer identity by + attesting the libc binary and release, effective public headers, compiler, + target, semantic flags, preprocessor macros, and scip-clang binary. The + remaining blocker is an implementation producer: the matching patched glibc + source must be configured and indexed under that same environment before a + bundle can be published. +- TypeScript and JavaScript built-ins resolve to versioned TypeScript `.d.ts` + declarations. Those files have no executable bodies, while the real + implementations belong to a particular JS engine and release. + `javascript/semantic_environment.rb` now pins the Node binary and release, + V8 release, native-module ABI, and scip-typescript binary for both languages. + This removes runtime identity as an excuse for a guessed declaration model; + the remaining producer must analyze the matching V8/Node bodies and generate + the declaration bridge while retaining callback and reflective cost + parameters. +- PHP built-ins can be connected to their `php-src` C implementations through + the generic cross-indexer symbol bridge, and + `php/scip-php-exact-version.patch` replaces scip-php's hard-coded `0.0.1` + metadata with its exact source revision. A PHP 8.4.21 producer trial + nevertheless exported zero of 70 analyzed `zif_*` string built-ins under the + source-proof policy. Weak scalar coercion can invoke user object handlers, + while Zend allocation depends on allocator state and hooks; both costs must + remain parametric. Publishing the apparent native helper cost would silently + discard those executable paths, so PHP remains fail-closed until generated + summaries can carry and compose those runtime cost parameters. +- Ruby has a pinned CRuby 3.2.3 producer that indexes native bodies with + `scip-clang` and bridges only `rb_define_*` registrations to NilKill's + versioned `nil-kill-runtime ruby ruby 3.2.3` identities. The initial + core-A shard exports six source-proven rows. It is deliberately small: + unproved native calls, callback paths, and VM-state-sensitive bodies remain + absent rather than being replaced by a manual Ruby YAML model. Additional + CRuby shards use the same manifest-driven producer and bridge. +- The SCIP ecosystem also includes Scala through scip-java, Visual Basic + through scip-dotnet, and Dart through scip-dart. They were previously absent + from the inventory. FactMine does not yet have syntax/CFG/DFG adapters for + those languages, so claiming stdlib quality would be meaningless. Once an + adapter exists, Scala can immediately reuse exact JDK rows and Visual Basic + can reuse exact CoreLib rows because those bundles join on compiler symbols, + not the source language. Dart additionally needs a pinned SDK-source + manifest. + +These are compatibility failures, not requests for manual overrides. Move an +entry to `bundled` only after its required identity/body capability exists and a +manifest passes the same producer and consumer checks. Missing version text in +a SCIP symbol is no longer itself a blocker: a reproducible environment +attestation may supply the missing compatibility identity without weakening the +exact-symbol join. + +The C# manifest works around scip-dotnet 0.2.14's stale +`0.1.0-SNAPSHOT` SCIP metadata without trusting that text: its language-owned +index recipe checks the released CLI version, and its compatibility sidecar +pins both the installed indexer binary and .NET reference-assembly digests. +The generated runtime bundle maps only exact, assembly-qualified consumer +symbols. On the current Serilog production corpus the released indexer itself +raises completion from 755/888 (85.02%) to 765/888 (86.15%); the generated +bundle has zero additional count impact because all overlapping calls already +had complete fallback models, and it produces no complete/complete +disagreements. + +The three C++ manifests preprocess bounded libstdc++ surfaces under the exact +consumer compiler configuration before SCIP indexing. Because scip-clang uses +unversioned `cxx . .` symbols, each bundle requires an opaque language-owned +attestation of the compiler binary, target, C++ standard, macro set, effective +generic and architecture-specific header overlays, and scip-clang binary. +Only exact-bound `std::` rows cross the generated identity bridge. C++ template +functions with unmodeled implicit construction, assignment, or destruction +fail the generic source-export gate through the C++ behavior interface rather +than being published as O(1). The eventpp, plog, and proxy audits join 2, 30, +and 4 calls respectively with zero complete/complete disagreements; all were +already covered by complete fallback models, so their function-completion +counts do not change. diff --git a/gems/fact-mine/config/stdlib_maps/c/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/c/semantic_environment.rb new file mode 100644 index 000000000..9c5013009 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/c/semantic_environment.rb @@ -0,0 +1,96 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +source_root, output = ARGV +abort "usage: semantic_environment.rb SOURCE_ROOT OUTPUT.json" unless source_root && output + +def capture!(*command, chdir:) + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout.strip +end + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + path = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + abort "#{fallback} was not found; set #{environment}" unless path + + path +end + +def files_digest(paths) + digest = Digest::SHA256.new + paths.map { |path| File.expand_path(path) }.sort.each do |path| + abort "C libc compatibility input was not found at #{path}" unless File.file?(path) + + digest << path << "\0" << Digest::SHA256.file(path).hexdigest << "\n" + end + digest.hexdigest +end + +source_root = File.expand_path(source_root) +database = JSON.parse(File.read(File.join(source_root, "compile_commands.json"))) +entries = database.map { |entry| entry.fetch("arguments") } +abort "compile_commands.json has no entries" if entries.empty? + +compiler = File.expand_path(entries.first.fetch(0)) +abort "compile_commands.json uses multiple C compilers" unless entries.all? do |arguments| + File.expand_path(arguments.fetch(0)) == compiler +end +semantic_flags = entries.flat_map do |arguments| + arguments.select do |argument| + argument.start_with?("-std=", "-D", "-U", "-m", "--target=") + end +end.uniq.sort + +headers = ENV.fetch( + "C_LIBC_HEADERS", + [ + "/usr/include/features.h", + "/usr/include/string.h", + "/usr/include/stdlib.h", + "/usr/include/stdio.h", + "/usr/include/unistd.h" + ].join(File::PATH_SEPARATOR) +).split(File::PATH_SEPARATOR).reject(&:empty?) +libc = File.expand_path(ENV.fetch("C_LIBC_BINARY", "/lib/x86_64-linux-gnu/libc.so.6")) +indexer = executable("SCIP_CLANG", "scip-clang") +macros = capture!( + compiler, + *semantic_flags, + "-dM", + "-E", + "-x", + "c", + "/dev/null", + chdir: source_root +).lines.sort.join + +claims = { + "c.libc.implementation" => ENV.fetch("C_LIBC_IMPLEMENTATION", "glibc"), + "c.libc.release" => capture!(libc, "--version", chdir: source_root).lines.first.to_s, + "c.libc.binary.sha256" => "sha256:#{Digest::SHA256.file(libc).hexdigest}", + "c.libc.headers.sha256" => "sha256:#{files_digest(headers)}", + "c.compiler.version" => capture!(compiler, "--version", chdir: source_root).lines.first.to_s, + "c.compiler.target" => capture!(compiler, "-dumpmachine", chdir: source_root), + "c.compiler.sha256" => "sha256:#{Digest::SHA256.file(compiler).hexdigest}", + "c.semantic_flags.sha256" => + "sha256:#{Digest::SHA256.hexdigest(semantic_flags.join("\0"))}", + "c.preprocessor_macros.sha256" => "sha256:#{Digest::SHA256.hexdigest(macros)}", + "c.scip_clang.version" => capture!(indexer, "--version", chdir: source_root).lines.first.to_s, + "c.scip_clang.sha256" => "sha256:#{Digest::SHA256.file(indexer).hexdigest}" +} + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => claims +})) diff --git a/gems/fact-mine/config/stdlib_maps/consumers/java-21/pom.xml b/gems/fact-mine/config/stdlib_maps/consumers/java-21/pom.xml new file mode 100644 index 000000000..e1e59c024 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/consumers/java-21/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + dev.factmine + stdlib-consumer + 1.0.0 + + 21 + UTF-8 + + diff --git a/gems/fact-mine/config/stdlib_maps/consumers/java-21/src/main/java/dev/factmine/StdlibConsumer.java b/gems/fact-mine/config/stdlib_maps/consumers/java-21/src/main/java/dev/factmine/StdlibConsumer.java new file mode 100644 index 000000000..c7dd0044f --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/consumers/java-21/src/main/java/dev/factmine/StdlibConsumer.java @@ -0,0 +1,9 @@ +package dev.factmine; + +import java.util.BitSet; + +final class StdlibConsumer { + static long[] materialize(BitSet values) { + return values.toLongArray(); + } +} diff --git a/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/impact.py b/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/impact.py new file mode 100644 index 000000000..65211e1ad --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/impact.py @@ -0,0 +1,5 @@ +from collections import OrderedDict + + +def move_last(values: OrderedDict[str, int]) -> None: + values.move_to_end("key") diff --git a/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/pyproject.toml b/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/pyproject.toml new file mode 100644 index 000000000..b9b1d14b0 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/consumers/python-3.11/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "fact-mine-stdlib-consumer" +version = "1.0.0" + +[tool.pyright] +include = ["impact.py"] +pythonVersion = "3.11" diff --git a/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-containers.yml b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-containers.yml new file mode 100644 index 000000000..621e482ce --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-containers.yml @@ -0,0 +1,55 @@ +schema: fact-mine.stdlib-map.v1 +language: cpp + +source: + root_command: + - ruby + - "{manifest_dir}/cpp/materialize_source.rb" + - "{workspace_root}" + revision: libstdcxx-13.3.0-ubuntu24.04-cxx17-containers + revision_check: + command: + - ruby + - "{manifest_dir}/cpp/revision.rb" + - "{source_root}" + equals: "libstdcxx-13.3.0 effective sha256:cab188eb89bb4b5cf99c97a16f9d1d3196d1d2ce9536ea70a9f74b782bc7bec2" + include: + - "preprocessed/c++17/containers.cpp" + stage_selected_files: true + +index: + command: + - ruby + - "{manifest_dir}/cpp/index_stdlib.rb" + - "{source_root}" + - "{index}" + - c++17 + - containers + working_directory: "{source_root}" + output: libstdcxx.scip + expected: + tool: scip-clang + version: "0.4.0" + +compatibility: + command: + - ruby + - "{manifest_dir}/cpp/semantic_environment.rb" + - "{source_root}" + - "{environment}" + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 1500 + +summary: + corpus: libstdcxx-13.3.0-cxx17-containers + output: ../complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-containers.json.gz + minimum_symbols: 350 + symbol_bridge: + command: + - ruby + - "{manifest_dir}/cpp/build_symbol_bridge.rb" + - "{producer_summary}" + - "{symbol_bridge}" + working_directory: "{source_root}" diff --git a/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-strings.yml b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-strings.yml new file mode 100644 index 000000000..4ed9f8805 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx17-strings.yml @@ -0,0 +1,55 @@ +schema: fact-mine.stdlib-map.v1 +language: cpp + +source: + root_command: + - ruby + - "{manifest_dir}/cpp/materialize_source.rb" + - "{workspace_root}" + revision: libstdcxx-13.3.0-ubuntu24.04-cxx17-strings + revision_check: + command: + - ruby + - "{manifest_dir}/cpp/revision.rb" + - "{source_root}" + equals: "libstdcxx-13.3.0 effective sha256:cab188eb89bb4b5cf99c97a16f9d1d3196d1d2ce9536ea70a9f74b782bc7bec2" + include: + - "preprocessed/c++17/strings.cpp" + stage_selected_files: true + +index: + command: + - ruby + - "{manifest_dir}/cpp/index_stdlib.rb" + - "{source_root}" + - "{index}" + - c++17 + - strings + working_directory: "{source_root}" + output: libstdcxx.scip + expected: + tool: scip-clang + version: "0.4.0" + +compatibility: + command: + - ruby + - "{manifest_dir}/cpp/semantic_environment.rb" + - "{source_root}" + - "{environment}" + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 2000 + +summary: + corpus: libstdcxx-13.3.0-cxx17-strings + output: ../complexity_summaries/cpp-libstdcxx.13.3.0-cxx17-strings.json.gz + minimum_symbols: 420 + symbol_bridge: + command: + - ruby + - "{manifest_dir}/cpp/build_symbol_bridge.rb" + - "{producer_summary}" + - "{symbol_bridge}" + working_directory: "{source_root}" diff --git a/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx20-memory.yml b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx20-memory.yml new file mode 100644 index 000000000..374b03169 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp-libstdcxx-13.3.0-cxx20-memory.yml @@ -0,0 +1,55 @@ +schema: fact-mine.stdlib-map.v1 +language: cpp + +source: + root_command: + - ruby + - "{manifest_dir}/cpp/materialize_source.rb" + - "{workspace_root}" + revision: libstdcxx-13.3.0-ubuntu24.04-cxx20-memory + revision_check: + command: + - ruby + - "{manifest_dir}/cpp/revision.rb" + - "{source_root}" + equals: "libstdcxx-13.3.0 effective sha256:cab188eb89bb4b5cf99c97a16f9d1d3196d1d2ce9536ea70a9f74b782bc7bec2" + include: + - "preprocessed/c++20/memory.cpp" + stage_selected_files: true + +index: + command: + - ruby + - "{manifest_dir}/cpp/index_stdlib.rb" + - "{source_root}" + - "{index}" + - c++20 + - memory + working_directory: "{source_root}" + output: libstdcxx.scip + expected: + tool: scip-clang + version: "0.4.0" + +compatibility: + command: + - ruby + - "{manifest_dir}/cpp/semantic_environment.rb" + - "{source_root}" + - "{environment}" + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 2500 + +summary: + corpus: libstdcxx-13.3.0-cxx20-memory + output: ../complexity_summaries/cpp-libstdcxx.13.3.0-cxx20-memory.json.gz + minimum_symbols: 520 + symbol_bridge: + command: + - ruby + - "{manifest_dir}/cpp/build_symbol_bridge.rb" + - "{producer_summary}" + - "{symbol_bridge}" + working_directory: "{source_root}" diff --git a/gems/fact-mine/config/stdlib_maps/cpp/build_symbol_bridge.rb b/gems/fact-mine/config/stdlib_maps/cpp/build_symbol_bridge.rb new file mode 100644 index 000000000..0838bc8ed --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp/build_symbol_bridge.rb @@ -0,0 +1,24 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "zlib" + +summary, output = ARGV +abort "usage: build_symbol_bridge.rb PRODUCER.json.gz OUTPUT.json" unless summary && output + +producer = Zlib::GzipReader.open(summary) { |gzip| JSON.parse(gzip.read) } +symbols = producer.fetch("symbols") + .select do |symbol, row| + symbol.start_with?("cxx . . $ std/") && + row.fetch("bound_quality") == "upper_bound_exact_symbol" + end + .keys + .sort + .to_h { |symbol| [symbol, symbol] } +abort "producer exported no exact std symbols" if symbols.empty? + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.symbol-bridge.v1", + "symbols" => symbols +})) diff --git a/gems/fact-mine/config/stdlib_maps/cpp/index_stdlib.rb b/gems/fact-mine/config/stdlib_maps/cpp/index_stdlib.rb new file mode 100755 index 000000000..6946efac6 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp/index_stdlib.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +INDEXER_VERSION = "0.4.0" +INDEXER_SHA256 = "06fd18c576f979a726c651594644ec4a35db4f471f2160b3f72eb89fa6001784" +source_root, output, standard, surface = ARGV +abort "usage: index_stdlib.rb SOURCE_ROOT OUTPUT.scip C++_STANDARD SURFACE" unless source_root && output && standard && surface +abort "unsupported C++ standard #{standard.inspect}" unless %w[c++17 c++20].include?(standard) +abort "invalid surface #{surface.inspect}" unless surface.match?(/\A[a-z][a-z0-9-]*\z/) + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + candidate = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |path| File.file?(path) && File.executable?(path) } + abort "#{fallback} was not found; set #{environment}" unless candidate + + candidate +end + +def capture!(*command, chdir:) + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout +end + +source_root = File.expand_path(source_root) +output = File.expand_path(output) +compiler = executable("CLANGXX", "clang++-20") +indexer = executable("SCIP_CLANG", "scip-clang") +version = capture!(indexer, "--version", chdir: source_root) +abort "scip-clang #{INDEXER_VERSION} is required" unless version.lines.first&.strip == "scip-clang #{INDEXER_VERSION}" +abort "unexpected scip-clang binary" unless Digest::SHA256.file(indexer).hexdigest == INDEXER_SHA256 + +implementation = File.join(source_root, "preprocessed", standard, "#{surface}.cpp") +abort "preprocessed surface was not found at #{implementation}" unless File.file?(implementation) +arguments = [ + compiler, + "-std=#{standard}", + "-c", + implementation +] +File.write( + File.join(source_root, "compile_commands.json"), + JSON.pretty_generate([{ + "directory" => source_root, + "file" => implementation, + "arguments" => arguments + }]) +) +capture!("git", "init", "--quiet", chdir: source_root) +capture!( + indexer, + "--compdb-path", + "compile_commands.json", + "--index-output-path", + output, + "--no-progress-report", + "-j", + "1", + chdir: source_root +) +abort "scip-clang did not produce #{output}" unless File.size?(output) diff --git a/gems/fact-mine/config/stdlib_maps/cpp/materialize_source.rb b/gems/fact-mine/config/stdlib_maps/cpp/materialize_source.rb new file mode 100755 index 000000000..ef6dc3d38 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp/materialize_source.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "open3" + +VERSION = "13.3.0-6ubuntu2~24.04.1" +TREE_SHA256 = "be6bb0b28e319291ad1c08d2aaee0509feb8eb98a24b67edff98fa047b33593a" +ARCH_TREE_SHA256 = "39dd70479b380d3552526891efea1103d478cd9804d8858ae48190d103b08823" +EFFECTIVE_TREE_SHA256 = "cab188eb89bb4b5cf99c97a16f9d1d3196d1d2ce9536ea70a9f74b782bc7bec2" +SURFACES = { + ["c++17", "containers"] => %w[array atomic list memory tuple utility], + ["c++17", "strings"] => %w[cctype iomanip iostream sstream string vector], + ["c++20", "memory"] => %w[memory optional tuple utility] +}.freeze + +workspace = ARGV.fetch(0) do + abort "usage: materialize_source.rb WORKSPACE_ROOT" +end + +def tree_digest(root) + digest = Digest::SHA256.new + Dir.glob(File.join(root, "**/*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .sort + .each do |path| + relative = path.delete_prefix("#{root}#{File::SEPARATOR}") + digest << relative << "\0" << Digest::SHA256.file(path).hexdigest << "\n" + end + digest.hexdigest +end + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + candidate = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |path| File.file?(path) && File.executable?(path) } + abort "#{fallback} was not found; set #{environment}" unless candidate + + candidate +end + +source = ENV.fetch("LIBSTDCXX_INCLUDE", "/usr/include/c++/13") +architecture = ENV.fetch( + "LIBSTDCXX_ARCH_INCLUDE", + "/usr/include/x86_64-linux-gnu/c++/13" +) +abort "libstdc++ headers were not found at #{source}" unless File.directory?(source) +abort "architecture-specific libstdc++ headers were not found at #{architecture}" unless File.directory?(architecture) +abort "unexpected libstdc++ tree digest" unless tree_digest(source) == TREE_SHA256 +abort "unexpected architecture-specific libstdc++ tree digest" unless tree_digest(architecture) == ARCH_TREE_SHA256 + +cache = File.join( + File.expand_path(workspace), + ".cache", + "stdlib-sources", + "libstdcxx-#{VERSION}" +) +effective = File.join(cache, "include") +preprocessed = File.join(cache, "preprocessed") +marker = File.join(cache, ".complete") + +complete = File.file?(marker) && + SURFACES.keys.all? do |standard, surface| + File.file?(File.join(preprocessed, standard, "#{surface}.cpp")) + end && + File.directory?(effective) && + tree_digest(effective) == EFFECTIVE_TREE_SHA256 +unless complete + temporary = "#{cache}.#{Process.pid}.tmp" + FileUtils.rm_rf(temporary) + FileUtils.mkdir_p(File.join(temporary, "include")) + FileUtils.cp_r("#{source}/.", File.join(temporary, "include")) + FileUtils.cp_r("#{architecture}/.", File.join(temporary, "include")) + + compiler = executable("CLANGXX", "clang++-20") + SURFACES.each do |(standard, surface), headers| + audit = File.join(temporary, "#{surface}-#{standard}.cpp") + File.write(audit, headers.map { |header| "#include <#{header}>\n" }.join) + stdout, stderr, status = Open3.capture3( + compiler, + "-std=#{standard}", + "-I#{File.join(temporary, 'include')}", + "-E", + "-P", + audit + ) + abort "failed to preprocess #{surface} for #{standard}:\n#{stderr}" unless status.success? + + destination = File.join(temporary, "preprocessed", standard, "#{surface}.cpp") + FileUtils.mkdir_p(File.dirname(destination)) + File.write(destination, stdout) + FileUtils.rm_f(audit) + end + File.write(File.join(temporary, ".complete"), "#{VERSION}\n") + FileUtils.mkdir_p(File.dirname(cache)) + FileUtils.rm_rf(cache) + FileUtils.mv(temporary, cache) +end + +abort "materialized libstdc++ include tree is missing" unless File.directory?(effective) +abort "materialized libstdc++ preprocessed surfaces are missing" unless File.directory?(preprocessed) +puts cache diff --git a/gems/fact-mine/config/stdlib_maps/cpp/revision.rb b/gems/fact-mine/config/stdlib_maps/cpp/revision.rb new file mode 100755 index 000000000..67c8f84d8 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp/revision.rb @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" + +EXPECTED = "cab188eb89bb4b5cf99c97a16f9d1d3196d1d2ce9536ea70a9f74b782bc7bec2" + +root = ARGV.fetch(0) { abort "usage: revision.rb SOURCE_ROOT" } +include_root = File.join(File.expand_path(root), "include") +digest = Digest::SHA256.new +Dir.glob(File.join(include_root, "**/*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .sort + .each do |path| + relative = path.delete_prefix("#{include_root}#{File::SEPARATOR}") + digest << relative << "\0" << Digest::SHA256.file(path).hexdigest << "\n" + end +actual = digest.hexdigest +abort "unexpected effective libstdc++ digest #{actual}" unless actual == EXPECTED + +puts "libstdcxx-13.3.0 effective sha256:#{actual}" diff --git a/gems/fact-mine/config/stdlib_maps/cpp/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/cpp/semantic_environment.rb new file mode 100755 index 000000000..6014119c6 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/cpp/semantic_environment.rb @@ -0,0 +1,92 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +source_root, output = ARGV +abort "usage: semantic_environment.rb SOURCE_ROOT OUTPUT.json" unless source_root && output + +def overlay_digest(*roots) + files = {} + roots.each do |root| + Dir.glob(File.join(root, "**/*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .each do |path| + relative = path.delete_prefix("#{root}#{File::SEPARATOR}") + files[relative] = path + end + end + digest = Digest::SHA256.new + files.sort.each do |relative, path| + digest << relative << "\0" << Digest::SHA256.file(path).hexdigest << "\n" + end + digest.hexdigest +end + +def capture!(*command, chdir:) + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout.strip +end + +source_root = File.expand_path(source_root) +database = JSON.parse(File.read(File.join(source_root, "compile_commands.json"))) +arguments = database.fetch(0).fetch("arguments") +compiler = File.expand_path(arguments.fetch(0)) +standard = arguments.find { |argument| argument.start_with?("-std=") } +abort "compile command has no explicit C++ standard" unless standard + +semantic_flags = arguments.select do |argument| + argument.start_with?("-std=", "-D", "-U") +end +base_headers = ENV.fetch("LIBSTDCXX_INCLUDE", "/usr/include/c++/13") +architecture_headers = ENV.fetch( + "LIBSTDCXX_ARCH_INCLUDE", + "/usr/include/x86_64-linux-gnu/c++/13" +) +abort "libstdc++ headers were not found at #{base_headers}" unless File.directory?(base_headers) +abort "architecture-specific libstdc++ headers were not found at #{architecture_headers}" unless File.directory?(architecture_headers) +macros = capture!( + compiler, + *semantic_flags, + "-I#{base_headers}", + "-I#{architecture_headers}", + "-dM", + "-E", + "-x", + "c++", + "-include", + "bits/c++config.h", + "/dev/null", + chdir: source_root +).lines.sort.join +indexer = ENV["SCIP_CLANG"] +indexer ||= ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, "scip-clang") } + .find { |path| File.file?(path) && File.executable?(path) } +abort "scip-clang was not found; set SCIP_CLANG" unless indexer + +config = File.join(architecture_headers, "bits", "c++config.h") +version = capture!(compiler, "--version", chdir: source_root).lines.first.strip +target = capture!(compiler, "-dumpmachine", chdir: source_root) +indexer_version = capture!(indexer, "--version", chdir: source_root).lines.first.strip + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => { + "cpp.stdlib.vendor" => "libstdc++", + "cpp.stdlib.effective_headers.sha256" => + "sha256:#{overlay_digest(base_headers, architecture_headers)}", + "cpp.stdlib.config.sha256" => "sha256:#{Digest::SHA256.file(config).hexdigest}", + "cpp.compiler.version" => version, + "cpp.compiler.target" => target, + "cpp.compiler.sha256" => "sha256:#{Digest::SHA256.file(compiler).hexdigest}", + "cpp.preprocessor_macros.sha256" => "sha256:#{Digest::SHA256.hexdigest(macros)}", + "cpp.language_standard" => standard, + "cpp.scip_clang.version" => indexer_version, + "cpp.scip_clang.sha256" => "sha256:#{Digest::SHA256.file(indexer).hexdigest}" + } +})) diff --git a/gems/fact-mine/config/stdlib_maps/csharp-10.0.10.yml b/gems/fact-mine/config/stdlib_maps/csharp-10.0.10.yml new file mode 100644 index 000000000..982de4861 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/csharp-10.0.10.yml @@ -0,0 +1,53 @@ +schema: fact-mine.stdlib-map.v1 +language: csharp + +source: + revision: dotnet-runtime-10.0.10 + git: + repository: https://github.com/dotnet/runtime.git + commit: 8f030f80c0dd2722eb2f618984e9db6784765963 + sparse_paths: + - src/libraries/System.Private.CoreLib/src/System + include: + - "src/libraries/System.Private.CoreLib/src/System/Collections/**/*.cs" + - "src/libraries/System.Private.CoreLib/src/System/String.cs" + - "src/libraries/System.Private.CoreLib/src/System/Array.cs" + +index: + command: + - ruby + - "{manifest_dir}/csharp/index_corelib.rb" + - "{source_root}" + - "{index}" + working_directory: "{source_root}" + output: csharp-corelib.scip + expected: + # scip-dotnet 0.2.14 still writes its historical development version into + # SCIP metadata. The language-owned index command separately verifies the + # released package and the compatibility attestation pins its binary hash. + tool: scip-dotnet + version: 0.1.0-SNAPSHOT + +compatibility: + command: + - ruby + - "{manifest_dir}/csharp/semantic_environment.rb" + - "{source_root}" + - "{environment}" + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 1300 + +summary: + corpus: dotnet-runtime-corelib-collections-string-array + output: ../complexity_summaries/csharp-corelib.dotnet10.0.10.json.gz + minimum_symbols: 250 + symbol_bridge: + command: + - ruby + - "{manifest_dir}/csharp/build_symbol_bridge.rb" + - "{producer_summary}" + - "{symbol_bridge}" + - 10.0.0.0 + working_directory: "{source_root}" diff --git a/gems/fact-mine/config/stdlib_maps/csharp/build_symbol_bridge.rb b/gems/fact-mine/config/stdlib_maps/csharp/build_symbol_bridge.rb new file mode 100644 index 000000000..9833276cb --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/csharp/build_symbol_bridge.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "zlib" + +producer_summary, output, runtime_version = ARGV +abort "usage: build_symbol_bridge.rb PRODUCER.json.gz OUTPUT.json RUNTIME_VERSION" unless runtime_version + +summary = Zlib::GzipReader.open(producer_summary) { |gzip| JSON.parse(gzip.read) } +prefix = "scip-dotnet nuget . . " + +def runtime_package(descriptor) + case descriptor + when /\A(?:System\/(?:Array|String))#/ + "System.Runtime" + when /\A(?:Generic|ObjectModel)\// + "System.Collections" + when /\AConcurrent\// + "System.Collections.Concurrent" + when %r{\ACollections/(?:ArrayList|Hashtable|BitArray)#} + "System.Collections.NonGeneric" + when /\ACollections\/DictionaryEntry#/ + "System.Runtime" + end +end + +symbols = summary.fetch("symbols").keys.sort.filter_map do |symbol| + next unless symbol.start_with?(prefix) + + descriptor = symbol.delete_prefix(prefix) + package = runtime_package(descriptor) + next unless package + + [symbol, "scip-dotnet nuget #{package} #{runtime_version} #{descriptor}"] +end.to_h +abort "no compatible C# runtime symbols were found" if symbols.empty? + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.symbol-bridge.v1", + "symbols" => symbols +})) diff --git a/gems/fact-mine/config/stdlib_maps/csharp/index_corelib.rb b/gems/fact-mine/config/stdlib_maps/csharp/index_corelib.rb new file mode 100644 index 000000000..910cd88e2 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/csharp/index_corelib.rb @@ -0,0 +1,78 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "open3" + +EXPECTED_INDEXER = "0.2.14" +TARGET_FRAMEWORK = "net10.0" + +source_root, output = ARGV +abort "usage: index_corelib.rb SOURCE_ROOT OUTPUT.scip" unless source_root && output + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + path = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + abort "#{fallback} was not found; set #{environment}" unless path + + path +end + +def capture!(*command, chdir:, env: {}) + stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout +end + +source_root = File.expand_path(source_root) +output = File.expand_path(output) +dotnet = executable("DOTNET", "dotnet") +indexer = executable("SCIP_DOTNET", "scip-dotnet") +runtime_root = ENV["DOTNET_ROOT"] +runtime_root = File.dirname(dotnet) if runtime_root.nil? || runtime_root.empty? +environment = { + "DOTNET_ROOT" => runtime_root, + "PATH" => "#{File.dirname(dotnet)}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" +} +environment["NUGET_PACKAGES"] = ENV["NUGET_PACKAGES"] if ENV["NUGET_PACKAGES"] +version = capture!(indexer, "--version", chdir: source_root, env: environment).strip +unless version == EXPECTED_INDEXER || version.start_with?("#{EXPECTED_INDEXER}+") + abort "scip-dotnet #{EXPECTED_INDEXER} is required, got #{version.inspect}" +end + +project = File.join(source_root, "FactMine.CoreLib.csproj") +File.write(project, <<~XML) + + + #{TARGET_FRAMEWORK} + false + enable + disable + 0436 + + + + + + + +XML + +capture!(dotnet, "restore", project, chdir: source_root, env: environment) +FileUtils.mkdir_p(File.dirname(output)) +capture!( + indexer, + "index", + File.basename(project), + "--skip-dotnet-restore", + "--output", + output, + chdir: source_root, + env: environment +) +abort "scip-dotnet did not produce #{output}" unless File.size?(output) diff --git a/gems/fact-mine/config/stdlib_maps/csharp/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/csharp/semantic_environment.rb new file mode 100644 index 000000000..77af2edf7 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/csharp/semantic_environment.rb @@ -0,0 +1,71 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +EXPECTED_INDEXER = "0.2.14" +TARGET_FRAMEWORK = "net10.0" + +source_root, output = ARGV +abort "usage: semantic_environment.rb SOURCE_ROOT OUTPUT.json" unless source_root && output + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + path = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + abort "#{fallback} was not found; set #{environment}" unless path + + path +end + +def capture!(*command, chdir:, env: {}) + stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout.strip +end + +source_root = File.expand_path(source_root) +dotnet = executable("DOTNET", "dotnet") +indexer = executable("SCIP_DOTNET", "scip-dotnet") +runtime_root = ENV["DOTNET_ROOT"] +runtime_root = File.dirname(dotnet) if runtime_root.nil? || runtime_root.empty? +environment = { + "DOTNET_ROOT" => runtime_root, + "PATH" => "#{File.dirname(dotnet)}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" +} +indexer_version = capture!(indexer, "--version", chdir: source_root, env: environment) +unless indexer_version == EXPECTED_INDEXER || indexer_version.start_with?("#{EXPECTED_INDEXER}+") + abort "scip-dotnet #{EXPECTED_INDEXER} is required, got #{indexer_version.inspect}" +end + +reference = Dir[File.join( + runtime_root, + "packs/Microsoft.NETCore.App.Ref/10.0.*/ref/#{TARGET_FRAMEWORK}/System.Runtime.dll" +)].max +abort "the .NET 10 System.Runtime reference assembly was not found below #{runtime_root}" unless reference + +indexer_dll = Dir[File.join( + File.dirname(indexer), + ".store/scip-dotnet/#{EXPECTED_INDEXER}/scip-dotnet/#{EXPECTED_INDEXER}/tools/**/scip-dotnet.dll" +)].first +indexer_dll ||= indexer +revision = capture!("git", "rev-parse", "HEAD", chdir: source_root) + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => { + "csharp.runtime.source" => "dotnet/runtime@#{revision}", + "csharp.target_framework" => TARGET_FRAMEWORK, + "csharp.reference_pack.system_runtime.sha256" => + "sha256:#{Digest::SHA256.file(reference).hexdigest}", + "csharp.scip_dotnet.release" => EXPECTED_INDEXER, + "csharp.scip_dotnet.sha256" => + "sha256:#{Digest::SHA256.file(indexer_dll).hexdigest}" + } +})) diff --git a/gems/fact-mine/config/stdlib_maps/go-1.22.2.yml b/gems/fact-mine/config/stdlib_maps/go-1.22.2.yml new file mode 100644 index 000000000..44dd85294 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/go-1.22.2.yml @@ -0,0 +1,63 @@ +schema: fact-mine.stdlib-map.v1 +language: go + +source: + root_command: ["go", "env", "GOROOT"] + root_suffix: src + revision: go1.22.2 + revision_check: + command: ["go", "version"] + includes: ["go1.22.2"] + include: + - "{syscall,os,time,flag,bytes,bufio,sort,strings,strconv,math,regexp,fmt}/**/*.go" + - "encoding/{json,xml}/**/*.go" + - "io/fs/**/*.go" + exclude: + - "**/*_test.go" + +index: + command: + - scip-go + - index + - syscall/... + - os/... + - time/... + - flag/... + - bytes/... + - bufio/... + - encoding/json/... + - encoding/xml/... + - sort/... + - strings/... + - io/fs/... + - strconv/... + - math/... + - regexp/... + - fmt/... + - --module-root + - "{source_root}" + - --repository-remote + - github.com/golang/go + - --module-path + - github.com/golang/go/src + - --module-version + - go1.22 + - --go-version + - go1.22.2 + - --output + - "{index}" + - --skip-tests + working_directory: "{source_root}" + output: go-stdlib.scip + expected: + tool: scip-go + version: 0.2.7 + +soundness: + minimum_export_eligible_methods: 3000 + +summary: + corpus: go-stdlib-core-surface + output: ../complexity_summaries/go-stdlib.go1.22.2.json.gz + minimum_symbols: 310 + expected_symbol_prefix: "scip-go gomod github.com/golang/go/src go1.22 " diff --git a/gems/fact-mine/config/stdlib_maps/java-21.0.12.yml b/gems/fact-mine/config/stdlib_maps/java-21.0.12.yml new file mode 100644 index 000000000..5591b2e9a --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/java-21.0.12.yml @@ -0,0 +1,60 @@ +schema: fact-mine.stdlib-map.v1 +language: java + +source: + revision: jdk-21.0.12+8 + git: + repository: https://github.com/adoptium/jdk21u.git + commit: 9de4f68c88a0a1510373f291d1a95b1f6b0db8c8 + sparse_paths: + - src/java.base/share/classes/java/lang + - src/java.base/share/classes/java/util + root_suffix: src/java.base/share/classes + include: + - "java/{lang,util}/**/*.java" + +index: + command: + - scip-java + - index + - --output + - "{index}" + - --build-tool + - maven + - -- + - package + - "-Dstdlib.source={source_root}/java" + working_directory: jdk-21.0.12 + output: java-stdlib.scip + expected: + tool: scip-java + version: 0.12.3 + +soundness: + minimum_export_eligible_methods: 5000 + +summary: + corpus: java-stdlib-lang-util + output: ../complexity_summaries/java-stdlib.jdk21.0.12.json.gz + minimum_symbols: 1 + expected_symbol_prefix: "semanticdb maven jdk 21 " + symbol_relocation: + from: "semanticdb maven maven/jdk/java.base 21.0.12 " + to: "semanticdb maven jdk 21 " + +consumers: + - name: java-21-exact-symbol + source_root: consumers/java-21 + include: ["src/main/java/**/*.java"] + index: + command: + - scip-java + - index + - --output + - "{index}" + - --build-tool + - maven + - -- + - package + output: java-consumer.scip + minimum_complete_percent: 100 diff --git a/gems/fact-mine/config/stdlib_maps/javascript/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/javascript/semantic_environment.rb new file mode 100644 index 000000000..b2ef94704 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/javascript/semantic_environment.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +source_root, output = ARGV +abort "usage: semantic_environment.rb SOURCE_ROOT OUTPUT.json" unless source_root && output + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + path = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + abort "#{fallback} was not found; set #{environment}" unless path + + path +end + +def capture!(*command, chdir:) + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout.strip +end + +source_root = File.expand_path(source_root) +node = executable("NODE", "node") +indexer = executable("SCIP_TYPESCRIPT", "scip-typescript") +versions = JSON.parse(capture!( + node, + "-p", + "JSON.stringify(process.versions)", + chdir: source_root +)) +%w[node v8 modules].each do |key| + abort "Node did not report process.versions.#{key}" if versions[key].to_s.empty? +end + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => { + "javascript.runtime" => "node", + "javascript.node.version" => versions.fetch("node"), + "javascript.v8.version" => versions.fetch("v8"), + "javascript.node.modules_abi" => versions.fetch("modules"), + "javascript.node.sha256" => "sha256:#{Digest::SHA256.file(node).hexdigest}", + "javascript.scip_typescript.version" => + capture!(indexer, "--version", chdir: source_root).lines.first.to_s, + "javascript.scip_typescript.sha256" => + "sha256:#{Digest::SHA256.file(indexer).hexdigest}" + } +})) diff --git a/gems/fact-mine/config/stdlib_maps/jdk-21.0.12/pom.xml b/gems/fact-mine/config/stdlib_maps/jdk-21.0.12/pom.xml new file mode 100644 index 000000000..7536aeaad --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/jdk-21.0.12/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + jdk + java.base + 21.0.12 + + 21 + UTF-8 + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + + + add-jdk-source + generate-sources + + add-source + + + + ${stdlib.source} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + --patch-module + java.base=${stdlib.source} + + + + + + diff --git a/gems/fact-mine/config/stdlib_maps/kotlin-2.2.0.yml b/gems/fact-mine/config/stdlib_maps/kotlin-2.2.0.yml new file mode 100644 index 000000000..820b48653 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin-2.2.0.yml @@ -0,0 +1,67 @@ +schema: fact-mine.stdlib-map.v1 +language: kotlin + +source: + root_command: + - ruby + - "{manifest_dir}/kotlin/materialize_source.rb" + - "{workspace_root}" + revision: kotlin-stdlib-2.2.0 + revision_check: + command: + - ruby + - "{manifest_dir}/kotlin/revision.rb" + - "{source_root}" + equals: "kotlin-stdlib-2.2.0 sources sha256:967ad9599254e3a60d96d6c789547cc35c22d770d9c8fb1e3f15fac3b4c3b65d" + include: + - "commonMain/generated/*.kt" + - "jvmMain/generated/*.kt" + - "commonMain/kotlin/collections/*.kt" + exclude: + - "**/AbstractMutableCollection.kt" + - "**/AbstractMutableList.kt" + - "**/AbstractMutableMap.kt" + - "**/AbstractMutableSet.kt" + - "**/ArrayList.kt" + - "**/Collections.kt" + - "**/CollectionsH.kt" + - "**/HashMap.kt" + - "**/HashSet.kt" + - "**/LinkedHashMap.kt" + - "**/LinkedHashSet.kt" + - "**/Maps.kt" + - "**/Sets.kt" + +index: + command: + - ruby + - "{manifest_dir}/kotlin/index_stdlib.rb" + - "{source_root}" + - "{index}" + - "{workspace_root}" + - "{manifest_dir}/kotlin/scip-kotlin-top-level-symbols.patch" + working_directory: "{source_root}" + output: kotlin-stdlib.scip + expected: + tool: scip-java + version: 0.12.3 + +compatibility: + command: + - ruby + - "{manifest_dir}/kotlin/semantic_environment.rb" + - "{source_root}" + - "{environment}" + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 3500 + +summary: + corpus: kotlin-stdlib-jvm + output: ../complexity_summaries/kotlin-stdlib.kotlin2.2.0.json.gz + minimum_symbols: 80 + expected_symbol_prefix: "scip-java maven . . kotlin/" + symbol_relocation: + from: "semanticdb maven . . kotlin/" + to: "scip-java maven . . kotlin/" diff --git a/gems/fact-mine/config/stdlib_maps/kotlin/index_stdlib.rb b/gems/fact-mine/config/stdlib_maps/kotlin/index_stdlib.rb new file mode 100644 index 000000000..4a56e5310 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin/index_stdlib.rb @@ -0,0 +1,158 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" +require "open-uri" +require "open3" + +SCIP_JAVA_VERSION = "0.12.3" +SCIP_JAVA_SHA256 = "2d4d8a31333dfa0daf3aa0381a51de465e40b0dac5622e49363786a65f743f34" +SCIP_KOTLIN_COMMIT = "2648c7dc04c2cc999cae9e3fd19863177a07492d" +PATCHED_PLUGIN_SHA256 = "4a141d34551466881b4cfdd5127718358ddd0917971e0254664b48e6bcf3a319" + +source_root, output, workspace_root, patch = ARGV +abort "usage: index_stdlib.rb SOURCE_ROOT OUTPUT.scip WORKSPACE_ROOT PATCH" unless patch +source_root = File.expand_path(source_root) +output = File.expand_path(output) +cache = File.join(File.expand_path(workspace_root), ".cache", "stdlib-indexers", "kotlin-2.2.0") +FileUtils.mkdir_p(cache) + +java_home = ENV["JAVA_HOME"] +abort "JAVA_HOME must identify JDK 21" unless java_home && File.executable?(File.join(java_home, "bin", "java")) + +def capture!(*command, chdir:, env: {}) + stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + stdout +end + +java_stdout, java_stderr, java_status = Open3.capture3( + File.join(java_home, "bin", "java"), "-version", chdir: source_root +) +abort "failed to inspect JAVA_HOME" unless java_status.success? +java_version = "#{java_stdout}\n#{java_stderr}" +abort "JDK 21 is required, got #{java_version.inspect}" unless java_version.include?("21.") + +scip_java = ENV["SCIP_JAVA"] +if scip_java && File.executable?(scip_java) + actual = Digest::SHA256.file(scip_java).hexdigest + abort "scip-java digest mismatch: expected #{SCIP_JAVA_SHA256}, got #{actual}" unless actual == SCIP_JAVA_SHA256 +else + scip_java = File.join(cache, "scip-java-v#{SCIP_JAVA_VERSION}") + unless File.file?(scip_java) && Digest::SHA256.file(scip_java).hexdigest == SCIP_JAVA_SHA256 + temporary = "#{scip_java}.download-#{Process.pid}" + url = "https://github.com/scip-code/scip-java/releases/download/v#{SCIP_JAVA_VERSION}/scip-java-v#{SCIP_JAVA_VERSION}" + URI.open(url) { |input| File.open(temporary, "wb") { |file| IO.copy_stream(input, file) } } + actual = Digest::SHA256.file(temporary).hexdigest + abort "scip-java digest mismatch: expected #{SCIP_JAVA_SHA256}, got #{actual}" unless actual == SCIP_JAVA_SHA256 + File.rename(temporary, scip_java) + FileUtils.chmod(0o755, scip_java) + end +end +version = capture!(scip_java, "--version", chdir: source_root, env: {"JAVA_HOME" => java_home}).strip +abort "scip-java #{SCIP_JAVA_VERSION} is required, got #{version.inspect}" unless version == SCIP_JAVA_VERSION + +plugin = ENV["SEMANTICDB_KOTLINC"] +if plugin && File.file?(plugin) + actual = Digest::SHA256.file(plugin).hexdigest + unless actual == PATCHED_PLUGIN_SHA256 + abort "semanticdb-kotlinc digest mismatch: expected #{PATCHED_PLUGIN_SHA256}, got #{actual}" + end +else + checkout = File.join(cache, "scip-kotlin") + unless File.directory?(File.join(checkout, ".git")) + capture!("git", "clone", "--quiet", "https://github.com/sourcegraph/scip-kotlin.git", checkout, chdir: cache) + end + head = capture!("git", "rev-parse", "HEAD", chdir: checkout).strip + unless head == SCIP_KOTLIN_COMMIT + capture!("git", "fetch", "--quiet", "--depth", "1", "origin", SCIP_KOTLIN_COMMIT, chdir: checkout) + capture!("git", "checkout", "--quiet", "--detach", "FETCH_HEAD", chdir: checkout) + end + _stdout, _stderr, applied = Open3.capture3("git", "apply", "--check", File.expand_path(patch), chdir: checkout) + capture!("git", "apply", File.expand_path(patch), chdir: checkout) if applied.success? + unless system("git", "apply", "--reverse", "--check", File.expand_path(patch), chdir: checkout, + out: File::NULL, err: File::NULL) + abort "the pinned scip-kotlin patch could not be applied cleanly" + end + capture!( + File.join(checkout, "gradlew"), "--no-daemon", ":semanticdb-kotlinc:shadowJar", + chdir: checkout, + env: {"JAVA_HOME" => java_home} + ) + plugin = Dir[File.join(checkout, "semanticdb-kotlinc/build/libs/semanticdb-kotlinc-*.jar")] + .reject { |path| path.end_with?("-slim.jar") } + .max + abort "patched semanticdb-kotlinc jar was not produced" unless plugin +end +actual_plugin = Digest::SHA256.file(plugin).hexdigest +unless actual_plugin == PATCHED_PLUGIN_SHA256 + abort "patched semanticdb-kotlinc is not reproducible: expected #{PATCHED_PLUGIN_SHA256}, got #{actual_plugin}" +end + +marker = JSON.parse(File.read(File.join(source_root, ".fact-mine-source.json"))) +binary = marker.fetch("binary") +semanticdb = File.join(source_root, ".fact-mine", "semanticdb") +FileUtils.rm_rf(semanticdb) +FileUtils.mkdir_p(semanticdb) +settings = File.join(source_root, "settings.gradle") +build = File.join(source_root, "build.gradle") +properties = File.join(source_root, "gradle.properties") +File.write(settings, <<~GRADLE) + pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + } + rootProject.name = "fact-mine-kotlin-stdlib" +GRADLE +File.write(build, <<~GRADLE) + plugins { + id "org.jetbrains.kotlin.multiplatform" version "2.2.0" + } + repositories { mavenCentral() } + dependencies { + commonMainImplementation files("#{binary}") + } + kotlin { + jvm() + targets.configureEach { + compilations.configureEach { + compileTaskProvider.configure { + compilerOptions { + freeCompilerArgs.addAll( + "-Xallow-kotlin-package", + "-opt-in=kotlin.contracts.ExperimentalContracts", + "-Xfriend-paths=#{binary}", + "-Xplugin=#{plugin}", + "-P", "plugin:semanticdb-kotlinc:sourceroot=#{source_root}", + "-P", "plugin:semanticdb-kotlinc:targetroot=#{semanticdb}" + ) + } + } + } + } + sourceSets { + commonMain.kotlin.srcDirs("commonMain/generated", "commonMain/kotlin/collections") + commonMain.kotlin.exclude( + "**/AbstractMutableCollection.kt", "**/AbstractMutableList.kt", + "**/AbstractMutableMap.kt", "**/AbstractMutableSet.kt", "**/ArrayList.kt", + "**/Collections.kt", "**/CollectionsH.kt", "**/HashMap.kt", "**/HashSet.kt", + "**/LinkedHashMap.kt", "**/LinkedHashSet.kt", "**/Maps.kt", "**/Sets.kt" + ) + jvmMain.kotlin.srcDir("jvmMain/generated") + } + } +GRADLE +File.write(properties, "kotlin.stdlib.default.dependency=false\n") + +gradle = ENV["GRADLE"] +gradle ||= File.join(cache, "scip-kotlin", "gradlew") +capture!(gradle, "--no-daemon", "clean", "compileKotlinJvm", chdir: source_root, + env: {"JAVA_HOME" => java_home}) +FileUtils.mkdir_p(File.dirname(output)) +capture!(scip_java, "index-semanticdb", semanticdb, "--output", output, + chdir: source_root, env: {"JAVA_HOME" => java_home}) +abort "scip-java did not produce #{output}" unless File.size?(output) diff --git a/gems/fact-mine/config/stdlib_maps/kotlin/materialize_source.rb b/gems/fact-mine/config/stdlib_maps/kotlin/materialize_source.rb new file mode 100644 index 000000000..4f5668dd5 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin/materialize_source.rb @@ -0,0 +1,59 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" +require "open-uri" +require "open3" + +VERSION = "2.2.0" +SOURCES_SHA256 = "967ad9599254e3a60d96d6c789547cc35c22d770d9c8fb1e3f15fac3b4c3b65d" +BINARY_SHA256 = "65d12d85a3b865c160db9147851712a64b10dadd68b22eea22a95bf8a8670dca" +BASE_URL = "https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/#{VERSION}" + +workspace = ARGV.fetch(0) do + abort "usage: materialize_source.rb WORKSPACE_ROOT" +end +cache = File.join(File.expand_path(workspace), ".cache", "stdlib-sources", "kotlin-stdlib-#{VERSION}") +archive_dir = File.join(cache, "artifacts") +source_root = File.join(cache, "source") +marker = File.join(source_root, ".fact-mine-source.json") +FileUtils.mkdir_p(archive_dir) + +def download(url, destination, sha256) + if File.file?(destination) && Digest::SHA256.file(destination).hexdigest == sha256 + return + end + + temporary = "#{destination}.download-#{Process.pid}" + URI.open(url) { |input| File.open(temporary, "wb") { |output| IO.copy_stream(input, output) } } + actual = Digest::SHA256.file(temporary).hexdigest + abort "digest mismatch for #{url}: expected #{sha256}, got #{actual}" unless actual == sha256 + + File.rename(temporary, destination) +ensure + FileUtils.rm_f(temporary) if defined?(temporary) +end + +sources = File.join(archive_dir, "kotlin-stdlib-#{VERSION}-sources.jar") +binary = File.join(archive_dir, "kotlin-stdlib-#{VERSION}.jar") +download("#{BASE_URL}/kotlin-stdlib-#{VERSION}-sources.jar", sources, SOURCES_SHA256) +download("#{BASE_URL}/kotlin-stdlib-#{VERSION}.jar", binary, BINARY_SHA256) + +expected_marker = { + "version" => VERSION, + "sources_sha256" => SOURCES_SHA256, + "binary_sha256" => BINARY_SHA256, + "binary" => binary +} +current_marker = JSON.parse(File.read(marker)) if File.file?(marker) +unless current_marker == expected_marker + FileUtils.rm_rf(source_root) + FileUtils.mkdir_p(source_root) + _stdout, stderr, status = Open3.capture3("unzip", "-q", sources, "-d", source_root) + abort "failed to extract #{sources}: #{stderr}" unless status.success? + File.write(marker, JSON.pretty_generate(expected_marker)) +end + +puts source_root diff --git a/gems/fact-mine/config/stdlib_maps/kotlin/revision.rb b/gems/fact-mine/config/stdlib_maps/kotlin/revision.rb new file mode 100644 index 000000000..f6060af74 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin/revision.rb @@ -0,0 +1,18 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" + +EXPECTED = { + "version" => "2.2.0", + "sources_sha256" => "967ad9599254e3a60d96d6c789547cc35c22d770d9c8fb1e3f15fac3b4c3b65d", + "binary_sha256" => "65d12d85a3b865c160db9147851712a64b10dadd68b22eea22a95bf8a8670dca" +}.freeze + +source_root = File.expand_path(ARGV.fetch(0) { abort "usage: revision.rb SOURCE_ROOT" }) +marker = JSON.parse(File.read(File.join(source_root, ".fact-mine-source.json"))) +EXPECTED.each do |key, value| + abort "Kotlin stdlib marker mismatch for #{key}" unless marker[key] == value +end + +puts "kotlin-stdlib-2.2.0 sources sha256:#{EXPECTED.fetch('sources_sha256')}" diff --git a/gems/fact-mine/config/stdlib_maps/kotlin/scip-kotlin-top-level-symbols.patch b/gems/fact-mine/config/stdlib_maps/kotlin/scip-kotlin-top-level-symbols.patch new file mode 100644 index 000000000..c816ada18 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin/scip-kotlin-top-level-symbols.patch @@ -0,0 +1,44 @@ +diff --git a/build.gradle.kts b/build.gradle.kts +index e9066a2..954d945 100644 +--- a/build.gradle.kts ++++ b/build.gradle.kts +@@ -65,7 +65,7 @@ allprojects { + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } + jvmToolchain { +- (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8)) ++ (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(21)) + } + } +diff --git a/semanticdb-kotlin/build.gradle.kts b/semanticdb-kotlin/build.gradle.kts +index a4dfa19..cc678ac 100644 +--- a/semanticdb-kotlin/build.gradle.kts ++++ b/semanticdb-kotlin/build.gradle.kts +@@ -34,12 +34,4 @@ afterEvaluate { + tasks.compileKotlin { + dependsOn(tasks.getByName("generateProto")) + } +- +- tasks.withType { +- val sourceroot = rootDir.path +- val targetroot = this.project.buildDir.resolve( "semanticdb-targetroot") +- options.compilerArgs = options.compilerArgs + listOf( +- "-Xplugin:semanticdb -sourceroot:$sourceroot -targetroot:$targetroot" +- ) +- } + } +diff --git a/semanticdb-kotlinc/src/main/kotlin/com/sourcegraph/semanticdb_kotlinc/SymbolsCache.kt b/semanticdb-kotlinc/src/main/kotlin/com/sourcegraph/semanticdb_kotlinc/SymbolsCache.kt +index 5f27dd3..20a2a96 100644 +--- a/semanticdb-kotlinc/src/main/kotlin/com/sourcegraph/semanticdb_kotlinc/SymbolsCache.kt ++++ b/semanticdb-kotlinc/src/main/kotlin/com/sourcegraph/semanticdb_kotlinc/SymbolsCache.kt +@@ -183,6 +183,8 @@ class GlobalSymbolsCache( + is FirClassSymbol -> + (containingSymbol.fir as FirClass).declarations.map { it.symbol } +- is FirFileSymbol -> containingSymbol.fir.declarations.map { it.symbol } ++ is FirFileSymbol -> ++ symbol.moduleData.session.symbolProvider.getTopLevelCallableSymbols( ++ symbol.packageFqName(), symbol.name) + null -> + symbol.moduleData.session.symbolProvider.getTopLevelCallableSymbols( + symbol.packageFqName(), symbol.name) diff --git a/gems/fact-mine/config/stdlib_maps/kotlin/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/kotlin/semantic_environment.rb new file mode 100644 index 000000000..a6ff97649 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/kotlin/semantic_environment.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" + +source_root, output = ARGV +abort "usage: semantic_environment.rb SOURCE_ROOT OUTPUT.json" unless source_root && output + +marker = JSON.parse(File.read(File.join(File.expand_path(source_root), ".fact-mine-source.json"))) +binary = marker.fetch("binary") +actual = Digest::SHA256.file(binary).hexdigest +expected = marker.fetch("binary_sha256") +abort "Kotlin stdlib binary digest mismatch: expected #{expected}, got #{actual}" unless actual == expected + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => { + "kotlin.platform" => "jvm", + "kotlin.stdlib.version" => marker.fetch("version"), + "kotlin.stdlib.binary.sha256" => "sha256:#{actual}" + } +})) diff --git a/gems/fact-mine/config/stdlib_maps/php/scip-php-exact-version.patch b/gems/fact-mine/config/stdlib_maps/php/scip-php-exact-version.patch new file mode 100644 index 000000000..14de5f831 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/php/scip-php-exact-version.patch @@ -0,0 +1,8 @@ +diff --git a/bin/scip-php b/bin/scip-php +--- a/bin/scip-php ++++ b/bin/scip-php +@@ -36,3 +36,3 @@ + $projectRoot = \getcwd(); +-$version = '0.0.1'; ++$version = '0.0.1+71a5b117'; + $args = \array_splice($argv, 1); diff --git a/gems/fact-mine/config/stdlib_maps/python-3.11.9.yml b/gems/fact-mine/config/stdlib_maps/python-3.11.9.yml new file mode 100644 index 000000000..2daa5c1c7 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/python-3.11.9.yml @@ -0,0 +1,56 @@ +schema: fact-mine.stdlib-map.v1 +language: python + +source: + revision: cpython-3.11.9 + git: + repository: https://github.com/python/cpython.git + commit: de54cf5be371a6f5e2e9f208c38def5f81d3ef02 + sparse_paths: + - Lib + root_suffix: Lib + include: + - "{bisect,copy,dataclasses,enum,fnmatch,functools,glob,heapq,operator,pathlib,random,shutil,statistics,string,textwrap,types,weakref}.py" + - "{collections,concurrent,json,logging,re,urllib}/**/*.py" + exclude: + - "**/test/**" + - "**/tests/**" + stage_selected_files: true + +index: + command: + - scip-python + - index + - "." + - --project-name=python-stdlib + - --project-version=3.11 + - "--output={index}" + working_directory: "{source_root}" + output: python-stdlib.scip + expected: + tool: scip-python + version: 0.6.6 + +soundness: + minimum_export_eligible_methods: 250 + +summary: + corpus: python-stdlib-pure-python-core + output: ../complexity_summaries/python-stdlib.cpython3.11.9.json.gz + minimum_symbols: 1 + expected_symbol_prefix: "scip-python python python-stdlib 3.11 " + +consumers: + - name: python-3.11-exact-symbol + source_root: consumers/python-3.11 + include: ["impact.py"] + index: + command: + - scip-python + - index + - "." + - --project-name=fact-mine-stdlib-consumer + - --project-version=1.0.0 + - "--output={index}" + output: python-consumer.scip + minimum_complete_percent: 100 diff --git a/gems/fact-mine/config/stdlib_maps/ruby-3.2.3.yml b/gems/fact-mine/config/stdlib_maps/ruby-3.2.3.yml new file mode 100644 index 000000000..a9efc1753 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/ruby-3.2.3.yml @@ -0,0 +1,53 @@ +schema: fact-mine.stdlib-map.v1 +language: c + +source: + revision: cruby-3.2.3 + git: + repository: https://github.com/ruby/ruby.git + commit: 52bb2ac0a6971d0391efa2275f7a66bff319087c + include: + # `index_cruby.rb` indexes the complete core-A frontier. Keep the + # producer profile aligned with that frontier: `re.c` owns Regexp.escape + # and Regexp#match?, both of which are emitted by NilKill consumers. + - "{array,dir,enum,error,file,hash,io,math,numeric,re,string,struct}.c" + +index: + command: + - ruby + - "{manifest_dir}/ruby/index_cruby.rb" + - "{source_root}" + - "{index}" + working_directory: "{source_root}" + output: cruby.scip + expected: + tool: scip-clang + version: "0.4.0" + +compatibility: + command: + - ruby + - "{manifest_dir}/ruby/semantic_environment.rb" + - "{source_root}" + - "{environment}" + - 3.2.3 + working_directory: "{source_root}" + +soundness: + minimum_export_eligible_methods: 100 + +summary: + corpus: cruby-core-3.2.3-core-a + output: ../complexity_summaries/ruby-cruby.3.2.3-core-a.json.gz + minimum_symbols: 1 + consumer_indexers: ["nil-kill-runtime@2"] + symbol_bridge: + command: + - ruby + - "{manifest_dir}/ruby/build_symbol_bridge.rb" + - "{producer_summary}" + - "{profile}" + - "{source_root}" + - "{symbol_bridge}" + - 3.2.3 + working_directory: "{source_root}" diff --git a/gems/fact-mine/config/stdlib_maps/ruby/build_symbol_bridge.rb b/gems/fact-mine/config/stdlib_maps/ruby/build_symbol_bridge.rb new file mode 100644 index 000000000..8ce918e14 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/ruby/build_symbol_bridge.rb @@ -0,0 +1,117 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Connect source-proven CRuby C functions to the stable runtime identities +# emitted by NilKill. Registration is the authoritative ownership relation: +# no Ruby method is admitted unless both its registration and the exact C +# declaration symbol appear in the producer's generated summary. + +require "json" +require "zlib" + +producer_path, profile_path, source_root, output, runtime_version = ARGV +abort "usage: build_symbol_bridge.rb PRODUCER.json.gz PROFILE.json SOURCE_ROOT OUTPUT.json RUBY_VERSION" unless runtime_version +abort "invalid Ruby runtime version #{runtime_version.inspect}" unless runtime_version.match?(/\A\d+\.\d+\.\d+\z/) + +producer = Zlib::GzipReader.open(producer_path) { |gzip| JSON.parse(gzip.read) } +profile = JSON.parse(File.read(profile_path)) +source_root = File.expand_path(source_root) + +def ruby_owner(receiver) + return unless receiver.match?(/\Arb_[cme][A-Za-z0-9_]+\z/) + + receiver.delete_prefix("rb_")[1..] +end + +def runtime_atom(name) + operators = %w[[] []= + - * / % ** << >> < <= > >= == === =~ !~ & | ^ ~ +@ -@ <=>] + operators.include?(name) ? "`#{name}`" : name +end + +def runtime_symbol(owner, separator, name, version) + "nil-kill-runtime ruby ruby #{version} #{owner}#{separator}#{runtime_atom(name)}()." +end + +def registrations(source_root) + rows = [] + Dir.glob(File.join(source_root, "**", "*.c")).sort.each do |path| + source = File.read(path) + relative = path.delete_prefix("#{source_root}#{File::SEPARATOR}") + source.scan( + /rb_define_(singleton_method|method|module_function|private_method)\s*\(\s*(rb_[cme][A-Za-z0-9_]+)\s*,\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/m + ) do |kind, receiver, name, function| + owner = ruby_owner(receiver) + next unless owner + + separators = case kind + when "singleton_method" then ["."] + when "module_function" then ["#", "."] + else ["#"] + end + separators.each do |separator| + rows << { "path" => relative, "function" => function, "owner" => owner, + "separator" => separator, "name" => name } + end + end + source.scan( + /rb_define_global_function\s*\(\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/m + ) do |name, function| + rows << { "path" => relative, "function" => function, "owner" => "Kernel", + "separator" => "#", "name" => name } + end + # file.c intentionally registers FileTest module functions and File + # singleton methods through this macro. Preserve both exact public + # identities rather than hard-coding their costs. + source.scan( + /define_filetest_function\s*\(\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/m + ) do |name, function| + rows << { "path" => relative, "function" => function, "owner" => "FileTest", + "separator" => "#", "name" => name } + rows << { "path" => relative, "function" => function, "owner" => "File", + "separator" => ".", "name" => name } + end + end + rows.uniq +end + +exact_summary_symbols = producer.fetch("symbols").select do |_symbol, row| + row.fetch("bound_quality", "") == "upper_bound_exact_symbol" +end.keys.to_h { |symbol| [symbol, true] } + +methods = profile.fetch("methods").filter_map do |method| + symbol = method["semantic_symbol"].to_s + next unless exact_summary_symbols[symbol] + + path = method.fetch("path").to_s + relative = path.delete_prefix("#{source_root}#{File::SEPARATOR}") + next if relative == path + + [[relative, method.fetch("name").to_s], symbol] +end.to_h + +# A registration may be repeated, but one runtime method must never be +# assigned an arbitrary implementation when CRuby has multiple declarations. +targets = Hash.new { |hash, key| hash[key] = [] } +registrations(source_root).each do |registration| + symbol = methods[[registration.fetch("path"), registration.fetch("function")]] + next unless symbol + + target = runtime_symbol( + registration.fetch("owner"), registration.fetch("separator"), registration.fetch("name"), runtime_version + ) + targets[target] << symbol unless targets[target].include?(symbol) +end + +symbols = Hash.new { |hash, key| hash[key] = [] } +targets.each do |target, candidates| + next unless candidates.length == 1 + + symbols[candidates.fetch(0)] << target +end +symbols.transform_values!(&:sort) +abort "no source-proven CRuby registrations matched the producer summary" if symbols.empty? + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.symbol-bridge.v1", + "symbols" => symbols.sort.to_h +})) diff --git a/gems/fact-mine/config/stdlib_maps/ruby/index_cruby.rb b/gems/fact-mine/config/stdlib_maps/ruby/index_cruby.rb new file mode 100644 index 000000000..808002a98 --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/ruby/index_cruby.rb @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" + +INDEXER_VERSION = "0.4.0" +INDEXER_SHA256 = "06fd18c576f979a726c651594644ec4a35db4f471f2160b3f72eb89fa6001784" +CORE_SOURCES = %w[ + array.c bignum.c class.c compar.c dir.c enum.c error.c file.c hash.c io.c + math.c numeric.c object.c proc.c random.c range.c re.c string.c struct.c + time.c variable.c vm_method.c +].freeze + +source_root, output = ARGV +abort "usage: index_cruby.rb CRUBY_SOURCE_ROOT OUTPUT.scip" unless source_root && output +source_root = File.expand_path(source_root) +output = File.expand_path(output) + +def executable(environment, fallback) + configured = ENV[environment] + return File.expand_path(configured) if configured && !configured.empty? + + candidate = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| File.join(directory, fallback) } + .find { |path| File.file?(path) && File.executable?(path) } + abort "#{fallback} was not found; set #{environment}" unless candidate + + candidate +end + +def capture!(*command, chdir:) + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + abort "#{command.join(' ')} failed:\n#{stderr}" unless status.success? + + stdout +end + +compiler = executable("CLANG", "clang-20") +indexer = executable("SCIP_CLANG", "scip-clang") +version = capture!(indexer, "--version", chdir: source_root) +abort "scip-clang #{INDEXER_VERSION} is required" unless version.lines.first&.strip == "scip-clang #{INDEXER_VERSION}" +abort "unexpected scip-clang binary" unless Digest::SHA256.file(indexer).hexdigest == INDEXER_SHA256 + +files = CORE_SOURCES.map { |name| File.join(source_root, name) } +missing = files.reject { |path| File.file?(path) } +abort "CRuby core sources were not found: #{missing.join(', ')}" unless missing.empty? +database = files.map do |file| + { + "directory" => source_root, + "file" => file, + "arguments" => [compiler, "-I", source_root, "-DRUBY_EXPORT", "-c", file] + } +end +database_path = File.join(File.dirname(output), "compile_commands.json") +File.write(database_path, JSON.pretty_generate(database)) +capture!( + indexer, + "--compdb-path", database_path, + "--index-output-path", output, + "--no-progress-report", + "-j", "1", + chdir: source_root +) +abort "scip-clang did not produce #{output}" unless File.size?(output) diff --git a/gems/fact-mine/config/stdlib_maps/ruby/semantic_environment.rb b/gems/fact-mine/config/stdlib_maps/ruby/semantic_environment.rb new file mode 100644 index 000000000..aa36ce32a --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/ruby/semantic_environment.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" + +source_root, output, runtime_version = ARGV +abort "usage: semantic_environment.rb CRUBY_SOURCE_ROOT OUTPUT.json RUBY_VERSION" unless runtime_version +abort "invalid Ruby runtime version #{runtime_version.inspect}" unless runtime_version.match?(/\A\d+\.\d+\.\d+\z/) +version_header = File.join(File.expand_path(source_root), "version.h") +abort "CRuby version.h was not found" unless File.file?(version_header) +header = File.read(version_header) +api_header = File.join(File.expand_path(source_root), "include", "ruby", "version.h") +abort "CRuby API version header was not found" unless File.file?(api_header) +api_header = File.read(api_header) +major, minor, teeny = runtime_version.split(".") +{ + "RUBY_API_VERSION_MAJOR" => major, + "RUBY_API_VERSION_MINOR" => minor, + "RUBY_VERSION_TEENY" => teeny +}.each do |name, value| + source = name.start_with?("RUBY_API") ? api_header : header + actual = source[/^#\s*define\s+#{name}\s+(\d+)/, 1] + abort "CRuby version header #{name}=#{actual.inspect} does not match #{runtime_version}" unless actual == value +end + +File.write(output, JSON.pretty_generate({ + "schema" => "fact-mine.semantic-environment.v1", + "claims" => { + "runtime.language" => "ruby", + "runtime.engine" => "ruby", + "runtime.version" => runtime_version, + "runtime.engine_version" => runtime_version + } +})) diff --git a/gems/fact-mine/config/stdlib_maps/rust-1.96.0.yml b/gems/fact-mine/config/stdlib_maps/rust-1.96.0.yml new file mode 100644 index 000000000..cd4d48a9a --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/rust-1.96.0.yml @@ -0,0 +1,36 @@ +schema: fact-mine.stdlib-map.v1 +language: rust + +source: + root_command: ["rustc", "--print", "sysroot"] + root_suffix: lib/rustlib/src/rust + revision: rustc-1.96.0-ac68faa20c58 + revision_check: + command: ["rustc", "--version", "--verbose"] + includes: + - "release: 1.96.0" + - "commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96" + include: + - "library/{core,alloc,std}/**/*.rs" + +index: + command: + - rust-analyzer + - scip + - "{source_root}/library" + - --output + - "{index}" + working_directory: "{source_root}/library" + output: rust-stdlib.scip + expected: + tool: rust-analyzer + version: "1.96.0 (ac68faa 2026-05-25)" + +soundness: + minimum_export_eligible_methods: 15000 + +summary: + corpus: rust-stdlib-core-alloc-std + output: ../complexity_summaries/rust-stdlib.rustc1.96.0.json.gz + minimum_symbols: 1500 + expected_symbol_prefix: "rust-analyzer cargo " diff --git a/gems/fact-mine/config/stdlib_maps/support.yml b/gems/fact-mine/config/stdlib_maps/support.yml new file mode 100644 index 000000000..c516e531b --- /dev/null +++ b/gems/fact-mine/config/stdlib_maps/support.yml @@ -0,0 +1,66 @@ +schema: fact-mine.stdlib-map-support.v1 +languages: + go: + status: bundled + manifest: go-1.22.2.yml + rust: + status: bundled + manifest: rust-1.96.0.yml + java: + status: bundled + manifest: java-21.0.12.yml + python: + status: bundled + manifest: python-3.11.9.yml + csharp: + status: bundled + manifest: csharp-10.0.10.yml + kotlin: + status: bundled + manifest: kotlin-2.2.0.yml + c: + status: blocked + blocker: implementation_source_not_indexed + compatibility_probe: c/semantic_environment.rb + required_fix: A pinned glibc source/build recipe must expose executable libc bodies under the same compiler, ABI, headers, and scip-clang environment now captured by the compatibility probe. + cpp: + status: bundled + manifests: + - cpp-libstdcxx-13.3.0-cxx17-containers.yml + - cpp-libstdcxx-13.3.0-cxx17-strings.yml + - cpp-libstdcxx-13.3.0-cxx20-memory.yml + typescript: + status: blocked + blocker: declaration_only_standard_library + compatibility_probe: javascript/semantic_environment.rb + required_fix: A Node/V8 producer must bridge executable runtime implementations to TypeScript declaration symbols while preserving callback and reflective costs; the runtime identity is captured by the compatibility probe. + javascript: + status: blocked + blocker: declaration_only_standard_library + compatibility_probe: javascript/semantic_environment.rb + required_fix: A Node/V8 producer must bridge executable runtime implementations to TypeScript declaration symbols while preserving callback and reflective costs; the runtime identity is captured by the compatibility probe. + php: + status: blocked + blocker: parametric_runtime_callbacks_and_allocator_state + indexer_patch: php/scip-php-exact-version.patch + required_fix: Generated summaries must preserve and compose PHP weak-coercion callback costs and Zend allocator-state costs before C implementation symbols can be bridged to scip-php builtin declarations. + ruby: + status: bundled + manifest: ruby-3.2.3.yml + scala: + status: blocked + blocker: fact_mine_language_adapter_missing + indexer: scip-java + reusable_bundle: java-21.0.12.yml + required_fix: Add a Scala syntax/CFG/DFG adapter and validate scip-java's Scala symbols; JDK calls can then reuse the existing exact Java bundle before a scala-library manifest is added. + visual_basic: + status: blocked + blocker: fact_mine_language_adapter_missing + indexer: scip-dotnet + reusable_bundle: csharp-10.0.10.yml + required_fix: Add a Visual Basic syntax/CFG/DFG adapter and validate scip-dotnet's language-neutral assembly symbols; CoreLib calls can then reuse the existing exact C# producer bundle. + dart: + status: blocked + blocker: fact_mine_language_adapter_missing + indexer: scip-dart + required_fix: Add a Dart syntax/CFG/DFG adapter, pin a scip-dart and Dart SDK pair, and express the SDK source mapping as a standard manifest. diff --git a/gems/fact-mine/docs/agents/go-stdlib-bigo-investigation.md b/gems/fact-mine/docs/agents/go-stdlib-bigo-investigation.md new file mode 100644 index 000000000..6566b9b50 --- /dev/null +++ b/gems/fact-mine/docs/agents/go-stdlib-bigo-investigation.md @@ -0,0 +1,181 @@ +# Go stdlib/vendor Big-O completeness investigation + +## Question + +Would resolving external (stdlib/vendor) callee bounds substantially increase +`complete` Big-O for Go **production** code? Is >80% reachable? If so, what is +the smarter method, and should we build it? + +Tests are excluded throughout: we care about the library/production runtime +complexity, not table-driven test harnesses. + +## Method + +Sampled `~/unslop` and `gems/boobytrap/src` (boobytrap is itself Go). For each, +`espalier -f architecture` (per-function `time_complete` + `big_o_time` known +component) and `-f unknown_operations` (ranked blockers + the functions each +blocks). Classified every blocker against GOROOT's 224-package set +(`/usr/lib/go-1.22/src`) and the repo's own packages. A function is "completable +by X" only if it has a concrete known structural component **and** all its +call-blockers are resolvable by X. + +## Findings + +### 1. For production Go, structural loops/recursion are a non-issue + +The "structural ceiling" is a test-code artifact. Structurally-unbounded +(`big_o_time = None`) production functions: + +| Repo | all fns | `_test.go` incl. structural-unknown | **production** structural-unknown | +|---|---:|---:|---:| +| unslop | 157 | 39 | **3** | +| boobytrap | 131 | 46 | **0** | + +Test functions are table-driven (constant cases) or call unresolved `testing` +methods; they dominated the earlier "unbounded loop" bucket. Production Go loops +are almost all range-over-collection with a known cardinality domain, which the +existing analyzer already bounds. + +### 2. Production completeness is gated by callee resolution, and it climbs fast + +| Step (production only) | unslop (121 fns) | boobytrap (85 fns) | +|---|---:|---:| +| complete now | 24% | 46% | +| + stdlib/project package-call bounds | **64%** | **88%** | +| + typed stdlib-type receiver methods | **74%** | 89% | +| truly structural-unbounded (irreducible) | 3 fns | 0 fns | + +The residual after package-call bounds is "unresolved receiver" calls. Inspected +directly, these are **overwhelmingly stdlib type methods on locals whose type +was not propagated**: `mu.Lock` (sync.Mutex), `wg.Done` (sync.WaitGroup), +`info.ModTime`/`info.Size` (os.FileInfo), `d.IsDir` (fs.DirEntry), +`f.Close`/`f.Fd` (*os.File), `flags.Bool` (*flag.FlagSet), +`inputBuf.WriteByte` (bytes.Buffer), `effTime.After` (time.Time). Only a few are +project methods. None are unbounded - each is O(1)/amortized-O(1) once the +receiver type is known. Resolving them pushes unslop from 74% toward **~90%**. + +This matches `espalier/docs/agents/complexity-coverage.md`: "the dominant +remaining gap is **receiver/callee resolution, not loop or recursion +recognition**." + +### 3. But raw analysis of the stdlib does not, by itself, deliver the bounds + +The Go stdlib is only **33% complete** when analyzed (170/515 over +`fmt`,`filepath`,`strconv`,`strings`,`sort`), and the highest-frequency blockers +are themselves incomplete: `fmt.Sprintf/Errorf/Fprintf` (reflection - ~40% of +stdlib-call blockers), `filepath.Base/Dir/Ext`, `strconv.Itoa/Atoi`. Only +`filepath.Join`/`ToSlash` came back complete. Completeness is transitive, so +consuming an incomplete stdlib bound does not complete the caller. The bounds +must be **asserted** (a sound semantic bound, e.g. `fmt.Sprintf` = O(total input +size)), with analysis supplying the draft. + +### 4. Vendor is repo-specific and secondary + +Both repos are near-zero-dependency; the vendor/project blocker share was +negligible. Vendor's value only appears on dependency-heavy code and must be +measured on such a repo (e.g. fzf) before it is judged - it is not the lever +for typical Go code. + +## Determination + +**>80% complete Big-O for production Go is reachable, and my earlier ~55-60% +ceiling was wrong** - it counted test-code loop noise. Excluding tests, +structural incompleteness is ~0; completeness is bounded almost entirely by +**callee/receiver resolution into the standard library**, which is tractable +because Go calls are statically typed and package-qualified. + +The smarter method is two coupled pieces, not "analyze GOROOT and consume it": + +1. **An asserted Go stdlib bound table covering both forms** in + `config/stdlib_complexity/go.yml`: + - package functions (`fmt.*`, `filepath.*`, `os.*`, `exec.*`, `strconv.*`, + `strings.*`), and + - **type methods** (`sync.Mutex`, `sync.WaitGroup`, `*os.File`, + `os.FileInfo`, `fs.DirEntry`, `bytes.Buffer`, `strings.Builder`, + `time.Time`, `*flag.FlagSet`). + + Espalier's stdlib analysis supplies the draft known component; a human closes + the reflection/callback cases with a sound bound. Ranked by + `unknown_operations` occurrence, ~30-40 entries cover the bulk. + +2. **Local/flow receiver type resolution** so `mu`, `info`, `f`, `flags` resolve + to their stdlib types and pick up (1). This is the dominant gap named in + `complexity-coverage.md`; for Go it is available from declared/assigned local + types (or `go/types`/SSA for the hard cases). + +With both, the sampled repos project to ~90% (unslop) / ~89% (boobytrap) +complete on production code. Vendor is a later, repo-specific add-on. + +This revises the "don't build" of `minimal-call-graph-feasibility.md`, whose +data was Ruby (dynamic dispatch). Go's static typing makes the same lever pay +off very differently. + +## Experiment: precomputed stdlib bounds + "trust the known component" + +Built `config/stdlib_complexity/go.stdlib.json.gz` (2,412 functions) by running +`espalier -f architecture` over 330 files of the commonly-imported stdlib, then +tested two consumption models. + +**1. Propagating stdlib bounds by corpus union gives ~zero uplift.** Analyzing +unslop *together with* the stdlib source left production completeness at +**24% -> 24%**. Because the stdlib is only 38% self-complete (1,172/3,071), its +bounds are themselves `unknown`, and transitive completeness cascades the +incompleteness straight back to the caller. Requiring transitive completeness is +the wrong bar. + +**2. "Trust the known component unless a hidden callee can exceed it" reaches +~90%.** For every incomplete function we already emit a known structural +component (`O(1)`, `O(N)`, ...). It is only *wrong* if a hidden callee is +asymptotically larger - which needs FFI (opaque C/syscall/runtime/unsafe), +reflection, or a super-linear callback. Classifying incomplete functions by +whether any blocker is in those danger categories: + +| Corpus | incomplete | known component RIGHT | risky: FFI | superlinear | reflection | +|---|---:|---:|---:|---:|---:| +| Go stdlib (worst case for FFI) | 1,708 | **81%** | 18% | 0% | 1% | +| unslop | 92 | **88%** | 11% | 1% | 0% | +| boobytrap | 46 | **83%** | 2% | 15% | 0% | + +Combined with the already-complete fraction, **effective trustworthy Big-O is +~88-91%** on all three, and the residual risk is concentrated exactly where you +predicted: FFI, plus a little super-linear-stdlib-in-a-cheap-function +(boobytrap's `sort` usage). This holds on the stdlib itself, the most +FFI-dense Go there is; ordinary application code is safer. + +**Caveat - the known component is also sometimes *over*-estimated.** The +analyzer emits garbage for interface-callback and unbounded-recursion functions: +`sort.Sort` -> a nonsensical multivariate blob (should be `O(N log N)`), +`os.MkdirAll` -> `O(2^N)` (should be `O(depth)`). "Trust the known component" +therefore also needs the structural analyzer's recursion/callback handling +tightened, or those cases would be confidently wrong. + +## Revised recommendation + +The lever for >80% Go is **not** propagating stdlib completeness (0 uplift) and +**not** a name-matched bounds table (fragile keys, self-incomplete source). It +is a **completeness-model change**: report the known component as an +*authoritative, confidence-tiered* answer, and reserve `unknown` for functions +whose blockers are FFI / reflection / unbounded callback. That alone yields +~88-91% trustworthy bounds. Support it with two fixes: (a) an explicit +FFI/reflection boundary tag on facts, and (b) tighter structural handling of +recursion and interface-callbacks so the trusted component is not over-estimated +(`sort.Sort`, `os.MkdirAll`). `go.stdlib.json.gz` remains useful as a reference +for the ~38% of stdlib functions that *are* complete, not as a propagation feed. + +## Recommended validation before building + +Assert the top ~30 Go stdlib bounds (package fns + type methods) in `go.yml`, +add the local receiver-type resolution, re-run architecture on unslop + +boobytrap + a dependency-heavy repo (fzf), production functions only, and +confirm `time_complete` clears 80%. Build in that order; stop if step 1 alone +does not reach ~65%. + +## Reproduction + +```sh +espalier -f architecture -o arch.json +espalier -f unknown_operations -o unknown.json +espalier -f architecture -o stdlib.json \ + /usr/lib/go-1.22/src/{fmt,path/filepath,strconv,strings,sort}/*.go # exclude _test +# then: filter arch nodes to paths not ending _test.go; measure time_complete +``` diff --git a/gems/fact-mine/docs/agents/scip-vs-fact-mine-boundary.md b/gems/fact-mine/docs/agents/scip-vs-fact-mine-boundary.md new file mode 100644 index 000000000..8abec86c1 --- /dev/null +++ b/gems/fact-mine/docs/agents/scip-vs-fact-mine-boundary.md @@ -0,0 +1,240 @@ +# Design: The SCIP Boundary — what SCIP owns, what fact-mine owns + +Status: decided, evidence-based. Supersedes the implicit assumption (held through +the 2026-07-25/26 work) that fact-mine should compute its own call resolution and +type inference for every language. + +All numbers below are measured, not estimated. Method: for each corpus, run +`espalier -f architecture` four ways — pre-session baseline (`9221f8f17`) and HEAD, +each with and without a SCIP index — and compare `time_complete` per function. + +--- + +## 0. The evidence + +### 0.1 Completeness by corpus and configuration + +| Corpus | Lang | BASE src | HEAD src | BASE+SCIP | HEAD+SCIP | +|---|---|---|---|---|---| +| fact-mine `src/` | Rust | 27.0% | 30.2% | 58.8% | **66.5%** | +| Go stdlib (sort+strings) | Go | 24.5% | 48.6% | 46.4% | **60.4%** | +| gremlins | Go | 32.0% | 35.0% | 53.1% | **56.0%** | +| boobytrap | Go | 45.9% | 51.0% | 56.5% | **61.2%** | +| unslop | Go | 24.0% | 23.6% | 28.1% | **27.7%** | +| cheat | Ruby | 6.0% | 5.7% | *no indexer* | — | + +**SCIP is worth more than every resolution feature we have ever written**, in every +language that has an indexer: +36.3 pts (Rust), +21.0 (gremlins), +11.8 (Go stdlib), ++10.2 (boobytrap), +4.1 (unslop). + +### 0.2 Who actually resolves calls when SCIP is present + +| Corpus | calls | resolved by SCIP | resolved by our passes | +|---|---|---|---| +| fact-mine (Rust) | 8727 | 8406 | **0** | +| gremlins (Go) | 649 | 519 | **0** | +| boobytrap (Go) | 1030 | 673 | **0** | +| Go stdlib | 731 | 391 | **7** | + +`scip.rs` overwrites `call.target`/`semantic_symbol` unconditionally for any call it +matches. Our resolution contributes ~nothing whenever SCIP is available. + +### 0.3 Proof by deletion + +Reverting the entire local-call-result type-inference feature (`2327bbc70` — design +doc + implementation, the single largest investment of the session): + +``` +HEAD + SCIP: 956/1438 = 66.5% +HEAD − inference + SCIP: 956/1438 = 66.5% ← byte-identical +``` + +Zero contribution under SCIP. + +### 0.4 What our work *does* add on top of SCIP + +Comparing BASE+SCIP → HEAD+SCIP: Go stdlib **+14.0 pts**, Rust **+7.7**, boobytrap ++4.7, gremlins +2.9, unslop −0.4. Mechanically this is **+2778 newly-priced call +contexts**, dominated by cost-layer work: field reads (~1000), `Some`/`None`/`Ok`/`Err` +(+393), constructors (+347), collection methods (+50). + +### 0.5 What a SCIP index actually contains (verified on gremlins.scip) + +- 15809 occurrences, 2675 symbol entries +- **582 external symbols**, versioned: + `scip-go gomod github.com/golang/go/src go1.25.0 context/` +- **128 `is_implementation` relationships** (interface satisfaction) +- **2673/2675 symbols carry `signature_documentation`** with full types: + `func newRootCmd(ctx context.Context, version string) (*gremlinsCmd, error)` + +--- + +## 1. What SCIP does that is useful to us + +1. **Call resolution** — the occurrence at a call site maps to a canonical symbol. + This is the single highest-value input we get, and it is compiler-grade + (produced by `go/types`, rust-analyzer, tsc, …). §0.1/§0.2. +2. **Receiver/expression typing, implicitly** — because the symbol path encodes the + owner (`…/Buf#Add().`), receiver typing comes free with resolution. This is the + entire problem the session's inference/field-access/generic-owner work tried to + solve by hand. +3. **Declared type signatures** — `signature_documentation` gives parameter and + return types verbatim. **Currently discarded** (see §4.1). +4. **Interface satisfaction** — `is_implementation` relationships. Already consumed + (`scip.rs:522`), and it is exactly what `compute_dispatch_impls` re-derives. +5. **Stable, versioned external identity** — stdlib/dependency symbols carry a + version (`go1.25.0 context/`). This is a far better key for a cost registry than + the name-based matching we use today. +6. **Cross-file / cross-package / cross-module linking** — for free, at whole-project + scope, without our namespace-reconciliation machinery. + +## 2. What we must NEVER do if we use SCIP + +These are the rules that would have prevented two days of waste. + +1. **Never write language-specific call resolution for a language that has an + indexer.** Trait/generic/overload/macro resolution is a type-checker's job. Our + Rust source-only mode caps at 30% against SCIP's 66%; the gap is unclosable by + construction. +2. **Never infer a type we can read.** Local call-result types, receiver types, + field types, return types — SCIP has them. Consume, don't derive. +3. **Never re-derive interface satisfaction.** `is_implementation` is authoritative; + structural methodset matching is a strictly worse approximation. +4. **Never report a completeness number without recording which resolution tier + produced it.** The root cause of the session's failure was measuring source-only + and believing it was the ceiling. Every reported metric must carry its tier. +5. **Never trust a SCIP index without asserting it is non-empty and covering.** + `scip-go .` on unslop produced a *valid, well-formed, 79-byte, completely empty* + index. Silent degradation to source-only is the most dangerous failure mode in + this system. +6. **Never fix a "call resolution" symptom in the cost layer without checking the + tier.** Several session commits (operators, member reads, conversions, enum + constructors) *look* like resolution failures. They are legitimately cost-layer + fixes — the target genuinely has no body — but the check must be conscious. + +## 3. What SCIP does NOT do, which we need + +SCIP is a *symbol graph*. It has no notion of cost, control flow, or program shape. +Everything below is ours and always will be: + +1. **Big-O cost of an operation.** SCIP says `Vec#push()`; it never says + `linear_materialize`. The `config/stdlib_complexity/*.yml` registries are ours. +2. **The complexity algebra** — loop nesting and power, recursion classification, + size domains, callback-cost substitution (`C`, `N*C`), parametric bounds, the + per-function fixpoint. Nothing in SCIP participates. +3. **AST/CFG extraction** — normalized nodes, control-flow graph, reaching + definitions, liveness. SCIP has ranges, not structure. Without our extractor, + lambdas were not analyzed as functions *at all*, and Kotlin expression bodies + were dropped entirely (`e043b0989`, `822aad153`, `5c1ecf7d0`). +4. **Syntactic constructs that have no callee** — operators, subscripts, field + reads, type conversions, enum constructors. There is no symbol to resolve; these + must be priced. This is where the majority of our SCIP-additive value came from + (§0.4). +5. **Cost of *unindexed* code** — stdlib/dependency *bodies*. See §4.2. +6. **The no-build path.** SCIP requires a compiling project. Snippets, partial + checkouts, broken builds, and unsupported languages have no index. +7. **Nil-Kill / Decomplex facts** — nullability, hazards, clone detection, path + conditions. Entirely outside SCIP's model. + +## 4. The biggest gaps — and which we can close + +### 4.1 GAP (ours, not SCIP's): we throw away the types SCIP gives us — **CLOSE NOW** + +`SymbolInformation` in `scip.rs` deserializes only `symbol` and `relationships`. +`signature_documentation` — which contains complete parameter and return types for +2673/2675 symbols — is **discarded**. + +This is the highest-value, lowest-risk work available. It replaces, with authoritative +data, the exact thing the session tried to hand-roll (return types → local types → +receiver types → method pricing). **Closeable: yes, immediately.** + +### 4.2 GAP: SCIP indexes references to dependencies, not their bodies — **CLOSE, and it is our moat** + +`scip-go` on a probe module produced an index of `main.go` only; stdlib source was +not indexed. Dependency/stdlib calls resolve to a *symbol* but there is no body to +derive a cost from. Verified: `strings.ToUpper` / `Builder.WriteString` in the Go +test carried no SCIP provenance at all. + +Two complementary closures: +- **Index stdlib source directly** — the Go stdlib *is* indexable (`/usr/lib/go/src` + has a `go.mod`; `scip-go ./sort/... ./strings/...` works). Analyze stdlib source + with Espalier and emit a cost registry keyed by SCIP symbol. +- **Key cost registries on versioned SCIP symbols** rather than names, eliminating + the name-collision guessing in the current registries. + +This is the Espalier-maps-the-stdlib idea, and SCIP makes it *more* valuable, not +less: SCIP supplies precise identity, we supply the cost. **Closeable: yes; highest +long-term value.** + +### 4.3 GAP: languages with no indexer — **PARTIALLY CLOSEABLE, and currently neglected** + +| Have an indexer | No usable indexer | +|---|---| +| Go, Rust, TS/JS, Java/Kotlin, Python, C#, C/C++¹ | **Swift, Lua, PHP, Zig, Ruby²** | + +¹ `scip-clang` needs `compile_commands.json`, often absent. +² `scip-ruby` requires Sorbet; unusable on ordinary Ruby. + +For these, our own resolution is the *only* option. NilKill now defines a +language-neutral `runtime_call` event contract and emits canonical SCIP indexes +with `observed-open` authority. FactMine consumes that authority generically: +observed targets remain explicit open candidate sets, while compiler SCIP keeps +precedence. Ruby supplies the first tracing implementation. Python can reuse the +same event/SCIP/FactMine/Espalier path; PHP and JavaScript need provider-owned +tracers rather than new consumer logic. **Closeable: partially** — runtime +observations improve identity and cost joins but cannot prove unobserved dispatch +targets absent. Swift/Zig remain tractable statically. + +### 4.4 GAP: SCIP is a stale snapshot requiring a build — **MITIGATE, not close** + +Index cost is small (rust-analyzer on fact-mine: 24s/17MB; scip-go on gremlins: +7.6s/1.5MB), but it requires a working build and goes stale on edit. Mitigation: +cache indexes by commit, fall back to source-only with a *loud* tier annotation, and +never silently degrade (§2.5). + +### 4.5 NON-GAP: source-only resolution quality for indexed languages — **DO NOT CLOSE** + +Tempting and wrong. Rust source-only cannot approach 66%. Go source-only is closer +but still behind SCIP on real projects (35.0 vs 56.0 on gremlins). Effort here is +strictly dominated by just running the indexer. + +--- + +## 5. Path forward + +### Phase 1 — Stop the bleeding (immediate) +1. **Make SCIP the default resolution tier** for every language with an indexer. + Already implemented; make it the default path, not an opt-in flag. +2. **Add tier assertions**: index non-empty, and ≥ threshold of calls carrying + `target_provenance == "scip"`. Fail loudly otherwise. +3. **Stamp the resolution tier on every emitted metric** and on architecture output. +4. **Delete Rust source-only resolution**: `2327bbc70` (proven zero, §0.3), + `2b33520ca`, and the resolver half of `b84058a0d` (keep its `scoped_call_parts` + extraction half). Freeze that layer for indexed languages. + +### Phase 2 — Consume what SCIP already gives us (highest ROI) +5. **Parse `signature_documentation`** into parameter/return types and feed the + existing type maps (§4.1). This retires the entire hand-rolled inference path. +6. **Prefer `is_implementation`** over `compute_dispatch_impls` when SCIP is present; + keep the structural computation only as the no-SCIP fallback. + +### Phase 3 — Invest in the moat (the only durable differentiator) +7. **Espalier-map stdlib/dependency costs, keyed by versioned SCIP symbol** (§4.2). + Measured precedent: cost work delivered **+14 pts on the Go stdlib with SCIP on**. +8. **Extend the cost algebra** — recursion classification (77 `O(2^N)` functions + remain in the Rust corpus), callback/interface parametric closure, the + complete-vs-complete-worst-case distinction. + +### Phase 4 — Serve the languages SCIP abandons +9. **Redirect all resolution effort to Swift, Lua, PHP, Zig, Ruby.** Hold it to a + measured bar: ship only if completeness moves on a real corpus. +10. **Ruby/PHP need the runtime-evidence overlay**, not a static resolver — + their ceiling is type *availability*, not type *inference*. NilKill records + language-owned runtime values in the language-neutral evidence contract; + FactMine alone joins those values through normalized CFG/DFG facts and + emits the inferred SCIP index. + +### The one-line rule + +> **SCIP (or a real type checker) owns "what is this and what does it call." +> fact-mine owns "what does it cost." Never invert that.** diff --git a/gems/fact-mine/examples/profile/oracles/python_sample.json b/gems/fact-mine/examples/profile/oracles/python_sample.json index 789397484..0018fd148 100644 --- a/gems/fact-mine/examples/profile/oracles/python_sample.json +++ b/gems/fact-mine/examples/profile/oracles/python_sample.json @@ -85,12 +85,12 @@ "fields": [ { "declared_type": "int", - "id": "state:41638fe5d66c2aa3", + "id": "state:f001f40fc32da24e", "language": "python", "line": 5, "name": "@port", "owner": "Database", - "owner_id": "owner:1a8faee6057d3971", + "owner_id": "owner:b8064726d03619ce", "path": "examples/profile/python_sample.py", "source": "syntax", "span": [ @@ -103,12 +103,12 @@ }, { "declared_type": null, - "id": "state:04ff731a0d59825a", + "id": "state:708fb7824917db17", "language": "python", "line": 9, "name": "@_db", "owner": "Greeter", - "owner_id": "owner:d2af1adb22a6d402", + "owner_id": "owner:a8957e23a055d851", "path": "examples/profile/python_sample.py", "source": "syntax", "span": [ @@ -122,7 +122,10 @@ ], "flow_local_types": [ { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/python_sample.py", "function": "__init__", "line": 9, @@ -150,12 +153,15 @@ ] }, { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/python_sample.py", "function": "hello", "line": 12, "name": "name", - "node_id": "cfg:Greeter#hello:stmt:1:12:8", + "node_id": "cfg:Greeter#hello:return:0:12:8", "owner": "Greeter", "place_id": "place:Greeter#hello:local:name", "reaching_definitions": [ @@ -182,7 +188,8 @@ "methods": [ { "dispatch_name": "__init__", - "id": "fn:887fdfc886d5b516", + "generated_declaration": false, + "id": "fn:36d86df03e91b9f5", "key": [ "Database", "__init__", @@ -195,7 +202,7 @@ "name": "__init__", "normalized_source": "def __init__(self): self.port: int = 5432", "owner": "Database", - "owner_id": "owner:1a8faee6057d3971", + "owner_id": "owner:b8064726d03619ce", "params": [ "self" ], @@ -206,6 +213,7 @@ "signature": "def __init__(self):", "type_system": "python-typing" }, + "source_export_eligible": true, "span": [ 4, 4, @@ -217,7 +225,8 @@ }, { "dispatch_name": "__init__", - "id": "fn:8254a2e8c05c9d0b", + "generated_declaration": false, + "id": "fn:da9413a4cfff38ee", "key": [ "Greeter", "__init__", @@ -230,7 +239,7 @@ "name": "__init__", "normalized_source": "def __init__(self, db: Database): self._db = db", "owner": "Greeter", - "owner_id": "owner:d2af1adb22a6d402", + "owner_id": "owner:a8957e23a055d851", "params": [ "self", "db" @@ -242,6 +251,7 @@ "signature": "def __init__(self, db: Database):", "type_system": "python-typing" }, + "source_export_eligible": true, "span": [ 8, 4, @@ -253,7 +263,8 @@ }, { "dispatch_name": "hello", - "id": "fn:66deac84d584cdb7", + "generated_declaration": false, + "id": "fn:d8362c17d1674f24", "key": [ "Greeter", "hello", @@ -266,7 +277,7 @@ "name": "hello", "normalized_source": "def hello(self, name: str) -> str: return f\"Hello {name}\"", "owner": "Greeter", - "owner_id": "owner:d2af1adb22a6d402", + "owner_id": "owner:a8957e23a055d851", "params": [ "self", "name" @@ -278,6 +289,7 @@ "signature": "def hello(self, name: str) -> str:", "type_system": "python-typing" }, + "source_export_eligible": true, "span": [ 11, 4, @@ -293,7 +305,7 @@ "owners": [ { "confidence": "high", - "id": "owner:1a8faee6057d3971", + "id": "owner:b8064726d03619ce", "kind": "class", "language": "python", "line": 3, @@ -309,7 +321,7 @@ }, { "confidence": "high", - "id": "owner:d2af1adb22a6d402", + "id": "owner:a8957e23a055d851", "kind": "class", "language": "python", "line": 7, @@ -336,8 +348,8 @@ "confidence": "high", "field": "@port", "function": "__init__", - "function_id": "fn:887fdfc886d5b516", - "id": "edge:dc2b379a57c32afa", + "function_id": "fn:36d86df03e91b9f5", + "id": "edge:a32086b5b46ec814", "kind": "writes", "line": 5, "owner": "Database", @@ -349,15 +361,15 @@ 5, 29 ], - "state_id": "state:41638fe5d66c2aa3" + "state_id": "state:f001f40fc32da24e" }, { "conditional": false, "confidence": "high", "field": "@_db", "function": "__init__", - "function_id": "fn:8254a2e8c05c9d0b", - "id": "edge:833205b939f16c46", + "function_id": "fn:da9413a4cfff38ee", + "id": "edge:a487e75c5dac1bcc", "kind": "writes", "line": 9, "owner": "Greeter", @@ -369,7 +381,7 @@ 9, 21 ], - "state_id": "state:04ff731a0d59825a" + "state_id": "state:708fb7824917db17" } ], "state_param_origin_records": [ diff --git a/gems/fact-mine/examples/profile/oracles/ruby_calculator.json b/gems/fact-mine/examples/profile/oracles/ruby_calculator.json index 951aae044..fe6b0197c 100644 --- a/gems/fact-mine/examples/profile/oracles/ruby_calculator.json +++ b/gems/fact-mine/examples/profile/oracles/ruby_calculator.json @@ -18,6 +18,7 @@ "raw_parser_call_sites": 1, "semantically_accounted_call_percent": 0.0, "semantically_accounted_call_sites": 0, + "source_export_eligible_methods_overlapping_raw_call_loss": 0, "total_call_sites": 0, "unresolved_call_percent": 0.0, "unresolved_call_sites": 0, @@ -27,10 +28,14 @@ "calls": [ { "argument_count": 1, + "arguments": [ + ":total" + ], "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:32f7ae34c97f05b8", + "id": "edge:cebb9c2d26f6f0e8", "implicit_receiver": true, "kind": "unresolved_call", "line": 2, @@ -40,7 +45,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:e15b261be3125599", + "selector_span": [ + 2, + 2, + 2, + 15 + ], + "source": "fn:736040629b8b4788", "span": [ 2, 2, @@ -100,6 +111,7 @@ "halving_calls": 0, "loop_contained_shrinking_calls": 0, "shrinking_calls": 0, + "structural_calls": 0, "unknown_progress_calls": 0, "visited_guarded_calls": 0 }, @@ -155,12 +167,12 @@ "fields": [ { "declared_type": null, - "id": "state:9401a0fe39c9038e", + "id": "state:45b35020fc10ea6f", "language": "ruby", "line": 5, "name": "total", "owner": "Calculator", - "owner_id": "owner:c89bc43899119c68", + "owner_id": "owner:4b045cb69a6f8a63", "path": "examples/profile/ruby_calculator.rb", "source": "syntax", "span": [ @@ -174,7 +186,10 @@ ], "flow_local_types": [ { + "callback_binding_position": null, "complete": false, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_calculator.rb", "function": "add", "line": 5, @@ -193,7 +208,10 @@ "types": [] }, { + "callback_binding_position": null, "complete": false, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_calculator.rb", "function": "add", "line": 5, @@ -214,7 +232,10 @@ "types": [] }, { + "callback_binding_position": null, "complete": false, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_calculator.rb", "function": "result", "line": 9, @@ -240,7 +261,8 @@ "boolean_ops": 1 }, "dispatch_name": "add", - "id": "fn:b4b63e5d9de70e44", + "generated_declaration": false, + "id": "fn:e726a8cb5d1043bf", "key": [ "Calculator", "add", @@ -253,7 +275,7 @@ "name": "add", "normalized_source": "def add(value) @total = (@total || 0) + value end", "owner": "Calculator", - "owner_id": "owner:c89bc43899119c68", + "owner_id": "owner:4b045cb69a6f8a63", "params": [ "value" ], @@ -261,6 +283,7 @@ "raw_source": "def add(value)\n @total = (@total || 0) + value\n end", "signature": "", "source": {}, + "source_export_eligible": true, "span": [ 4, 2, @@ -271,7 +294,8 @@ }, { "dispatch_name": "result", - "id": "fn:02c66c914712165d", + "generated_declaration": false, + "id": "fn:e4bc7514d4fff252", "key": [ "Calculator", "result", @@ -284,12 +308,13 @@ "name": "result", "normalized_source": "def result @total end", "owner": "Calculator", - "owner_id": "owner:c89bc43899119c68", + "owner_id": "owner:4b045cb69a6f8a63", "params": [], "path": "examples/profile/ruby_calculator.rb", "raw_source": "def result\n @total\n end", "signature": "", "source": {}, + "source_export_eligible": true, "span": [ 8, 2, @@ -300,7 +325,8 @@ }, { "dispatch_name": "total", - "id": "fn:588c35772c399506", + "generated_declaration": true, + "id": "fn:1fc2156dc148b3bf", "key": [ "Calculator", "total", @@ -313,12 +339,13 @@ "name": "total", "normalized_source": "attr_accessor :total", "owner": "Calculator", - "owner_id": "owner:c89bc43899119c68", + "owner_id": "owner:4b045cb69a6f8a63", "params": [], "path": "examples/profile/ruby_calculator.rb", "raw_source": "attr_accessor :total", "signature": "", "source": {}, + "source_export_eligible": true, "span": [ 2, 2, @@ -329,7 +356,8 @@ }, { "dispatch_name": "total=", - "id": "fn:bc2167e86fe1e86f", + "generated_declaration": true, + "id": "fn:9a1c2c9298c4a8f0", "key": [ "Calculator", "total=", @@ -342,7 +370,7 @@ "name": "total=", "normalized_source": "attr_accessor :total", "owner": "Calculator", - "owner_id": "owner:c89bc43899119c68", + "owner_id": "owner:4b045cb69a6f8a63", "params": [ "value" ], @@ -350,6 +378,7 @@ "raw_source": "attr_accessor :total", "signature": "", "source": {}, + "source_export_eligible": true, "span": [ 2, 2, @@ -364,7 +393,7 @@ "owners": [ { "confidence": "high", - "id": "owner:c89bc43899119c68", + "id": "owner:4b045cb69a6f8a63", "kind": "class", "language": "ruby", "line": 1, @@ -386,8 +415,8 @@ "confidence": "high", "field": "total", "function": "add", - "function_id": "fn:b4b63e5d9de70e44", - "id": "edge:5c77df72bc6857e8", + "function_id": "fn:e726a8cb5d1043bf", + "id": "edge:0fd334b1095d3d2a", "kind": "reads", "line": 5, "owner": "Calculator", @@ -399,15 +428,15 @@ 5, 20 ], - "state_id": "state:9401a0fe39c9038e" + "state_id": "state:45b35020fc10ea6f" }, { "conditional": false, "confidence": "high", "field": "total", "function": "result", - "function_id": "fn:02c66c914712165d", - "id": "edge:766d575d6dfd5769", + "function_id": "fn:e4bc7514d4fff252", + "id": "edge:73594ef170faf329", "kind": "reads", "line": 9, "owner": "Calculator", @@ -419,15 +448,15 @@ 9, 10 ], - "state_id": "state:9401a0fe39c9038e" + "state_id": "state:45b35020fc10ea6f" }, { "conditional": false, "confidence": "high", "field": "total", "function": "add", - "function_id": "fn:b4b63e5d9de70e44", - "id": "edge:f14dbb90ca9496a1", + "function_id": "fn:e726a8cb5d1043bf", + "id": "edge:42a4ddc6eb248bf5", "kind": "writes", "line": 5, "owner": "Calculator", @@ -439,7 +468,7 @@ 5, 34 ], - "state_id": "state:9401a0fe39c9038e" + "state_id": "state:45b35020fc10ea6f" } ], "state_param_origin_records": [ diff --git a/gems/fact-mine/examples/profile/oracles/ruby_safe_navigation_nil_kill.json b/gems/fact-mine/examples/profile/oracles/ruby_safe_navigation_nil_kill.json index 461e9d92f..c3bbbb798 100644 --- a/gems/fact-mine/examples/profile/oracles/ruby_safe_navigation_nil_kill.json +++ b/gems/fact-mine/examples/profile/oracles/ruby_safe_navigation_nil_kill.json @@ -40,6 +40,7 @@ "raw_parser_call_sites": 15, "semantically_accounted_call_percent": 0.0, "semantically_accounted_call_sites": 0, + "source_export_eligible_methods_overlapping_raw_call_loss": 0, "total_call_sites": 0, "unresolved_call_percent": 0.0, "unresolved_call_sites": 0, @@ -49,10 +50,14 @@ "calls": [ { "argument_count": 1, + "arguments": [ + "T::Sig" + ], "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:7b0515a548c9d614", + "id": "edge:552a5e6389b12181", "implicit_receiver": true, "kind": "unresolved_call", "line": 2, @@ -62,7 +67,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 2, + 2, + 2, + 8 + ], + "source": "fn:8b060578990b3bc3", "span": [ 2, 2, @@ -75,8 +86,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 4, + 2, + 10, + 5 + ], "function": "(top-level)", - "id": "edge:a34b224c826a5d90", + "id": "edge:387473d4b6dd9291", "implicit_receiver": true, "kind": "unresolved_call", "line": 4, @@ -86,7 +104,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "source": "fn:8b060578990b3bc3", "span": [ 4, 2, @@ -97,12 +115,16 @@ }, { "argument_count": 1, + "arguments": [ + "UnionMatchArmPlan" + ], "complexity_bound_quality": "upper_bound_declared_receiver", "complexity_provenance": "language_stdlib_registry", "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:2b7bf67bcdb65d15", + "id": "edge:dc8eaf947944fe2a", "kind": "external_call", "known_space_complexity": "O(1)", "known_time_complexity": "O(1)", @@ -113,7 +135,13 @@ "receiver": "T::Array", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 8, + 20, + 8, + 21 + ], + "source": "fn:8b060578990b3bc3", "span": [ 8, 12, @@ -124,10 +152,16 @@ }, { "argument_count": 3, + "arguments": [ + "node: AST::MatchStatement", + "facts: MatchLoweringFacts", + "arms: T::Array[UnionMatchArmPlan]" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:ba0cae20ce7a2a65", + "id": "edge:291694a00f03581e", "implicit_receiver": true, "kind": "unresolved_call", "line": 5, @@ -137,7 +171,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 5, + 4, + 5, + 10 + ], + "source": "fn:8b060578990b3bc3", "span": [ 5, 4, @@ -146,12 +186,50 @@ ], "unresolved_reason": "target_not_defined_in_document" }, + { + "argument_count": 0, + "complexity_bound_quality": "upper_bound_declared_receiver", + "complexity_provenance": "language_stdlib_registry", + "conditional": true, + "confidence": "partial", + "consumer_closed_candidate_set": false, + "function": "(top-level)", + "id": "edge:7de05ee0cf649dba", + "kind": "external_call", + "known_space_complexity": "O(1)", + "known_time_complexity": "O(1)", + "line": 9, + "message": "untyped", + "owner": "MIRLoweringControlFlow", + "path": "examples/profile/ruby_safe_navigation_nil_kill.rb", + "receiver": "T", + "receiver_binding_kind": "type", + "receiver_kind": "type", + "selector_span": [ + 9, + 16, + 9, + 23 + ], + "source": "fn:8b060578990b3bc3", + "span": [ + 9, + 13, + 9, + 24 + ], + "unresolved_reason": "receiver_requires_corpus_resolution" + }, { "argument_count": 1, + "arguments": [ + "T.untyped" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:837e80a42529c213", + "id": "edge:3cc531b658f90ac0", "kind": "external_call", "line": 5, "message": "returns", @@ -166,7 +244,13 @@ 5 ], "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 9, + 6, + 9, + 13 + ], + "source": "fn:8b060578990b3bc3", "span": [ 5, 4, @@ -177,12 +261,17 @@ }, { "argument_count": 2, + "arguments": [ + "self", + "MIRLowering" + ], "complexity_bound_quality": "upper_bound_declared_receiver", "complexity_provenance": "language_stdlib_registry", "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:9a03cada03345b35", + "id": "edge:e748fb38ed2f23b2", "kind": "external_call", "known_space_complexity": "O(1)", "known_time_complexity": "O(1)", @@ -193,7 +282,13 @@ "receiver": "T", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 12, + 6, + 12, + 10 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 12, 4, @@ -206,8 +301,9 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:2702383c132ca9cf", + "id": "edge:827f22624742a690", "implicit_receiver": true, "kind": "unresolved_call", "line": 13, @@ -217,7 +313,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "source": "fn:f40acd31cc807dfc", "span": [ 13, 13, @@ -230,8 +326,9 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:c542973fb5c36ec0", + "id": "edge:18bdc4cbce5b460f", "kind": "external_call", "line": 13, "message": "expr_type_sym", @@ -243,7 +340,13 @@ "receiver_symbol_origin": "unqualified_declared_type", "receiver_type": "MatchLoweringFacts", "receiver_type_origin": "declared_parameter", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 13, + 33, + 13, + 46 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 13, 27, @@ -254,10 +357,14 @@ }, { "argument_count": 1, + "arguments": [ + "facts.expr_type_sym" + ], "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:f10062392d8cf010", + "id": "edge:d389a4a401509509", "kind": "external_call", "line": 13, "message": "[]", @@ -272,7 +379,13 @@ 26 ], "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 13, + 26, + 13, + 27 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 13, 13, @@ -285,8 +398,9 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:7a889eb841551e81", + "id": "edge:421c7527244699dc", "kind": "external_call", "line": 14, "message": "variants", @@ -303,7 +417,13 @@ ] ], "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 14, + 27, + 14, + 35 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 14, 19, @@ -316,8 +436,9 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:92a99b420fbd802c", + "id": "edge:9535de7cd16c47d1", "kind": "external_call", "line": 14, "message": "keys", @@ -332,7 +453,13 @@ 35 ], "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 14, + 37, + 14, + 41 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 14, 19, @@ -343,10 +470,14 @@ }, { "argument_count": 1, + "arguments": [ + "&:to_s" + ], "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:e83d21a88a399e54", + "id": "edge:118ae32d050545df", "kind": "external_call", "line": 14, "message": "map", @@ -361,7 +492,13 @@ 41 ], "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "selector_span": [ + 14, + 43, + 14, + 46 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 14, 19, @@ -372,11 +509,16 @@ }, { "argument_count": 0, + "complexity_bound_quality": "upper_bound_declared_receiver", + "complexity_provenance": "language_stdlib_registry", "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "union_match_default_body", - "id": "edge:ecfa5ba2b8e83bf9", + "id": "edge:9d051e0e28a89598", "kind": "external_call", + "known_space_complexity": "O(N)", + "known_time_complexity": "O(N log N)", "line": 14, "message": "sort", "owner": "MIRLoweringControlFlow", @@ -390,7 +532,15 @@ 54 ], "receiver_kind": "value", - "source": "fn:870869be8ea36bbd", + "receiver_type": "T::Array[T.untyped]", + "receiver_type_origin": "static_call_result_contract", + "selector_span": [ + 14, + 56, + 14, + 60 + ], + "source": "fn:f40acd31cc807dfc", "span": [ 14, 19, @@ -403,8 +553,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 18, + 2, + 18, + 54 + ], "function": "(top-level)", - "id": "edge:2c02c8959c8d4c86", + "id": "edge:fc57a4693722e9bf", "implicit_receiver": true, "kind": "unresolved_call", "line": 18, @@ -414,7 +571,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "source": "fn:8b060578990b3bc3", "span": [ 18, 2, @@ -425,10 +582,14 @@ }, { "argument_count": 1, + "arguments": [ + "required: String" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:73862c6f05bad739", + "id": "edge:113bbe2725b47440", "implicit_receiver": true, "kind": "unresolved_call", "line": 18, @@ -438,7 +599,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 18, + 8, + 18, + 14 + ], + "source": "fn:8b060578990b3bc3", "span": [ 18, 8, @@ -449,10 +616,14 @@ }, { "argument_count": 1, + "arguments": [ + "T::Boolean" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:3358eff9e35eb10a", + "id": "edge:b8e729c0258e5f6d", "kind": "external_call", "line": 18, "message": "returns", @@ -467,7 +638,13 @@ 32 ], "receiver_kind": "value", - "source": "fn:91d9d9a6eeda5c2a", + "selector_span": [ + 18, + 33, + 18, + 40 + ], + "source": "fn:8b060578990b3bc3", "span": [ 18, 8, @@ -480,8 +657,9 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "missing?", - "id": "edge:e3223b4a641db6a6", + "id": "edge:f79f8a43eba75b22", "kind": "external_call", "line": 20, "message": "nil?", @@ -493,7 +671,13 @@ "receiver_symbol_origin": "unqualified_declared_type", "receiver_type": "String", "receiver_type_origin": "declared_parameter", - "source": "fn:4c6945d1a79599a2", + "selector_span": [ + 20, + 13, + 20, + 17 + ], + "source": "fn:4c68f8ecd3cbf937", "span": [ 20, 4, @@ -526,7 +710,87 @@ ], "complexity_facts": [ { - "allocations": [], + "allocations": [ + { + "bound_classification": "output", + "cardinality_relation": "output_size", + "domain_expression": [ + "schema" + ], + "kind": "keys", + "line": 14, + "parameter_domains": [], + "span": [ + 14, + 19, + 14, + 41 + ], + "symbolic_size": { + "complete": true, + "factors": [ + { + "domain_id": "allocation:examples/profile/ruby_safe_navigation_nil_kill.rb:14:19", + "exponent": 1 + } + ], + "logarithmic": false + } + }, + { + "bound_classification": "output", + "cardinality_relation": "output_size", + "domain_expression": [ + "schema" + ], + "kind": "map", + "line": 14, + "parameter_domains": [], + "span": [ + 14, + 19, + 14, + 54 + ], + "symbolic_size": { + "complete": true, + "factors": [ + { + "domain_id": "allocation:examples/profile/ruby_safe_navigation_nil_kill.rb:14:19", + "exponent": 1 + } + ], + "logarithmic": false + } + }, + { + "bound_classification": "output", + "cardinality_relation": "output_size", + "domain_expression": [ + "schema" + ], + "kind": "sort", + "line": 14, + "parameter_domains": [], + "receiver_is_call": true, + "span": [ + 14, + 19, + 14, + 60 + ], + "symbolic_size": { + "complete": true, + "factors": [ + { + "domain_id": "allocation:examples/profile/ruby_safe_navigation_nil_kill.rb:14:19", + "exponent": 1 + } + ], + "logarithmic": false + } + } + ], "block_invocations": [], "call_contexts": [ { @@ -634,6 +898,100 @@ "factors": [], "logarithmic": false } + }, + { + "argument_cardinality_relation": "same", + "argument_progress": "unknown", + "argument_size_domains": [], + "evidence_gap": "unresolved_receiver_type", + "execution_multiplicity": "O(1)", + "line": 14, + "message": "sort", + "parameter_arguments": [], + "power": 0, + "receiver_size_domains": [], + "span": [ + 14, + 19, + 14, + 60 + ], + "symbolic_execution": { + "complete": true, + "factors": [], + "logarithmic": false + } + }, + { + "argument_cardinality_relation": "same", + "argument_progress": "unknown", + "argument_size_domains": [ + [] + ], + "evidence_gap": "unresolved_receiver_type", + "execution_multiplicity": "O(1)", + "line": 14, + "message": "map", + "parameter_arguments": [], + "power": 0, + "receiver_size_domains": [], + "span": [ + 14, + 19, + 14, + 54 + ], + "symbolic_execution": { + "complete": true, + "factors": [], + "logarithmic": false + } + }, + { + "argument_cardinality_relation": "same", + "argument_progress": "unknown", + "argument_size_domains": [], + "evidence_gap": "unresolved_receiver_type", + "execution_multiplicity": "O(1)", + "line": 14, + "message": "keys", + "parameter_arguments": [], + "power": 0, + "receiver_size_domains": [], + "span": [ + 14, + 19, + 14, + 41 + ], + "symbolic_execution": { + "complete": true, + "factors": [], + "logarithmic": false + } + }, + { + "argument_cardinality_relation": "same", + "argument_progress": "unknown", + "argument_size_domains": [], + "evidence_gap": "unresolved_receiver_type", + "execution_multiplicity": "O(1)", + "line": 14, + "message": "variants", + "parameter_arguments": [], + "power": 0, + "receiver_size_domains": [], + "span": [ + 14, + 19, + 14, + 35 + ], + "symbolic_execution": { + "complete": true, + "factors": [], + "logarithmic": false + } } ], "collection_parameters": [ @@ -655,10 +1013,23 @@ "halving_calls": 0, "loop_contained_shrinking_calls": 0, "shrinking_calls": 0, + "structural_calls": 0, "unknown_progress_calls": 0, "visited_guarded_calls": 0 }, "size_domains": [ + { + "id": "allocation:examples/profile/ruby_safe_navigation_nil_kill.rb:14:19", + "name": "materialized schema", + "path": "examples/profile/ruby_safe_navigation_nil_kill.rb", + "source_kind": "output", + "span": [ + 14, + 19, + 14, + 60 + ] + }, { "id": "param:MIRLoweringControlFlow#union_match_default_body:arms", "name": "arms", @@ -753,6 +1124,7 @@ "halving_calls": 0, "loop_contained_shrinking_calls": 0, "shrinking_calls": 0, + "structural_calls": 0, "unknown_progress_calls": 0, "visited_guarded_calls": 0 }, @@ -917,7 +1289,10 @@ "fields": [], "flow_local_types": [ { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_safe_navigation_nil_kill.rb", "function": "missing?", "line": 20, @@ -945,7 +1320,10 @@ ] }, { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_safe_navigation_nil_kill.rb", "function": "union_match_default_body", "line": 13, @@ -973,7 +1351,19 @@ ] }, { + "callback_binding_position": null, "complete": false, + "definition_call_sources": { + "cfg:MIRLoweringControlFlow#union_match_default_body:stmt:2:13:4": [ + [ + 13, + 13, + 13, + 47 + ] + ] + }, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_safe_navigation_nil_kill.rb", "function": "union_match_default_body", "line": 14, @@ -994,7 +1384,10 @@ "types": [] }, { + "callback_binding_position": null, "complete": false, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_safe_navigation_nil_kill.rb", "function": "union_match_default_body", "line": 15, @@ -1095,7 +1488,8 @@ "rescues": 2 }, "dispatch_name": "union_match_default_body", - "id": "fn:870869be8ea36bbd", + "generated_declaration": false, + "id": "fn:f40acd31cc807dfc", "key": [ "MIRLoweringControlFlow", "union_match_default_body", @@ -1108,7 +1502,7 @@ "name": "union_match_default_body", "normalized_source": "def union_match_default_body(node, facts, arms) T.bind(self, MIRLowering) rescue nil schema = union_schemas[facts.expr_type_sym] all_variants = schema&.variants&.keys&.map(&:to_s)&.sort || [] all_variants end", "owner": "MIRLoweringControlFlow", - "owner_id": "owner:1764295d8fbc4733", + "owner_id": "owner:9f30e8c74316e548", "params": [ "node", "facts", @@ -1123,6 +1517,7 @@ "source": "annotation", "type_system": "sorbet" }, + "source_export_eligible": true, "span": [ 11, 2, @@ -1133,7 +1528,8 @@ }, { "dispatch_name": "missing?", - "id": "fn:4c6945d1a79599a2", + "generated_declaration": false, + "id": "fn:4c68f8ecd3cbf937", "key": [ "MIRLoweringControlFlow", "missing?", @@ -1146,7 +1542,7 @@ "name": "missing?", "normalized_source": "def missing?(required) required.nil? end", "owner": "MIRLoweringControlFlow", - "owner_id": "owner:1764295d8fbc4733", + "owner_id": "owner:9f30e8c74316e548", "params": [ "required" ], @@ -1159,6 +1555,7 @@ "source": "annotation", "type_system": "sorbet" }, + "source_export_eligible": true, "span": [ 19, 2, @@ -1215,7 +1612,7 @@ "owners": [ { "confidence": "high", - "id": "owner:1764295d8fbc4733", + "id": "owner:9f30e8c74316e548", "kind": "module", "language": "ruby", "line": 1, @@ -2279,4 +2676,4 @@ "types": [] } ] -} +} \ No newline at end of file diff --git a/gems/fact-mine/examples/profile/oracles/ruby_shapes.json b/gems/fact-mine/examples/profile/oracles/ruby_shapes.json index 7bc46c231..49dbabf16 100644 --- a/gems/fact-mine/examples/profile/oracles/ruby_shapes.json +++ b/gems/fact-mine/examples/profile/oracles/ruby_shapes.json @@ -55,7 +55,8 @@ "methods": [ { "dispatch_name": "initialize", - "id": "fn:ec941d71faca5951", + "generated_declaration": false, + "id": "fn:d81de45ef5aeaa40", "key": [ "RubyShapes", "initialize", @@ -68,12 +69,13 @@ "name": "initialize", "normalized_source": "def initialize [1, \"two\", :three, true, nil, CustomClass] end", "owner": "RubyShapes", - "owner_id": "owner:b26fe4ac5692839d", + "owner_id": "owner:24675cda39b19392", "params": [], "path": "examples/profile/ruby_shapes.rb", "raw_source": "def initialize\n [1, \"two\", :three, true, nil, CustomClass]\n end", "signature": "", "source": {}, + "source_export_eligible": true, "span": [ 13, 2, @@ -88,7 +90,7 @@ "owners": [ { "confidence": "high", - "id": "owner:b26fe4ac5692839d", + "id": "owner:24675cda39b19392", "kind": "class", "language": "ruby", "line": 3, diff --git a/gems/fact-mine/examples/profile/oracles/ruby_tuple_arrays_nil_kill.json b/gems/fact-mine/examples/profile/oracles/ruby_tuple_arrays_nil_kill.json index 1e0322e92..595881657 100644 --- a/gems/fact-mine/examples/profile/oracles/ruby_tuple_arrays_nil_kill.json +++ b/gems/fact-mine/examples/profile/oracles/ruby_tuple_arrays_nil_kill.json @@ -90,6 +90,7 @@ "raw_parser_call_sites": 9, "semantically_accounted_call_percent": 0.0, "semantically_accounted_call_sites": 0, + "source_export_eligible_methods_overlapping_raw_call_loss": 0, "total_call_sites": 0, "unresolved_call_percent": 0.0, "unresolved_call_sites": 0, @@ -99,10 +100,14 @@ "calls": [ { "argument_count": 1, + "arguments": [ + "T::Sig" + ], "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:ff1a68259204d148", + "id": "edge:cdffe927ac3f9ffa", "implicit_receiver": true, "kind": "unresolved_call", "line": 4, @@ -112,7 +117,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 4, + 2, + 4, + 8 + ], + "source": "fn:50c72744be604cfe", "span": [ 4, 2, @@ -125,8 +136,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 6, + 2, + 6, + 74 + ], "function": "(top-level)", - "id": "edge:43d35f2eaeb4e354", + "id": "edge:076f3c3aeabfa362", "implicit_receiver": true, "kind": "unresolved_call", "line": 6, @@ -136,7 +154,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "source": "fn:50c72744be604cfe", "span": [ 6, 2, @@ -147,10 +165,15 @@ }, { "argument_count": 2, + "arguments": [ + "name: String", + "node: Integer" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:6f9ce6b0c86529e3", + "id": "edge:76ccf5be66afda01", "implicit_receiver": true, "kind": "unresolved_call", "line": 6, @@ -160,7 +183,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 6, + 8, + 6, + 14 + ], + "source": "fn:50c72744be604cfe", "span": [ 6, 8, @@ -169,14 +198,52 @@ ], "unresolved_reason": "target_not_defined_in_document" }, + { + "argument_count": 0, + "complexity_bound_quality": "upper_bound_declared_receiver", + "complexity_provenance": "language_stdlib_registry", + "conditional": true, + "confidence": "partial", + "consumer_closed_candidate_set": false, + "function": "(top-level)", + "id": "edge:3dbb9db644478691", + "kind": "external_call", + "known_space_complexity": "O(1)", + "known_time_complexity": "O(1)", + "line": 6, + "message": "untyped", + "owner": "TupleArrayEvidence", + "path": "examples/profile/ruby_tuple_arrays_nil_kill.rb", + "receiver": "T", + "receiver_binding_kind": "type", + "receiver_kind": "type", + "selector_span": [ + 6, + 63, + 6, + 70 + ], + "source": "fn:50c72744be604cfe", + "span": [ + 6, + 61, + 6, + 70 + ], + "unresolved_reason": "receiver_requires_corpus_resolution" + }, { "argument_count": 1, + "arguments": [ + "T.untyped" + ], "complexity_bound_quality": "upper_bound_declared_receiver", "complexity_provenance": "language_stdlib_registry", "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:b7b09cfc47b13c91", + "id": "edge:6882f14e9bd38faf", "kind": "external_call", "known_space_complexity": "O(1)", "known_time_complexity": "O(1)", @@ -187,7 +254,13 @@ "receiver": "T::Array", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 6, + 60, + 6, + 61 + ], + "source": "fn:50c72744be604cfe", "span": [ 6, 52, @@ -198,10 +271,14 @@ }, { "argument_count": 1, + "arguments": [ + "T::Array[T.untyped]" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:7efbdfbf5018b4a6", + "id": "edge:6c8f91d877151140", "kind": "external_call", "line": 6, "message": "returns", @@ -216,7 +293,13 @@ 43 ], "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 6, + 44, + 6, + 51 + ], + "source": "fn:50c72744be604cfe", "span": [ 6, 8, @@ -227,10 +310,15 @@ }, { "argument_count": 2, + "arguments": [ + ":CHAR", + "\">\"" + ], "conditional": false, "confidence": "high", + "consumer_closed_candidate_set": false, "function": "build", - "id": "edge:778219360a79f571", + "id": "edge:416e344494dba7b4", "implicit_receiver": true, "kind": "internal_call", "line": 8, @@ -240,21 +328,28 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:8674f476ac85a3cb", + "selector_span": [ + 8, + 4, + 8, + 11 + ], + "source": "fn:5806bc2a76308928", "span": [ 8, 4, 8, 23 ], - "target": "fn:1884cd1bd6dcfeff" + "target": "fn:44e64e0b9ace44e0" }, { "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "build", - "id": "edge:c60bc64c59d16ea7", + "id": "edge:63ca1822919c0c60", "implicit_receiver": true, "kind": "unresolved_call", "line": 9, @@ -264,7 +359,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:8674f476ac85a3cb", + "source": "fn:5806bc2a76308928", "span": [ 9, 19, @@ -277,8 +372,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 12, + 2, + 12, + 49 + ], "function": "(top-level)", - "id": "edge:5ad5ca69b25060fe", + "id": "edge:37b2cae7c28e488c", "implicit_receiver": true, "kind": "unresolved_call", "line": 12, @@ -288,7 +390,7 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "source": "fn:50c72744be604cfe", "span": [ 12, 2, @@ -299,10 +401,15 @@ }, { "argument_count": 2, + "arguments": [ + "kind: Symbol", + "text: String" + ], "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:2ef535ce9096e5da", + "id": "edge:3cd81ecb12dec218", "implicit_receiver": true, "kind": "unresolved_call", "line": 12, @@ -312,7 +419,13 @@ "receiver": "self", "receiver_binding_kind": "implicit", "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 12, + 8, + 12, + 14 + ], + "source": "fn:50c72744be604cfe", "span": [ 12, 8, @@ -325,8 +438,9 @@ "argument_count": 0, "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:c39cb5d499cfe191", + "id": "edge:1e874f9f09744053", "kind": "external_call", "line": 12, "message": "void", @@ -341,7 +455,13 @@ 42 ], "receiver_kind": "value", - "source": "fn:9e3ca9e165154fb5", + "selector_span": [ + 12, + 43, + 12, + 47 + ], + "source": "fn:50c72744be604cfe", "span": [ 12, 8, @@ -422,6 +542,7 @@ "halving_calls": 0, "loop_contained_shrinking_calls": 0, "shrinking_calls": 0, + "structural_calls": 0, "unknown_progress_calls": 0, "visited_guarded_calls": 0 }, @@ -569,7 +690,10 @@ "fields": [], "flow_local_types": [ { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_tuple_arrays_nil_kill.rb", "function": "build", "line": 9, @@ -597,7 +721,10 @@ ] }, { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/ruby_tuple_arrays_nil_kill.rb", "function": "build", "line": 9, @@ -659,7 +786,8 @@ "methods": [ { "dispatch_name": "build", - "id": "fn:8674f476ac85a3cb", + "generated_declaration": false, + "id": "fn:5806bc2a76308928", "key": [ "TupleArrayEvidence", "build", @@ -672,7 +800,7 @@ "name": "build", "normalized_source": "def build(name, node) consume(:CHAR, \">\") [[name, node], current] end", "owner": "TupleArrayEvidence", - "owner_id": "owner:7a8369774f6ec03b", + "owner_id": "owner:d4ede25f803af7ee", "params": [ "name", "node" @@ -686,6 +814,7 @@ "source": "annotation", "type_system": "sorbet" }, + "source_export_eligible": true, "span": [ 7, 2, @@ -696,7 +825,8 @@ }, { "dispatch_name": "consume", - "id": "fn:1884cd1bd6dcfeff", + "generated_declaration": false, + "id": "fn:44e64e0b9ace44e0", "key": [ "TupleArrayEvidence", "consume", @@ -709,7 +839,7 @@ "name": "consume", "normalized_source": "def consume(kind, text); end", "owner": "TupleArrayEvidence", - "owner_id": "owner:7a8369774f6ec03b", + "owner_id": "owner:d4ede25f803af7ee", "params": [ "kind", "text" @@ -723,6 +853,7 @@ "source": "annotation", "type_system": "sorbet" }, + "source_export_eligible": true, "span": [ 13, 2, @@ -759,7 +890,7 @@ "owners": [ { "confidence": "high", - "id": "owner:7a8369774f6ec03b", + "id": "owner:d4ede25f803af7ee", "kind": "class", "language": "ruby", "line": 3, @@ -1175,7 +1306,7 @@ { "array_element_shape": null, "blockers": [ - "unknown return expression ARGS at /home/yahn/cheat/gems/fact-mine/examples/profile/ruby_tuple_arrays_nil_kill.rb:13" + "unknown return expression ARGS at examples/profile/ruby_tuple_arrays_nil_kill.rb:13" ], "candidate_type": { "kind": "Untyped" @@ -1496,7 +1627,7 @@ "name": "name", "owner": "TupleArrayEvidence", "requirements": [ - "type-definition:/home/yahn/cheat/gems/fact-mine/examples/profile/ruby_tuple_arrays_nil_kill.rb:cfg:TupleArrayEvidence#build:entry:0:7:2:place:TupleArrayEvidence#build:local:name" + "type-definition:examples/profile/ruby_tuple_arrays_nil_kill.rb:cfg:TupleArrayEvidence#build:entry:0:7:2:place:TupleArrayEvidence#build:local:name" ], "resolved": true, "span": [ @@ -1520,7 +1651,7 @@ "name": "node", "owner": "TupleArrayEvidence", "requirements": [ - "type-definition:/home/yahn/cheat/gems/fact-mine/examples/profile/ruby_tuple_arrays_nil_kill.rb:cfg:TupleArrayEvidence#build:entry:0:7:2:place:TupleArrayEvidence#build:local:node" + "type-definition:examples/profile/ruby_tuple_arrays_nil_kill.rb:cfg:TupleArrayEvidence#build:entry:0:7:2:place:TupleArrayEvidence#build:local:node" ], "resolved": true, "span": [ @@ -1534,4 +1665,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/gems/fact-mine/examples/profile/oracles/ruby_type_aliases.json b/gems/fact-mine/examples/profile/oracles/ruby_type_aliases.json index db07ca1d4..f5df67b25 100644 --- a/gems/fact-mine/examples/profile/oracles/ruby_type_aliases.json +++ b/gems/fact-mine/examples/profile/oracles/ruby_type_aliases.json @@ -29,6 +29,7 @@ "raw_parser_call_sites": 3, "semantically_accounted_call_percent": 0.0, "semantically_accounted_call_sites": 0, + "source_export_eligible_methods_overlapping_raw_call_loss": 0, "total_call_sites": 0, "unresolved_call_percent": 0.0, "unresolved_call_sites": 0, @@ -40,8 +41,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 4, + 12, + 4, + 48 + ], "function": "(top-level)", - "id": "edge:83b665a49e685e36", + "id": "edge:beab494f7c5451e2", "kind": "external_call", "line": 4, "message": "type_alias", @@ -50,7 +58,13 @@ "receiver": "T", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:0372f2d939681931", + "selector_span": [ + 4, + 14, + 4, + 24 + ], + "source": "fn:a4f16908d1ca0abc", "span": [ 4, 12, @@ -61,12 +75,16 @@ }, { "argument_count": 1, + "arguments": [ + "AST::Node" + ], "complexity_bound_quality": "upper_bound_declared_receiver", "complexity_provenance": "language_stdlib_registry", "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:c4ea3e6aae6d0ae3", + "id": "edge:ddcb74a4f9f53f77", "kind": "external_call", "known_space_complexity": "O(1)", "known_time_complexity": "O(1)", @@ -77,7 +95,13 @@ "receiver": "T::Array", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:0372f2d939681931", + "selector_span": [ + 4, + 35, + 4, + 36 + ], + "source": "fn:a4f16908d1ca0abc", "span": [ 4, 27, @@ -90,8 +114,15 @@ "argument_count": 0, "conditional": false, "confidence": "partial", + "consumer_closed_candidate_set": false, + "execution_span": [ + 5, + 14, + 7, + 5 + ], "function": "(top-level)", - "id": "edge:e01a6684bb0bbe20", + "id": "edge:7d2185a3d4415574", "kind": "external_call", "line": 5, "message": "type_alias", @@ -100,7 +131,13 @@ "receiver": "T", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:0372f2d939681931", + "selector_span": [ + 5, + 16, + 5, + 26 + ], + "source": "fn:a4f16908d1ca0abc", "span": [ 5, 14, @@ -111,12 +148,17 @@ }, { "argument_count": 2, + "arguments": [ + "Schemas::EnumSchema", + "Schemas::StructSchema" + ], "complexity_bound_quality": "upper_bound_declared_receiver", "complexity_provenance": "language_stdlib_registry", "conditional": true, "confidence": "partial", + "consumer_closed_candidate_set": false, "function": "(top-level)", - "id": "edge:ffa598aa0528895e", + "id": "edge:b81839abd4b0a9da", "kind": "external_call", "known_space_complexity": "O(1)", "known_time_complexity": "O(1)", @@ -127,7 +169,13 @@ "receiver": "T", "receiver_binding_kind": "type", "receiver_kind": "type", - "source": "fn:0372f2d939681931", + "selector_span": [ + 6, + 6, + 6, + 9 + ], + "source": "fn:a4f16908d1ca0abc", "span": [ 6, 4, @@ -209,7 +257,7 @@ "owners": [ { "confidence": "high", - "id": "owner:0ac3079501ab632a", + "id": "owner:9aa2b17c9a0ef3e7", "kind": "module", "language": "ruby", "line": 3, diff --git a/gems/fact-mine/examples/profile/oracles/typescript_sample.json b/gems/fact-mine/examples/profile/oracles/typescript_sample.json index 65c5493eb..4a3cb227c 100644 --- a/gems/fact-mine/examples/profile/oracles/typescript_sample.json +++ b/gems/fact-mine/examples/profile/oracles/typescript_sample.json @@ -105,12 +105,12 @@ "fields": [ { "declared_type": "public port: number = 5432", - "id": "state:bf9e0c74e6d34945", + "id": "state:a93cdf3c47ad143a", "language": "typescript", "line": 4, "name": "port", "owner": "Database", - "owner_id": "owner:7d2c51a8280a8ec5", + "owner_id": "owner:9516ca35bd69d700", "path": "examples/profile/typescript_sample.ts", "source": "syntax", "span": [ @@ -123,12 +123,12 @@ }, { "declared_type": "Database", - "id": "state:a502c0b8f885cf82", + "id": "state:1e0c9f18052b6f45", "language": "typescript", "line": 8, "name": "_db", "owner": "Greeter", - "owner_id": "owner:412c61b10a84d1c2", + "owner_id": "owner:9ed9e010a773c7a7", "path": "examples/profile/typescript_sample.ts", "source": "syntax", "span": [ @@ -142,7 +142,10 @@ ], "flow_local_types": [ { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/typescript_sample.ts", "function": "constructor", "line": 10, @@ -170,12 +173,15 @@ ] }, { + "callback_binding_position": null, "complete": true, + "definition_call_sources": {}, + "definition_sequence_projections": {}, "file": "examples/profile/typescript_sample.ts", "function": "hello", "line": 14, "name": "name", - "node_id": "cfg:Greeter#hello:stmt:1:14:8", + "node_id": "cfg:Greeter#hello:return:0:14:8", "owner": "Greeter", "place_id": "place:Greeter#hello:local:name", "reaching_definitions": [ @@ -202,7 +208,8 @@ "methods": [ { "dispatch_name": "constructor", - "id": "fn:6a43a20e36918915", + "generated_declaration": false, + "id": "fn:f7ea68c88fbece5a", "key": [ "Greeter", "constructor", @@ -215,7 +222,7 @@ "name": "constructor", "normalized_source": "constructor(db: Database) { this._db = db; }", "owner": "Greeter", - "owner_id": "owner:412c61b10a84d1c2", + "owner_id": "owner:9ed9e010a773c7a7", "params": [ "db" ], @@ -226,17 +233,20 @@ "signature": "constructor(db: Database) {", "type_system": "typescript" }, + "source_export_eligible": true, "span": [ 9, 4, 11, 5 ], + "symbol_owner": "examples/profile/typescript_sample.Greeter", "visibility": "public" }, { "dispatch_name": "hello", - "id": "fn:96d27b98d810ce50", + "generated_declaration": false, + "id": "fn:1eacf03142d5eb15", "key": [ "Greeter", "hello", @@ -249,7 +259,7 @@ "name": "hello", "normalized_source": "public hello(name: string): string { return `Hello ${name}`; }", "owner": "Greeter", - "owner_id": "owner:412c61b10a84d1c2", + "owner_id": "owner:9ed9e010a773c7a7", "params": [ "name" ], @@ -260,12 +270,14 @@ "signature": "public hello(name: string): string {", "type_system": "typescript" }, + "source_export_eligible": true, "span": [ 13, 4, 15, 5 ], + "symbol_owner": "examples/profile/typescript_sample.Greeter", "visibility": "public" } ], @@ -274,7 +286,7 @@ "owners": [ { "confidence": "high", - "id": "owner:7d2c51a8280a8ec5", + "id": "owner:9516ca35bd69d700", "kind": "class", "language": "typescript", "line": 3, @@ -285,11 +297,12 @@ 0, 5, 1 - ] + ], + "symbol": "examples/profile/typescript_sample.Database" }, { "confidence": "high", - "id": "owner:412c61b10a84d1c2", + "id": "owner:9ed9e010a773c7a7", "kind": "class", "language": "typescript", "line": 7, @@ -300,7 +313,8 @@ 0, 16, 1 - ] + ], + "symbol": "examples/profile/typescript_sample.Greeter" } ], "presence_correlations": [], @@ -314,8 +328,8 @@ "confidence": "high", "field": "_db", "function": "constructor", - "function_id": "fn:6a43a20e36918915", - "id": "edge:6ecb5fbd9ccebc3f", + "function_id": "fn:f7ea68c88fbece5a", + "id": "edge:ef8c8dbc43fd43b3", "kind": "writes", "line": 10, "owner": "Greeter", @@ -327,7 +341,7 @@ 10, 21 ], - "state_id": "state:a502c0b8f885cf82" + "state_id": "state:1e0c9f18052b6f45" } ], "state_param_origin_records": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/block_conjunction_decisions/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/block_conjunction_decisions/ruby.json index 424c1f989..77e79bebe 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/block_conjunction_decisions/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/block_conjunction_decisions/ruby.json @@ -380,13 +380,25 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" }, { "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" } ], "control_flow_metrics": [ @@ -405,8 +417,13 @@ "line": 9 }, { - "id": "cfg:SourceFactBlockConjunctionDecisions#fallback:stmt:1:5:4", + "id": "cfg:SourceFactBlockConjunctionDecisions#fallback:stmt:0.loop.0:6:6", "kind": "statement", + "line": 6 + }, + { + "id": "cfg:SourceFactBlockConjunctionDecisions#fallback:stmt:1:5:4", + "kind": "loop", "line": 5 }, { @@ -420,9 +437,14 @@ "line": 13 }, { - "id": "cfg:SourceFactBlockConjunctionDecisions#filter_paths:stmt:1:12:4", + "id": "cfg:SourceFactBlockConjunctionDecisions#filter_paths:stmt:0.loop.0:12:27", "kind": "statement", "line": 12 + }, + { + "id": "cfg:SourceFactBlockConjunctionDecisions#filter_paths:stmt:1:12:4", + "kind": "loop", + "line": 12 } ], "decisions": [ @@ -461,6 +483,8 @@ } ], "def_use": [ + {}, + {}, {}, {} ], @@ -471,6 +495,8 @@ {}, {}, {}, + {}, + {}, {} ], "flow_types": [ @@ -505,6 +531,8 @@ {}, {}, {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -596,9 +624,14 @@ }, { "reads": [ - "place:SourceFactBlockConjunctionDecisions#fallback:local:files", "place:SourceFactBlockConjunctionDecisions#fallback:local:rel" ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactBlockConjunctionDecisions#fallback:local:files" + ], "writes": [ "place:SourceFactBlockConjunctionDecisions#fallback:local:rel" ] @@ -615,9 +648,14 @@ }, { "reads": [ - "place:SourceFactBlockConjunctionDecisions#filter_paths:local:hash", "place:SourceFactBlockConjunctionDecisions#filter_paths:local:rel" ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactBlockConjunctionDecisions#filter_paths:local:hash" + ], "writes": [ "place:SourceFactBlockConjunctionDecisions#filter_paths:local:_", "place:SourceFactBlockConjunctionDecisions#filter_paths:local:rel" @@ -803,6 +841,8 @@ {}, {}, {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/block_receiver_calls/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/block_receiver_calls/ruby.json index 1b1a7531d..4d57adf31 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/block_receiver_calls/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/block_receiver_calls/ruby.json @@ -45,6 +45,19 @@ "branch_arms": [], "branch_decisions": [], "calls": [ + { + "arguments": [ + "item.name" + ], + "block": false, + "conditional": true, + "control": "iterates", + "function": "collect", + "line": 7, + "message": "<<", + "receiver": "names", + "safe_navigation": false + }, { "arguments": [], "block": false, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/ruby.json index 6b560956f..530cc524e 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/ruby.json @@ -12,6 +12,11 @@ } ] }, + { + "boundaries": [], + "method": "method", + "statements": [] + }, { "boundaries": [], "method": "OtherFunc", @@ -98,6 +103,12 @@ { "kind": "entry" }, + { + "kind": "callback_return" + }, + { + "kind": "callback_body" + }, { "kind": "fallthrough" }, @@ -106,6 +117,7 @@ } ], "control_flow_metrics": [ + {}, {}, {} ], @@ -121,8 +133,13 @@ "line": 5 }, { - "id": "cfg:(top-level)#MyFunc:stmt:1:2:2", + "id": "cfg:(top-level)#MyFunc:stmt:0.callback.0:3:4", "kind": "statement", + "line": 3 + }, + { + "id": "cfg:(top-level)#MyFunc:stmt:1:2:2", + "kind": "callback", "line": 2 }, { @@ -134,12 +151,25 @@ "id": "cfg:(top-level)#OtherFunc:exit:1:7:18", "kind": "exit", "line": 7 + }, + { + "id": "cfg:(top-level)#method:entry:0:3:4", + "kind": "entry", + "line": 3 + }, + { + "id": "cfg:(top-level)#method:exit:1:3:19", + "kind": "exit", + "line": 3 } ], "decisions": [], "def_use": [], "dispatch_sites": [], "dominators": [ + {}, + {}, + {}, {}, {}, {}, @@ -171,6 +201,9 @@ } ], "liveness": [ + {}, + {}, + {}, {}, {}, {}, @@ -189,6 +222,11 @@ "id": "(top-level)#OtherFunc", "score": 0.0, "signals": {} + }, + { + "id": "(top-level)#method", + "score": 0.0, + "signals": {} } ], "local_methods": [ @@ -226,6 +264,15 @@ "name": "OtherFunc", "owner": "(top-level)", "statements": [] + }, + { + "boundaries": [], + "id": "(top-level)#method", + "line": 3, + "local_contract_assignments": {}, + "name": "method", + "owner": "(top-level)", + "statements": [] } ], "node_effects": [ @@ -245,6 +292,18 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [] @@ -298,6 +357,9 @@ } ], "reachability": [ + {}, + {}, + {}, {}, {}, {}, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/rust.json index 289b187ac..e4ae704b8 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/rust.json @@ -18,6 +18,11 @@ } ] }, + { + "boundaries": [], + "method": "method", + "statements": [] + }, { "boundaries": [], "method": "OtherFunc", @@ -87,11 +92,15 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, { "kind": "fallthrough" } ], "control_flow_metrics": [ + {}, {}, {} ], @@ -125,6 +134,16 @@ "id": "cfg:(top-level)#OtherFunc:exit:1:8:17", "kind": "exit", "line": 8 + }, + { + "id": "cfg:Local#method:entry:0:4:8", + "kind": "entry", + "line": 4 + }, + { + "id": "cfg:Local#method:exit:1:4:26", + "kind": "exit", + "line": 4 } ], "decisions": [], @@ -136,6 +155,8 @@ {}, {}, {}, + {}, + {}, {} ], "flow_types": [], @@ -168,6 +189,8 @@ {}, {}, {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -180,6 +203,11 @@ "id": "(top-level)#OtherFunc", "score": 0.0, "signals": {} + }, + { + "id": "Local#method", + "score": 0.0, + "signals": {} } ], "local_methods": [ @@ -233,6 +261,15 @@ "name": "OtherFunc", "owner": "(top-level)", "statements": [] + }, + { + "boundaries": [], + "id": "Local#method", + "line": 4, + "local_contract_assignments": {}, + "name": "method", + "owner": "Local", + "statements": [] } ], "node_effects": [ @@ -256,6 +293,14 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [] @@ -325,6 +370,8 @@ {}, {}, {}, + {}, + {}, {} ], "reaching_definitions": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/zig.json b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/zig.json index 257cda2d4..a2bf89dd4 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/body_owner/zig.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/body_owner/zig.json @@ -52,7 +52,7 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "return" }, { "kind": "fallthrough" @@ -74,8 +74,8 @@ "line": 5 }, { - "id": "cfg:(top-level)#MyFunc:stmt:1:4:4", - "kind": "statement", + "id": "cfg:(top-level)#MyFunc:return:0:4:4", + "kind": "jump", "line": 4 }, { diff --git a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/ruby.json index 9627375ee..4851a9411 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/ruby.json @@ -123,6 +123,18 @@ }, { "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "short_circuit" + }, + { + "kind": "short_circuit" } ], "control_flow_metrics": [ @@ -140,8 +152,13 @@ "line": 5 }, { - "id": "cfg:(top-level)#method_seven:stmt:1:2:2", + "id": "cfg:(top-level)#method_seven:stmt:0.then.0:3:4", "kind": "statement", + "line": 3 + }, + { + "id": "cfg:(top-level)#method_seven:stmt:1:2:2", + "kind": "branch", "line": 2 } ], @@ -170,6 +187,7 @@ ], "dispatch_sites": [], "dominators": [ + {}, {}, {}, {} @@ -193,6 +211,7 @@ } ], "liveness": [ + {}, {}, {}, {} @@ -265,13 +284,21 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_seven:local:x", "place:(top-level)#method_seven:local:y", "place:(top-level)#method_seven:local:z" ], - "writes": [] + "writes": [ + "place:(top-level)#method_seven:local:x", + "place:(top-level)#method_seven:local:y", + "place:(top-level)#method_seven:local:z" + ] } ], "owners": [], @@ -338,6 +365,7 @@ } ], "reachability": [ + {}, {}, {}, {} diff --git a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/rust.json index 26ac92001..2ee1f94fb 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/rust.json @@ -123,6 +123,18 @@ }, { "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "short_circuit" + }, + { + "kind": "short_circuit" } ], "control_flow_metrics": [ @@ -140,8 +152,13 @@ "line": 5 }, { - "id": "cfg:(top-level)#method_seven:stmt:1:2:4", + "id": "cfg:(top-level)#method_seven:stmt:0.then.0:3:8", "kind": "statement", + "line": 3 + }, + { + "id": "cfg:(top-level)#method_seven:stmt:1:2:4", + "kind": "branch", "line": 2 } ], @@ -170,6 +187,7 @@ ], "dispatch_sites": [], "dominators": [ + {}, {}, {}, {} @@ -193,6 +211,7 @@ } ], "liveness": [ + {}, {}, {}, {} @@ -265,6 +284,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_seven:local:x", @@ -338,6 +361,7 @@ } ], "reachability": [ + {}, {}, {}, {} diff --git a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/zig.json b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/zig.json index aa45c2bd1..7fc08dbe3 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/zig.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/boolean_short_circuit/zig.json @@ -136,6 +136,18 @@ }, { "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "short_circuit" + }, + { + "kind": "short_circuit" } ], "control_flow_metrics": [ @@ -153,9 +165,14 @@ "line": 5 }, { - "id": "cfg:bool#method_seven:stmt:1:2:4", + "id": "cfg:bool#method_seven:stmt:0.then.0:2:22", "kind": "statement", "line": 2 + }, + { + "id": "cfg:bool#method_seven:stmt:1:2:4", + "kind": "branch", + "line": 2 } ], "decisions": [ @@ -183,6 +200,7 @@ ], "dispatch_sites": [], "dominators": [ + {}, {}, {}, {} @@ -206,6 +224,7 @@ } ], "liveness": [ + {}, {}, {}, {} @@ -278,6 +297,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:bool#method_seven:local:x", @@ -344,6 +367,7 @@ } ], "reachability": [ + {}, {}, {}, {} diff --git a/gems/fact-mine/examples/source-facts/oracles/general/branch_nested_scope_refs/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/branch_nested_scope_refs/ruby.json index a82bb452b..e2b1e497a 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/branch_nested_scope_refs/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/branch_nested_scope_refs/ruby.json @@ -896,11 +896,35 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "continue" + }, { "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" }, { "kind": "branch_true" @@ -947,10 +971,30 @@ "line": 17 }, { - "id": "cfg:SourceFactBranchNestedScopeRefs#case_arms:stmt:1:11:4", + "id": "cfg:SourceFactBranchNestedScopeRefs#case_arms:stmt:0.case0.0:13:8", + "kind": "statement", + "line": 13 + }, + { + "id": "cfg:SourceFactBranchNestedScopeRefs#case_arms:stmt:0.default.0:15:8", "kind": "statement", + "line": 15 + }, + { + "id": "cfg:SourceFactBranchNestedScopeRefs#case_arms:stmt:1:11:4", + "kind": "case", "line": 11 }, + { + "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:branch:0.loop.0:21:6", + "kind": "branch", + "line": 21 + }, + { + "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:continue:0.loop.0.then.0:21:6", + "kind": "jump", + "line": 21 + }, { "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:entry:0:19:2", "kind": "entry", @@ -962,8 +1006,13 @@ "line": 25 }, { - "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:stmt:1:20:4", + "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:stmt:0.loop.1:23:6", "kind": "statement", + "line": 23 + }, + { + "id": "cfg:SourceFactBranchNestedScopeRefs#do_block_branch:stmt:1:20:4", + "kind": "loop", "line": 20 }, { @@ -1009,6 +1058,7 @@ {}, {}, {}, + {}, {} ], "dispatch_sites": [], @@ -1025,6 +1075,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -1035,6 +1090,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "functions": [ @@ -1080,6 +1138,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -1241,6 +1304,28 @@ ], "writes": [] }, + { + "reads": [ + "place:SourceFactBranchNestedScopeRefs#case_arms:local:edge" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactBranchNestedScopeRefs#case_arms:local:edge" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactBranchNestedScopeRefs#do_block_branch:local:component" + ], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [ @@ -1253,7 +1338,12 @@ }, { "reads": [ - "place:SourceFactBranchNestedScopeRefs#do_block_branch:local:component", + "place:SourceFactBranchNestedScopeRefs#do_block_branch:local:component" + ], + "writes": [] + }, + { + "reads": [ "place:SourceFactBranchNestedScopeRefs#do_block_branch:local:components" ], "writes": [ @@ -1481,6 +1571,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ @@ -1491,6 +1586,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "redundant_nil_guards": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/ruby.json index 03042f9b2..0fc0f15fb 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/ruby.json @@ -370,17 +370,44 @@ { "kind": "fallthrough" }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" + }, { "kind": "entry" }, { "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" } ], "control_flow_metrics": [ @@ -400,8 +427,13 @@ "line": 17 }, { - "id": "cfg:(top-level)#method_case_no_val:stmt:1:13:2", + "id": "cfg:(top-level)#method_case_no_val:stmt:0.case0.0:15:4", "kind": "statement", + "line": 15 + }, + { + "id": "cfg:(top-level)#method_case_no_val:stmt:1:13:2", + "kind": "case", "line": 13 }, { @@ -415,8 +447,13 @@ "line": 24 }, { - "id": "cfg:(top-level)#method_case_one_pattern:stmt:1:20:2", + "id": "cfg:(top-level)#method_case_one_pattern:stmt:0.case0.0:22:4", "kind": "statement", + "line": 22 + }, + { + "id": "cfg:(top-level)#method_case_one_pattern:stmt:1:20:2", + "kind": "case", "line": 20 }, { @@ -430,8 +467,23 @@ "line": 10 }, { - "id": "cfg:(top-level)#method_eight:stmt:1:2:2", + "id": "cfg:(top-level)#method_eight:stmt:0.case0.0:4:4", + "kind": "statement", + "line": 4 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:0.case1.0:6:4", + "kind": "statement", + "line": 6 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:0.default.0:8:4", "kind": "statement", + "line": 8 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:1:2:2", + "kind": "case", "line": 2 } ], @@ -469,6 +521,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -514,6 +571,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -636,11 +698,17 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_case_no_val:local:x" ], - "writes": [] + "writes": [ + "place:(top-level)#method_case_no_val:local:x" + ] }, { "reads": [], @@ -652,6 +720,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_case_one_pattern:local:x" @@ -668,6 +740,18 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_eight:local:x" @@ -847,6 +931,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/rust.json index 14b3ac08d..31327974f 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/rust.json @@ -304,17 +304,50 @@ { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" + }, { "kind": "entry" }, { "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" } ], "control_flow_metrics": [ @@ -334,8 +367,13 @@ "line": 13 }, { - "id": "cfg:(top-level)#method_case_no_val:stmt:1:10:4", + "id": "cfg:(top-level)#method_case_no_val:stmt:0.then.0:11:8", "kind": "statement", + "line": 11 + }, + { + "id": "cfg:(top-level)#method_case_no_val:stmt:1:10:4", + "kind": "branch", "line": 10 }, { @@ -349,8 +387,13 @@ "line": 20 }, { - "id": "cfg:(top-level)#method_case_one_pattern:stmt:1:16:4", + "id": "cfg:(top-level)#method_case_one_pattern:stmt:0.case0.0:17:13", "kind": "statement", + "line": 17 + }, + { + "id": "cfg:(top-level)#method_case_one_pattern:stmt:1:16:4", + "kind": "case", "line": 16 }, { @@ -364,8 +407,23 @@ "line": 7 }, { - "id": "cfg:(top-level)#method_eight:stmt:1:2:4", + "id": "cfg:(top-level)#method_eight:stmt:0.case0.0:3:17", + "kind": "statement", + "line": 3 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:0.case1.0:4:13", + "kind": "statement", + "line": 4 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:0.case2.0:5:13", "kind": "statement", + "line": 5 + }, + { + "id": "cfg:(top-level)#method_eight:stmt:1:2:4", + "kind": "case", "line": 2 } ], @@ -402,6 +460,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -447,6 +510,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -570,6 +638,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_case_no_val:local:x" @@ -586,6 +658,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_case_one_pattern:local:x" @@ -602,6 +678,18 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:(top-level)#method_eight:local:x" @@ -781,6 +869,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/zig.json b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/zig.json index c7e5e9f6b..f7108b45b 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/case_statements/zig.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/case_statements/zig.json @@ -379,17 +379,50 @@ { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" + }, { "kind": "entry" }, { "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_arm" + }, + { + "kind": "case_default" } ], "control_flow_metrics": [ @@ -409,10 +442,15 @@ "line": 13 }, { - "id": "cfg:i32#method_case_no_val:stmt:1:10:4", + "id": "cfg:i32#method_case_no_val:stmt:0.then.0:10:16", "kind": "statement", "line": 10 }, + { + "id": "cfg:i32#method_case_no_val:stmt:1:10:4", + "kind": "branch", + "line": 10 + }, { "id": "cfg:i32#method_case_one_pattern:entry:0:15:0", "kind": "entry", @@ -424,8 +462,13 @@ "line": 20 }, { - "id": "cfg:i32#method_case_one_pattern:stmt:1:16:4", + "id": "cfg:i32#method_case_one_pattern:stmt:0.case0.0:17:13", "kind": "statement", + "line": 17 + }, + { + "id": "cfg:i32#method_case_one_pattern:stmt:1:16:4", + "kind": "case", "line": 16 }, { @@ -439,8 +482,23 @@ "line": 7 }, { - "id": "cfg:i32#method_eight:stmt:1:2:4", + "id": "cfg:i32#method_eight:stmt:0.case0.0:3:16", + "kind": "statement", + "line": 3 + }, + { + "id": "cfg:i32#method_eight:stmt:0.case1.0:4:13", + "kind": "statement", + "line": 4 + }, + { + "id": "cfg:i32#method_eight:stmt:0.case2.0:5:16", "kind": "statement", + "line": 5 + }, + { + "id": "cfg:i32#method_eight:stmt:1:2:4", + "kind": "case", "line": 2 } ], @@ -493,6 +551,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -538,6 +601,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -661,6 +729,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:i32#method_case_no_val:local:x" @@ -677,6 +749,10 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:i32#method_case_one_pattern:local:x" @@ -693,6 +769,18 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:i32#method_eight:local:x" @@ -872,6 +960,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/command_strings/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/command_strings/rust.json index eb3e32013..2918c0541 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/command_strings/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/command_strings/rust.json @@ -22,14 +22,80 @@ "syntax": { "branch_arms": [], "branch_decisions": [], - "calls": [], + "calls": [ + { + "arguments": [ + "\"ls\"" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "method_four", + "line": 2, + "message": "new", + "receiver": "std::process::Command", + "safe_navigation": false + }, + { + "arguments": [], + "block": false, + "conditional": false, + "control": "always", + "function": "method_four", + "line": 2, + "message": "output", + "receiver": "std::process::Command.new(\"ls\")", + "safe_navigation": false + } + ], "clone_candidates": [ + { + "child_fingerprints": [ + "call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))" + ], + "child_masses": [ + 8 + ], + "fingerprint": "call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit)))", + "line": 2, + "mass": 9, + "method_name": "method_four", + "node_name": "call" + }, + { + "child_fingerprints": [ + "lit" + ], + "child_masses": [ + 1 + ], + "fingerprint": "argument_list(lit)", + "line": 2, + "mass": 2, + "method_name": "method_four", + "node_name": "argument_list" + }, + { + "child_fingerprints": [ + "scoped_identifier(scoped_identifier(id id) id)", + "argument_list(lit)" + ], + "child_masses": [ + 5, + 2 + ], + "fingerprint": "call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))", + "line": 2, + "mass": 8, + "method_name": "method_four", + "node_name": "call" + }, { "child_fingerprints": [], "child_masses": [], - "fingerprint": "method(id)", + "fingerprint": "method(id body(let_declaration(assignment(id call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit)))))))", "line": 1, - "mass": 1, + "mass": 13, "method_name": "method_four", "node_name": "defn" } @@ -71,7 +137,11 @@ {}, {} ], - "flow_types": [], + "flow_types": [ + {}, + {}, + {} + ], "functions": [ { "line": 1, @@ -133,13 +203,43 @@ "writes": [] }, { - "reads": [], - "writes": [] + "reads": [ + "place:(top-level)#method_four:local:Command", + "place:(top-level)#method_four:local:process", + "place:(top-level)#method_four:local:std" + ], + "writes": [ + "place:(top-level)#method_four:local:Command", + "place:(top-level)#method_four:local:_", + "place:(top-level)#method_four:local:process", + "place:(top-level)#method_four:local:std" + ] } ], "owners": [], "path_conditions": [], - "places": [], + "places": [ + { + "id": "place:(top-level)#method_four:local:Command", + "kind": "local", + "name": "Command" + }, + { + "id": "place:(top-level)#method_four:local:_", + "kind": "local", + "name": "_" + }, + { + "id": "place:(top-level)#method_four:local:process", + "kind": "local", + "name": "process" + }, + { + "id": "place:(top-level)#method_four:local:std", + "kind": "local", + "name": "std" + } + ], "predicate_bodies": [], "protocol_call_paths": [ { @@ -163,9 +263,26 @@ {}, {} ], - "reaching_definitions": [], + "reaching_definitions": [ + {}, + {}, + {} + ], "redundant_nil_guards": [], - "semantic_effects": [], + "semantic_effects": [ + { + "detail": "std::process::Command.new", + "function": "method_four", + "kind": "hidden_io", + "line": 2 + }, + { + "detail": "std::process::Command.new(\"ls\").output", + "function": "method_four", + "kind": "hidden_io", + "line": 2 + } + ], "state_declarations": [], "state_param_origins": [], "state_reads": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/exception_handling/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/exception_handling/rust.json index 716b6bfb3..e621ab156 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/exception_handling/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/exception_handling/rust.json @@ -27,6 +27,18 @@ "writes": [] } ] + }, + { + "boundaries": [], + "method": "", + "statements": [ + { + "co_uses": [], + "dependencies": [], + "reads": [], + "writes": [] + } + ] } ], "path_condition": { @@ -54,7 +66,7 @@ "block": false, "conditional": false, "control": "always", - "function": "method_two", + "function": "", "line": 3, "message": "Err", "receiver": "self", @@ -138,19 +150,6 @@ "method_name": "method_two", "node_name": "when" }, - { - "child_fingerprints": [ - "closure_expression(generic_type(id type_arguments(id)) body_statement(return(call(id argument_list(id)))))" - ], - "child_masses": [ - 10 - ], - "fingerprint": "call(id closure_expression(generic_type(id type_arguments(id)) body_statement(return(call(id argument_list(id))))))", - "line": 2, - "mass": 11, - "method_name": "method_two", - "node_name": "call" - }, { "child_fingerprints": [ "id", @@ -194,31 +193,31 @@ }, { "child_fingerprints": [ - "let_declaration(assignment(id call(id closure_expression(generic_type(id type_arguments(id)) body_statement(return(call(id argument_list(id))))))))", - "case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id))))", - "id" + "lambda(body(return(call(id argument_list(id)))))" ], "child_masses": [ - 13, - 10, - 1 + 6 ], - "fingerprint": "body_statement(let_declaration(assignment(id call(id closure_expression(generic_type(id type_arguments(id)) body_statement(return(call(id argument_list(id)))))))) case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id)))) id)", - "line": 1, - "mass": 25, + "fingerprint": "call(id lambda(body(return(call(id argument_list(id))))))", + "line": 2, + "mass": 7, "method_name": "method_two", - "node_name": "body_statement" + "node_name": "call" }, { "child_fingerprints": [ - "return(call(id argument_list(id)))" + "let_declaration(assignment(id call(id lambda(body(return(call(id argument_list(id))))))))", + "case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id))))", + "id" ], "child_masses": [ - 4 + 9, + 10, + 1 ], - "fingerprint": "body_statement(return(call(id argument_list(id))))", - "line": 2, - "mass": 5, + "fingerprint": "body_statement(let_declaration(assignment(id call(id lambda(body(return(call(id argument_list(id)))))))) case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id)))) id)", + "line": 1, + "mass": 21, "method_name": "method_two", "node_name": "body_statement" }, @@ -238,15 +237,21 @@ { "child_fingerprints": [], "child_masses": [], - "fingerprint": "method(id body(body_statement(let_declaration(assignment(id call(id closure_expression(generic_type(id type_arguments(id)) body_statement(return(call(id argument_list(id)))))))) case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id)))) id)))", + "fingerprint": "method(id body(body_statement(let_declaration(assignment(id call(id lambda(body(return(call(id argument_list(id)))))))) case(id when(argument_list(tuple_struct_pattern(id id)) call(id argument_list(id)))) id)))", "line": 1, - "mass": 27, + "mass": 23, "method_name": "method_two", "node_name": "defn" } ], "comparisons": [], "control_flow_edges": [ + { + "kind": "entry" + }, + { + "kind": "return" + }, { "kind": "entry" }, @@ -270,9 +275,25 @@ } ], "control_flow_metrics": [ + {}, {} ], "control_flow_nodes": [ + { + "id": "cfg:(top-level)#:entry:0:2:18", + "kind": "entry", + "line": 2 + }, + { + "id": "cfg:(top-level)#:exit:2:4:5", + "kind": "exit", + "line": 4 + }, + { + "id": "cfg:(top-level)#:return:0:3:8", + "kind": "jump", + "line": 3 + }, { "id": "cfg:(top-level)#method_two:entry:0:1:0", "kind": "entry", @@ -315,6 +336,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -329,6 +353,13 @@ "owner": "rust", "params": [], "visibility": "private" + }, + { + "line": 2, + "name": "", + "owner": "rust", + "params": [], + "visibility": "private" } ], "liveness": [ @@ -337,9 +368,17 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ + { + "id": "(top-level)#", + "score": 0.0, + "signals": {} + }, { "id": "(top-level)#method_two", "score": 1.5, @@ -349,6 +388,32 @@ } ], "local_methods": [ + { + "boundaries": [], + "id": "(top-level)#", + "line": 2, + "local_contract_assignments": {}, + "name": "", + "owner": "(top-level)", + "statements": [ + { + "co_uses": [], + "dependencies": [], + "end_line": 3, + "index": 0, + "line": 3, + "reads": [], + "source": "return Err(Error)", + "span": [ + 3, + 8, + 3, + 25 + ], + "writes": [] + } + ] + }, { "boundaries": [], "id": "(top-level)#method_two", @@ -425,14 +490,26 @@ }, { "reads": [ - "place:(top-level)#method_two:local:e" + "place:(top-level)#:local:Error" ], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [ - "place:(top-level)#method_two:local:Error" + "place:(top-level)#method_two:local:e" ], + "writes": [] + }, + { + "reads": [], "writes": [ "place:(top-level)#method_two:local:result" ] @@ -452,14 +529,14 @@ "path_conditions": [], "places": [ { - "id": "place:(top-level)#method_two:local:Error", + "id": "place:(top-level)#method_two:local:e", "kind": "local", - "name": "Error" + "name": "e" }, { - "id": "place:(top-level)#method_two:local:e", + "id": "place:(top-level)#:local:Error", "kind": "local", - "name": "e" + "name": "Error" }, { "id": "place:(top-level)#method_two:local:result", @@ -480,7 +557,14 @@ 3, 25 ] - }, + } + ], + "line": 2, + "name": "", + "owner": "rust" + }, + { + "calls": [ { "line": 6, "mid": "log", @@ -508,16 +592,6 @@ }, { "calls": [ - { - "line": 3, - "mid": "Err", - "span": [ - 3, - 15, - 3, - 25 - ] - }, { "line": 9, "mid": "cleanup", @@ -540,11 +614,19 @@ "name": "method_two", "owner": "rust", "reads": [ - "Err", "cleanup", "log" ], "writes": [] + }, + { + "line": 2, + "name": "", + "owner": "rust", + "reads": [ + "Err" + ], + "writes": [] } ], "reachability": [ @@ -553,6 +635,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/indexed_assignments/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/indexed_assignments/ruby.json index 49d6a810a..60968f16c 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/indexed_assignments/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/indexed_assignments/ruby.json @@ -56,6 +56,19 @@ "branch_arms": [], "branch_decisions": [], "calls": [ + { + "arguments": [ + "1" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "method_six", + "line": 3, + "message": "status=", + "receiver": "self.cache[index]", + "safe_navigation": false + }, { "arguments": [ "index" @@ -82,6 +95,19 @@ "receiver": "obj", "safe_navigation": false }, + { + "arguments": [ + "obj[key]" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "method_six", + "line": 2, + "message": "status=", + "receiver": "self.cache", + "safe_navigation": false + }, { "arguments": [], "block": false, @@ -563,7 +589,9 @@ "name": "method_six", "owner": "ruby", "reads": [ - "cache" + "cache", + "self.cache.status=", + "self.cache[index].status=" ], "writes": [ "cache", @@ -613,6 +641,18 @@ "function": "method_six", "line": 4, "receiver": "self" + }, + { + "field": "status=", + "function": "method_six", + "line": 2, + "receiver": "self.cache" + }, + { + "field": "status=", + "function": "method_six", + "line": 3, + "receiver": "self.cache[index]" } ], "state_writes": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/local_flow_edges/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/local_flow_edges/ruby.json index 9977fa24a..c230c3132 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/local_flow_edges/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/local_flow_edges/ruby.json @@ -217,6 +217,19 @@ "receiver": "self", "safe_navigation": false }, + { + "arguments": [ + "\"| #{key.join(\":\")} | #{audit.findings.size} |\"" + ], + "block": false, + "conditional": true, + "control": "iterates", + "function": "build", + "line": 16, + "message": "<<", + "receiver": "rows", + "safe_navigation": false + }, { "arguments": [ "audit.findings" diff --git a/gems/fact-mine/examples/source-facts/oracles/general/local_methods_contracts/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/local_methods_contracts/ruby.json index 1009cf92b..002647ca9 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/local_methods_contracts/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/local_methods_contracts/ruby.json @@ -102,6 +102,19 @@ } ], "calls": [ + { + "arguments": [ + "item.name" + ], + "block": false, + "conditional": true, + "control": "conditional", + "function": "process", + "line": 9, + "message": "<<", + "receiver": "names", + "safe_navigation": false + }, { "arguments": [], "block": false, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/locals_not_state/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/locals_not_state/ruby.json index 989ec64e6..f243b7ae8 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/locals_not_state/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/locals_not_state/ruby.json @@ -107,6 +107,20 @@ "branch_arms": [], "branch_decisions": [], "calls": [ + { + "arguments": [ + ":path", + "path" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "build", + "line": 11, + "message": "[]=", + "receiver": "config", + "safe_navigation": false + }, { "arguments": [ "key" diff --git a/gems/fact-mine/examples/source-facts/oracles/general/mutation_receiver_scope/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/mutation_receiver_scope/ruby.json index 2fe81ba99..cb37a25ee 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/mutation_receiver_scope/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/mutation_receiver_scope/ruby.json @@ -28,7 +28,21 @@ "syntax": { "branch_arms": [], "branch_decisions": [], - "calls": [], + "calls": [ + { + "arguments": [ + "item" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "append", + "line": 5, + "message": "<<", + "receiver": "items", + "safe_navigation": false + } + ], "clone_candidates": [ { "child_fingerprints": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/normalized_boolean_complexity/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/normalized_boolean_complexity/ruby.json index 796f7acc9..20e6bfe72 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/normalized_boolean_complexity/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/normalized_boolean_complexity/ruby.json @@ -259,6 +259,15 @@ }, { "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "short_circuit" } ], "control_flow_metrics": [ @@ -276,8 +285,13 @@ "line": 8 }, { - "id": "cfg:SourceFactNormalizedBooleanComplexity#eligible?:stmt:1:5:4", + "id": "cfg:SourceFactNormalizedBooleanComplexity#eligible?:stmt:0.then.0:6:6", "kind": "statement", + "line": 6 + }, + { + "id": "cfg:SourceFactNormalizedBooleanComplexity#eligible?:stmt:1:5:4", + "kind": "branch", "line": 5 } ], @@ -300,16 +314,19 @@ } ], "def_use": [ + {}, {}, {} ], "dispatch_sites": [], "dominators": [ + {}, {}, {}, {} ], "flow_types": [ + {}, {}, {} ], @@ -326,6 +343,7 @@ } ], "liveness": [ + {}, {}, {}, {} @@ -388,12 +406,21 @@ "reads": [], "writes": [] }, + { + "reads": [ + "place:SourceFactNormalizedBooleanComplexity#eligible?:local:cart" + ], + "writes": [] + }, { "reads": [ "place:SourceFactNormalizedBooleanComplexity#eligible?:local:cart", "place:SourceFactNormalizedBooleanComplexity#eligible?:local:user" ], - "writes": [] + "writes": [ + "place:SourceFactNormalizedBooleanComplexity#eligible?:local:cart", + "place:SourceFactNormalizedBooleanComplexity#eligible?:local:user" + ] } ], "owners": [ @@ -481,11 +508,13 @@ } ], "reachability": [ + {}, {}, {}, {} ], "reaching_definitions": [ + {}, {}, {} ], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/operator_mutation_locals/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/operator_mutation_locals/ruby.json index 1f8ea3173..fd64e091f 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/operator_mutation_locals/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/operator_mutation_locals/ruby.json @@ -45,6 +45,19 @@ "branch_arms": [], "branch_decisions": [], "calls": [ + { + "arguments": [ + "item" + ], + "block": false, + "conditional": true, + "control": "iterates", + "function": "append_all", + "line": 6, + "message": "<<", + "receiver": "out", + "safe_navigation": false + }, { "arguments": [], "block": true, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/path_condition_report/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/path_condition_report/ruby.json index 2285b2c66..18b6ab125 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/path_condition_report/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/path_condition_report/ruby.json @@ -833,35 +833,113 @@ ], "comparisons": [], "control_flow_edges": [ + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" } ], "control_flow_metrics": [ @@ -872,6 +950,11 @@ {} ], "control_flow_nodes": [ + { + "id": "cfg:SourceFactPathConditionReport#duplicate_guard:branch:0.then.0:55:6", + "kind": "branch", + "line": 55 + }, { "id": "cfg:SourceFactPathConditionReport#duplicate_guard:entry:0:53:2", "kind": "entry", @@ -883,10 +966,25 @@ "line": 59 }, { - "id": "cfg:SourceFactPathConditionReport#duplicate_guard:stmt:1:54:4", + "id": "cfg:SourceFactPathConditionReport#duplicate_guard:stmt:0.then.0.then.0:56:8", "kind": "statement", + "line": 56 + }, + { + "id": "cfg:SourceFactPathConditionReport#duplicate_guard:stmt:1:54:4", + "kind": "branch", "line": 54 }, + { + "id": "cfg:SourceFactPathConditionReport#first:branch:0.then.0.then.0:13:8", + "kind": "branch", + "line": 13 + }, + { + "id": "cfg:SourceFactPathConditionReport#first:branch:0.then.0:12:6", + "kind": "branch", + "line": 12 + }, { "id": "cfg:SourceFactPathConditionReport#first:entry:0:10:2", "kind": "entry", @@ -898,10 +996,20 @@ "line": 23 }, { - "id": "cfg:SourceFactPathConditionReport#first:stmt:1:11:4", + "id": "cfg:SourceFactPathConditionReport#first:stmt:0.then.0.then.0.then.0:14:10", "kind": "statement", + "line": 14 + }, + { + "id": "cfg:SourceFactPathConditionReport#first:stmt:1:11:4", + "kind": "branch", "line": 11 }, + { + "id": "cfg:SourceFactPathConditionReport#missing_enabled:branch:0.then.0:47:6", + "kind": "branch", + "line": 47 + }, { "id": "cfg:SourceFactPathConditionReport#missing_enabled:entry:0:45:2", "kind": "entry", @@ -913,10 +1021,25 @@ "line": 51 }, { - "id": "cfg:SourceFactPathConditionReport#missing_enabled:stmt:1:46:4", + "id": "cfg:SourceFactPathConditionReport#missing_enabled:stmt:0.then.0.then.0:48:8", "kind": "statement", + "line": 48 + }, + { + "id": "cfg:SourceFactPathConditionReport#missing_enabled:stmt:1:46:4", + "kind": "branch", "line": 46 }, + { + "id": "cfg:SourceFactPathConditionReport#second:branch:0.then.0.then.0:28:8", + "kind": "branch", + "line": 28 + }, + { + "id": "cfg:SourceFactPathConditionReport#second:branch:0.then.0:27:6", + "kind": "branch", + "line": 27 + }, { "id": "cfg:SourceFactPathConditionReport#second:entry:0:25:2", "kind": "entry", @@ -928,10 +1051,25 @@ "line": 33 }, { - "id": "cfg:SourceFactPathConditionReport#second:stmt:1:26:4", + "id": "cfg:SourceFactPathConditionReport#second:stmt:0.then.0.then.0.then.0:29:10", "kind": "statement", + "line": 29 + }, + { + "id": "cfg:SourceFactPathConditionReport#second:stmt:1:26:4", + "kind": "branch", "line": 26 }, + { + "id": "cfg:SourceFactPathConditionReport#third:branch:0.then.0.then.0:38:8", + "kind": "branch", + "line": 38 + }, + { + "id": "cfg:SourceFactPathConditionReport#third:branch:0.then.0:37:6", + "kind": "branch", + "line": 37 + }, { "id": "cfg:SourceFactPathConditionReport#third:entry:0:35:2", "kind": "entry", @@ -943,8 +1081,13 @@ "line": 43 }, { - "id": "cfg:SourceFactPathConditionReport#third:stmt:1:36:4", + "id": "cfg:SourceFactPathConditionReport#third:stmt:0.then.0.then.0.then.0:39:10", "kind": "statement", + "line": 39 + }, + { + "id": "cfg:SourceFactPathConditionReport#third:stmt:1:36:4", + "kind": "branch", "line": 36 } ], @@ -961,10 +1104,31 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "dispatch_sites": [], "dominators": [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {}, {}, {}, @@ -993,6 +1157,14 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "functions": [ @@ -1050,6 +1222,19 @@ } ], "liveness": [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {}, {}, {}, @@ -1307,6 +1492,12 @@ } ], "node_effects": [ + { + "reads": [ + "place:SourceFactPathConditionReport#duplicate_guard:local:user" + ], + "writes": [] + }, { "reads": [], "writes": [ @@ -1317,10 +1508,28 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactPathConditionReport#duplicate_guard:local:user" ], + "writes": [ + "place:SourceFactPathConditionReport#duplicate_guard:local:user" + ] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#first:local:enabled" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#first:local:feature" + ], "writes": [] }, { @@ -1335,12 +1544,26 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactPathConditionReport#first:local:enabled", "place:SourceFactPathConditionReport#first:local:feature", "place:SourceFactPathConditionReport#first:local:user" ], + "writes": [ + "place:SourceFactPathConditionReport#first:local:enabled", + "place:SourceFactPathConditionReport#first:local:feature", + "place:SourceFactPathConditionReport#first:local:user" + ] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#missing_enabled:local:feature" + ], "writes": [] }, { @@ -1354,11 +1577,30 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactPathConditionReport#missing_enabled:local:feature", "place:SourceFactPathConditionReport#missing_enabled:local:user" ], + "writes": [ + "place:SourceFactPathConditionReport#missing_enabled:local:feature", + "place:SourceFactPathConditionReport#missing_enabled:local:user" + ] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#second:local:enabled" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#second:local:feature" + ], "writes": [] }, { @@ -1373,12 +1615,32 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactPathConditionReport#second:local:enabled", "place:SourceFactPathConditionReport#second:local:feature", "place:SourceFactPathConditionReport#second:local:user" ], + "writes": [ + "place:SourceFactPathConditionReport#second:local:enabled", + "place:SourceFactPathConditionReport#second:local:feature", + "place:SourceFactPathConditionReport#second:local:user" + ] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#third:local:enabled" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactPathConditionReport#third:local:feature" + ], "writes": [] }, { @@ -1393,13 +1655,21 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactPathConditionReport#third:local:enabled", "place:SourceFactPathConditionReport#third:local:feature", "place:SourceFactPathConditionReport#third:local:user" ], - "writes": [] + "writes": [ + "place:SourceFactPathConditionReport#third:local:enabled", + "place:SourceFactPathConditionReport#third:local:feature", + "place:SourceFactPathConditionReport#third:local:user" + ] } ], "owners": [ @@ -1687,6 +1957,19 @@ } ], "reachability": [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {}, {}, {}, @@ -1715,6 +1998,14 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "redundant_nil_guards": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/protocols_nil_clone/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/protocols_nil_clone/ruby.json index 7792ef4fa..dedde9246 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/protocols_nil_clone/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/protocols_nil_clone/ruby.json @@ -1446,12 +1446,30 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, @@ -1473,6 +1491,12 @@ { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, @@ -1494,6 +1518,12 @@ { "kind": "fallthrough" }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, { "kind": "entry" }, @@ -1632,8 +1662,18 @@ "line": 49 }, { - "id": "cfg:SourceFactProtocolsNilClone#guard_both_terminate:stmt:1:44:4", + "id": "cfg:SourceFactProtocolsNilClone#guard_both_terminate:stmt:0.else.0:47:6", "kind": "statement", + "line": 47 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_both_terminate:stmt:0.then.0:45:6", + "kind": "statement", + "line": 45 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_both_terminate:stmt:1:44:4", + "kind": "branch", "line": 44 }, { @@ -1647,8 +1687,18 @@ "line": 57 }, { - "id": "cfg:SourceFactProtocolsNilClone#guard_else_terminate:stmt:1:52:4", + "id": "cfg:SourceFactProtocolsNilClone#guard_else_terminate:stmt:0.else.0:55:6", "kind": "statement", + "line": 55 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_else_terminate:stmt:0.then.0:53:6", + "kind": "statement", + "line": 53 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_else_terminate:stmt:1:52:4", + "kind": "branch", "line": 52 }, { @@ -1687,8 +1737,13 @@ "line": 34 }, { - "id": "cfg:SourceFactProtocolsNilClone#guard_ne_nil:stmt:1:31:4", + "id": "cfg:SourceFactProtocolsNilClone#guard_ne_nil:stmt:0.then.0:32:6", "kind": "statement", + "line": 32 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_ne_nil:stmt:1:31:4", + "kind": "branch", "line": 31 }, { @@ -1727,8 +1782,13 @@ "line": 63 }, { - "id": "cfg:SourceFactProtocolsNilClone#guard_safe_nav_cond:stmt:1:60:4", + "id": "cfg:SourceFactProtocolsNilClone#guard_safe_nav_cond:stmt:0.then.0:61:6", "kind": "statement", + "line": 61 + }, + { + "id": "cfg:SourceFactProtocolsNilClone#guard_safe_nav_cond:stmt:1:60:4", + "kind": "branch", "line": 60 }, { @@ -1785,6 +1845,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "dispatch_sites": [], @@ -1836,6 +1899,12 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -1855,6 +1924,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "functions": [ @@ -2011,6 +2083,12 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -2716,11 +2794,21 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactProtocolsNilClone#guard_both_terminate:local:value" ], - "writes": [] + "writes": [ + "place:SourceFactProtocolsNilClone#guard_both_terminate:local:value" + ] }, { "reads": [], @@ -2732,12 +2820,24 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [ "place:SourceFactProtocolsNilClone#guard_else_terminate:local:value" ], "writes": [] }, + { + "reads": [ + "place:SourceFactProtocolsNilClone#guard_else_terminate:local:value" + ], + "writes": [ + "place:SourceFactProtocolsNilClone#guard_else_terminate:local:value" + ] + }, { "reads": [], "writes": [ @@ -2780,6 +2880,14 @@ ], "writes": [] }, + { + "reads": [ + "place:SourceFactProtocolsNilClone#guard_ne_nil:local:value" + ], + "writes": [ + "place:SourceFactProtocolsNilClone#guard_ne_nil:local:value" + ] + }, { "reads": [], "writes": [ @@ -2822,6 +2930,14 @@ ], "writes": [] }, + { + "reads": [ + "place:SourceFactProtocolsNilClone#guard_safe_nav_cond:local:value" + ], + "writes": [ + "place:SourceFactProtocolsNilClone#guard_safe_nav_cond:local:value" + ] + }, { "reads": [], "writes": [ @@ -3343,6 +3459,12 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ @@ -3362,6 +3484,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "redundant_nil_guards": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/receiver_attribute_local_flow/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/receiver_attribute_local_flow/ruby.json index 0359abe40..edfd3df92 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/receiver_attribute_local_flow/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/receiver_attribute_local_flow/ruby.json @@ -99,6 +99,19 @@ "branch_arms": [], "branch_decisions": [], "calls": [ + { + "arguments": [ + "(row.risk * row.multiplier).round(4)" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "apply", + "line": 9, + "message": "risk=", + "receiver": "row", + "safe_navigation": false + }, { "arguments": [ "4" @@ -112,6 +125,32 @@ "receiver": "row.risk * row.multiplier", "safe_navigation": false }, + { + "arguments": [ + "fact.count" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "apply", + "line": 8, + "message": "count=", + "receiver": "row", + "safe_navigation": false + }, + { + "arguments": [ + "fact.summary" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "apply", + "line": 7, + "message": "status=", + "receiver": "row", + "safe_navigation": false + }, { "arguments": [ "row.file", @@ -887,11 +926,14 @@ "fact.count", "fact.summary", "facts.status_for", + "row.count=", "row.file", "row.multiplier", "row.name", "row.risk", - "row.risk * row.multiplier.round" + "row.risk * row.multiplier.round", + "row.risk=", + "row.status=" ], "writes": [ "repo", @@ -952,6 +994,12 @@ "line": 8, "receiver": "fact" }, + { + "field": "count=", + "function": "apply", + "line": 8, + "receiver": "row" + }, { "field": "file", "function": "apply", @@ -982,12 +1030,24 @@ "line": 9, "receiver": "row" }, + { + "field": "risk=", + "function": "apply", + "line": 9, + "receiver": "row" + }, { "field": "round", "function": "apply", "line": 9, "receiver": "row.risk * row.multiplier" }, + { + "field": "status=", + "function": "apply", + "line": 7, + "receiver": "row" + }, { "field": "status_for", "function": "apply", diff --git a/gems/fact-mine/examples/source-facts/oracles/general/rust_behavior/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/rust_behavior/rust.json index 2aec11244..f2c38efc1 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/rust_behavior/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/rust_behavior/rust.json @@ -248,7 +248,7 @@ "function": "update", "line": 19, "message": "ok", - "receiver": "std::fs::read_to_string.call(\"config\")", + "receiver": "std::fs.read_to_string(\"config\")", "safe_navigation": false } ], @@ -270,18 +270,18 @@ "child_fingerprints": [ "assignment(id id argument_list(id))", "call(id id)", - "call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit)))", + "call(id call(id scoped_identifier(id id) argument_list(lit)))", "macro_invocation(id token_tree(lit id))" ], "child_masses": [ 4, 2, - 9, + 7, 5 ], - "fingerprint": "body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id)))", + "fingerprint": "body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id)))", "line": 16, - "mass": 21, + "mass": 19, "method_name": "update", "node_name": "body_statement" }, @@ -289,18 +289,18 @@ "child_fingerprints": [ "assignment(id id argument_list(id))", "call(id id)", - "call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit)))", + "call(id call(id scoped_identifier(id id) argument_list(lit)))", "macro_invocation(id token_tree(lit id))" ], "child_masses": [ 4, 2, - 9, + 7, 5 ], - "fingerprint": "if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id))))", + "fingerprint": "if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id))))", "line": 16, - "mass": 26, + "mass": 24, "method_name": "update", "node_name": "if" }, @@ -349,14 +349,14 @@ }, { "child_fingerprints": [ - "call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))" + "call(id scoped_identifier(id id) argument_list(lit))" ], "child_masses": [ - 8 + 6 ], - "fingerprint": "call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit)))", + "fingerprint": "call(id call(id scoped_identifier(id id) argument_list(lit)))", "line": 19, - "mass": 9, + "mass": 7, "method_name": "update", "node_name": "call" }, @@ -470,26 +470,52 @@ }, { "child_fingerprints": [ - "let_declaration(assignment(id call(id scoped_identifier(id id))))", - "let_declaration(id generic_type(id type_arguments(id)))", + "id" + ], + "child_masses": [ + 1 + ], + "fingerprint": "call(id id)", + "line": 8, + "mass": 2, + "method_name": "update", + "node_name": "call" + }, + { + "child_fingerprints": [ + "id" + ], + "child_masses": [ + 1 + ], + "fingerprint": "call(id id)", + "line": 9, + "mass": 2, + "method_name": "update", + "node_name": "call" + }, + { + "child_fingerprints": [ + "let_declaration(assignment(id call(id id)))", + "let_declaration(assignment(id call(id id)))", "let_declaration(assignment(id id))", "if(call(id id) macro_invocation(id token_tree(lit)))", - "if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id))))", + "if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id))))", "call(id id argument_list(lit))", "call(id argument_list(id))" ], "child_masses": [ - 6, - 6, + 4, + 4, 3, 7, - 26, + 24, 4, 3 ], - "fingerprint": "body_statement(let_declaration(assignment(id call(id scoped_identifier(id id)))) let_declaration(id generic_type(id type_arguments(id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))", + "fingerprint": "body_statement(let_declaration(assignment(id call(id id))) let_declaration(assignment(id call(id id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))", "line": 7, - "mass": 56, + "mass": 50, "method_name": "update", "node_name": "body_statement" }, @@ -521,53 +547,40 @@ }, { "child_fingerprints": [ - "method(id body(body_statement(let_declaration(assignment(id call(id scoped_identifier(id id)))) let_declaration(id generic_type(id type_arguments(id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))))", + "method(id body(body_statement(let_declaration(assignment(id call(id id))) let_declaration(assignment(id call(id id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))))", "method(id body(call(id call(id id))))" ], "child_masses": [ - 58, + 52, 5 ], - "fingerprint": "body_statement(method(id body(body_statement(let_declaration(assignment(id call(id scoped_identifier(id id)))) let_declaration(id generic_type(id type_arguments(id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id))))) method(id body(call(id call(id id)))))", + "fingerprint": "body_statement(method(id body(body_statement(let_declaration(assignment(id call(id id))) let_declaration(assignment(id call(id id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id))))) method(id body(call(id call(id id)))))", "line": 6, - "mass": 64, + "mass": 58, "method_name": "(top-level)", "node_name": "body_statement" }, { "child_fingerprints": [ - "scoped_identifier(id id)" - ], - "child_masses": [ - 3 - ], - "fingerprint": "call(id scoped_identifier(id id))", - "line": 8, - "mass": 4, - "method_name": "update", - "node_name": "call" - }, - { - "child_fingerprints": [ - "scoped_identifier(scoped_identifier(id id) id)", + "scoped_identifier(id id)", "argument_list(lit)" ], "child_masses": [ - 5, + 3, 2 ], - "fingerprint": "call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))", + "fingerprint": "call(id scoped_identifier(id id) argument_list(lit))", "line": 19, - "mass": 8, + "mass": 6, "method_name": "update", "node_name": "call" }, { "child_fingerprints": [], "child_masses": [], - "fingerprint": "method(id body(body_statement(let_declaration(assignment(id call(id scoped_identifier(id id)))) let_declaration(id generic_type(id type_arguments(id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(scoped_identifier(id id) id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))))", + "fingerprint": "method(id body(body_statement(let_declaration(assignment(id call(id id))) let_declaration(assignment(id call(id id))) let_declaration(assignment(id id)) if(call(id id) macro_invocation(id token_tree(lit))) if(and(call(id id) id) body_statement(assignment(id id argument_list(id)) call(id id) call(id call(id scoped_identifier(id id) argument_list(lit))) macro_invocation(id token_tree(lit id)))) call(id id argument_list(lit)) call(id argument_list(id)))))", "line": 7, - "mass": 58, + "mass": 52, "method_name": "update", "node_name": "defn" }, @@ -787,7 +800,6 @@ {}, {}, {}, - {}, {} ], "functions": [ @@ -1085,8 +1097,7 @@ }, { "reads": [ - "place:RustSourceFactBehavior#update:local:String", - "place:RustSourceFactBehavior#update:local:new" + "place:RustSourceFactBehavior#update:local:String" ], "writes": [ "place:RustSourceFactBehavior#update:local:String", @@ -1095,7 +1106,7 @@ }, { "reads": [ - "place:RustSourceFactBehavior#update:local:methods" + "place:RustSourceFactBehavior#update:local:Vec" ], "writes": [ "place:RustSourceFactBehavior#update:local:methods" @@ -1128,7 +1139,6 @@ { "reads": [ "place:RustSourceFactBehavior#update:local:fs", - "place:RustSourceFactBehavior#update:local:read_to_string", "place:RustSourceFactBehavior#update:local:std" ], "writes": [ @@ -1157,7 +1167,9 @@ "writes": [] }, { - "reads": [], + "reads": [ + "place:RustSourceFactBehavior#update:local:result" + ], "writes": [] }, { @@ -1243,6 +1255,11 @@ "kind": "local", "name": "&mut self" }, + { + "id": "place:RustSourceFactBehavior#update:local:Vec", + "kind": "local", + "name": "Vec" + }, { "id": "place:RustSourceFactBehavior#update:local:enabled", "kind": "local", @@ -1253,11 +1270,6 @@ "kind": "local", "name": "input" }, - { - "id": "place:RustSourceFactBehavior#update:local:new", - "kind": "local", - "name": "new" - }, { "id": "place:RustSourceFactBehavior#update:local:panic", "kind": "local", @@ -1268,11 +1280,6 @@ "kind": "local", "name": "println" }, - { - "id": "place:RustSourceFactBehavior#update:local:read_to_string", - "kind": "local", - "name": "read_to_string" - }, { "id": "place:RustSourceFactBehavior#update:local:String", "kind": "local", @@ -1405,7 +1412,6 @@ {}, {}, {}, - {}, {} ], "redundant_nil_guards": [], @@ -1435,7 +1441,7 @@ "line": 19 }, { - "detail": "std::fs::read_to_string.call(\"config\").ok", + "detail": "std::fs.read_to_string(\"config\").ok", "function": "update", "kind": "hidden_io", "line": 19 diff --git a/gems/fact-mine/examples/source-facts/oracles/general/semantic_effects/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/semantic_effects/ruby.json index e362ffd75..fd5254e61 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/semantic_effects/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/semantic_effects/ruby.json @@ -207,6 +207,47 @@ "receiver": "self", "safe_navigation": false }, + { + "arguments": [ + ":name", + "value" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "mutate", + "line": 12, + "message": "[]=", + "receiver": "target", + "safe_navigation": false + }, + { + "arguments": [ + "item" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "add_item", + "line": 31, + "message": "<<", + "receiver": "items", + "safe_navigation": false + }, + { + "arguments": [ + "key", + "[]" + ], + "block": false, + "conditional": true, + "control": "iterates", + "function": "shape_hash", + "line": 18, + "message": "[]=", + "receiver": "hash", + "safe_navigation": false + }, { "arguments": [ "name" @@ -233,6 +274,19 @@ "receiver": "callback", "safe_navigation": false }, + { + "arguments": [ + "value" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "mutate", + "line": 13, + "message": "<<", + "receiver": "target.items", + "safe_navigation": false + }, { "arguments": [], "block": false, @@ -968,6 +1022,7 @@ {}, {}, {}, + {}, {} ], "dispatch_sites": [], @@ -1016,6 +1071,7 @@ {}, {}, {}, + {}, {} ], "functions": [ @@ -1522,6 +1578,7 @@ }, { "reads": [ + "place:SourceFactSemanticEffects#perform:local:callback", "place:SourceFactSemanticEffects#perform:local:name" ], "writes": [] @@ -1812,6 +1869,7 @@ {}, {}, {}, + {}, {} ], "redundant_nil_guards": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/ruby.json index 1af1d01d3..18c283e1b 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/ruby.json @@ -17,6 +17,16 @@ "writes": [] } ] + }, + { + "boundaries": [], + "method": "singleton_method", + "statements": [] + }, + { + "boundaries": [], + "method": "self_singleton_method", + "statements": [] } ], "path_condition": { @@ -91,11 +101,19 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, + { + "kind": "fallthrough" + }, { "kind": "fallthrough" } ], "control_flow_metrics": [ + {}, + {}, {} ], "control_flow_nodes": [ @@ -118,12 +136,36 @@ "id": "cfg:(top-level)#method_five:stmt:2:6:2", "kind": "statement", "line": 6 + }, + { + "id": "cfg:(top-level)#self_singleton_method:entry:0:7:4", + "kind": "entry", + "line": 7 + }, + { + "id": "cfg:(top-level)#self_singleton_method:exit:1:8:7", + "kind": "exit", + "line": 8 + }, + { + "id": "cfg:(top-level)#singleton_method:entry:0:3:4", + "kind": "entry", + "line": 3 + }, + { + "id": "cfg:(top-level)#singleton_method:exit:1:4:7", + "kind": "exit", + "line": 4 } ], "decisions": [], "def_use": [], "dispatch_sites": [], "dominators": [ + {}, + {}, + {}, + {}, {}, {}, {}, @@ -154,6 +196,10 @@ } ], "liveness": [ + {}, + {}, + {}, + {}, {}, {}, {}, @@ -164,6 +210,16 @@ "id": "(top-level)#method_five", "score": 0.0, "signals": {} + }, + { + "id": "(top-level)#self_singleton_method", + "score": 0.0, + "signals": {} + }, + { + "id": "(top-level)#singleton_method", + "score": 0.0, + "signals": {} } ], "local_methods": [ @@ -208,9 +264,43 @@ "writes": [] } ] + }, + { + "boundaries": [], + "id": "(top-level)#self_singleton_method", + "line": 7, + "local_contract_assignments": {}, + "name": "self_singleton_method", + "owner": "(top-level)", + "statements": [] + }, + { + "boundaries": [], + "id": "(top-level)#singleton_method", + "line": 3, + "local_contract_assignments": {}, + "name": "singleton_method", + "owner": "(top-level)", + "statements": [] } ], "node_effects": [ + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [] @@ -289,6 +379,10 @@ } ], "reachability": [ + {}, + {}, + {}, + {}, {}, {}, {}, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/rust.json index 2737b90c5..855ed36d7 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/rust.json @@ -17,6 +17,11 @@ "writes": [] } ] + }, + { + "boundaries": [], + "method": "singleton_method", + "statements": [] } ], "path_condition": { @@ -70,11 +75,15 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, { "kind": "fallthrough" } ], "control_flow_metrics": [ + {}, {} ], "control_flow_nodes": [ @@ -97,12 +106,24 @@ "id": "cfg:(top-level)#method_five:stmt:2:3:4", "kind": "statement", "line": 3 + }, + { + "id": "cfg:Obj#singleton_method:entry:0:4:8", + "kind": "entry", + "line": 4 + }, + { + "id": "cfg:Obj#singleton_method:exit:1:4:37", + "kind": "exit", + "line": 4 } ], "decisions": [], "def_use": [], "dispatch_sites": [], "dominators": [ + {}, + {}, {}, {}, {}, @@ -128,6 +149,8 @@ } ], "liveness": [ + {}, + {}, {}, {}, {}, @@ -138,6 +161,11 @@ "id": "(top-level)#method_five", "score": 0.0, "signals": {} + }, + { + "id": "Obj#singleton_method", + "score": 0.0, + "signals": {} } ], "local_methods": [ @@ -182,6 +210,15 @@ "writes": [] } ] + }, + { + "boundaries": [], + "id": "Obj#singleton_method", + "line": 4, + "local_contract_assignments": {}, + "name": "singleton_method", + "owner": "Obj", + "statements": [] } ], "node_effects": [ @@ -197,6 +234,16 @@ "reads": [], "writes": [] }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [ + "place:Obj#singleton_method:local:&self" + ] + }, { "reads": [], "writes": [] @@ -215,7 +262,13 @@ } ], "path_conditions": [], - "places": [], + "places": [ + { + "id": "place:Obj#singleton_method:local:&self", + "kind": "local", + "name": "&self" + } + ], "predicate_bodies": [], "protocol_call_paths": [ { @@ -248,6 +301,8 @@ } ], "reachability": [ + {}, + {}, {}, {}, {}, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/zig.json b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/zig.json index 4fa7c28a2..8355a0ab2 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/zig.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/singleton_classes/zig.json @@ -28,6 +28,11 @@ ] } ] + }, + { + "boundaries": [], + "method": "singleton_method", + "statements": [] } ], "path_condition": { @@ -81,11 +86,15 @@ { "kind": "fallthrough" }, + { + "kind": "fallthrough" + }, { "kind": "fallthrough" } ], "control_flow_metrics": [ + {}, {} ], "control_flow_nodes": [ @@ -108,6 +117,16 @@ "id": "cfg:(top-level)#method_five:stmt:2:5:4", "kind": "statement", "line": 5 + }, + { + "id": "cfg:Obj#singleton_method:entry:0:3:8", + "kind": "entry", + "line": 3 + }, + { + "id": "cfg:Obj#singleton_method:exit:1:3:37", + "kind": "exit", + "line": 3 } ], "decisions": [], @@ -116,6 +135,8 @@ ], "dispatch_sites": [], "dominators": [ + {}, + {}, {}, {}, {}, @@ -141,6 +162,8 @@ } ], "liveness": [ + {}, + {}, {}, {}, {}, @@ -151,6 +174,11 @@ "id": "(top-level)#method_five", "score": 0.0, "signals": {} + }, + { + "id": "Obj#singleton_method", + "score": 0.0, + "signals": {} } ], "local_methods": [ @@ -209,6 +237,15 @@ ] } ] + }, + { + "boundaries": [], + "id": "Obj#singleton_method", + "line": 3, + "local_contract_assignments": {}, + "name": "singleton_method", + "owner": "Obj", + "statements": [] } ], "node_effects": [ @@ -233,6 +270,14 @@ "writes": [ "place:(top-level)#method_five:local:_" ] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] } ], "owners": [ @@ -287,6 +332,8 @@ } ], "reachability": [ + {}, + {}, {}, {}, {}, diff --git a/gems/fact-mine/examples/source-facts/oracles/general/slopcop_parity_edges/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/slopcop_parity_edges/ruby.json index cba439eb8..64d5dcede 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/slopcop_parity_edges/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/slopcop_parity_edges/ruby.json @@ -1002,8 +1002,26 @@ { "kind": "entry" }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" + }, { "kind": "fallthrough" + }, + { + "kind": "loop_backedge" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" + }, + { + "kind": "short_circuit" } ], "control_flow_metrics": [ @@ -1093,8 +1111,23 @@ "line": 17 }, { - "id": "cfg:SourceFactSlopcopParityEdges#scan:stmt:1:12:4", + "id": "cfg:SourceFactSlopcopParityEdges#scan:loop:0.then.0:13:6", + "kind": "loop", + "line": 13 + }, + { + "id": "cfg:SourceFactSlopcopParityEdges#scan:stmt:0.else.0:15:6", "kind": "statement", + "line": 15 + }, + { + "id": "cfg:SourceFactSlopcopParityEdges#scan:stmt:0.then.0.loop.0:13:35", + "kind": "statement", + "line": 13 + }, + { + "id": "cfg:SourceFactSlopcopParityEdges#scan:stmt:1:12:4", + "kind": "branch", "line": 12 } ], @@ -1123,6 +1156,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "dispatch_sites": [], @@ -1143,6 +1179,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -1159,6 +1198,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "functions": [ @@ -1208,6 +1250,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -1501,7 +1546,9 @@ ] }, { - "reads": [], + "reads": [ + "place:SourceFactSlopcopParityEdges#report:local:x" + ], "writes": [] }, { @@ -1543,12 +1590,31 @@ }, { "reads": [ - "place:SourceFactSlopcopParityEdges#scan:local:path", "place:SourceFactSlopcopParityEdges#scan:local:paths" ], "writes": [ "place:SourceFactSlopcopParityEdges#scan:local:path" ] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [ + "place:SourceFactSlopcopParityEdges#scan:local:path" + ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactSlopcopParityEdges#scan:local:path", + "place:SourceFactSlopcopParityEdges#scan:local:paths" + ], + "writes": [ + "place:SourceFactSlopcopParityEdges#scan:local:path", + "place:SourceFactSlopcopParityEdges#scan:local:paths" + ] } ], "owners": [ @@ -1795,6 +1861,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ @@ -1811,6 +1880,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "redundant_nil_guards": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/state_read_chains_and_constants/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/state_read_chains_and_constants/ruby.json index 57667d874..80f3a7f0a 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/state_read_chains_and_constants/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/state_read_chains_and_constants/ruby.json @@ -94,6 +94,20 @@ "receiver": "ranked", "safe_navigation": false }, + { + "arguments": [ + "item.name", + "true" + ], + "block": false, + "conditional": true, + "control": "iterates", + "function": "helper_result", + "line": 20, + "message": "[]=", + "receiver": "out", + "safe_navigation": false + }, { "arguments": [ "items" @@ -146,6 +160,19 @@ "receiver": "Custom::Sarif", "safe_navigation": false }, + { + "arguments": [ + "{}" + ], + "block": true, + "conditional": false, + "control": "always", + "function": "helper_result", + "line": 19, + "message": "each_with_object", + "receiver": "decorate(items)", + "safe_navigation": false + }, { "arguments": [], "block": false, @@ -201,17 +228,6 @@ "receiver": "row", "safe_navigation": false }, - { - "arguments": [], - "block": true, - "conditional": false, - "control": "always", - "function": "helper_result", - "line": 19, - "message": "each_with_object", - "receiver": "decorate(items)", - "safe_navigation": false - }, { "arguments": [], "block": true, @@ -249,19 +265,6 @@ "method_name": "helper_result", "node_name": "call" }, - { - "child_fingerprints": [ - "call(id call(id argument_list(id)))" - ], - "child_masses": [ - 4 - ], - "fingerprint": "block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))", - "line": 19, - "mass": 15, - "method_name": "helper_result", - "node_name": "block" - }, { "child_fingerprints": [ "call(id call(id id argument_list(id)))" @@ -310,12 +313,25 @@ "child_masses": [ 3 ], - "fingerprint": "call(id call(id argument_list(id)))", + "fingerprint": "call(id call(id argument_list(id)))", "line": 19, "mass": 4, "method_name": "helper_result", "node_name": "call" }, + { + "child_fingerprints": [ + "call(id call(id argument_list(id)))" + ], + "child_masses": [ + 4 + ], + "fingerprint": "block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))", + "line": 19, + "mass": 15, + "method_name": "helper_result", + "node_name": "block" + }, { "child_fingerprints": [ "call(id colon2(id id) argument_list(id))", @@ -569,7 +585,7 @@ "method(id body(block(call(id call(id id argument_list(id))) body(parameters(id) call(id colon2(id id) argument_list(hash(lit call(id id))))))))", "method(id body(parameters(id) call(id id argument_list(id))))", "method(id body(block(call(id id) body(parameters(id) call(id id)))))", - "method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))))", + "method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))))", "method(id body(parameters(id) body_statement(call(id colon2(id id) argument_list(id)) call(id id))))" ], "child_masses": [ @@ -579,7 +595,7 @@ 19, 12 ], - "fingerprint": "body_statement(method(id body(block(call(id call(id id argument_list(id))) body(parameters(id) call(id colon2(id id) argument_list(hash(lit call(id id)))))))) method(id body(parameters(id) call(id id argument_list(id)))) method(id body(block(call(id id) body(parameters(id) call(id id))))) method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool)))))) method(id body(parameters(id) body_statement(call(id colon2(id id) argument_list(id)) call(id id)))))", + "fingerprint": "body_statement(method(id body(block(call(id call(id id argument_list(id))) body(parameters(id) call(id colon2(id id) argument_list(hash(lit call(id id)))))))) method(id body(parameters(id) call(id id argument_list(id)))) method(id body(block(call(id id) body(parameters(id) call(id id))))) method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool)))))) method(id body(parameters(id) body_statement(call(id colon2(id id) argument_list(id)) call(id id)))))", "line": 4, "mass": 69, "method_name": "(top-level)", @@ -606,7 +622,7 @@ { "child_fingerprints": [], "child_masses": [], - "fingerprint": "method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))))", + "fingerprint": "method(id body(parameters(id) block(call(id call(id argument_list(id))) body(parameters(id id) assignment<[]=>(lit id argument_list(call(id id) bool))))))", "line": 18, "mass": 19, "method_name": "helper_result", @@ -652,19 +668,37 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" }, { "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" }, { "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" } ], "control_flow_metrics": [ @@ -721,8 +755,13 @@ "line": 22 }, { - "id": "cfg:SourceFactStateReadChainsAndConstants#helper_result:stmt:1:19:4", + "id": "cfg:SourceFactStateReadChainsAndConstants#helper_result:stmt:0.loop.0:20:6", "kind": "statement", + "line": 20 + }, + { + "id": "cfg:SourceFactStateReadChainsAndConstants#helper_result:stmt:1:19:4", + "kind": "loop", "line": 19 }, { @@ -736,10 +775,15 @@ "line": 16 }, { - "id": "cfg:SourceFactStateReadChainsAndConstants#provider_paths:stmt:1:15:4", + "id": "cfg:SourceFactStateReadChainsAndConstants#provider_paths:stmt:0.loop.0:15:45", "kind": "statement", "line": 15 }, + { + "id": "cfg:SourceFactStateReadChainsAndConstants#provider_paths:stmt:1:15:4", + "kind": "loop", + "line": 15 + }, { "id": "cfg:SourceFactStateReadChainsAndConstants#results:entry:0:4:2", "kind": "entry", @@ -751,13 +795,22 @@ "line": 8 }, { - "id": "cfg:SourceFactStateReadChainsAndConstants#results:stmt:1:5:4", + "id": "cfg:SourceFactStateReadChainsAndConstants#results:stmt:0.loop.0:6:6", "kind": "statement", + "line": 6 + }, + { + "id": "cfg:SourceFactStateReadChainsAndConstants#results:stmt:1:5:4", + "kind": "loop", "line": 5 } ], "decisions": [], "def_use": [ + {}, + {}, + {}, + {}, {}, {}, {} @@ -779,6 +832,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "flow_types": [ @@ -851,6 +907,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -1090,9 +1149,14 @@ { "reads": [ "place:SourceFactStateReadChainsAndConstants#helper_result:local:item", - "place:SourceFactStateReadChainsAndConstants#helper_result:local:items", "place:SourceFactStateReadChainsAndConstants#helper_result:local:out" ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactStateReadChainsAndConstants#helper_result:local:items" + ], "writes": [ "place:SourceFactStateReadChainsAndConstants#helper_result:local:item", "place:SourceFactStateReadChainsAndConstants#helper_result:local:out" @@ -1110,6 +1174,10 @@ "reads": [ "place:SourceFactStateReadChainsAndConstants#provider_paths:local:provider" ], + "writes": [] + }, + { + "reads": [], "writes": [ "place:SourceFactStateReadChainsAndConstants#provider_paths:local:provider" ] @@ -1124,10 +1192,15 @@ }, { "reads": [ - "place:SourceFactStateReadChainsAndConstants#results:instance_field:@ranked", - "place:SourceFactStateReadChainsAndConstants#results:instance_field:@top", "place:SourceFactStateReadChainsAndConstants#results:local:row" ], + "writes": [] + }, + { + "reads": [ + "place:SourceFactStateReadChainsAndConstants#results:instance_field:@ranked", + "place:SourceFactStateReadChainsAndConstants#results:instance_field:@top" + ], "writes": [ "place:SourceFactStateReadChainsAndConstants#results:local:row" ] @@ -1308,6 +1381,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/ruby.json b/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/ruby.json index 37ff2f519..c874bd4a9 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/ruby.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/ruby.json @@ -123,13 +123,16 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "yield_return" }, { "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_exit" } ], "control_flow_metrics": [ @@ -149,7 +152,7 @@ }, { "id": "cfg:(top-level)#method_three:stmt:1:2:2", - "kind": "statement", + "kind": "callback", "line": 2 }, { @@ -164,7 +167,7 @@ }, { "id": "cfg:(top-level)#method_with_empty_block:stmt:1:6:2", - "kind": "statement", + "kind": "loop", "line": 6 } ], diff --git a/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/rust.json b/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/rust.json index 39bdc4771..90416d7cc 100644 --- a/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/rust.json +++ b/gems/fact-mine/examples/source-facts/oracles/general/yield_blocks/rust.json @@ -35,6 +35,11 @@ "writes": [] } ] + }, + { + "boundaries": [], + "method": "", + "statements": [] } ], "path_condition": { @@ -155,6 +160,9 @@ ], "comparisons": [], "control_flow_edges": [ + { + "kind": "fallthrough" + }, { "kind": "entry" }, @@ -168,14 +176,25 @@ "kind": "fallthrough" }, { - "kind": "fallthrough" + "kind": "callback_return" } ], "control_flow_metrics": [ + {}, {}, {} ], "control_flow_nodes": [ + { + "id": "cfg:(top-level)#:entry:0:7:24", + "kind": "entry", + "line": 7 + }, + { + "id": "cfg:(top-level)#:exit:1:7:30", + "kind": "exit", + "line": 7 + }, { "id": "cfg:(top-level)#method_three:entry:0:1:0", "kind": "entry", @@ -208,12 +227,14 @@ }, { "id": "cfg:(top-level)#method_with_empty_block:stmt:2:7:4", - "kind": "statement", + "kind": "callback", "line": 7 } ], "decisions": [], - "def_use": [], + "def_use": [ + {} + ], "dispatch_sites": [], "dominators": [ {}, @@ -222,9 +243,13 @@ {}, {}, {}, + {}, + {}, + {} + ], + "flow_types": [ {} ], - "flow_types": [], "functions": [ { "line": 1, @@ -241,6 +266,13 @@ "owner": "rust", "params": [], "visibility": "private" + }, + { + "line": 7, + "name": "", + "owner": "rust", + "params": [], + "visibility": "private" } ], "liveness": [ @@ -250,9 +282,16 @@ {}, {}, {}, + {}, + {}, {} ], "local_complexity_scores": [ + { + "id": "(top-level)#", + "score": 0.0, + "signals": {} + }, { "id": "(top-level)#method_three", "score": 0.0, @@ -265,6 +304,15 @@ } ], "local_methods": [ + { + "boundaries": [], + "id": "(top-level)#", + "line": 7, + "local_contract_assignments": {}, + "name": "", + "owner": "(top-level)", + "statements": [] + }, { "boundaries": [], "id": "(top-level)#method_three", @@ -343,6 +391,14 @@ } ], "node_effects": [ + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [ @@ -372,7 +428,9 @@ ] }, { - "reads": [], + "reads": [ + "place:(top-level)#method_with_empty_block:local:arr" + ], "writes": [] } ], @@ -398,6 +456,12 @@ "name": "method_with_empty_block", "owner": "rust" }, + { + "calls": [], + "line": 7, + "name": "", + "owner": "rust" + }, { "calls": [ { @@ -432,6 +496,13 @@ "owner": "rust", "reads": [], "writes": [] + }, + { + "line": 7, + "name": "", + "owner": "rust", + "reads": [], + "writes": [] } ], "reachability": [ @@ -441,9 +512,13 @@ {}, {}, {}, + {}, + {}, + {} + ], + "reaching_definitions": [ {} ], - "reaching_definitions": [], "redundant_nil_guards": [], "semantic_effects": [], "state_declarations": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-block_constant_effects.json b/gems/fact-mine/examples/source-facts/oracles/ruby-block_constant_effects.json index 10a098809..9f5f6bf8b 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-block_constant_effects.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-block_constant_effects.json @@ -381,7 +381,13 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" } ], "control_flow_metrics": [ @@ -420,13 +426,20 @@ "line": 9 }, { - "id": "cfg:SourceFactBlockConstantEffects#resolve:stmt:1:7:4", + "id": "cfg:SourceFactBlockConstantEffects#resolve:stmt:0.loop.0:8:34", "kind": "statement", + "line": 8 + }, + { + "id": "cfg:SourceFactBlockConstantEffects#resolve:stmt:1:7:4", + "kind": "loop", "line": 7 } ], "decisions": [], "def_use": [ + {}, + {}, {}, {} ], @@ -438,6 +451,7 @@ {}, {}, {}, + {}, {} ], "flow_types": [ @@ -474,6 +488,7 @@ {}, {}, {}, + {}, {} ], "local_complexity_scores": [ @@ -601,7 +616,12 @@ }, { "reads": [ - "place:SourceFactBlockConstantEffects#resolve:local:candidate", + "place:SourceFactBlockConstantEffects#resolve:local:candidate" + ], + "writes": [] + }, + { + "reads": [ "place:SourceFactBlockConstantEffects#resolve:local:path", "place:SourceFactBlockConstantEffects#resolve:local:rel" ], @@ -679,6 +699,7 @@ {}, {}, {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-branch_qualified_predicates.json b/gems/fact-mine/examples/source-facts/oracles/ruby-branch_qualified_predicates.json index 7d560452d..9c16aeba4 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-branch_qualified_predicates.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-branch_qualified_predicates.json @@ -185,6 +185,28 @@ "receiver": "::File", "safe_navigation": false }, + { + "arguments": [], + "block": false, + "conditional": true, + "control": "conditional", + "function": "checks", + "line": 5, + "message": "enabled?", + "receiver": "External::Risk", + "safe_navigation": false + }, + { + "arguments": [], + "block": false, + "conditional": true, + "control": "conditional", + "function": "checks", + "line": 6, + "message": "load_config", + "receiver": "External::Risk", + "safe_navigation": false + }, { "arguments": [], "block": false, diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-implicit_self_chain_state_reads.json b/gems/fact-mine/examples/source-facts/oracles/ruby-implicit_self_chain_state_reads.json index 3e1c7128f..ecded3df7 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-implicit_self_chain_state_reads.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-implicit_self_chain_state_reads.json @@ -653,7 +653,13 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "loop_backedge" + }, + { + "kind": "loop_body" + }, + { + "kind": "loop_exit" }, { "kind": "entry" @@ -741,10 +747,15 @@ "line": 15 }, { - "id": "cfg:SourceFactImplicitSelfChainStateReads#owner_nodes:stmt:1:14:4", + "id": "cfg:SourceFactImplicitSelfChainStateReads#owner_nodes:stmt:0.loop.0:14:26", "kind": "statement", "line": 14 }, + { + "id": "cfg:SourceFactImplicitSelfChainStateReads#owner_nodes:stmt:1:14:4", + "kind": "loop", + "line": 14 + }, { "id": "cfg:SourceFactImplicitSelfChainStateReads#self.build:entry:0:4:2", "kind": "entry", @@ -767,6 +778,7 @@ {}, {}, {}, + {}, {} ], "dispatch_sites": [], @@ -788,6 +800,7 @@ {}, {}, {}, + {}, {} ], "flow_types": [ @@ -860,6 +873,7 @@ {}, {}, {}, + {}, {} ], "local_complexity_scores": [ @@ -1126,6 +1140,10 @@ "reads": [ "place:SourceFactImplicitSelfChainStateReads#owner_nodes:local:node" ], + "writes": [] + }, + { + "reads": [], "writes": [ "place:SourceFactImplicitSelfChainStateReads#owner_nodes:local:node" ] @@ -1345,6 +1363,7 @@ {}, {}, {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-indexed_state_reads.json b/gems/fact-mine/examples/source-facts/oracles/ruby-indexed_state_reads.json index 986dcddd4..5f325c2fb 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-indexed_state_reads.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-indexed_state_reads.json @@ -106,6 +106,19 @@ "receiver": "lineage", "safe_navigation": false }, + { + "arguments": [ + "\"- [Lineage Unit Risk (#{@lineage[:units].size})]\"" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "lineage_summary", + "line": 11, + "message": "<<", + "receiver": "out", + "safe_navigation": false + }, { "arguments": [], "block": false, @@ -366,7 +379,13 @@ "kind": "entry" }, { - "kind": "fallthrough" + "kind": "return" + }, + { + "kind": "branch_false" + }, + { + "kind": "branch_true" }, { "kind": "entry" @@ -390,9 +409,14 @@ "kind": "exit", "line": 8 }, + { + "id": "cfg:SourceFactIndexedStateReads#label:return:0.then.0:5:4", + "kind": "jump", + "line": 5 + }, { "id": "cfg:SourceFactIndexedStateReads#label:stmt:1:5:4", - "kind": "statement", + "kind": "branch", "line": 5 }, { @@ -422,9 +446,11 @@ {}, {}, {}, + {}, {} ], "flow_types": [ + {}, {}, {}, {} @@ -453,6 +479,7 @@ {}, {}, {}, + {}, {} ], "local_complexity_scores": [ @@ -541,6 +568,12 @@ ], "writes": [] }, + { + "reads": [ + "place:SourceFactIndexedStateReads#label:instance_field:@data" + ], + "writes": [] + }, { "reads": [], "writes": [ @@ -631,9 +664,11 @@ {}, {}, {}, + {}, {} ], "reaching_definitions": [ + {}, {}, {}, {} diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-memoized_helper_calls.json b/gems/fact-mine/examples/source-facts/oracles/ruby-memoized_helper_calls.json index 58565fdb3..9ce3a7aaf 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-memoized_helper_calls.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-memoized_helper_calls.json @@ -298,17 +298,19 @@ "writes": [] }, { - "reads": [], - "writes": [] + "reads": [ + "place:SourceFactMemoizedHelperCalls#owner_edges:instance_field:@owner_edges" + ], + "writes": [ + "place:SourceFactMemoizedHelperCalls#owner_edges:instance_field:@owner_edges" + ] }, { "reads": [], "writes": [] }, { - "reads": [ - "place:SourceFactMemoizedHelperCalls#owner_edges:instance_field:@owner_edges" - ], + "reads": [], "writes": [] } ], diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-ruby_coverage.json b/gems/fact-mine/examples/source-facts/oracles/ruby-ruby_coverage.json index d98b9dc59..985a2a145 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-ruby_coverage.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-ruby_coverage.json @@ -161,6 +161,11 @@ "writes": [] } ] + }, + { + "boundaries": [], + "method": "class_method", + "statements": [] } ], "path_condition": { @@ -1500,6 +1505,9 @@ } ], "control_flow_edges": [ + { + "kind": "fallthrough" + }, { "kind": "entry" }, @@ -1637,9 +1645,20 @@ {}, {}, {}, + {}, {} ], "control_flow_nodes": [ + { + "id": "cfg:RubyCoverageParent::RubyCoverageChild#class_method:entry:0:66:8", + "kind": "entry", + "line": 66 + }, + { + "id": "cfg:RubyCoverageParent::RubyCoverageChild#class_method:exit:1:68:11", + "kind": "exit", + "line": 68 + }, { "id": "cfg:RubyCoverageParent::RubyCoverageChild#initialize:entry:0:13:4", "kind": "entry", @@ -1881,6 +1900,8 @@ {}, {}, {}, + {}, + {}, {} ], "flow_types": [ @@ -1973,9 +1994,16 @@ {}, {}, {}, + {}, + {}, {} ], "local_complexity_scores": [ + { + "id": "RubyCoverageParent::RubyCoverageChild#class_method", + "score": 0.0, + "signals": {} + }, { "id": "RubyCoverageParent::RubyCoverageChild#initialize", "score": 0.0, @@ -2008,6 +2036,15 @@ } ], "local_methods": [ + { + "boundaries": [], + "id": "RubyCoverageParent::RubyCoverageChild#class_method", + "line": 66, + "local_contract_assignments": {}, + "name": "class_method", + "owner": "RubyCoverageParent::RubyCoverageChild", + "statements": [] + }, { "boundaries": [], "id": "RubyCoverageParent::RubyCoverageChild#initialize", @@ -2360,6 +2397,14 @@ } ], "node_effects": [ + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [ @@ -2482,7 +2527,9 @@ }, { "reads": [], - "writes": [] + "writes": [ + "place:RubyCoverageParent::RubyCoverageChild#some_call:local:e" + ] }, { "reads": [], @@ -2803,6 +2850,8 @@ {}, {}, {}, + {}, + {}, {} ], "reaching_definitions": [ diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-sequence_call_edges.json b/gems/fact-mine/examples/source-facts/oracles/ruby-sequence_call_edges.json index 017bdb8eb..3fb09217f 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-sequence_call_edges.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-sequence_call_edges.json @@ -1862,6 +1862,12 @@ { "kind": "fallthrough" }, + { + "kind": "callback_return" + }, + { + "kind": "callback_body" + }, { "kind": "entry" }, @@ -1917,8 +1923,18 @@ "line": 13 }, { - "id": "cfg:SourceFactSequenceCallEdges#assertion_single_call_argument:stmt:1:9:4", + "id": "cfg:SourceFactSequenceCallEdges#assertion_single_call_argument:stmt:0.callback.0:10:6", + "kind": "statement", + "line": 10 + }, + { + "id": "cfg:SourceFactSequenceCallEdges#assertion_single_call_argument:stmt:0.callback.1:11:6", "kind": "statement", + "line": 11 + }, + { + "id": "cfg:SourceFactSequenceCallEdges#assertion_single_call_argument:stmt:1:9:4", + "kind": "callback", "line": 9 }, { @@ -1980,6 +1996,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "dispatch_sites": [], @@ -1998,6 +2017,8 @@ {}, {}, {}, + {}, + {}, {} ], "flow_types": [ @@ -2016,6 +2037,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "functions": [ @@ -2081,6 +2107,8 @@ {}, {}, {}, + {}, + {}, {} ], "local_complexity_scores": [ @@ -2543,12 +2571,21 @@ "reads": [ "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:coverage", "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:dir", - "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:file", + "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:file" + ], + "writes": [] + }, + { + "reads": [ "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:path", "place:SourceFactSequenceCallEdges#assertion_single_call_argument:local:v" ], "writes": [] }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [ @@ -2602,15 +2639,23 @@ "writes": [] }, { - "reads": [], + "reads": [ + "place:SourceFactSequenceCallEdges#symbol_proc_maps:local:arms" + ], "writes": [] }, { - "reads": [], + "reads": [ + "place:SourceFactSequenceCallEdges#symbol_proc_maps:local:f", + "place:SourceFactSequenceCallEdges#symbol_proc_maps:local:rsf" + ], "writes": [] }, { - "reads": [], + "reads": [ + "place:SourceFactSequenceCallEdges#symbol_proc_maps:local:f", + "place:SourceFactSequenceCallEdges#symbol_proc_maps:local:rsf" + ], "writes": [] } ], @@ -2925,6 +2970,8 @@ {}, {}, {}, + {}, + {}, {} ], "reaching_definitions": [ @@ -2943,6 +2990,11 @@ {}, {}, {}, + {}, + {}, + {}, + {}, + {}, {} ], "redundant_nil_guards": [], diff --git a/gems/fact-mine/examples/source-facts/oracles/ruby-visibility.json b/gems/fact-mine/examples/source-facts/oracles/ruby-visibility.json index 6fd7c8423..798b7a68a 100644 --- a/gems/fact-mine/examples/source-facts/oracles/ruby-visibility.json +++ b/gems/fact-mine/examples/source-facts/oracles/ruby-visibility.json @@ -25,6 +25,18 @@ ] } ] + }, + { + "boundaries": [], + "method": "inline_guard", + "statements": [ + { + "co_uses": [], + "dependencies": [], + "reads": [], + "writes": [] + } + ] } ], "path_condition": { @@ -198,15 +210,37 @@ { "kind": "entry" }, + { + "kind": "fallthrough" + }, + { + "kind": "entry" + }, { "kind": "fallthrough" } ], "control_flow_metrics": [ + {}, {}, {} ], "control_flow_nodes": [ + { + "id": "cfg:SourceFactVisibility#inline_guard:entry:0:14:12", + "kind": "entry", + "line": 14 + }, + { + "id": "cfg:SourceFactVisibility#inline_guard:exit:2:16:5", + "kind": "exit", + "line": 16 + }, + { + "id": "cfg:SourceFactVisibility#inline_guard:stmt:1:15:4", + "kind": "statement", + "line": 15 + }, { "id": "cfg:SourceFactVisibility#prepare:entry:0:10:2", "kind": "entry", @@ -247,6 +281,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "flow_types": [], @@ -279,9 +316,17 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "local_complexity_scores": [ + { + "id": "SourceFactVisibility#inline_guard", + "score": 0.0, + "signals": {} + }, { "id": "SourceFactVisibility#prepare", "score": 0.0, @@ -294,6 +339,32 @@ } ], "local_methods": [ + { + "boundaries": [], + "id": "SourceFactVisibility#inline_guard", + "line": 14, + "local_contract_assignments": {}, + "name": "inline_guard", + "owner": "SourceFactVisibility", + "statements": [ + { + "co_uses": [], + "dependencies": [], + "end_line": 15, + "index": 0, + "line": 15, + "reads": [], + "source": "true", + "span": [ + 15, + 4, + 15, + 8 + ], + "writes": [] + } + ] + }, { "boundaries": [], "id": "SourceFactVisibility#prepare", @@ -350,6 +421,18 @@ } ], "node_effects": [ + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, + { + "reads": [], + "writes": [] + }, { "reads": [], "writes": [ @@ -464,6 +547,9 @@ {}, {}, {}, + {}, + {}, + {}, {} ], "reaching_definitions": [], diff --git a/gems/fact-mine/examples/syntax-facts/oracles/go-core.json b/gems/fact-mine/examples/syntax-facts/oracles/go-core.json index 9437cef99..0eb4b3945 100644 --- a/gems/fact-mine/examples/syntax-facts/oracles/go-core.json +++ b/gems/fact-mine/examples/syntax-facts/oracles/go-core.json @@ -504,45 +504,6 @@ 17 ] }, - { - "field": "audit", - "function": "Process", - "line": 66, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 66, - 4, - 66, - 11 - ] - }, - { - "field": "audit", - "function": "Process", - "line": 67, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 67, - 7, - 67, - 14 - ] - }, - { - "field": "children", - "function": "Process", - "line": 62, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 62, - 2, - 62, - 12 - ] - }, { "field": "count", "function": "Process", @@ -569,45 +530,6 @@ 15 ] }, - { - "field": "defaultCase", - "function": "Process", - "line": 51, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 51, - 2, - 51, - 15 - ] - }, - { - "field": "escalate", - "function": "Process", - "line": 47, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 47, - 2, - 47, - 12 - ] - }, - { - "field": "fallback", - "function": "Process", - "line": 49, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 49, - 2, - 49, - 12 - ] - }, { "field": "lookup", "function": "Process", @@ -621,32 +543,6 @@ 9 ] }, - { - "field": "publish", - "function": "Process", - "line": 56, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 56, - 2, - 56, - 11 - ] - }, - { - "field": "send", - "function": "audit", - "line": 74, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 74, - 1, - 74, - 7 - ] - }, { "field": "status", "function": "Process", @@ -672,19 +568,6 @@ 75, 13 ] - }, - { - "field": "warn", - "function": "Process", - "line": 58, - "owner": "GoSyntaxFactsCore", - "receiver": "self", - "span": [ - 58, - 2, - 58, - 8 - ] } ], "state_writes": [ diff --git a/gems/fact-mine/examples/syntax-facts/oracles/java-core.json b/gems/fact-mine/examples/syntax-facts/oracles/java-core.json index 7d966fabc..15af65321 100644 --- a/gems/fact-mine/examples/syntax-facts/oracles/java-core.json +++ b/gems/fact-mine/examples/syntax-facts/oracles/java-core.json @@ -139,6 +139,27 @@ 26 ] }, + { + "arguments": [ + "name", + "user.active()" + ], + "block": false, + "conditional": false, + "control": "always", + "function": "process", + "line": 14, + "message": "call", + "owner": "JavaSyntaxFactsCore", + "receiver": "Account", + "safe_navigation": false, + "span": [ + 14, + 22, + 14, + 54 + ] + }, { "arguments": [ "name" diff --git a/gems/fact-mine/examples/syntax-facts/oracles/kotlin-core.json b/gems/fact-mine/examples/syntax-facts/oracles/kotlin-core.json index 43ecec581..a06a9578b 100644 --- a/gems/fact-mine/examples/syntax-facts/oracles/kotlin-core.json +++ b/gems/fact-mine/examples/syntax-facts/oracles/kotlin-core.json @@ -283,6 +283,22 @@ "dispatch_sites": [], "file": "gems/fact-mine/examples/syntax-facts/kotlin/core.kt", "functions": [ + { + "line": 3, + "name": "KotlinSyntaxFactsCore", + "owner": "KotlinSyntaxFactsCore", + "params": [ + "status", + "sink" + ], + "span": [ + 3, + 6, + 4, + 25 + ], + "visibility": "public" + }, { "line": 31, "name": "audit", @@ -311,6 +327,19 @@ ], "visibility": "public" }, + { + "line": 42, + "name": "Status", + "owner": "Status", + "params": [], + "span": [ + 42, + 11, + 45, + 1 + ], + "visibility": "public" + }, { "line": 6, "name": "process", diff --git a/gems/fact-mine/examples/syntax-facts/oracles/typescript-cfg_callbacks.json b/gems/fact-mine/examples/syntax-facts/oracles/typescript-cfg_callbacks.json index 7450426e8..5e36124bc 100644 --- a/gems/fact-mine/examples/syntax-facts/oracles/typescript-cfg_callbacks.json +++ b/gems/fact-mine/examples/syntax-facts/oracles/typescript-cfg_callbacks.json @@ -2,6 +2,118 @@ "documents": [ { "control_flow_edges": [ + { + "from": "cfg:CfgCallbacks#:entry:0:10:18", + "function": "", + "kind": "entry", + "line": 10, + "owner": "CfgCallbacks", + "span": [ + 10, + 18, + 14, + 5 + ], + "to": "cfg:CfgCallbacks#:stmt:1:11:6" + }, + { + "from": "cfg:CfgCallbacks#:stmt:0.callback.0:12:8", + "function": "", + "kind": "callback_return", + "line": 12, + "owner": "CfgCallbacks", + "span": [ + 12, + 8, + 12, + 24 + ], + "to": "cfg:CfgCallbacks#:exit:2:14:5" + }, + { + "from": "cfg:CfgCallbacks#:stmt:1:11:6", + "function": "", + "kind": "callback_body", + "line": 11, + "owner": "CfgCallbacks", + "span": [ + 11, + 6, + 13, + 8 + ], + "to": "cfg:CfgCallbacks#:stmt:0.callback.0:12:8" + }, + { + "from": "cfg:CfgCallbacks#:entry:0:11:16", + "function": "", + "kind": "entry", + "line": 11, + "owner": "CfgCallbacks", + "span": [ + 11, + 16, + 13, + 7 + ], + "to": "cfg:CfgCallbacks#:stmt:1:12:8" + }, + { + "from": "cfg:CfgCallbacks#:stmt:1:12:8", + "function": "", + "kind": "fallthrough", + "line": 12, + "owner": "CfgCallbacks", + "span": [ + 12, + 8, + 12, + 24 + ], + "to": "cfg:CfgCallbacks#:exit:2:13:7" + }, + { + "from": "cfg:CfgCallbacks#:entry:0:19:18", + "function": "", + "kind": "fallthrough", + "line": 19, + "owner": "CfgCallbacks", + "span": [ + 19, + 18, + 19, + 26 + ], + "to": "cfg:CfgCallbacks#:exit:1:19:26" + }, + { + "from": "cfg:CfgCallbacks#:entry:0:3:18", + "function": "", + "kind": "entry", + "line": 3, + "owner": "CfgCallbacks", + "span": [ + 3, + 18, + 5, + 5 + ], + "to": "cfg:CfgCallbacks#:stmt:1:4:6" + }, + { + "from": "cfg:CfgCallbacks#:stmt:1:4:6", + "function": "", + "kind": "fallthrough", + "line": 4, + "owner": "CfgCallbacks", + "span": [ + 4, + 6, + 4, + 22 + ], + "to": "cfg:CfgCallbacks#:exit:2:5:5" + }, { "from": "cfg:CfgCallbacks#callbackBlock:entry:0:2:2", "function": "callbackBlock", @@ -172,6 +284,186 @@ } ], "control_flow_nodes": [ + { + "function": "", + "id": "cfg:CfgCallbacks#:entry:0:10:18", + "kind": "entry", + "line": 10, + "owner": "CfgCallbacks", + "role": "function_entry", + "source": "", + "span": [ + 10, + 18, + 14, + 5 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:exit:2:14:5", + "kind": "exit", + "line": 14, + "owner": "CfgCallbacks", + "role": "function_exit", + "source": "", + "span": [ + 10, + 18, + 14, + 5 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:stmt:0.callback.0:12:8", + "kind": "statement", + "line": 12, + "owner": "CfgCallbacks", + "role": "linear_statement", + "source": "this.audit(user)", + "span": [ + 12, + 8, + 12, + 24 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:stmt:1:11:6", + "kind": "callback", + "line": 11, + "owner": "CfgCallbacks", + "role": "callback_region", + "source": "this.hook(() => { this.audit(user); })", + "span": [ + 11, + 6, + 13, + 8 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:entry:0:11:16", + "kind": "entry", + "line": 11, + "owner": "CfgCallbacks", + "role": "function_entry", + "source": "", + "span": [ + 11, + 16, + 13, + 7 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:exit:2:13:7", + "kind": "exit", + "line": 13, + "owner": "CfgCallbacks", + "role": "function_exit", + "source": "", + "span": [ + 11, + 16, + 13, + 7 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:stmt:1:12:8", + "kind": "statement", + "line": 12, + "owner": "CfgCallbacks", + "role": "linear_statement", + "source": "this.audit(user)", + "span": [ + 12, + 8, + 12, + 24 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:entry:0:19:18", + "kind": "entry", + "line": 19, + "owner": "CfgCallbacks", + "role": "function_entry", + "source": "", + "span": [ + 19, + 18, + 19, + 26 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:exit:1:19:26", + "kind": "exit", + "line": 19, + "owner": "CfgCallbacks", + "role": "function_exit", + "source": "", + "span": [ + 19, + 18, + 19, + 26 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:entry:0:3:18", + "kind": "entry", + "line": 3, + "owner": "CfgCallbacks", + "role": "function_entry", + "source": "", + "span": [ + 3, + 18, + 5, + 5 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:exit:2:5:5", + "kind": "exit", + "line": 5, + "owner": "CfgCallbacks", + "role": "function_exit", + "source": "", + "span": [ + 3, + 18, + 5, + 5 + ] + }, + { + "function": "", + "id": "cfg:CfgCallbacks#:stmt:1:4:6", + "kind": "statement", + "line": 4, + "owner": "CfgCallbacks", + "role": "linear_statement", + "source": "this.audit(user)", + "span": [ + 4, + 6, + 4, + 22 + ] + }, { "function": "callbackBlock", "id": "cfg:CfgCallbacks#callbackBlock:entry:0:2:2", diff --git a/gems/fact-mine/src/architecture_test.rs b/gems/fact-mine/src/architecture_test.rs index ddd7277bb..c0320e562 100644 --- a/gems/fact-mine/src/architecture_test.rs +++ b/gems/fact-mine/src/architecture_test.rs @@ -791,6 +791,65 @@ fn production_source(source: &str) -> String { .join("\n") } +#[test] +fn shared_analysis_does_not_own_concrete_language_policy() { + let checked = [ + "ast.rs", + "ast/adapters/base.rs", + "incremental.rs", + "lsp_scip.rs", + "profile.rs", + "scip.rs", + "external_summary.rs", + "syntax/cfg/effects.rs", + "syntax/complexity_facts.rs", + "syntax/tree_sitter_adapter.rs", + "syntax/normalized_extractor.rs", + "syntax/local_flow.rs", + "syntax/protocols.rs", + ]; + let languages = [ + ("ruby", "Ruby"), + ("python", "Python"), + ("javascript", "JavaScript"), + ("typescript", "TypeScript"), + ("java", "Java"), + ("swift", "Swift"), + ("kotlin", "Kotlin"), + ("go", "Go"), + ("rust", "Rust"), + ("zig", "Zig"), + ("lua", "Lua"), + ("c", "C"), + ("cpp", "Cpp"), + ("csharp", "CSharp"), + ("php", "Php"), + ]; + let mut offenders = Vec::new(); + for relative in checked { + let path = crate_src().join(relative); + let source = production_source(&fs::read_to_string(&path).expect("read shared analysis")); + for (name, variant) in languages { + let mut tokens = vec![format!("\"{name}\""), format!("Language::{variant}")]; + if name.len() > 1 { + tokens.push(format!("{name}_")); + tokens.push(format!("{name}::")); + } + for token in tokens { + if source.contains(&token) { + offenders.push(format!("{}: {}", path.display(), token)); + } + } + } + } + + assert!( + offenders.is_empty(), + "Shared analysis must call language-neutral adapter contracts; concrete language policy belongs in syntax/.rs or ast/adapters/.rs:\n{}", + offenders.join("\n") + ); +} + #[test] fn generic_cfg_does_not_own_concrete_language_knowledge() { let cfg_dir = crate_src().join("syntax/cfg"); diff --git a/gems/fact-mine/src/ast.rs b/gems/fact-mine/src/ast.rs index 38519d2bd..d9f04c07c 100644 --- a/gems/fact-mine/src/ast.rs +++ b/gems/fact-mine/src/ast.rs @@ -216,6 +216,14 @@ pub(crate) fn preprocessor_callable_names( adapters::normalization_adapter(language).preprocessor_callable_names(root, source) } +pub(crate) fn preprocessor_callable_definitions( + root: TreeSitterNode<'_>, + source: &str, + language: Language, +) -> Vec<(String, String)> { + adapters::normalization_adapter(language).preprocessor_callable_definitions(root, source) +} + pub fn node(child: &Child) -> Option<&Node> { match child { Child::Node(node) => Some(node), @@ -223,12 +231,29 @@ pub fn node(child: &Child) -> Option<&Node> { } } +pub(crate) fn reconcile_presence_correlation_spans( + root: tree_sitter::Node<'_>, + source: &str, + language: Language, + seeds: &mut Vec, +) { + adapters::normalization_adapter(language) + .reconcile_presence_correlation_spans(root, source, seeds); +} + pub fn slice(node: &Node, _lines: &[String]) -> String { normalize_text(&node.text) } pub fn body_stmts(defn_node: &Node) -> Vec<&Node> { - let scope_index = if defn_node.r#type == "DEFS" { 2 } else { 1 }; + // A normalized function wraps its SCOPE at a type-specific child index: a + // singleton-method DEFS after its receiver/name, a LAMBDA directly, an + // ordinary DEFN after its name. + let scope_index = match defn_node.r#type.as_str() { + "DEFS" => 2, + "LAMBDA" => 0, + _ => 1, + }; let Some(scope) = defn_node.children.get(scope_index).and_then(node) else { return Vec::new(); }; @@ -418,6 +443,7 @@ const STATEMENT_BLOCK_PARENT_KINDS: &[&str] = &[ "finally_clause", "do_statement", "lambda_expression", + "func_literal", ]; const EMPTY_BODY_WRAPPER_KINDS: &[&str] = &["body_statement", "block", "block_body", "statement"]; const HEREDOC_BODY_WRAPPER_KINDS: &[&str] = &["body_statement", "block_body", "statement", "then"]; diff --git a/gems/fact-mine/src/ast/adapters/base.rs b/gems/fact-mine/src/ast/adapters/base.rs index 6cf8084cd..1c80c0cca 100644 --- a/gems/fact-mine/src/ast/adapters/base.rs +++ b/gems/fact-mine/src/ast/adapters/base.rs @@ -11,6 +11,7 @@ use super::super::{ LEADING_LOOP_WRAPPER_KINDS, LEADING_OWNER_WRAPPER_KINDS, LOOP_NODE_KINDS, OWNER_NODE_KINDS, OWNER_STATEMENT_NESTED_KINDS, QUESTION_COLON_TERNARY_KINDS, }; +use crate::syntax::nullable::PresenceCorrelationSeed; use tree_sitter::Node as TreeSitterNode; pub(crate) const COMMON_ASSIGNMENT_OPERATORS: &[&str] = &["=", "+=", "-=", "*=", "/=", "%="]; @@ -30,6 +31,16 @@ pub(crate) struct ConditionalBranchParts<'tree> { use super::super::TreeSitterNormalizer; pub(crate) trait AstNormalizationAdapter: Sync { + /// Reconcile normalized presence correlations with exact raw parser spans + /// when the native grammar exposes stronger source ownership. + fn reconcile_presence_correlation_spans( + &self, + _root: TreeSitterNode<'_>, + _source: &str, + _seeds: &mut Vec, + ) { + } + /// Language-native namespace and explicit-import facts used to form /// canonical symbol identities. The empty default deliberately means /// "not proven", rather than treating a filename or short owner as a @@ -62,6 +73,25 @@ pub(crate) trait AstNormalizationAdapter: Sync { Vec::new() } + fn preprocessor_callable_definitions( + &self, + _root: TreeSitterNode<'_>, + _source: &str, + ) -> Vec<(String, String)> { + Vec::new() + } + + fn variable_declarator_node(&self, node: TreeSitterNode<'_>) -> bool { + node.kind() == "variable_declarator" + } + + fn variable_declarator_alternative<'tree>( + &self, + _node: TreeSitterNode<'tree>, + ) -> Option> { + None + } + /// Pre-parse source transformation, fed to tree-sitter's `parse()` call /// only - never used for digests, snippets, or spans, which always read /// the untouched original source. Defaults to a no-op; override only @@ -104,7 +134,7 @@ pub(crate) trait AstNormalizationAdapter: Sync { | "assignment_statement" | "annotated_assignment" ), - "variable_declarator" => kind == "variable_declarator", + "variable_declarator" => self.variable_declarator_node(node), "super" => kind == "super", "return_or_break" => matches!( kind, @@ -274,6 +304,18 @@ pub(crate) trait AstNormalizationAdapter: Sync { node } + /// Start/end nodes for a callable whose declaration and executable body + /// are split by grammar recovery. Unlike `function_declaration_node`, + /// this preserves the exact union span without swallowing neighboring + /// declarations from a broad recovery wrapper. + fn function_declaration_span_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option<(TreeSitterNode<'tree>, TreeSitterNode<'tree>)> { + None + } + /// Tree-sitter error recovery can occasionally label a malformed region /// as a function definition. Adapters with syntax that makes a reliable /// declaration check possible may reject that recovery node here. @@ -320,6 +362,18 @@ pub(crate) trait AstNormalizationAdapter: Sync { None } + /// Some languages allow a statement before a switch/case value (for + /// example Go's `switch value := next(); value.Kind()`). Preserve it as + /// executable work instead of treating only the trailing case value as + /// the condition. + fn case_initializer<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + None + } + fn conditional_keyword_node_type(&self, keyword: &str) -> Option<&'static str> { match keyword { "if" => Some("IF"), @@ -386,6 +440,10 @@ pub(crate) trait AstNormalizationAdapter: Sync { None } + fn case_arm_guard<'tree>(&self, _node: TreeSitterNode<'tree>) -> Option> { + None + } + fn case_else_node<'tree>( &self, node: TreeSitterNode<'tree>, @@ -443,6 +501,41 @@ pub(crate) trait AstNormalizationAdapter: Sync { None } + /// Executable nodes that precede the grammar's ordinary function body. + /// This is used for source constructs such as C# constructor delegation, + /// which tree-sitter stores beside (rather than inside) the body block. + fn function_body_prefix_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + Vec::new() + } + + /// Split a path-qualified call callee (`Cell::new`, `Foo::bar`) into its + /// receiver node and method name, so the normalized call carries the real + /// method as its message instead of a generic `call` placeholder. Returns + /// None for languages/nodes that are not scope-path callees. + fn scoped_call_parts<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option<(TreeSitterNode<'tree>, String)> { + None + } + + /// Unwrap a callee that carries explicit type arguments (`parse::`, + /// `collect::>`) to the callee itself. The type arguments are a + /// parametric annotation, never a message: leaving the wrapper in place + /// makes the normalizer read `` as the method name, which loses the + /// real call and leaves a callee no indexer can resolve. + fn type_argument_callee<'tree>( + &self, + _node: TreeSitterNode<'tree>, + ) -> Option> { + None + } + fn singleton_function_kind(&self, _kind: &str) -> bool { false } @@ -1097,6 +1190,12 @@ pub(crate) trait AstNormalizationAdapter: Sync { false } + /// Calls in compile-time-only syntax can look like ordinary call + /// expressions to tree-sitter without contributing runtime work. + fn nonruntime_call_node(&self, _node: TreeSitterNode<'_>, _source: &str) -> bool { + false + } + fn call_argument_nodes<'tree>( &self, _node: TreeSitterNode<'tree>, @@ -1118,6 +1217,17 @@ pub(crate) trait AstNormalizationAdapter: Sync { None } + /// Source declarations nested inside a call expression but not part of + /// its runtime argument list. The generic normalizer keeps these beside + /// the normalized call so declaration extraction remains lossless. + fn supplementary_call_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + Vec::new() + } + fn intrinsic_call_name( &self, _node: TreeSitterNode<'_>, @@ -1352,6 +1462,28 @@ pub(crate) trait AstNormalizationAdapter: Sync { Vec::new() } + /// Identifies a source-declared constructor that a grammar represents as + /// part of the class header instead of as an ordinary function node. + /// The normalizer emits it as a first-class project function so compiler + /// indexes can join constructor calls to their definition. + fn class_constructor_node<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + None + } + + /// Executable regions charged to a header-declared constructor, such as + /// delegation, property initializers, and explicit initializer blocks. + fn class_constructor_body_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + Vec::new() + } + fn loop_node_type(&self, kind: &str) -> Option<&'static str> { match kind { "while" | "while_statement" | "while_modifier" => Some("WHILE"), @@ -1371,6 +1503,16 @@ pub(crate) trait AstNormalizationAdapter: Sync { None } + /// Supplies the binding/pattern for normalized `FOR` nodes. The canonical + /// shape is `[binding, iterable, body]`, which CFG and DFG consumers use + /// to connect each element to the collection it came from. + fn loop_binding_node<'tree>( + &self, + _node: TreeSitterNode<'tree>, + ) -> Option> { + None + } + fn modifier_loop_kind(&self, _kind: &str) -> bool { false } @@ -1478,6 +1620,17 @@ pub(crate) trait AstNormalizationAdapter: Sync { ) && named_child_count == 1 } + /// Language-owned expression wrappers whose only named child carries all + /// runtime behavior (for example Rust borrow/dereference expressions). + fn transparent_expression( + &self, + _node: TreeSitterNode<'_>, + _source: &str, + _named_child_count: usize, + ) -> bool { + false + } + fn interpolated_string( &self, node: TreeSitterNode<'_>, @@ -1502,6 +1655,16 @@ pub(crate) trait AstNormalizationAdapter: Sync { } } + /// Supplies executable statements when a grammar keeps lambda bodies + /// directly under the lambda node instead of inside a body wrapper. + fn lambda_body_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + None + } + fn interpolation_node(&self, node: TreeSitterNode<'_>) -> bool { node.kind() == "interpolation" } @@ -1632,6 +1795,14 @@ pub(crate) trait AstNormalizationAdapter: Sync { false } + fn block_parameter_nodes<'tree>( + &self, + _node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + None + } + fn function_parameter_nodes<'tree>( &self, _node: TreeSitterNode<'tree>, diff --git a/gems/fact-mine/src/ast/adapters/c.rs b/gems/fact-mine/src/ast/adapters/c.rs index 3ed99fd7a..7ab263bb7 100644 --- a/gems/fact-mine/src/ast/adapters/c.rs +++ b/gems/fact-mine/src/ast/adapters/c.rs @@ -10,10 +10,18 @@ impl AstNormalizationAdapter for CAstAdapter { preprocessor_callable_names(root, source) } + fn preprocessor_callable_definitions( + &self, + root: TreeSitterNode<'_>, + source: &str, + ) -> Vec<(String, String)> { + preprocessor_callable_definitions(root, source) + } + fn source_preprocessing(&self, source: &str) -> Option { - Some(strip_native_nullability_annotations( - &strip_linkage_macros_before_type_name(source), - )) + let source = strip_linkage_macros_before_type_name(source); + let source = strip_calling_convention_macros_before_function_name(&source); + Some(strip_native_nullability_annotations(&source)) } fn case_arm_body_nodes<'tree>( @@ -63,6 +71,25 @@ impl AstNormalizationAdapter for CAstAdapter { } } +/// Calling-convention/export macros between a return type and the real +/// function name can be consumed as the declarator by tree-sitter-c. Blank +/// only convention-shaped tokens with reviewed suffixes, preserving offsets. +fn strip_calling_convention_macros_before_function_name(source: &str) -> String { + let masked = mask_comments_and_strings(source); + let pattern = Regex::new( + r"\b([A-Z][A-Z0-9_]*(?:CDECL|CALLBACK|CALL|API|EXPORT|PUBLIC))[ \t]+[A-Za-z_]\w*[ \t]*\(", + ) + .expect("static regex is valid"); + let mut result = source.as_bytes().to_vec(); + for caps in pattern.captures_iter(&masked) { + let convention = caps.get(1).expect("group 1 is present"); + for byte in &mut result[convention.range()] { + *byte = b' '; + } + } + String::from_utf8(result).unwrap_or_else(|_| source.to_string()) +} + /// A C/C++ linkage/visibility macro (`class MYLIB_API Foo`, `struct /// PLOG_LINKAGE Logger`) sits between the `class`/`struct` keyword and the /// real type name. tree-sitter never expands macros, so it greedily @@ -215,6 +242,31 @@ pub(super) fn preprocessor_callable_names(root: TreeSitterNode<'_>, source: &str names } +pub(super) fn preprocessor_callable_definitions( + root: TreeSitterNode<'_>, + source: &str, +) -> Vec<(String, String)> { + fn visit(node: TreeSitterNode<'_>, source: &str, definitions: &mut Vec<(String, String)>) { + if node.kind() == "preproc_function_def" { + if let Some(name) = node.child_by_field_name("name") { + let name = super::super::node_text(name, source).trim(); + let definition = super::super::node_text(node, source).trim(); + if !name.is_empty() && !definition.is_empty() { + definitions.push((name.to_string(), definition.to_string())); + } + } + } + for child in named_children(node) { + visit(child, source, definitions); + } + } + let mut definitions = Vec::new(); + visit(root, source, &mut definitions); + definitions.sort(); + definitions.dedup(); + definitions +} + #[cfg(test)] mod tests { use super::*; @@ -241,6 +293,24 @@ mod tests { assert_eq!(stripped.find("Logger"), Some(real_name_pos)); } + #[test] + fn strips_calling_convention_macro_before_function_name() { + let source = + "static void * CJSON_CDECL internal_malloc(size_t size) { return malloc(size); }\n"; + let stripped = strip_calling_convention_macros_before_function_name(source); + assert_eq!(stripped.len(), source.len()); + assert!(!stripped.contains("CJSON_CDECL")); + assert_eq!( + stripped.find("internal_malloc"), + source.find("internal_malloc") + ); + let legitimate = "static MYTYPE factory(void) { return value; }\n"; + assert_eq!( + strip_calling_convention_macros_before_function_name(legitimate), + legitimate + ); + } + #[test] fn does_not_strip_a_legitimate_one_word_type_name() { let source = "class URL {\npublic:\n void parse() {}\n};\n"; diff --git a/gems/fact-mine/src/ast/adapters/cpp.rs b/gems/fact-mine/src/ast/adapters/cpp.rs index 0ad9a8dac..b7c55fb77 100644 --- a/gems/fact-mine/src/ast/adapters/cpp.rs +++ b/gems/fact-mine/src/ast/adapters/cpp.rs @@ -9,6 +9,14 @@ impl AstNormalizationAdapter for CppAstAdapter { super::c::preprocessor_callable_names(root, source) } + fn preprocessor_callable_definitions( + &self, + root: TreeSitterNode<'_>, + source: &str, + ) -> Vec<(String, String)> { + super::c::preprocessor_callable_definitions(root, source) + } + fn source_preprocessing(&self, source: &str) -> Option { Some(super::c::strip_linkage_macros_before_type_name(source)) } @@ -51,6 +59,27 @@ impl AstNormalizationAdapter for CppAstAdapter { cpp_function_declarator(declarator) } + fn block_node_kind(&self, kind: &str) -> bool { + matches!( + kind, + "field_declaration_list" + | "block" + | "body_statement" + | "statement_block" + | "statement_list" + | "class_body" + | "switch_body" + | "match_block" + | "then" + | "block_body" + | "control_structure_body" + | "compound_statement" + | "declaration_list" + | "function_body" + | "statements" + ) + } + fn assignment_target_name(&self, node: TreeSitterNode<'_>, source: &str) -> Option { if node.kind() != "pointer_declarator" { return None; diff --git a/gems/fact-mine/src/ast/adapters/csharp.rs b/gems/fact-mine/src/ast/adapters/csharp.rs index edb2df262..f235b053f 100644 --- a/gems/fact-mine/src/ast/adapters/csharp.rs +++ b/gems/fact-mine/src/ast/adapters/csharp.rs @@ -81,8 +81,90 @@ impl AstNormalizationAdapter for CSharpAstAdapter { .map(|field| format!("@{field}")) } - fn call_node(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { - matches!(node.kind(), "invocation_expression") + fn call_node(&self, node: TreeSitterNode<'_>, source: &str) -> bool { + matches!( + node.kind(), + "invocation_expression" | "constructor_initializer" + ) && !csharp_attribute_invocation(node, source) + } + + fn intrinsic_call_name(&self, node: TreeSitterNode<'_>, source: &str) -> Option<&'static str> { + if node.kind() != "constructor_initializer" { + return None; + } + let text = node_text(node, source).trim_start(); + if text.starts_with(": this") { + Some("this") + } else if text.starts_with(": base") { + Some("base") + } else { + None + } + } + + fn scoped_call_parts<'tree>( + &self, + node: TreeSitterNode<'tree>, + source: &str, + ) -> Option<(TreeSitterNode<'tree>, String)> { + if node.kind() == "member_access_expression" { + let receiver = node.child_by_field_name("expression")?; + let name = node.child_by_field_name("name")?; + if name.kind() == "generic_name" { + let identifier = named_children(name) + .into_iter() + .find(|child| child.kind() == "identifier")?; + return Some((receiver, node_text(identifier, source).to_string())); + } + } + if node.kind() != "conditional_access_expression" { + return None; + } + let children = named_children(node); + let receiver = *children.first()?; + let binding = *children.last()?; + let method = named_children(binding) + .into_iter() + .last() + .map(|name| node_text(name, source)) + .unwrap_or_else(|| node_text(binding, source)) + .trim_start_matches(['.', '?']) + .to_string(); + (!method.is_empty()).then_some((receiver, method)) + } + + fn member_read_excluded(&self, node: TreeSitterNode<'_>) -> bool { + node.kind() == "member_access_expression" + && node + .child_by_field_name("name") + .is_some_and(|name| name.kind() == "generic_name") + } + + fn type_argument_callee<'tree>( + &self, + node: TreeSitterNode<'tree>, + ) -> Option> { + (node.kind() == "generic_name") + .then(|| { + named_children(node) + .into_iter() + .find(|child| child.kind() == "identifier") + }) + .flatten() + } + + fn function_body_prefix_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + if node.kind() != "constructor_declaration" { + return Vec::new(); + } + named_children(node) + .into_iter() + .filter(|child| child.kind() == "constructor_initializer") + .collect() } fn loop_node_type(&self, kind: &str) -> Option<&'static str> { @@ -93,13 +175,86 @@ impl AstNormalizationAdapter for CSharpAstAdapter { } } + fn loop_condition_node<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + (node.kind() == "foreach_statement") + .then(|| node.child_by_field_name("right")) + .flatten() + } + + fn lambda_target<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + matches!( + node.kind(), + "lambda_expression" | "anonymous_method_expression" + ) + .then_some(node) + } + + fn normalize_block_parameters(&self) -> bool { + true + } + + fn block_parameter_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + let parameters = node.child_by_field_name("parameters")?; + if parameters.kind() == "implicit_parameter" { + Some(vec![parameters]) + } else { + Some(named_children(parameters)) + } + } + + fn is_parameter_name_kind(&self, kind: &str) -> bool { + matches!( + kind, + "identifier" + | "implicit_parameter" + | "hash_splat_parameter" + | "splat_parameter" + | "block_parameter" + | "keyword_parameter" + | "optional_parameter" + ) + } + + fn local_identifier_text(&self, node: TreeSitterNode<'_>, source: &str) -> Option { + (node.kind() == "implicit_parameter").then(|| node_text(node, source).to_string()) + } + fn function_kind(&self, kind: &str) -> bool { matches!( kind, - "method_declaration" | "constructor_declaration" | "property_declaration" + "method_declaration" + | "constructor_declaration" + | "property_declaration" + | "local_function_statement" ) } + fn valid_function_definition(&self, node: TreeSitterNode<'_>, source: &str) -> bool { + csharp_split_preprocessor_method(node, source).is_none_or(|split| split.declaration != node) + } + + fn function_declaration_span_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + source: &str, + ) -> Option<(TreeSitterNode<'tree>, TreeSitterNode<'tree>)> { + csharp_split_preprocessor_method(node, source) + .filter(|split| split.implementation == node) + .map(|split| (split.declaration, split.implementation)) + } + fn function_body<'tree>( &self, node: TreeSitterNode<'tree>, @@ -135,25 +290,179 @@ impl AstNormalizationAdapter for CSharpAstAdapter { node: TreeSitterNode<'tree>, _source: &str, ) -> Option>> { - if node.kind() != "switch_section" { + match node.kind() { + "switch_section" => { + let body = named_children(node) + .into_iter() + .filter(|child| { + !matches!( + child.kind(), + "case_switch_label" + | "switch_label" + | "case_pattern_switch_label" + | "constant_pattern" + | "default_switch_label" + | "break_statement" + ) + }) + .collect::>(); + (!body.is_empty()).then_some(body) + } + "switch_expression_arm" => named_children(node) + .into_iter() + .rev() + .find(|child| child.kind() != "when_clause") + .map(|body| vec![body]), + _ => None, + } + } + + fn case_arm(&self, node: TreeSitterNode<'_>, source: &str) -> bool { + matches!(node.kind(), "switch_section" | "switch_expression_arm") + && !self.case_else_arm(node, source) + } + + fn case_arm_pattern_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + if node.kind() != "switch_expression_arm" { return None; } - let body = named_children(node) + named_children(node) .into_iter() - .filter(|child| { - !matches!( - child.kind(), - "case_switch_label" - | "switch_label" - | "case_pattern_switch_label" - | "constant_pattern" - | "default_switch_label" - | "break_statement" - ) - }) - .collect::>(); - (!body.is_empty()).then_some(body) + .find(|child| child.kind() != "when_clause") + .map(|pattern| vec![pattern]) + } + + fn case_arm_guard<'tree>(&self, node: TreeSitterNode<'tree>) -> Option> { + named_children(node) + .into_iter() + .find(|child| child.kind() == "when_clause") + .and_then(|clause| named_children(clause).into_iter().next()) + } + + fn case_else_arm(&self, node: TreeSitterNode<'_>, source: &str) -> bool { + node.kind() == "switch_expression_arm" + && named_children(node) + .first() + .is_some_and(|pattern| node_text(*pattern, source).trim() == "_") + } +} + +fn csharp_has_any_ancestor(mut node: TreeSitterNode<'_>, kinds: &[&str]) -> bool { + while let Some(parent) = node.parent() { + if kinds.contains(&parent.kind()) { + return true; + } + node = parent; } + false +} + +#[derive(Clone, Copy)] +struct CSharpSplitPreprocessorMethod<'tree> { + declaration: TreeSitterNode<'tree>, + implementation: TreeSitterNode<'tree>, +} + +fn csharp_split_preprocessor_method<'tree>( + node: TreeSitterNode<'tree>, + source: &str, +) -> Option> { + if node.kind() != "method_declaration" { + return None; + } + let parent = node.parent()?; + let structured = match parent.kind() { + "preproc_if" => Some(parent), + "preproc_else" => parent + .parent() + .filter(|parent| parent.kind() == "preproc_if"), + _ => None, + }; + let structured_pair = structured.and_then(|wrapper| { + let declaration = named_children(wrapper) + .into_iter() + .find(|child| child.kind() == "method_declaration")?; + let alternative = named_children(wrapper) + .into_iter() + .find(|child| child.kind() == "preproc_else")?; + let implementation = named_children(alternative) + .into_iter() + .find(|child| child.kind() == "method_declaration")?; + implementation + .child_by_field_name("body") + .map(|_| (declaration, implementation)) + }); + let (declaration, implementation) = if let Some(pair) = structured_pair { + pair + } else { + let mut recovery = Some(parent); + while recovery.is_some_and(|candidate| candidate.kind() != "ERROR") { + recovery = recovery.and_then(|candidate| candidate.parent()); + } + let methods = csharp_descendant_methods(recovery?); + let name = node.child_by_field_name("name")?; + let name = node_text(name, source); + let declaration = methods.iter().copied().find(|candidate| { + candidate.child_by_field_name("body").is_none() + && candidate + .child_by_field_name("name") + .is_some_and(|candidate_name| node_text(candidate_name, source) == name) + })?; + let implementation = methods.iter().copied().find(|candidate| { + candidate.child_by_field_name("body").is_some() + && candidate.start_position().row >= declaration.end_position().row + && candidate.start_position().row <= declaration.end_position().row + 3 + && candidate + .child_by_field_name("name") + .is_some_and(|candidate_name| node_text(candidate_name, source) == name) + && source[declaration.end_byte()..candidate.start_byte()].contains("#else") + })?; + (declaration, implementation) + }; + let declaration_name = declaration.child_by_field_name("name")?; + let implementation_name = implementation.child_by_field_name("name")?; + if node_text(declaration_name, source) != node_text(implementation_name, source) + || declaration.child_by_field_name("body").is_some() + || implementation.child_by_field_name("body").is_none() + { + return None; + } + Some(CSharpSplitPreprocessorMethod { + declaration, + implementation, + }) +} + +fn csharp_descendant_methods(node: TreeSitterNode<'_>) -> Vec> { + let mut methods = Vec::new(); + let mut stack = named_children(node); + while let Some(candidate) = stack.pop() { + if candidate.kind() == "method_declaration" { + methods.push(candidate); + } else { + stack.extend(named_children(candidate)); + } + } + methods +} + +fn csharp_attribute_invocation(node: TreeSitterNode<'_>, source: &str) -> bool { + if csharp_has_any_ancestor(node, &["attribute", "attribute_list"]) { + return true; + } + // Conditional-compilation recovery can detach an attribute's invocation + // node from its normal `attribute_list` ancestor. The source line remains + // definitive: C# attributes begin with `[` before the invocation. + let line_start = source[..node.start_byte()] + .rfind('\n') + .map_or(0, |offset| offset + 1); + source[line_start..node.start_byte()] + .trim_start() + .starts_with('[') } fn csharp_local_declaration(node: TreeSitterNode<'_>) -> bool { @@ -213,6 +522,9 @@ fn collect_csharp_scope_locals( #[cfg(test)] mod tests { use super::*; + use crate::{profile, syntax, syntax::Language}; + use anyhow::Result; + use std::fs; use tree_sitter::Parser; #[test] @@ -232,4 +544,147 @@ mod tests { assert!(imports.is_empty()); } } + + #[test] + fn preserves_constructor_switch_foreach_and_lambda_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cs").tempfile()?; + fs::write( + tmp.path(), + r#" +class Parent { public Parent(string value) {} } +class Widget : Parent { + [System.Obsolete("metadata")] + public Widget(string value) : base(Normalize(value)) {} + static string Normalize(string value) => value; + string Render(string value, string[] items) { + foreach (var item in items.Where(candidate => Accept(candidate.ToString()))) + Use(item); + return value switch { + "upper" => value.ToUpperInvariant(), + _ => value.ToLowerInvariant() + }; + } + static bool Accept(string value) => true; + static void Use(string value) {} + void Notify(System.Action? callback) { + callback?.Invoke("message"); + } + string Transform(string value) { + string Local(string item) { return Normalize(item); } + return Local(value); + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::CSharp)?; + let output = profile::extract(&document, profile::Profile::Espalier); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + assert!( + output + .methods + .iter() + .any(|method| method.name.starts_with(">(); + for expected in [ + "base", + "Normalize", + "Where", + "Accept", + "Use", + "ToUpperInvariant", + "ToLowerInvariant", + "Invoke", + ] { + assert!(messages.contains(expected), "calls={messages:?}"); + } + assert!(!messages.contains("call"), "calls={messages:?}"); + assert!( + output.methods.iter().any(|method| method.name == "Local"), + "methods={:?}", + output.methods + ); + assert!( + output + .complexity_facts + .iter() + .any(|fact| fact.function == "Local"), + "C# local functions must receive CFG/DFG complexity facts" + ); + Ok(()) + } + + #[test] + fn preprocessor_split_signature_keeps_one_callable_with_its_body() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cs").tempfile()?; + fs::write( + tmp.path(), + r#"class Demo { +#if FEATURE_SPAN + void Process(System.Span values) +#else + void Process(object[] values) +#endif + { Use(values); } +#if FEATURE_SPAN + void Render(System.ReadOnlySpan values) +#else + void Render(object?[] values) +#endif + { Use(values); } + void Use(object value) {} +}"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::CSharp)?; + let output = profile::extract(&document, profile::Profile::Espalier); + let process = output + .methods + .iter() + .filter(|method| method.name == "Process") + .collect::>(); + assert_eq!(process.len(), 1, "methods={:?}", output.methods); + assert!( + output + .calls + .iter() + .any(|call| call.source == process[0].id && call.message == "Use"), + "calls={:?}", + output.calls + ); + let span = process[0].span.expect("process span"); + assert_eq!(span, [3, 2, 7, 18]); + assert_eq!( + output + .methods + .iter() + .filter(|method| method.name == "Render") + .count(), + 1, + "methods={:?}", + output.methods + ); + Ok(()) + } } diff --git a/gems/fact-mine/src/ast/adapters/go.rs b/gems/fact-mine/src/ast/adapters/go.rs index f1fab8fa9..d5605b21a 100644 --- a/gems/fact-mine/src/ast/adapters/go.rs +++ b/gems/fact-mine/src/ast/adapters/go.rs @@ -1,10 +1,20 @@ use super::super::{named_children, node_text}; use super::base::AstNormalizationAdapter; +use crate::syntax::nullable::PresenceCorrelationSeed; use tree_sitter::Node as TreeSitterNode; pub(crate) struct GoAstAdapter; impl AstNormalizationAdapter for GoAstAdapter { + fn reconcile_presence_correlation_spans( + &self, + root: TreeSitterNode<'_>, + source: &str, + seeds: &mut Vec, + ) { + crate::syntax::go::attach_raw_presence_correlation_spans(root, source, seeds); + } + fn symbol_scope( &self, root: TreeSitterNode<'_>, @@ -46,6 +56,17 @@ impl AstNormalizationAdapter for GoAstAdapter { (package, imports) } + /// A Go function literal `func(...) ... { ... }` is a lambda, so it is + /// normalized (and later extracted) as a first-class function whose Big-O is + /// computed with the same pipeline as a named function. + fn lambda_target<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + (node.kind() == "func_literal").then_some(node) + } + fn call_node(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { go_statement_without_inner_call(node) } @@ -60,6 +81,16 @@ impl AstNormalizationAdapter for GoAstAdapter { .flatten() } + fn case_initializer<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + (node.kind() == "expression_switch_statement") + .then(|| node.child_by_field_name("initializer")) + .flatten() + } + fn intrinsic_call_name(&self, node: TreeSitterNode<'_>, _source: &str) -> Option<&'static str> { go_statement_without_inner_call(node).then_some("go") } diff --git a/gems/fact-mine/src/ast/adapters/java.rs b/gems/fact-mine/src/ast/adapters/java.rs index ee9387a27..e4df9004b 100644 --- a/gems/fact-mine/src/ast/adapters/java.rs +++ b/gems/fact-mine/src/ast/adapters/java.rs @@ -55,7 +55,10 @@ impl AstNormalizationAdapter for JavaAstAdapter { } fn call_node(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { - matches!(node.kind(), "method_invocation") + matches!( + node.kind(), + "method_invocation" | "object_creation_expression" + ) } fn call_block_argument<'tree>( @@ -80,10 +83,40 @@ impl AstNormalizationAdapter for JavaAstAdapter { .find(|argument| argument.kind() == "lambda_expression") } + fn supplementary_call_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + if node.kind() != "object_creation_expression" { + return Vec::new(); + } + named_children(node) + .into_iter() + .find(|child| child.kind() == "class_body") + .map(named_children) + .unwrap_or_default() + } + fn loop_node_type(&self, kind: &str) -> Option<&'static str> { matches!(kind, "enhanced_for_statement").then_some("FOR") } + fn loop_condition_node<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + // In `for (T item : source.items())`, tree-sitter puts the binding + // before the iterable. The generic first-child fallback therefore + // discarded every call in the iterable expression. Preserve `value` + // as the normalized loop condition so its calls and cardinality enter + // the CFG/DFG. + (node.kind() == "enhanced_for_statement") + .then(|| node.child_by_field_name("value")) + .flatten() + } + fn case_arm_body_nodes<'tree>( &self, node: TreeSitterNode<'tree>, diff --git a/gems/fact-mine/src/ast/adapters/kotlin.rs b/gems/fact-mine/src/ast/adapters/kotlin.rs index dab425abf..47d60d64b 100644 --- a/gems/fact-mine/src/ast/adapters/kotlin.rs +++ b/gems/fact-mine/src/ast/adapters/kotlin.rs @@ -5,6 +5,45 @@ use tree_sitter::Node as TreeSitterNode; pub(crate) struct KotlinAstAdapter; impl AstNormalizationAdapter for KotlinAstAdapter { + fn normalize_default_parameters(&self) -> bool { + true + } + + fn function_parameter_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + let parameters = named_children(node).into_iter().find(|child| { + matches!( + child.kind(), + "function_value_parameters" | "class_parameters" + ) + })?; + Some( + named_children(parameters) + .into_iter() + .filter(|child| matches!(child.kind(), "parameter" | "class_parameter")) + .collect(), + ) + } + + fn named_field<'tree>( + &self, + node: TreeSitterNode<'tree>, + name: &str, + ) -> Option> { + if name == "value" && matches!(node.kind(), "parameter" | "class_parameter") { + return named_children(node).into_iter().rev().find(|child| { + !matches!( + child.kind(), + "identifier" | "modifiers" | "type" | "user_type" + ) + }); + } + node.child_by_field_name(name) + } + fn hash_literal_target<'tree>( &self, _node: TreeSitterNode<'tree>, @@ -17,6 +56,17 @@ impl AstNormalizationAdapter for KotlinAstAdapter { matches!(kind, "for_statement").then_some("FOR") } + // `fun f() = expr`: the expression is the function body, wrapped in a + // `function_body` node whose leading `=` is expression-body syntax, not an + // assignment. Without this the generic `assignment_rhs` check (prev sibling + // is `=`) skips the expression, dropping the whole body - so expression-body + // functions produced no calls, loops, or complexity facts at all. + fn single_assignment_block_child(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { + node.parent() + .map(|parent| parent.kind() == "function_body") + .unwrap_or(false) + } + fn call_node(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { matches!(node.kind(), "call_expression") } @@ -27,8 +77,42 @@ impl AstNormalizationAdapter for KotlinAstAdapter { _function: Option>, _source: &str, ) -> Option>> { - let args = descendant(node, &["value_arguments"])?; - Some(named_children(args)) + let mut args = Vec::new(); + for child in named_children(node).into_iter().skip(1) { + match child.kind() { + "value_arguments" => args.extend(named_children(child)), + "annotated_lambda" | "lambda_literal" => args.push(child), + _ => {} + } + } + Some(args) + } + + fn lambda_target<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + match node.kind() { + "lambda_literal" => Some(node), + "annotated_lambda" => named_children(node) + .into_iter() + .find(|child| child.kind() == "lambda_literal"), + _ => None, + } + } + + fn lambda_body_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option>> { + (node.kind() == "lambda_literal").then(|| { + named_children(node) + .into_iter() + .filter(|child| child.kind() != "lambda_parameters") + .collect() + }) } fn case_arm_pattern_nodes<'tree>( @@ -86,4 +170,156 @@ impl AstNormalizationAdapter for KotlinAstAdapter { }) .collect() } + + fn class_constructor_node<'tree>( + &self, + node: TreeSitterNode<'tree>, + source: &str, + ) -> Option> { + if node.kind() != "class_declaration" + || !node + .children(&mut node.walk()) + .any(|child| !child.is_named() && node_text(child, source) == "class") + { + return None; + } + named_children(node) + .into_iter() + .find(|child| child.kind() == "primary_constructor") + .or(Some(node)) + } + + fn class_constructor_body_nodes<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Vec> { + let mut body = named_children(node) + .into_iter() + .filter(|child| { + matches!( + child.kind(), + "primary_constructor" | "delegation_specifiers" + ) + }) + .collect::>(); + if let Some(class_body) = named_children(node) + .into_iter() + .find(|child| child.kind() == "class_body") + { + body.extend(named_children(class_body).into_iter().filter(|child| { + matches!( + child.kind(), + "property_declaration" | "anonymous_initializer" + ) + })); + } + body + } +} + +#[cfg(test)] +mod tests { + use crate::profile::{self, Profile}; + use crate::syntax::{self, Language}; + use anyhow::{Context, Result}; + use std::collections::BTreeSet; + use std::fs; + + #[test] + fn trailing_lambdas_preserve_all_nested_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".kt").tempfile()?; + fs::write( + tmp.path(), + r#"fun render(values: List) = buildString { + append("[") + values.forEach { value -> + append(value.trim()) + } + append("]") +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Kotlin)?; + let output = profile::extract(&document, Profile::Espalier); + output + .methods + .iter() + .find(|method| method.name == "render") + .context("render method")?; + let messages = output + .calls + .iter() + .map(|call| call.message.as_str()) + .collect::>(); + for expected in ["buildString", "append", "forEach", "trim"] { + assert!(messages.contains(expected), "calls={messages:?}"); + } + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) + } + + #[test] + fn primary_constructor_is_a_project_function_with_initializer_work() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".kt").tempfile()?; + fs::write( + tmp.path(), + r#"open class Parent(value: String) +fun defaultValue() = "default" +fun delegatedValue() = "parent" +fun propertyValue() = "property" +fun initialize() {} + +class Widget(val value: String = defaultValue()) : Parent(delegatedValue()) { + val property = propertyValue() + init { + initialize() + } + fun work() {} +} + +interface Contract +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Kotlin)?; + let output = profile::extract(&document, Profile::Espalier); + let constructor = output + .methods + .iter() + .find(|method| method.owner == "Widget" && method.name == "Widget") + .context("Widget primary constructor")?; + assert!( + !output + .methods + .iter() + .any(|method| method.owner == "Contract" && method.name == "Contract"), + "interfaces must not gain constructors" + ); + let messages = output + .calls + .iter() + .filter(|call| call.source == constructor.id) + .map(|call| call.message.as_str()) + .collect::>(); + for expected in [ + "defaultValue", + "delegatedValue", + "propertyValue", + "initialize", + ] { + assert!(messages.contains(expected), "calls={messages:?}"); + } + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) + } } diff --git a/gems/fact-mine/src/ast/adapters/ruby.rs b/gems/fact-mine/src/ast/adapters/ruby.rs index ca50afb48..4d3b50537 100644 --- a/gems/fact-mine/src/ast/adapters/ruby.rs +++ b/gems/fact-mine/src/ast/adapters/ruby.rs @@ -523,20 +523,23 @@ impl AstNormalizationAdapter for RubyAstAdapter { node: TreeSitterNode<'tree>, source: &str, ) -> Option> { - if !matches!( - node.kind(), - "body_statement" | "block_body" | "statement" | "argument_list" - ) { - return None; - } - let raw_named = raw_named_children(node); - if raw_named.len() == 1 - && raw_named[0].kind() == "call" - && node_text(node, source) == node_text(raw_named[0], source) - { - Some(raw_named[0]) - } else { - None + let mut target = node; + loop { + if target.kind() == "call" { + return (target != node).then_some(target); + } + if !matches!( + target.kind(), + "body_statement" | "block_body" | "statement" | "argument_list" + ) { + return None; + } + let raw_named = raw_named_children(target); + if raw_named.len() != 1 || node_text(target, source) != node_text(raw_named[0], source) + { + return None; + } + target = raw_named[0]; } } diff --git a/gems/fact-mine/src/ast/adapters/rust.rs b/gems/fact-mine/src/ast/adapters/rust.rs index 6ebb3ef80..3483d8b64 100644 --- a/gems/fact-mine/src/ast/adapters/rust.rs +++ b/gems/fact-mine/src/ast/adapters/rust.rs @@ -1,14 +1,63 @@ -use super::super::named_children; +use super::super::{named_children, node_text}; use super::base::AstNormalizationAdapter; use tree_sitter::Node as TreeSitterNode; pub(crate) struct RustAstAdapter; impl AstNormalizationAdapter for RustAstAdapter { + fn nonruntime_call_node(&self, node: TreeSitterNode<'_>, _source: &str) -> bool { + let mut ancestor = node.parent(); + while let Some(parent) = ancestor { + if matches!( + parent.kind(), + "type_arguments" | "type_parameters" | "const_item" | "macro_definition" + ) { + return true; + } + ancestor = parent.parent(); + } + false + } + + fn transparent_expression( + &self, + node: TreeSitterNode<'_>, + source: &str, + named_child_count: usize, + ) -> bool { + let source = node_text(node, source).trim_start(); + (node.kind() == "unsafe_block" && named_child_count == 1) + || (node.kind() == "unary_expression" + && named_child_count == 1 + && (source.starts_with('*') || source.starts_with('&'))) + } + + fn variable_declarator_node(&self, node: TreeSitterNode<'_>) -> bool { + matches!(node.kind(), "let_declaration" | "static_item") + } + + fn variable_declarator_alternative<'tree>( + &self, + node: TreeSitterNode<'tree>, + ) -> Option> { + (node.kind() == "let_declaration") + .then(|| node.child_by_field_name("alternative")) + .flatten() + } + fn class_like_owner_kind(&self, kind: &str) -> bool { kind == "impl_item" } + fn case_arm_guard<'tree>(&self, node: TreeSitterNode<'tree>) -> Option> { + (node.kind() == "match_arm") + .then(|| { + node.child_by_field_name("pattern") + .and_then(|pattern| pattern.child_by_field_name("condition")) + }) + .flatten() + } + fn class_like_owner_name<'tree>( &self, node: TreeSitterNode<'tree>, @@ -29,11 +78,78 @@ impl AstNormalizationAdapter for RustAstAdapter { node: TreeSitterNode<'tree>, _source: &str, ) -> Option> { - node.child_by_field_name("body").or(Some(node)) + node.child_by_field_name("body").or_else(|| { + named_children(node) + .into_iter() + .find(|child| child.kind() == "declaration_list") + }) } fn loop_node_type(&self, kind: &str) -> Option<&'static str> { - matches!(kind, "for_expression").then_some("FOR") + match kind { + "for_expression" => Some("FOR"), + "while_expression" | "loop_expression" => Some("WHILE"), + _ => None, + } + } + + fn loop_condition_node<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + // A Rust `for binding in iterable` stores the binding before `value`. + // Selecting the generic first named child drops calls and size domains + // from the iterable expression. + (node.kind() == "for_expression") + .then(|| node.child_by_field_name("value")) + .flatten() + } + + fn loop_binding_node<'tree>( + &self, + node: TreeSitterNode<'tree>, + ) -> Option> { + (node.kind() == "for_expression") + .then(|| node.child_by_field_name("pattern")) + .flatten() + } + + fn scoped_call_parts<'tree>( + &self, + node: TreeSitterNode<'tree>, + source: &str, + ) -> Option<(TreeSitterNode<'tree>, String)> { + if node.kind() != "scoped_identifier" { + return None; + } + // `Cell::new` -> receiver `Cell` (the `path` field), method `new` (the + // `name` field). `std::mem::replace` -> receiver `std::mem`, method + // `replace`. Only the terminal segment becomes the message. + let path = node.child_by_field_name("path")?; + let name = node.child_by_field_name("name")?; + Some((path, node_text(name, source).to_string())) + } + + /// A Rust closure `|x| ...` is a lambda, so it is normalized (and later + /// extracted) as a first-class function. Its own Big-O is what a caller + /// substitutes for the callee's parametric callback cost. + fn lambda_target<'tree>( + &self, + node: TreeSitterNode<'tree>, + _source: &str, + ) -> Option> { + (node.kind() == "closure_expression").then_some(node) + } + + fn type_argument_callee<'tree>( + &self, + node: TreeSitterNode<'tree>, + ) -> Option> { + if node.kind() != "generic_function" { + return None; + } + node.child_by_field_name("function") } fn hash_literal_target<'tree>( @@ -73,5 +189,15 @@ mod tests { .class_like_owner_body(impl_node, "impl Widget { }") .unwrap(); assert_eq!(body_node.kind(), "declaration_list"); + + // Error recovery can still classify an incomplete `impl` as an + // impl_item without producing a declaration_list. Returning the + // owner node as its own body makes normalization recurse forever. + let incomplete_tree = parser.parse("impl Widget", None).unwrap(); + let incomplete_impl = incomplete_tree.root_node().child(0).unwrap(); + assert_eq!(incomplete_impl.kind(), "impl_item"); + assert!(adapter + .class_like_owner_body(incomplete_impl, "impl Widget") + .is_none()); } } diff --git a/gems/fact-mine/src/ast/adapters/typescript.rs b/gems/fact-mine/src/ast/adapters/typescript.rs index 331f3639e..86985a211 100644 --- a/gems/fact-mine/src/ast/adapters/typescript.rs +++ b/gems/fact-mine/src/ast/adapters/typescript.rs @@ -20,6 +20,53 @@ const TYPESCRIPT_TERNARY_KINDS: &[&str] = &[ pub(crate) struct TypeScriptAstAdapter; impl AstNormalizationAdapter for TypeScriptAstAdapter { + /// Extract module imports so cross-file/module calls resolve. Mirrors the + /// Python adapter: emit `(local_name, "module\0exported_name")` for named + /// imports and `(local_name, "module")` for namespace imports; the module + /// path is left raw here and canonicalized against the importing file's + /// path in the tree-sitter adapter (see `canonical_ts_import`). The `\0` + /// keeps the module and the exported name unambiguously separable across + /// module paths that themselves contain dots. + fn symbol_scope( + &self, + root: TreeSitterNode<'_>, + source: &str, + ) -> (String, Vec<(String, String)>) { + let mut imports = Vec::new(); + for child in named_children(root) { + if child.kind() != "import_statement" { + continue; + } + let text = node_text(child, source).trim(); + let Some(module) = ts_import_module(text) else { + continue; + }; + if let (Some(open), Some(close)) = (text.find('{'), text.rfind('}')) { + if open < close { + for entry in text[open + 1..close].split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let (exported, local) = entry + .split_once(" as ") + .map(|(exported, local)| (exported.trim(), local.trim())) + .unwrap_or((entry, entry)); + if !local.is_empty() && !exported.is_empty() { + imports.push((local.to_string(), format!("{module}\u{0}{exported}"))); + } + } + } + } else if let Some(rest) = text.split("* as ").nth(1) { + let namespace = rest.split_whitespace().next().unwrap_or("").trim(); + if !namespace.is_empty() { + imports.push((namespace.to_string(), module.to_string())); + } + } + } + (String::new(), imports) + } + fn named_field<'tree>( &self, node: TreeSitterNode<'tree>, @@ -518,6 +565,17 @@ fn typescript_bound_callable_name(node: TreeSitterNode<'_>, source: &str) -> Opt (!text.is_empty()).then(|| text.to_string()) } +/// The module specifier of an `import ... from ""` statement: the +/// contents of the last quoted string in the statement text. +fn ts_import_module(statement: &str) -> Option { + let bytes = statement.as_bytes(); + let close = statement.rfind(['"', '\''])?; + let quote = bytes[close]; + let open = statement[..close].rfind(quote as char)?; + let module = statement[open + 1..close].trim(); + (!module.is_empty()).then(|| module.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gems/fact-mine/src/ast/normalizer.rs b/gems/fact-mine/src/ast/normalizer.rs index 36a48c264..10cc1a77e 100644 --- a/gems/fact-mine/src/ast/normalizer.rs +++ b/gems/fact-mine/src/ast/normalizer.rs @@ -340,11 +340,19 @@ impl<'source> TreeSitterNormalizer<'source> { return Some(self.wrap(&kind_type(node.kind()), Vec::new(), node)); } let assignment = self.normalize_assignment(node)?; - return Some(self.wrap( - &kind_type(node.kind()), - vec![Child::Node(Box::new(assignment))], - node, - )); + let mut children = vec![Child::Node(Box::new(assignment))]; + if let Some(alternative) = self + .normalization_adapter + .variable_declarator_alternative(node) + .and_then(|alternative| self.normalize_node(alternative)) + { + // The alternative executes only when a refutable pattern does + // not match. Keeping it after the assignment is a conservative + // upper-bound representation and, critically, retains its + // executable calls for CFG/DFG and call-soundness accounting. + children.push(Child::Node(Box::new(alternative))); + } + return Some(self.wrap(&kind_type(node.kind()), children, node)); } if self .normalization_adapter @@ -506,6 +514,7 @@ impl<'source> TreeSitterNormalizer<'source> { .function_body(node, normalizer.source) })?; let body = normalizer.normalize_body(body_node); + let body = normalizer.prepend_function_body_prefix(node, body); let body = normalizer.elide_tail_returns(body); let body = normalizer.prepend_inline_parameter_begin(node, body); normalizer.elide_implicit_nil_body(body) @@ -514,11 +523,40 @@ impl<'source> TreeSitterNormalizer<'source> { let declaration_node = self .normalization_adapter .function_declaration_node(node, self.source); - Some(self.wrap( - "DEFN", - vec![Child::Symbol(name), Child::Node(Box::new(scope))], - declaration_node, - )) + let children = vec![Child::Symbol(name), Child::Node(Box::new(scope))]; + Some( + if let Some((start, end)) = self + .normalization_adapter + .function_declaration_span_nodes(node, self.source) + { + self.wrap_from_nodes("DEFN", children, start, end) + } else { + self.wrap("DEFN", children, declaration_node) + }, + ) + } + + fn prepend_function_body_prefix( + &mut self, + function: TreeSitterNode<'_>, + body: Option, + ) -> Option { + let prefix_nodes = self + .normalization_adapter + .function_body_prefix_nodes(function, self.source); + if prefix_nodes.is_empty() { + return body; + } + let source = prefix_nodes.first().copied().unwrap_or(function); + let prefix = self.normalize_body_nodes(prefix_nodes, source); + let mut children = Vec::new(); + if let Some(prefix) = prefix { + append_flattened_block(prefix, &mut children); + } + if let Some(body) = body { + append_flattened_block(body, &mut children); + } + Some(self.wrap("BLOCK", children, function)) } pub(in crate::ast) fn normalize_leading_function_statement( @@ -589,6 +627,7 @@ impl<'source> TreeSitterNormalizer<'source> { .or_else(|| self.block_child(node)) .and_then(|body| self.normalize_body(body)); let body = self.with_supplementary_class_body(node, body); + let body = self.with_class_constructor(node, body); Some(self.wrap( "CLASS", vec![ @@ -600,6 +639,52 @@ impl<'source> TreeSitterNormalizer<'source> { )) } + fn with_class_constructor( + &mut self, + node: TreeSitterNode<'_>, + body: Option, + ) -> Option { + let Some(constructor) = self + .normalization_adapter + .class_constructor_node(node, self.source) + else { + return body; + }; + let name_node = self + .named_field(node, "name") + .or_else(|| self.first_named(node))?; + let name = node_text(name_node, self.source).to_string(); + let args = self.normalize_function_parameters(constructor); + let constructor_body_nodes = self + .normalization_adapter + .class_constructor_body_nodes(node, self.source); + let definition_end = constructor_body_nodes + .last() + .copied() + .unwrap_or(constructor); + let constructor_body = self.with_dynamic_scope(constructor, true, |normalizer| { + let source = constructor_body_nodes + .first() + .copied() + .unwrap_or(constructor); + normalizer.normalize_body_nodes(constructor_body_nodes, source) + }); + let definition = self.wrap_from_nodes( + "DEFN", + vec![ + Child::Symbol(name), + Child::Node(Box::new(self.scope(constructor_body, args, constructor))), + ], + name_node, + definition_end, + ); + let mut children = vec![Child::Node(Box::new(definition))]; + if let Some(existing) = body { + append_flattened_block(existing, &mut children); + } + Some(self.wrap("BLOCK", children, node)) + } + /// Splices `supplementary_class_body_nodes` (normalized individually) /// into `body`'s children, or creates a body from just those nodes if /// there was none. A no-op whenever the hook returns nothing, which is @@ -678,7 +763,7 @@ impl<'source> TreeSitterNormalizer<'source> { .or_else(|| self.block_child(node)) .and_then(|body| self.normalize_body(body)); let scope = self.scope(body, None, node); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![Child::Node(Box::new(call)), Child::Node(Box::new(scope))], node, @@ -727,14 +812,21 @@ impl<'source> TreeSitterNormalizer<'source> { pub(in crate::ast) fn normalize_lambda(&mut self, node: TreeSitterNode<'_>) -> Option { let target = self.lambda_target(node).unwrap_or(node); let args = self.normalize_block_parameters(Some(target)); - let body_node = self - .named_field(target, "body") - .or_else(|| self.block_child(target)) - .or_else(|| self.named_children(target).into_iter().last())?; let body = self.with_dynamic_scope(target, false, |normalizer| { - normalizer - .normalize_body(body_node) - .map(|node| normalizer.normalize_dynamic_scope(node)) + let body = if let Some(nodes) = normalizer + .normalization_adapter + .lambda_body_nodes(target, normalizer.source) + { + let source = nodes.first().copied().unwrap_or(target); + normalizer.normalize_body_nodes(nodes, source) + } else { + let body_node = normalizer + .named_field(target, "body") + .or_else(|| normalizer.block_child(target)) + .or_else(|| normalizer.named_children(target).into_iter().last())?; + normalizer.normalize_body(body_node) + }; + body.map(|node| normalizer.normalize_dynamic_scope(node)) }); let scope = self.scope(body, args, target); Some(self.wrap("LAMBDA", vec![Child::Node(Box::new(scope))], target)) @@ -1027,10 +1119,19 @@ impl<'source> TreeSitterNormalizer<'source> { .named_field(node, "body") .or_else(|| self.named_field(node, "consequence")) .or_else(|| self.block_child(node)); + let binding = self + .normalization_adapter + .loop_binding_node(node) + .and_then(|binding| self.normalize_node(binding)); let condition = optional_node(condition.and_then(|condition| self.normalize_node(condition))); let body = optional_node(body.and_then(|body| self.normalize_control_body(body))); - Some(self.wrap(node_type, vec![condition, body], node)) + let children = if let Some(binding) = binding { + vec![Child::Node(Box::new(binding)), condition, body] + } else { + vec![condition, body] + }; + Some(self.wrap(node_type, children, node)) } pub(in crate::ast) fn normalize_else_or_branch( @@ -1093,6 +1194,10 @@ impl<'source> TreeSitterNormalizer<'source> { } pub(in crate::ast) fn normalize_case(&mut self, node: TreeSitterNode<'_>) -> Option { + let initializer = self + .normalization_adapter + .case_initializer(node, self.source) + .and_then(|initializer| self.normalize_node(initializer)); let value_raw = self.case_value(node); let value = value_raw.and_then(|value| self.normalize_node(value)); let whens = self @@ -1102,20 +1207,32 @@ impl<'source> TreeSitterNormalizer<'source> { .collect::>(); let fallback = self.case_else_body(node); let chain = self.link_when_chain(whens, fallback); - if value_raw.is_none() { - Some(self.wrap("CASE2", vec![optional_node(chain)], node)) + let case = if value_raw.is_none() { + self.wrap("CASE2", vec![optional_node(chain)], node) } else { - Some(self.wrap( + self.wrap( "CASE", vec![optional_node(value), optional_node(chain)], node, + ) + }; + if let Some(initializer) = initializer { + Some(self.wrap( + "BEGIN", + vec![ + Child::Node(Box::new(initializer)), + Child::Node(Box::new(case)), + ], + node, )) + } else { + Some(case) } } pub(in crate::ast) fn normalize_when(&mut self, node: TreeSitterNode<'_>) -> Option { let patterns = self.normalize_patterns(node); - let body = if let Some(body_nodes) = self + let mut body = if let Some(body_nodes) = self .normalization_adapter .case_arm_body_nodes(node, self.source) { @@ -1127,6 +1244,21 @@ impl<'source> TreeSitterNormalizer<'source> { self.when_body(node) .and_then(|body| self.normalize_body(body)) }; + if let Some(guard) = self + .normalization_adapter + .case_arm_guard(node) + .and_then(|guard| self.normalize_node(guard)) + { + body = Some(self.wrap( + "IF", + vec![ + Child::Node(Box::new(guard)), + optional_node(body), + Child::Nil, + ], + node, + )); + } Some(self.wrap( "WHEN", vec![ @@ -1765,11 +1897,25 @@ impl<'source> TreeSitterNormalizer<'source> { if let Some(target) = self.assignment_target(left, right.clone(), node) { return Some(target); } - Some(self.wrap( + let assignment = self.wrap( "LASGN", vec![Child::String(self.target_name(left)), optional_node(right)], node, - )) + ); + let left_span = span(left); + let left_executes_call = self.parser_call_spans.iter().any(|call_span| { + (call_span[0], call_span[1]) >= (left_span[0], left_span[1]) + && (call_span[2], call_span[3]) <= (left_span[2], left_span[3]) + }); + if !left_executes_call { + return Some(assignment); + } + let mut target_effects = self.normalize_children(left); + if target_effects.is_empty() { + return Some(assignment); + } + target_effects.push(Child::Node(Box::new(assignment))); + Some(self.wrap("BLOCK", target_effects, node)) } pub(in crate::ast) fn normalize_operator_assignment( @@ -2094,7 +2240,21 @@ impl<'source> TreeSitterNormalizer<'source> { if let Some(normalized) = normalized.as_ref() { self.record_call_origin(span(node), normalized); } - normalized + let normalized = normalized?; + let supplementary = self + .normalization_adapter + .supplementary_call_nodes(node, self.source); + if supplementary.is_empty() { + return Some(normalized); + } + let mut children = vec![Child::Node(Box::new(normalized))]; + children.extend( + supplementary + .into_iter() + .filter_map(|child| self.normalize_node(child)) + .map(|child| Child::Node(Box::new(child))), + ); + Some(self.wrap("BEGIN", children, node)) } pub(in crate::ast) fn normalize_zero_child_call(&self, node: TreeSitterNode<'_>) -> Node { @@ -2136,7 +2296,8 @@ impl<'source> TreeSitterNormalizer<'source> { .normalization_adapter .statement_wrapped_call_target(node, self.source) .unwrap_or(node); - let call = self.normalize_call_without_block(call_source, block)?; + let mut call = self.normalize_call_without_block(call_source, block)?; + self.recover_wrapped_call_arguments(node, call_source, &mut call); let args = self.normalize_block_parameters(block); let body = block.and_then(|block| { self.with_dynamic_scope(block, false, |normalizer| { @@ -2150,7 +2311,7 @@ impl<'source> TreeSitterNormalizer<'source> { }) }); let scope = self.scope(body, args, node); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![Child::Node(Box::new(call)), Child::Node(Box::new(scope))], node, @@ -2308,7 +2469,7 @@ impl<'source> TreeSitterNormalizer<'source> { .normalize_body(body_node) .map(|node| normalizer.normalize_dynamic_scope(node)) }); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![ Child::Node(Box::new(call)), @@ -2324,7 +2485,8 @@ impl<'source> TreeSitterNormalizer<'source> { ) -> Option { let block = self.call_block(node); let call_source = self.statement_block_call(node)?; - let call = self.normalize_call_without_block(call_source, block)?; + let mut call = self.normalize_call_without_block(call_source, block)?; + self.recover_wrapped_call_arguments(node, call_source, &mut call); let args = self.normalize_block_parameters(block); let body = block.and_then(|block| { self.with_dynamic_scope(block, false, |normalizer| { @@ -2338,7 +2500,7 @@ impl<'source> TreeSitterNormalizer<'source> { }) }); let scope = self.scope(body, args, node); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![Child::Node(Box::new(call)), Child::Node(Box::new(scope))], node, @@ -2366,7 +2528,7 @@ impl<'source> TreeSitterNormalizer<'source> { .map(|node| normalizer.normalize_dynamic_scope(node)) }); let scope = self.scope(body, args, node); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![Child::Node(Box::new(call)), Child::Node(Box::new(scope))], node, @@ -2434,6 +2596,7 @@ impl<'source> TreeSitterNormalizer<'source> { .into_iter() .find(|child| Some(*child) != block) })?; + let function = self.type_argument_callee(function).unwrap_or(function); let args = self.call_arguments(node, Some(function)); if let Some(function_name) = self.identifier_text(function) { let node_type = if block.is_some() || !args.is_empty() { @@ -2485,6 +2648,22 @@ impl<'source> TreeSitterNormalizer<'source> { } return Some(self.wrap("CALL", children, node)); } + if let Some((receiver, method)) = self + .normalization_adapter + .scoped_call_parts(function, self.source) + { + let receiver = optional_node(self.normalize_node(receiver)); + let args = if let Some(source) = call_source.as_ref() { + self.list_or_nil_from_source_node(args, source) + } else { + list_or_nil(args, node, self) + }; + let children = vec![receiver, Child::Symbol(method), args]; + if let Some(source) = call_source.as_ref() { + return Some(self.wrap_from_source_node("CALL", children, source)); + } + return Some(self.wrap("CALL", children, node)); + } let function = optional_node(self.normalize_node(function)); let args = if let Some(source) = call_source.as_ref() { self.list_or_nil_from_source_node(args, source) @@ -2914,7 +3093,7 @@ impl<'source> TreeSitterNormalizer<'source> { .normalize_body(body_node) .map(|node| normalizer.normalize_dynamic_scope(node)) }); - Some(self.wrap( + Some(self.wrap_call_iter( "ITER", vec![ Child::Node(Box::new(call)), @@ -3678,6 +3857,12 @@ impl<'source> TreeSitterNormalizer<'source> { return None; } let block = block?; + if let Some(parameters) = self + .normalization_adapter + .block_parameter_nodes(block, self.source) + { + return self.normalize_parameter_nodes(parameters, block); + } let params = self.named_children(block).into_iter().find(|child| { self.normalization_adapter .check_node_role(*child, "block_parameters") @@ -3850,6 +4035,20 @@ impl<'source> TreeSitterNormalizer<'source> { normalized } + fn wrap_call_iter( + &self, + node_type: &str, + children: Vec, + source: TreeSitterNode<'_>, + ) -> Node { + let raw_call_span = span(source); + let normalized = self.wrap(node_type, children, source); + if self.parser_call_spans.contains(&raw_call_span) { + self.record_call_origin(raw_call_span, &normalized); + } + normalized + } + fn record_call_origin(&self, raw_call_span: Span, normalized: &Node) { let normalized_call_span = primary_normalized_call_span(normalized).unwrap_or({ [ @@ -3946,11 +4145,17 @@ impl<'source> TreeSitterNormalizer<'source> { return false; } - if self - .normalization_adapter - .check_node_role(parent, "block_wrapper") - || self.normalization_adapter.check_node_role(parent, "then") - && self.parent_named_child(parent, node) + // Only Ruby-like dynamic syntax permits a bare identifier in + // statement/tail position to dispatch as a zero-argument call. + // In Rust (and the other lexical languages) `{ value }` is a local + // read; treating it as VCALL manufactured unresolved calls such as + // `self.out()` for ordinary tail expressions. + if self.dynamic_syntax_enabled() + && (self + .normalization_adapter + .check_node_role(parent, "block_wrapper") + || self.normalization_adapter.check_node_role(parent, "then") + && self.parent_named_child(parent, node)) { return true; } @@ -5015,15 +5220,14 @@ impl<'source> TreeSitterNormalizer<'source> { &self, node: TreeSitterNode<'tree>, ) -> Option> { - if self.dotted_call(node) { - return Some(node); - } - let block = self.call_block(node); let child_source = self .normalization_adapter .statement_wrapped_call_target(node, self.source) .unwrap_or(node); + if self.dotted_call(child_source) { + return Some(child_source); + } let children = self.named_children(child_source); children.into_iter().find(|child| { @@ -5150,9 +5354,19 @@ impl<'source> TreeSitterNormalizer<'source> { .unwrap_or(false) } + /// A callee carrying explicit type arguments is the callee itself. Every + /// call-shape decision below must see through the wrapper, or the type + /// argument list is mistaken for the method name. + pub(in crate::ast) fn type_argument_callee<'tree>( + &self, + node: TreeSitterNode<'tree>, + ) -> Option> { + self.normalization_adapter.type_argument_callee(node) + } + pub(in crate::ast) fn dotted_call(&self, node: TreeSitterNode<'_>) -> bool { - if node.kind() == "generic_function" { - return false; + if let Some(callee) = self.type_argument_callee(node) { + return self.dotted_call(callee); } let raw_named = self.raw_named_children(node); if raw_named.len() == 1 @@ -5168,6 +5382,7 @@ impl<'source> TreeSitterNormalizer<'source> { // its own, so checking only its direct children silently degrades a // zero-argument method invocation into a property read. if raw_named.len() >= 2 + && !self.call_node(raw_named[0]) && self.dotted_call(raw_named[0]) && raw_named[1..].iter().all(|child| { self.normalization_adapter @@ -5211,6 +5426,9 @@ impl<'source> TreeSitterNormalizer<'source> { node: TreeSitterNode<'tree>, block: Option>, ) -> Option<(TreeSitterNode<'tree>, String)> { + if let Some(callee) = self.type_argument_callee(node) { + return self.dotted_call_parts(callee, block); + } let raw_named = self.raw_named_children(node); if raw_named.len() == 1 && node_text(node, self.source) == node_text(raw_named[0], self.source) @@ -5220,6 +5438,7 @@ impl<'source> TreeSitterNormalizer<'source> { } if raw_named.len() >= 2 + && !self.call_node(raw_named[0]) && self.dotted_call(raw_named[0]) && raw_named[1..].iter().all(|child| { self.normalization_adapter @@ -5261,6 +5480,9 @@ impl<'source> TreeSitterNormalizer<'source> { &self, node: TreeSitterNode<'tree>, ) -> Option<(TreeSitterNode<'tree>, String)> { + if let Some(callee) = self.type_argument_callee(node) { + return self.member_parts(callee); + } if self .normalization_adapter .check_node_role(node, "expression_list") @@ -5356,7 +5578,7 @@ impl<'source> TreeSitterNormalizer<'source> { { return children .into_iter() - .filter_map(|child| self.normalize_node(child)) + .filter_map(|child| self.normalize_call_argument_node(child)) .collect(); } let Some(args) = self @@ -5408,10 +5630,49 @@ impl<'source> TreeSitterNormalizer<'source> { children .into_iter() - .filter_map(|child| self.normalize_node(child)) + .filter_map(|child| self.normalize_call_argument_node(child)) .collect() } + fn normalize_call_argument_node(&mut self, node: TreeSitterNode<'_>) -> Option { + self.normalize_node(node).or_else(|| { + self.call_node(node) + .then(|| self.normalize_call(node)) + .flatten() + }) + } + + fn recover_wrapped_call_arguments( + &mut self, + wrapper: TreeSitterNode<'_>, + call_source: TreeSitterNode<'_>, + call: &mut Node, + ) { + let Some(argument_slot) = call.children.get_mut(2) else { + return; + }; + if !matches!(argument_slot, Child::Nil) { + return; + } + let mut arguments = self.call_arguments(call_source, None); + if arguments.is_empty() && !self.same_ts_node(wrapper, call_source) { + arguments = self.call_arguments(wrapper, None); + } + if arguments.is_empty() { + return; + } + *argument_slot = Child::Node(Box::new( + self.wrap( + "LIST", + arguments + .into_iter() + .map(|node| Child::Node(Box::new(node))) + .collect(), + wrapper, + ), + )); + } + pub(in crate::ast) fn literal_arguments_from_text( &mut self, args: TreeSitterNode<'_>, @@ -5583,6 +5844,8 @@ impl<'source> TreeSitterNormalizer<'source> { return self.named_field(node, "name"); } self.named_field(node, "left") + .or_else(|| self.named_field(node, "pattern")) + .or_else(|| self.named_field(node, "name")) .or_else(|| self.named_children(node).into_iter().next()) } @@ -5594,6 +5857,7 @@ impl<'source> TreeSitterNormalizer<'source> { return self.named_field(node, "value"); } self.named_field(node, "right") + .or_else(|| self.named_field(node, "value")) .or_else(|| self.named_children(node).into_iter().nth(1)) } @@ -5772,11 +6036,16 @@ impl<'source> TreeSitterNormalizer<'source> { }; let receiver = optional_node(self.normalize_node(receiver)); let args = list_or_nil(right.into_iter().collect(), left, self); - return Some(self.wrap( + let normalized = self.wrap( "ATTRASGN", vec![receiver, Child::Symbol(writer), args], source, - )); + ); + let raw_call_span = span(left); + if self.parser_call_spans.contains(&raw_call_span) { + self.record_call_origin(raw_call_span, &normalized); + } + return Some(normalized); } if self .normalization_adapter @@ -5806,14 +6075,32 @@ impl<'source> TreeSitterNormalizer<'source> { let right_node = right_node.filter(|r| !self.same_ts_node(*r, node)); let right = right_node.and_then(|sibling| self.normalize_node(sibling)); let source = node.parent().unwrap_or(node); - self.assignment_target(node, right.clone(), source) - .or_else(|| { - Some(self.wrap( - "LASGN", - vec![Child::String(self.target_name(node)), optional_node(right)], - source, - )) - }) + if let Some(target) = self.assignment_target(node, right.clone(), source) { + return Some(target); + } + let assignment = self.wrap( + "LASGN", + vec![Child::String(self.target_name(node)), optional_node(right)], + source, + ); + let target_span = span(node); + let target_executes_call = self.parser_call_spans.iter().any(|call_span| { + (call_span[0], call_span[1]) >= (target_span[0], target_span[1]) + && (call_span[2], call_span[3]) <= (target_span[2], target_span[3]) + }); + if !target_executes_call { + return Some(assignment); + } + let mut target_effects = self.normalize_children(node); + if target_effects.is_empty() { + return Some(assignment); + } + // A complex lvalue can execute calls before it is written, as in + // Rust's `*counts.entry(key).or_default() += 1`. The fallback LASGN + // retains the write conservatively; the leading children retain the + // executable receiver/index calculation for call and cost analysis. + target_effects.push(Child::Node(Box::new(assignment))); + Some(self.wrap("BLOCK", target_effects, source)) } pub(in crate::ast) fn target_name(&self, node: TreeSitterNode<'_>) -> String { @@ -6284,7 +6571,10 @@ impl<'source> TreeSitterNormalizer<'source> { } pub(in crate::ast) fn call_node(&self, node: TreeSitterNode<'_>) -> bool { - self.call_kind(node.kind()) || self.normalization_adapter.call_node(node, self.source) + (self.call_kind(node.kind()) || self.normalization_adapter.call_node(node, self.source)) + && !self + .normalization_adapter + .nonruntime_call_node(node, self.source) } pub(in crate::ast) fn function_kind(&self, kind: &str) -> bool { @@ -6354,8 +6644,14 @@ impl<'source> TreeSitterNormalizer<'source> { } pub(in crate::ast) fn unwrap_node(&self, node: TreeSitterNode<'_>) -> bool { + let named_child_count = self.named_children(node).len(); self.normalization_adapter - .unwrap_node(node, self.source, self.named_children(node).len()) + .unwrap_node(node, self.source, named_child_count) + || self.normalization_adapter.transparent_expression( + node, + self.source, + named_child_count, + ) } pub(in crate::ast) fn single_dotted_else_body<'tree>( @@ -6573,4 +6869,19 @@ impl<'source> TreeSitterNormalizer<'source> { } } +fn append_flattened_block(node: Node, output: &mut Vec) { + if node.r#type != "BLOCK" { + output.push(Child::Node(Box::new(node))); + return; + } + for child in node.children { + match child { + Child::Node(node) if node.r#type == "BLOCK" => { + append_flattened_block(*node, output); + } + other => output.push(other), + } + } +} + include!("normalizer-test.rs"); diff --git a/gems/fact-mine/src/canonical_transaction.rs b/gems/fact-mine/src/canonical_transaction.rs new file mode 100644 index 000000000..968c2a3f0 --- /dev/null +++ b/gems/fact-mine/src/canonical_transaction.rs @@ -0,0 +1,105 @@ +//! Replacing a collect's canonical artifacts, all of them or none. +//! +//! A collect rewrites several files that are only meaningful together -- the +//! merged evidence, the snapshot manifest, the SCIP index, the attestation, and +//! the shard store. Half of a new set beside half of an old one describes a +//! state that never existed, and nothing downstream can tell that it is looking +//! at one. +//! +//! So the previous contents are copied aside first and put back if anything +//! fails. A file that did not exist before is deleted rather than restored: +//! absent is a state too, and leaving a new artifact behind would be exactly +//! the half-written set this exists to prevent. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Saved { + /// Where each path's previous contents were copied, or absent when the + /// path did not exist. + pub entries: Vec<(PathBuf, Option)>, + pub directory: PathBuf, +} + +pub fn save(paths: &[PathBuf], into: &Path) -> Result { + std::fs::create_dir_all(into) + .with_context(|| format!("failed to prepare {}", into.display()))?; + let mut entries = Vec::new(); + let mut seen: Vec<&PathBuf> = Vec::new(); + for (at, path) in paths.iter().enumerate() { + if seen.contains(&path) { + continue; + } + seen.push(path); + if !path.is_file() { + entries.push((path.clone(), None)); + continue; + } + let copy = into.join(format!("{at}")); + std::fs::copy(path, ©) + .with_context(|| format!("failed to preserve {}", path.display()))?; + entries.push((path.clone(), Some(copy))); + } + Ok(Saved { entries, directory: into.to_path_buf() }) +} + +/// Put every path back the way it was. Best effort per path: one failure must +/// not abandon the rest half-restored. +pub fn restore(saved: &Saved) -> Result<()> { + let mut failures = Vec::new(); + for (path, copy) in &saved.entries { + let outcome = match copy { + Some(copy) => std::fs::copy(copy, path).map(|_| ()), + None if path.is_file() => std::fs::remove_file(path), + None => Ok(()), + }; + if let Err(error) = outcome { + failures.push(format!("{}: {error}", path.display())); + } + } + if failures.is_empty() { + return Ok(()); + } + anyhow::bail!("could not restore the canonical artifacts: {}", failures.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restores_what_was_there_and_removes_what_was_not() { + let root = tempfile::tempdir().expect("tempdir"); + let existing = root.path().join("evidence.json"); + let fresh = root.path().join("index.json"); + std::fs::write(&existing, "before").expect("write"); + + let saved = save( + &[existing.clone(), fresh.clone()], + &root.path().join("saved"), + ) + .expect("save"); + + // A collect then replaces one and creates the other, and fails. + std::fs::write(&existing, "after").expect("write"); + std::fs::write(&fresh, "new").expect("write"); + restore(&saved).expect("restore"); + + assert_eq!(std::fs::read_to_string(&existing).expect("read"), "before"); + assert!(!fresh.exists(), "a file that did not exist before must not survive"); + } + + #[test] + fn a_repeated_path_is_saved_once() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("evidence.json"); + std::fs::write(&path, "before").expect("write"); + + let saved = save(&[path.clone(), path.clone()], &root.path().join("saved")) + .expect("save"); + + assert_eq!(saved.entries.len(), 1); + } +} diff --git a/gems/fact-mine/src/collect.rs b/gems/fact-mine/src/collect.rs new file mode 100644 index 000000000..5e33e135b --- /dev/null +++ b/gems/fact-mine/src/collect.rs @@ -0,0 +1,791 @@ +//! `nil-kill collect`, without a Ruby process to drive it. +//! +//! Every stage below is already a FactMine function; what was left in Ruby was +//! the order they run in, the environment the traced programs are given, and +//! the transaction that keeps the canonical artifacts consistent. That is what +//! this is. +//! +//! The traced program still has whatever runtime it has -- collecting Ruby +//! means running Ruby -- but nothing between the command line and the evidence +//! needs one. + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub struct Config { + pub root: PathBuf, + pub tmp_dir: PathBuf, + pub runtime_dir: PathBuf, + pub trace_plan: PathBuf, + pub targets: Vec, + pub collector_extension: PathBuf, + pub commands: Vec>, + pub fast: bool, + pub continue_on_error: bool, + pub shard_jobs: usize, +} + +impl Config { + pub fn from_env(root: PathBuf, commands: Vec>) -> Self { + let tmp_dir = std::env::var("NIL_KILL_TMP_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| root.join("tmp").join("nil-kill")); + let targets = std::env::var("NIL_KILL_TARGETS") + .unwrap_or_else(|_| "src".to_string()) + .split(':') + .map(str::to_string) + .collect(); + Self { + runtime_dir: tmp_dir.join("runtime"), + trace_plan: tmp_dir.join("trace-plan.json"), + collector_extension: collector_extension(), + tmp_dir, + root, + targets, + commands, + fast: false, + continue_on_error: false, + shard_jobs: std::thread::available_parallelism().map_or(4, |n| n.get()), + } + } + + /// Every analyzed source file under the targets. + pub fn target_files(&self) -> Vec { + let mut found = Vec::new(); + for target in &self.targets { + let path = self.root.join(target); + collect_ruby(&path, &mut found); + } + found.sort(); + found.dedup(); + found + } +} + +/// The collector object the traced program loads. +/// +/// It ships beside this binary, not inside the project being collected, so it +/// is found from where nil-kill is installed rather than from the analyzed +/// root -- a collect of any repository but nil-kill's own would otherwise look +/// for it under that repository. +fn collector_extension() -> PathBuf { + const RELATIVE: &str = "gems/nil-kill/ext/nil_kill_trace/nil_kill_trace.so"; + if let Ok(path) = std::env::var("NIL_KILL_COLLECTOR_EXTENSION") { + return PathBuf::from(path); + } + // target//fact-mine-rust -> the workspace holding both gems. + std::env::current_exe() + .ok() + .and_then(|exe| exe.ancestors().nth(5).map(|root| root.join(RELATIVE))) + .filter(|path| path.is_file()) + .unwrap_or_else(|| PathBuf::from(RELATIVE)) +} + +fn collect_ruby(path: &Path, into: &mut Vec) { + if path.is_file() { + if path.extension().is_some_and(|e| e == "rb") { + into.push(path.to_path_buf()); + } + return; + } + for entry in std::fs::read_dir(path).into_iter().flatten().flatten() { + collect_ruby(&entry.path(), into); + } +} + +fn stage(name: &str, body: impl FnOnce() -> Result) -> Result { + if std::env::var("NIL_KILL_STAGE_TIMING").as_deref() != Ok("1") { + return body(); + } + let started = std::time::Instant::now(); + let out = body(); + eprintln!("stage {name:<26} {:6.2}s", started.elapsed().as_secs_f64()); + out +} + +/// The one shard per command a workload gets when no test runner is +/// recognizable. Nothing about such a command says which part of it a source +/// change affects, so every one of them reruns. +fn opaque_shards(commands: &[Vec]) -> Vec { + use sha2::{Digest, Sha256}; + commands + .iter() + .enumerate() + .map(|(at, command)| { + let digest = format!("{:x}", Sha256::digest(serde_json::to_string(command).unwrap_or_default().as_bytes())); + json!({ + "id": format!("command-{at}-{}", &digest[..12]), + "command": command, + "test_path": "", + }) + }) + .collect() +} + +/// The workload in the shape a manifest stores it: the tests and support files +/// whose fingerprints decide what reruns, and one shard per test. +fn workload_value(commands: &[Vec], config: &Config, files: &[PathBuf]) -> Value { + use sha2::{Digest, Sha256}; + let command_digest = format!( + "{:x}", + Sha256::digest(serde_json::to_string(commands).unwrap_or_default().as_bytes()) + ); + let fingerprints = |paths: &[String]| { + let mut map = Map::new(); + for path in paths { + if let Some(fingerprint) = + crate::source_fingerprint::of_file(&config.root.join(path)) + { + map.insert(path.clone(), json!(fingerprint)); + } + } + Value::Object(map) + }; + let planned = commands + .iter() + .find_map(|command| crate::workload_plan::build(files, command, &config.root)); + match planned { + Some(plan) => { + let mut shards = Map::new(); + for shard in &plan.shards { + shards.insert( + shard.id.clone(), + json!({"command": shard.command, "test_path": shard.test_path}), + ); + } + json!({ + "mode": plan.mode, + "commands": commands, + "command_digest": command_digest, + "tests": fingerprints(&plan.test_paths), + "support_files": fingerprints(&plan.support_paths), + "shards": Value::Object(shards), + }) + } + None => { + let mut shards = Map::new(); + for shard in opaque_shards(commands) { + shards.insert( + shard["id"].as_str().unwrap_or_default().to_string(), + json!({"command": shard["command"], "test_path": ""}), + ); + } + json!({ + "mode": "opaque", + "commands": commands, + "command_digest": command_digest, + "tests": {}, + "support_files": {}, + "shards": Value::Object(shards), + }) + } + } +} + +/// Every source file a collect must not report as production code. +fn nonproduction(workload: &Value) -> Vec { + let mut paths = ["tests", "support_files"] + .iter() + .flat_map(|field| { + workload[*field].as_object().into_iter().flatten().map(|(key, _)| key.clone()) + }) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +pub fn run(config: &Config) -> Result<()> { + if config.commands.is_empty() && !config.fast { + bail!("nil-kill collect requires a command: collect [--fast] -- "); + } + std::fs::create_dir_all(&config.runtime_dir)?; + let previous = if config.fast { + Some(crate::snapshot::load(&config.runtime_dir)?) + } else { + None + }; + + // ---- the plan -------------------------------------------------------- + stage("trace-plan", || { + if std::env::var("NIL_KILL_TRACE_PLAN").as_deref() == Ok("0") { + return Ok(()); + } + write_trace_plan(config) + })?; + let plan: Value = std::fs::read_to_string(&config.trace_plan) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| json!({})); + let plan_digest = plan["runtime_evidence"]["plan_digest"].as_str().unwrap_or("").to_string(); + + let files = config.target_files(); + let inventory_path = config.tmp_dir.join("function-inventory.json"); + stage("function-inventory", || { + let mut args = vec![ + "nil-kill-function-inventory".to_string(), + "--output".into(), inventory_path.to_string_lossy().to_string(), + "--root".into(), config.root.to_string_lossy().to_string(), + ]; + if config.trace_plan.is_file() { + args.push("--plan".into()); + args.push(config.trace_plan.to_string_lossy().to_string()); + } + for file in &files { + args.push("--file".into()); + args.push(file.to_string_lossy().to_string()); + } + self_call(&args) + })?; + let inventory: Value = serde_json::from_str(&std::fs::read_to_string(&inventory_path)?)?; + + // ---- what to run ----------------------------------------------------- + // An incremental collect with no command of its own reruns the workload the + // snapshot recorded -- the command it recorded, not the plan: replanning is + // what notices a test file that was added or deleted since, and + // re-fingerprints the ones that are still there. + let commands = match previous.as_ref() { + Some(manifest) if config.commands.is_empty() => manifest["workload"]["commands"] + .as_array() + .into_iter() + .flatten() + .map(|command| { + command + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part.as_str().map(str::to_string)) + .collect::>() + }) + .collect::>(), + _ => config.commands.clone(), + }; + if commands.is_empty() { + bail!("nil-kill collect requires a command: collect [--fast] -- "); + } + let workload = workload_value(&commands, config, &files); + // The environment is asked of the commands the workload will actually run, + // not of this invocation's argv -- `--fast` on its own has none, and an + // environment that changed shape between two collects would read as a + // changed runtime and retrace everything. + let workload_commands = workload["shards"] + .as_object() + .into_iter() + .flatten() + .map(|(_, shard)| { + shard["command"] + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part.as_str().map(str::to_string)) + .collect::>() + }) + .collect::>(); + let selection = match previous.as_ref() { + Some(manifest) => crate::snapshot::select(&crate::snapshot::Increment { + manifest, + current_hashes: &crate::snapshot::source_hashes(&files, &config.root), + current_environment: &crate::snapshot::environment(&config.root, &workload_commands), + functions: &inventory, + workload: &workload, + trace_plan_digest: &plan_digest, + }), + None => crate::snapshot::full_selection( + &files, &config.root, &inventory, &workload, &plan_digest, &workload_commands, + ), + }; + if config.fast && !selection["rebuild"].as_bool().unwrap_or(true) { + println!( + "nil-kill: incremental snapshot is current; \ + no semantic source/test changes, workload skipped" + ); + return Ok(()); + } + + let wanted = selection["selected_shards"] + .as_array() + .into_iter() + .flatten() + .filter_map(|id| id.as_str().map(str::to_string)) + .collect::>(); + let shards = wanted + .iter() + .filter_map(|id| { + let shard = workload["shards"].get(id)?; + Some(json!({ + "id": id, + "command": shard["command"], + "test_path": shard["test_path"], + })) + }) + .collect::>(); + + let generation = previous + .as_ref() + .map_or(0, |manifest| manifest["generation"].as_i64().unwrap_or_default() + 1); + let working = config + .runtime_dir + .join(if config.fast { "increments" } else { "runs" }) + .join(format!("{generation:06}")); + let _ = std::fs::remove_dir_all(&working); + std::fs::create_dir_all(&working)?; + + // Tests and their support files are what a collect must not report as + // production code. Getting this wrong does not fail a collect -- it + // publishes the test suite's own methods as observed production evidence. + let roles = working.join("source-roles.json"); + std::fs::write( + &roles, + serde_json::to_string(&json!({"nonproduction": nonproduction(&workload)}))?, + )?; + + // ---- run them -------------------------------------------------------- + // No more workers than there is work for them to do; what is left over is + // the parallelism each shard's own workload may use. + let shard_jobs = config.shard_jobs.max(1).min(shards.len().max(1)); + let runs = shards + .iter() + .map(|shard| { + let id = shard["id"].as_str().unwrap_or_default().to_string(); + let dir = working.join(&id); + std::fs::create_dir_all(&dir).ok(); + let run_id = format!("{generation}:{id}:{}", uuid()); + let mut env: BTreeMap> = BTreeMap::new(); + for (key, value) in std::env::vars() { + env.insert(key, Some(value)); + } + env.insert("NIL_KILL_TRACE".into(), Some("1".into())); + env.insert("NIL_KILL_RUNTIME_SCIP".into(), Some("1".into())); + env.insert("NIL_KILL_ROOT".into(), Some(config.root.to_string_lossy().to_string())); + env.insert("NIL_KILL_SOURCE_ROLES".into(), Some(roles.to_string_lossy().to_string())); + env.insert("NIL_KILL_RUNTIME_DIR".into(), Some(dir.to_string_lossy().to_string())); + env.insert("NIL_KILL_RUN_ID".into(), Some(run_id)); + env.insert("NIL_KILL_SHARD_ID".into(), Some(id.clone())); + // A workload that runs its tests in a different order each time + // observes different state and records different values. + env.insert( + "SEED".into(), + Some(std::env::var("NIL_KILL_WORKLOAD_SEED").unwrap_or_else(|_| "0".into())), + ); + // The workload's own parallelism, divided by how many shards run at + // once. A workload that picks its own thread count observes + // different interleavings and records different values. + let inner = (config.shard_jobs.max(1) / shard_jobs.max(1)).max(1).to_string(); + for key in ["WORKERS", "NK_JOBS", "NIL_KILL_JOBS"] { + if std::env::var(key).is_err() { + env.insert(key.into(), Some(inner.clone())); + } + } + let project = config + .root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + env.entry("NIL_KILL_PROJECT_NAME".into()).or_insert(Some(project)); + env.entry("NIL_KILL_PROJECT_VERSION".into()) + .or_insert(Some(head_revision(&config.root))); + let rubyopt = format!( + "{} -r{}", + std::env::var("RUBYOPT").unwrap_or_default(), + config.collector_extension.display() + ); + env.insert("RUBYOPT".into(), Some(rubyopt.trim().to_string())); + crate::shard_runner::Shard { + id, + command: shard["command"] + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part.as_str().map(str::to_string)) + .collect(), + env, + } + }) + .collect::>(); + + let failed = crate::shard_runner::run(&crate::shard_runner::Plan { + shards: runs, + jobs: shard_jobs, + continue_on_error: config.continue_on_error, + banner: String::new(), + })?; + if !failed.is_empty() { + // The previous evidence stays exactly where it is, and the manifest + // says it is now older than the source beside it. + if let Some(manifest) = previous.as_ref() { + crate::snapshot::mark_stale( + &config.runtime_dir, + manifest, + &format!("required trace shard(s) failed: {}", failed.join(", ")), + &selection, + ×tamp(), + )?; + } + bail!( + "required trace shard(s) failed; canonical evidence was not replaced: {}", + failed.join(", ") + ); + } + + let shard_dirs = shards + .iter() + .map(|shard| working.join(shard["id"].as_str().unwrap_or_default())) + .collect::>(); + + // ---- what the collector saw becomes what it means -------------------- + let root = config.root.to_string_lossy().to_string(); + let plan_path = config.trace_plan.to_string_lossy().to_string(); + if !shard_dirs.is_empty() { + stage("derive-domains", || { + let mut args = vec!["nil-kill-derive-domains".to_string(), + "--root".into(), root.clone(), + "--source-roles".into(), roles.to_string_lossy().to_string()]; + for dir in &shard_dirs { + for path in raw_documents(dir) { + args.push("--input".into()); + args.push(path.to_string_lossy().to_string()); + } + } + self_call(&args) + })?; + stage("collector-export", || { + let mut args = vec!["nil-kill-collector-export".to_string(), + "--root".into(), root.clone(), "--plan".into(), plan_path.clone(), + "--source-roles".into(), roles.to_string_lossy().to_string()]; + for dir in &shard_dirs { + args.push("--runtime-dir".into()); + args.push(dir.to_string_lossy().to_string()); + } + self_call(&args) + })?; + } + + // ---- what each shard reached ----------------------------------------- + // An incremental collect reruns a shard when a function it depended on + // changed, so every shard that ran records what it touched. + let bookkeeping = config.tmp_dir.join("shard-bookkeeping.json"); + if !shard_dirs.is_empty() { + stage("shard-bookkeeping", || { + let mut args = vec!["nil-kill-shard-bookkeeping".to_string(), + "--inventory".into(), inventory_path.to_string_lossy().to_string(), + "--output".into(), bookkeeping.to_string_lossy().to_string(), + "--root".into(), root.clone()]; + for dir in &shard_dirs { + args.push("--shard".into()); + args.push(dir.to_string_lossy().to_string()); + } + self_call(&args) + })?; + } + let answers: Value = std::fs::read_to_string(&bookkeeping) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| json!({})); + + // Carried forward from the previous snapshot, replaced for the shards that + // ran, dropped for the shards that no longer exist. + let mut dependencies = previous + .as_ref() + .map_or_else(|| json!({}), |manifest| manifest["dependencies"].clone()); + let mut callsites = previous + .as_ref() + .map_or_else(|| json!({}), |manifest| manifest["callsites"].clone()); + for shard in &shards { + let id = shard["id"].as_str().unwrap_or_default(); + let answer = &answers[id]; + dependencies[id] = answer["dependencies"].clone(); + callsites[id] = answer["callsites"].clone(); + } + for id in selection["deleted_shards"].as_array().into_iter().flatten() { + let id = id.as_str().unwrap_or_default(); + dependencies.as_object_mut().map(|map| map.remove(id)); + callsites.as_object_mut().map(|map| map.remove(id)); + } + + if !shard_dirs.is_empty() { + stage("trace-documents", || { + let mut args = vec!["nil-kill-trace-document".to_string(), + "--root".into(), root.clone(), "--plan".into(), plan_path.clone()]; + for dir in &shard_dirs { + args.push("--runtime-dir".into()); + args.push(dir.to_string_lossy().to_string()); + } + self_call(&args) + })?; + } + + // ---- join, merge, index --------------------------------------------- + let merged = working.join("merged-evidence.v1.json.gz"); + if !shard_dirs.is_empty() { + let traces = shard_dirs + .iter() + .map(|dir| dir.join("runtime-trace.json.gz")) + .collect::>(); + stage("join", || { + let mut args = vec!["runtime-trace".to_string(), + "--root".into(), root.clone(), "--plan".into(), plan_path.clone(), + "--merged-output".into(), merged.to_string_lossy().to_string()]; + for trace in &traces { + args.push("--runtime-trace".into()); + args.push(trace.to_string_lossy().to_string()); + } + self_call(&args) + })?; + } + + // Every shard the workload currently has, taking this run's evidence where + // it ran and the stored evidence where it did not. That is what makes an + // incremental collect a complete one. + let store = config.runtime_dir.join("shard-evidence"); + std::fs::create_dir_all(&store)?; + let staged = shards + .iter() + .map(|shard| { + let id = shard["id"].as_str().unwrap_or_default().to_string(); + let path = working.join(&id).join("runtime-evidence.v1.json.gz"); + (id, path) + }) + .collect::>(); + let current = strings(&workload["shards"]); + let destinations = current + .iter() + .map(|id| (id.clone(), store.join(format!("{id}.json.gz")))) + .collect::>(); + let effective = current + .iter() + .filter_map(|id| match staged.get(id) { + Some(path) => Some(path.clone()), + None => destinations.get(id).filter(|path| path.is_file()).cloned(), + }) + .collect::>(); + + let canonical = config.runtime_dir.join("runtime-evidence.v1.json.gz"); + let index = config.runtime_dir.join("runtime.scip.json"); + let attestation = config.runtime_dir.join("runtime-attestation.json.gz"); + let mut guarded = vec![ + canonical.clone(), + index.clone(), + attestation.clone(), + config.runtime_dir.join(crate::snapshot::MANIFEST), + ]; + guarded.extend(destinations.values().cloned()); + for id in selection["deleted_shards"].as_array().into_iter().flatten() { + guarded.push(store.join(format!("{}.json.gz", id.as_str().unwrap_or_default()))); + } + let saved = crate::canonical_transaction::save( + &guarded, + &config.tmp_dir.join("canonical-transaction.d"), + )?; + + let finish = || -> Result<()> { + stage("evidence-merge", || { + // This run's shards are the whole canonical set, so the join + // already merged them; otherwise stored evidence mixes in. + let only_this_run = effective.len() == staged.len() + && staged.values().all(|path| effective.contains(path)); + if only_this_run && merged.is_file() { + std::fs::copy(&merged, &canonical)?; + return Ok(()); + } + let mut args = vec!["nil-kill-merge-evidence".to_string(), + "--output".into(), canonical.to_string_lossy().to_string(), + "--plan".into(), plan_path.clone()]; + for path in &effective { + args.push("--input".into()); + args.push(path.to_string_lossy().to_string()); + } + self_call(&args) + })?; + // The overlay re-derives the plan from source to check the snapshot + // still matches, and reads the runtime-evidence contract rather than + // the private instrumentation controls beside it. + let evidence_plan = config.tmp_dir.join("runtime-evidence-contract.json"); + std::fs::write(&evidence_plan, serde_json::to_string(&plan["runtime_evidence"])?)?; + stage("scip-index", || { + self_call(&["nil-kill-scip-index".to_string(), + "--runtime-dir".into(), working.to_string_lossy().to_string(), + "--evidence".into(), canonical.to_string_lossy().to_string(), + "--plan".into(), evidence_plan.to_string_lossy().to_string(), + "--output".into(), index.to_string_lossy().to_string(), + "--attestation".into(), attestation.to_string_lossy().to_string(), + "--root".into(), root.clone()]) + })?; + + // The store is what the next incremental collect reuses, so it moves + // inside the transaction with everything else. + for (id, path) in &staged { + if path.is_file() { + std::fs::copy(path, destinations.get(id).context("shard has no destination")?)?; + } + } + for id in selection["deleted_shards"].as_array().into_iter().flatten() { + let path = store.join(format!("{}.json.gz", id.as_str().unwrap_or_default())); + if path.is_file() { + std::fs::remove_file(&path)?; + } + } + + let written = crate::snapshot::Written { + runtime_dir: &config.runtime_dir, + root: &config.root, + evidence: &canonical, + dependencies: dependencies.clone(), + callsites: callsites.clone(), + }; + match previous.as_ref() { + Some(manifest) => { + crate::snapshot::write_incremental(&written, manifest, &selection, ×tamp())? + } + None => crate::snapshot::write_full(&written, &selection, ×tamp())?, + }; + Ok(()) + }; + if let Err(error) = finish() { + crate::canonical_transaction::restore(&saved).ok(); + return Err(error); + } + if config.fast { + println!( + "nil-kill: updated compressed canonical snapshot generation {generation}; \ + {} changed functions, {} changed tests, {} traced shards", + selection["changed_functions"].as_array().map_or(0, Vec::len), + selection["changed_tests"].as_array().map_or(0, Vec::len), + shards.len() + ); + } + Ok(()) +} + +/// Every key of a JSON object, in order. +fn strings(value: &Value) -> Vec { + value.as_object().into_iter().flatten().map(|(key, _)| key.clone()).collect() +} + +/// When a collect happened, which the manifest records and nothing compares. +fn timestamp() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let days = seconds / 86_400; + let (mut year, mut remaining) = (1970u64, days); + loop { + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let length = if leap { 366 } else { 365 }; + if remaining < length { + break; + } + remaining -= length; + year += 1; + } + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let lengths = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 0; + while remaining >= lengths[month] { + remaining -= lengths[month]; + month += 1; + } + format!( + "{year:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + month + 1, + remaining + 1, + (seconds % 86_400) / 3600, + (seconds % 3600) / 60, + seconds % 60 + ) +} + +fn raw_documents(dir: &Path) -> Vec { + let mut found = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name().is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("collector-raw-") && name.ends_with(".json.gz") + }) + }) + .collect::>(); + found.sort(); + found +} + +/// The commit a collect ran against, which is the workspace package's version. +fn head_revision(root: &Path) -> String { + std::process::Command::new("git") + .args(["-C", &root.to_string_lossy(), "rev-parse", "HEAD"]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "workspace".to_string()) +} + +/// A run identity that is unique per shard per collect. +fn uuid() -> String { + use sha2::{Digest, Sha256}; + let seed = format!( + "{:?}{}", + std::time::SystemTime::now(), + std::process::id() + ); + format!("{:x}", Sha256::digest(seed.as_bytes()))[..32].to_string() +} + +/// A stage, run as its own invocation of this binary. Each is already a +/// verified subcommand; collect decides the order and the environment. +fn self_call(args: &[String]) -> Result<()> { + let binary = std::env::current_exe().context("cannot locate the fact-mine binary")?; + let name = args.first().cloned().unwrap_or_default(); + let status = std::process::Command::new(&binary) + .args(args) + .status() + .with_context(|| format!("failed to run {name}"))?; + if !status.success() { + bail!("fact-mine {name} failed"); + } + Ok(()) +} + +fn write_trace_plan(config: &Config) -> Result<()> { + let files = config.target_files(); + if files.is_empty() { + return Ok(()); + } + let text = |path: &Path| path.to_string_lossy().to_string(); + let facts = config.tmp_dir.join("static-facts.json"); + let evidence = config.tmp_dir.join("runtime-evidence-plan.json"); + let mut profile = vec!["profile".to_string(), "trace-plan".into(), + "--output".into(), text(&facts)]; + let mut runtime = vec!["runtime-plan".to_string(), "--output".into(), text(&evidence), + "--root".into(), text(&config.root)]; + for file in &files { + profile.push(text(file)); + runtime.push(text(file)); + } + self_call(&profile)?; + self_call(&runtime)?; + + let mut args = vec!["nil-kill-trace-plan".to_string(), + "--raw-facts".into(), text(&facts), + "--runtime-plan".into(), text(&evidence), + "--output".into(), text(&config.trace_plan), + "--root".into(), text(&config.root)]; + let mut sidecar = vec!["nil-kill-collector-plan".to_string(), + "--plan".into(), text(&config.trace_plan), + "--output".into(), text(&config.tmp_dir.join("collector-plan.tsv")), + "--root".into(), text(&config.root)]; + for target in &config.targets { + for list in [&mut args, &mut sidecar] { + list.push("--target-dir".into()); + list.push(text(&config.root.join(target))); + } + } + self_call(&args)?; + self_call(&sidecar) +} diff --git a/gems/fact-mine/src/collector_export.rs b/gems/fact-mine/src/collector_export.rs new file mode 100644 index 000000000..4b353acf0 --- /dev/null +++ b/gems/fact-mine/src/collector_export.rs @@ -0,0 +1,785 @@ +//! Turning what the collector saw into the rows the rest of the pipeline reads. +//! +//! The input is one document a traced program wrote when it exited: its own +//! tables, plus the facts only that process could know -- which gem each file +//! came from, what the interpreter's version was. None of the shaping needs a +//! VM, which is why it happens here. +//! +//! The Ruby this replaces read every field twice, `row["types"] || row[:types]`, +//! because the same rows arrived string-keyed from JSONL and symbol-keyed from +//! a parse. Deserializing once removes the question. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// The document a traced program wrote. +#[derive(Debug, Deserialize)] +pub struct CollectorDocument { + pub pid: i64, + #[serde(default)] + pub run_id: String, + #[serde(default)] + pub root: String, + #[serde(default)] + pub targets: Vec, + #[serde(default)] + pub ruby_version: String, + #[serde(default)] + pub records: Vec, + #[serde(default)] + pub domains: Vec, + #[serde(default)] + pub executed_callsites: Vec, + #[serde(default)] + pub function_entries: Vec, + #[serde(default)] + pub state_values: Vec, + #[serde(default)] + pub method_edges: Vec, + #[serde(default)] + pub collections: Vec, + #[serde(default)] + pub structs: Vec, + #[serde(default)] + pub tuples: Vec, + #[serde(default)] + pub tlets: Vec, + #[serde(default)] + pub gem_specs: Vec<(String, String, String)>, + #[serde(default)] + pub default_gem_specs: Vec<(String, String, String)>, + #[serde(default)] + pub coverage: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CallRecord { + pub caller: Value, + pub callee: Map, + pub callsite: Callsite, + #[serde(default)] + pub receiver_types: Vec, + #[serde(default)] + pub receiver_domain_indices: Vec, + #[serde(default)] + pub result_types: Vec, + #[serde(default)] + pub result_domain_indices: Vec, + #[serde(default)] + pub result_truths: Vec, + pub count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct Callsite { + pub path: String, + pub line: i64, + pub selector: String, +} + +/// The six fields a value domain carries into a row, in the order the rows +/// name them. +const DOMAIN_FIELDS: [&str; 6] = ["types", "singletons", "elements", "keys", "values", "shapes"]; + +/// "\x01\x01" => the anchor symbols the plan wants +/// there. One observed event can satisfy several requests, so the collector +/// reports coordinates and the fan-out happens here. +pub fn anchors_by_key(plan: Option<&Value>, root: &Path) -> BTreeMap> { + let mut anchors: BTreeMap> = BTreeMap::new(); + let Some(requests) = plan + .and_then(|plan| plan.get("runtime_evidence")) + .and_then(|evidence| evidence.get("requests")) + .and_then(Value::as_array) + else { + return anchors; + }; + for request in requests { + let Some(anchor) = request.get("anchor").filter(|value| value.is_object()) else { + continue; + }; + let range = request + .get("execution_range") + .filter(|value| value.is_object()) + .or_else(|| anchor.get("range").filter(|value| value.is_object())); + let Some(range) = range else { continue }; + + let path = root + .join(anchor["relative_path"].as_str().unwrap_or_default()) + .to_string_lossy() + .to_string(); + let selector = anchor["display_name"].as_str().unwrap_or_default(); + let symbol = anchor["symbol"].as_str().unwrap_or_default().to_string(); + let start = range["start_line"].as_i64().unwrap_or_default(); + let end = range["end_line"].as_i64().unwrap_or_default(); + for line in start..=end { + let symbols = anchors.entry(format!("{path}\u{1}{}\u{1}{selector}", line + 1)).or_default(); + if !symbols.contains(&symbol) { + symbols.push(symbol.clone()); + } + } + } + anchors +} + +pub struct Export<'a> { + document: &'a CollectorDocument, + anchors: &'a BTreeMap>, + nonproduction: BTreeSet, + project_name: String, + project_version: String, +} + +impl<'a> Export<'a> { + pub fn new( + document: &'a CollectorDocument, + anchors: &'a BTreeMap>, + nonproduction: BTreeSet, + project_name: String, + project_version: String, + ) -> Self { + Self { document, anchors, nonproduction, project_name, project_version } + } + + /// The same set of files the traced program used to write itself. + pub fn write(&self, runtime_dir: &Path) -> Result<()> { + let pid = self.document.pid; + let files: Vec<(&str, Vec)> = vec![ + ("runtime-calls", self.call_rows()), + ("methods", self.method_rows()), + ("method-edges", self.method_edge_rows()), + ("executed-callsites", self.executed_callsite_rows()), + ("exact-anchor-executions", self.exact_anchor_rows()), + ("function-entries", self.function_entry_rows()), + ("state-values", self.state_rows()), + ("ivars", self.ivar_rows()), + ("structs", self.document.structs.clone()), + ("tuples", self.document.tuples.clone()), + // A reader whose result is a collection was derived into this file + // too and then overwritten by these rows before anything read it, + // so only what the mutation hook observed is kept. + ("collections", self.collection_rows()), + ("tlets", self.tlet_rows()), + ]; + for (name, rows) in files { + write_jsonl(&runtime_dir.join(format!("{name}-{pid}.jsonl")), &rows)?; + } + self.write_coverage(runtime_dir) + } + + // ------------------------------------------------------------ packages + + fn ruby_package(&self) -> Value { + json!({ + "package_manager": "ruby", + "package": "ruby", + "version": self.document.ruby_version, + }) + } + + fn workspace_package(&self) -> Value { + json!({ + "package_manager": "workspace", + "package": self.project_name, + "version": self.project_version, + }) + } + + fn under(root: &str, absolute: &str) -> bool { + absolute == root || absolute.starts_with(&format!("{root}/")) + } + + /// Which gem owns a file is a fact only the traced VM held, and it wrote + /// its gem table down. What that makes the file is decided here. + fn package(&self, path: Option<&str>, native: bool) -> Value { + if native { + return self.ruby_package(); + } + // TracePoint uses pseudo-paths such as `` for + // Ruby-core implementations written outside the workspace. Expanding + // those would incorrectly label `Kernel#warn` and peers as project code. + let raw = path.unwrap_or_default(); + if raw.is_empty() || raw.starts_with(", native: bool) -> Map { + let mut facts = Map::new(); + let nonproduction = path.is_some_and(|path| { + self.nonproduction.contains(&expand(path, &self.document.root)) + }); + facts.insert( + "source_role".to_string(), + if nonproduction { json!("nonproduction") } else { Value::Null }, + ); + if let Value::Object(package) = self.package(path, native) { + facts.extend(package); + } + facts + } + + // ------------------------------------------------------------- domains + + fn symbols_for(&self, path: &str, line: i64, selector: &str) -> &[String] { + self.anchors + .get(&format!("{path}\u{1}{line}\u{1}{selector}")) + .map_or(&[][..], Vec::as_slice) + } + + /// Types the collector named directly, merged with the domains it recorded + /// by index. `production_only` drops what a test double contributed, which + /// call evidence must not export as a target. + fn domain_for(&self, types: &[String], indices: &[usize], production_only: bool) -> Value { + let mut domain: BTreeMap<&str, Vec> = + DOMAIN_FIELDS.iter().map(|field| (*field, Vec::new())).collect(); + merge_domain_field( + &mut domain, + "types", + &types.iter().map(|name| json!(name)).collect::>(), + ); + for index in indices { + let Some(observed) = self.document.domains.get(*index) else { continue }; + if production_only && observed["nonproduction"].as_bool().unwrap_or(false) { + continue; + } + for field in DOMAIN_FIELDS { + if let Some(values) = observed.get(field).and_then(Value::as_array) { + merge_domain_field(&mut domain, field, values); + } + } + } + let mut out = Map::new(); + for field in DOMAIN_FIELDS { + out.insert(field.to_string(), json!(domain[field])); + } + Value::Object(out) + } + + // ---------------------------------------------------------------- rows + + fn definition_path(path: Option<&str>) -> Option<&str> { + let path = path?; + if path.starts_with('<') || path.contains("/gems/nil-kill/lib/") { + return None; + } + Some(path) + } + + fn call_rows(&self) -> Vec { + let mut rows = Vec::new(); + for record in &self.document.records { + let path = Self::definition_path(record.callee["path"].as_str()); + let native = record.callee["native"].as_bool().unwrap_or(false) && path.is_none(); + let symbols = self.symbols_for( + &record.callsite.path, + record.callsite.line, + &record.callsite.selector, + ); + // A known definition site outranks the C-implementation flag for + // package attribution: a generated accessor on a workspace class is + // workspace code, not CRuby, even though the VM reported it native. + let mut callee = record.callee.clone(); + callee.insert("path".to_string(), path.map_or(Value::Null, |path| json!(path))); + if path.is_none() { + callee.insert("line".to_string(), Value::Null); + } + callee.extend(self.callee_facts(path, native)); + + let receiver = self.domain_for( + &record.receiver_types, + &record.receiver_domain_indices, + true, + ); + let result = + self.domain_for(&record.result_types, &record.result_domain_indices, true); + + let anchors: Vec = if symbols.is_empty() { + vec![Value::Null] + } else { + symbols.iter().map(|symbol| json!(symbol)).collect() + }; + for anchor_symbol in anchors { + rows.push(json!({ + "schema_version": 1, + "event": "runtime_call", + "language": "ruby", + "run_id": self.document.run_id, + "caller": record.caller, + "callsite": { + "path": record.callsite.path, + "line": record.callsite.line, + "anchor_symbol": anchor_symbol, + }, + "callee": callee, + "receiver_domain": receiver, + "result_domain": result, + "result_truths": record.result_truths, + "count": record.count, + })); + } + } + rows + } + + fn executed_callsite_rows(&self) -> Vec { + let mut rows = self.document.executed_callsites.clone(); + rows.sort_by_key(tuple_sort_key); + rows.iter() + .map(|row| { + json!({"path": row[0], "line": row[1], "selector": row[2], "count": row[3]}) + }) + .collect() + } + + fn exact_anchor_rows(&self) -> Vec { + let mut tally: BTreeMap<&str, i64> = BTreeMap::new(); + for row in &self.document.executed_callsites { + let (path, line, selector) = ( + row[0].as_str().unwrap_or_default(), + row[1].as_i64().unwrap_or_default(), + row[2].as_str().unwrap_or_default(), + ); + let count = row[3].as_i64().unwrap_or_default(); + for symbol in self.symbols_for(path, line, selector) { + *tally.entry(symbol.as_str()).or_default() += count; + } + } + tally.into_iter().map(|(symbol, count)| json!({"symbol": symbol, "count": count})).collect() + } + + fn function_entry_rows(&self) -> Vec { + let mut rows = self.document.function_entries.clone(); + rows.sort_by_key(tuple_sort_key); + rows.iter() + .map(|row| { + json!({ + "path": row[0], "owner": row[1], "name": row[2], + "kind": "instance", "line": row[3], "count": row[4], + }) + }) + .collect() + } + + fn state_rows(&self) -> Vec { + self.document + .state_values + .iter() + .map(|row| { + let mut classes = row[4] + .as_array() + .into_iter() + .flatten() + .filter(|value| !value.is_null()) + .cloned() + .collect::>(); + classes.sort_by_key(|value| value.as_str().unwrap_or_default().to_string()); + json!({ + "path": row[0], "line": row[1], "class": row[2], + "name": row[3], "classes": classes, "calls": row[5], + }) + }) + .collect() + } + + /// The same observations answer two questions: which classes a member holds + /// anywhere, and which it holds at one write site. + fn ivar_rows(&self) -> Vec { + let mut tally: Vec<((String, String), (i64, Vec))> = Vec::new(); + for row in self.state_rows() { + let owner = row["class"].as_str().unwrap_or_default().to_string(); + let name = format!("@{}", row["name"].as_str().unwrap_or_default()); + let calls = row["calls"].as_i64().unwrap_or_default(); + let classes = row["classes"] + .as_array() + .into_iter() + .flatten() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect::>(); + match tally.iter_mut().find(|(key, _)| *key == (owner.clone(), name.clone())) { + Some((_, record)) => { + record.0 += calls; + for class in classes { + if !record.1.contains(&class) { + record.1.push(class); + } + } + } + None => tally.push(((owner, name), (calls, classes))), + } + } + tally + .into_iter() + .map(|((owner, name), (calls, mut classes))| { + classes.sort(); + json!({"class": owner, "name": name, "calls": calls, "classes": classes}) + }) + .collect() + } + + fn collection_rows(&self) -> Vec { + self.document + .collections + .iter() + .map(|row| { + let mut row = row.clone(); + let sites = row["mutation_sites"].as_object().cloned().unwrap_or_default(); + let mut ordered = sites.into_iter().collect::>(); + ordered.sort_by(|(left_site, left), (right_site, right)| { + let by_count = right.as_i64().unwrap_or_default() + .cmp(&left.as_i64().unwrap_or_default()); + by_count.then_with(|| left_site.cmp(right_site)) + }); + row["mutation_sites"] = Value::Object(ordered.into_iter().collect()); + row + }) + .collect() + } + + fn tlet_rows(&self) -> Vec { + self.document + .tlets + .iter() + .map(|row| { + json!({ + "path": row["path"], "line": row["line"], + "calls": row["calls"], "classes": row["classes"], + }) + }) + .collect() + } + + /// The evidence emitter reads parameter and return domains from + /// methods-*.jsonl, which the Ruby type tier used to produce. The collector + /// already observes both -- parameters at analyzed method entry, returns + /// under the "return" selector -- so this regroups them per function. + fn method_rows(&self) -> Vec { + self.document + .function_entries + .iter() + .map(|entry| { + let (path, owner, name, line, count) = + (&entry[0], &entry[1], &entry[2], &entry[3], &entry[4]); + let at_site = self.document.records.iter().filter(|record| { + json!(record.callsite.path) == *path && json!(record.callsite.line) == *line + }); + let mut params_by_name = Map::new(); + let mut param_singleton_types = Map::new(); + let mut param_value_shapes = Map::new(); + let mut param_elem = Map::new(); + let mut param_elem_shapes = Map::new(); + let mut param_kv = Map::new(); + let mut param_kv_shapes = Map::new(); + let mut returned = json!({ + "returns": [], "return_singleton_types": [], "return_value_shapes": [], + "return_elem": [], "return_kv": [[], []], + }); + for record in at_site { + if record.callsite.selector == "return" { + let domain = self.domain_for( + &record.result_types, + &record.result_domain_indices, + false, + ); + returned = json!({ + "returns": domain["types"], + "return_singleton_types": domain["singletons"], + "return_value_shapes": domain["shapes"], + "return_elem": domain["elements"], + "return_kv": [domain["keys"], domain["values"]], + }); + continue; + } + let slot = record.callsite.selector.clone(); + let domain = self.domain_for( + &record.receiver_types, + &record.receiver_domain_indices, + false, + ); + params_by_name.insert(slot.clone(), domain["types"].clone()); + param_singleton_types.insert(slot.clone(), domain["singletons"].clone()); + param_value_shapes.insert(slot.clone(), domain["shapes"].clone()); + param_elem.insert(slot.clone(), domain["elements"].clone()); + param_elem_shapes.insert(slot.clone(), json!([])); + param_kv.insert( + slot.clone(), + json!([domain["keys"], domain["values"]]), + ); + param_kv_shapes.insert(slot, json!([[], []])); + } + json!({ + "class": owner, "method": name, "kind": "instance", + "path": path, "line": line, + "calls": count, "ok_calls": count, "raised_calls": 0, + "params_by_name": params_by_name, + "param_singleton_types": param_singleton_types, + "param_value_shapes": param_value_shapes, + "param_elem": param_elem, + "param_elem_shapes": param_elem_shapes, + "param_kv": param_kv, + "param_kv_shapes": param_kv_shapes, + "params_ok": {}, "params_raised": {}, "param_sites": {}, + "returns": returned["returns"], + "return_singleton_types": returned["return_singleton_types"], + "return_value_shapes": returned["return_value_shapes"], + "return_elem": returned["return_elem"], + "return_elem_shapes": [], + "return_kv": returned["return_kv"], + "return_kv_shapes": [[], []], + }) + }) + .collect() + } + + /// An edge is a fact about the call graph, not evidence about a requested + /// value, so it is recorded for every call between two analyzed methods + /// rather than only for callsites the plan demanded. + fn method_edge_rows(&self) -> Vec { + let entries = self + .document + .function_entries + .iter() + .map(|entry| { + ( + site_key(&entry[0], &entry[3]), + json!({ + "class": entry[1], "method": entry[2], "kind": "instance", + "path": entry[0], "line": entry[3], + }), + ) + }) + .collect::>(); + self.document + .method_edges + .iter() + .filter_map(|edge| { + let from = entries.get(&site_key(&edge[0], &edge[1]))?; + let to = entries.get(&site_key(&edge[2], &edge[3]))?; + Some(json!({ + "caller": from, "callee": to, + "calls": edge[4], "ok_calls": edge[4], "raised_calls": 0, + })) + }) + .collect() + } + + /// Ruby's line coverage for the traced run, so the report can tell a tracer + /// miss from a line the workload simply never reached. + fn write_coverage(&self, runtime_dir: &Path) -> Result<()> { + let Some(coverage) = &self.document.coverage else { return Ok(()) }; + + let mut rows = Vec::new(); + for (path, data) in coverage { + // Native collection uses oneshot_lines. A shared SimpleCov session + // may already be running in counted-lines mode, so accept that + // shape rather than restarting or weakening the external session. + let oneshot = data.get("oneshot_lines").and_then(Value::as_array); + let mut covered: Vec = match oneshot { + Some(lines) => lines.iter().filter_map(Value::as_i64).collect(), + None => { + let lines = data.get("lines").and_then(Value::as_array).or_else(|| data.as_array()); + lines + .into_iter() + .flatten() + .enumerate() + .filter_map(|(at, hits)| { + hits.as_i64().filter(|hits| *hits > 0).map(|_| at as i64 + 1) + }) + .collect() + } + }; + covered.sort_unstable(); + covered.dedup(); + if covered.is_empty() { + continue; + } + rows.push(json!({"path": path, "lines": covered})); + } + write_jsonl(&runtime_dir.join(format!("coverage-{}.jsonl", self.document.pid)), &rows)?; + // `loop_sites` has never been written into a plan, so this file has + // always been empty; it is still written because its absence and its + // emptiness mean different things to the reader. + write_jsonl(&runtime_dir.join(format!("loops-{}.jsonl", self.document.pid)), &[]) + } +} + +/// A method entry's identity: the file it is in and the line it starts on. +fn site_key(path: &Value, line: &Value) -> (String, i64) { + (path.as_str().unwrap_or_default().to_string(), line.as_i64().unwrap_or_default()) +} + +/// Ruby sorted these tuples by `row.map(&:to_s)`, so a line number orders as +/// text and 10 precedes 9. +fn tuple_sort_key(row: &Value) -> Vec { + row.as_array() + .into_iter() + .flatten() + .map(|value| match value { + Value::String(text) => text.clone(), + other => other.to_string(), + }) + .collect() +} + +/// Only rebuild a field when an alternative is genuinely new: domains are +/// stored unique and sorted by the same key, so re-merging an existing one is +/// exactly the identity. +fn merge_domain_field(domain: &mut BTreeMap<&str, Vec>, field: &str, values: &[Value]) { + if values.is_empty() { + return; + } + let Some(current) = domain.get_mut(field) else { return }; + let added = values.iter().filter(|value| !current.contains(value)).cloned().collect::>(); + if added.is_empty() { + return; + } + current.extend(added); + current.sort_by_cached_key(domain_sort_key); +} + +fn domain_sort_key(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + other => other.to_string(), + } +} + +fn expand(path: &str, root: &str) -> String { + if path.starts_with('/') { + return path.to_string(); + } + format!("{root}/{path}") +} + +fn write_jsonl(path: &Path, rows: &[Value]) -> Result<()> { + let mut out = String::new(); + for row in rows { + out.push_str(&serde_json::to_string(row)?); + out.push('\n'); + } + std::fs::write(path, out).with_context(|| format!("failed to write {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn document(root: &str, targets: Vec<&str>, gems: Vec<(&str, &str, &str)>) -> CollectorDocument { + let gems = gems + .into_iter() + .map(|(name, version, path)| { + (name.to_string(), version.to_string(), path.to_string()) + }) + .collect::>(); + serde_json::from_value(json!({ + "pid": 1, + "root": root, + "targets": targets, + "ruby_version": "3.2.3", + "gem_specs": gems, + "default_gem_specs": [], + })) + .expect("document") + } + + fn export<'a>( + document: &'a CollectorDocument, + anchors: &'a BTreeMap>, + ) -> Export<'a> { + Export::new( + document, + anchors, + BTreeSet::new(), + "clear".to_string(), + "workspace".to_string(), + ) + } + + /// Trace targets are narrower than the workspace: a project may call a + /// sibling tool without instrumenting it. That file is still a workspace + /// declaration, not a Ruby core method. + #[test] + fn workspace_source_outside_the_targets_keeps_workspace_identity() { + let document = document("/w", vec!["/w/src"], vec![]); + let anchors = BTreeMap::new(); + let export = export(&document, &anchors); + + assert_eq!( + export.package(Some("/w/tools/vopr_coverage.rb"), false), + json!({"package_manager": "workspace", "package": "clear", "version": "workspace"}) + ); + // A pseudo-path is a Ruby-core implementation with no workspace source, + // and expanding it would label `Kernel#warn` project code. + assert_eq!( + export.package(Some(""), false), + json!({"package_manager": "ruby", "package": "ruby", "version": "3.2.3"}) + ); + } + + /// Ruby ships a growing part of its standard library as default gems. + /// Bundler may activate a newer vendored copy; that does not turn StringIO + /// into a third-party API. + #[test] + fn a_default_gem_stays_a_versioned_standard_library_package() { + let mut document = document("/w", vec![], vec![("stringio", "3.2.0", "/g/stringio")]); + document.default_gem_specs = vec![( + "stringio".to_string(), + "3.2.0".to_string(), + "/g/stringio".to_string(), + )]; + let anchors = BTreeMap::new(); + + assert_eq!( + export(&document, &anchors).package(Some("/g/stringio/lib/stringio.rb"), false), + json!({"package_manager": "ruby", "package": "stringio", "version": "3.2.0"}) + ); + } + + /// A gem that is not a default gem is a third-party dependency. + #[test] + fn an_ordinary_gem_is_a_rubygems_package() { + let document = document("/w", vec![], vec![("rack", "3.1.0", "/g/rack")]); + let anchors = BTreeMap::new(); + + assert_eq!( + export(&document, &anchors).package(Some("/g/rack/lib/rack.rb"), false), + json!({"package_manager": "rubygems", "package": "rack", "version": "3.1.0"}) + ); + } +} diff --git a/gems/fact-mine/src/collector_plan.rs b/gems/fact-mine/src/collector_plan.rs new file mode 100644 index 000000000..cbedf8fd2 --- /dev/null +++ b/gems/fact-mine/src/collector_plan.rs @@ -0,0 +1,196 @@ +//! The plan the collector reads. +//! +//! A traced program is handed flat records rather than a document plus the code +//! to reshape it: what it should demand at which coordinate, which record fields +//! are still worth sampling, and where the T.let sites are. Everything here was +//! already decided by the time the plan was built, so deciding it again inside +//! the program under observation would be work in the worst possible place. +//! +//! Records are `\x02`-separated because demand keys contain `\x01` of their own. + +use anyhow::{Context, Result}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +const FIELD: char = '\u{2}'; + +/// Build the collector's sidecar from the plan and the instrumentation controls +/// beside it. Insertion order is preserved for the demands, so the file is +/// stable across builds of the same plan. +pub fn render(plan: &Value, target_dirs: &[String], root: &Path) -> String { + let mut lines: Vec = target_dirs + .iter() + .map(|dir| format!("t{FIELD}{}", absolute(dir, root))) + .collect(); + + // A coordinate answers one anchor: the first request to claim it wins, as + // it did when the collector was handed the whole plan and took `.first`. + let mut demands: Vec<(String, String)> = Vec::new(); + let mut seen_demand = BTreeMap::new(); + let mut states: BTreeMap = BTreeMap::new(); + + let requests = plan + .get("runtime_evidence") + .and_then(|evidence| evidence.get("requests")) + .or_else(|| plan.get("requests")) + .and_then(Value::as_array); + for request in requests.into_iter().flatten() { + let Some(anchor) = request.get("anchor").filter(|value| value.is_object()) else { + continue; + }; + let path = absolute(anchor["relative_path"].as_str().unwrap_or_default(), root); + let name = anchor["display_name"].as_str().unwrap_or_default(); + let range = request + .get("execution_range") + .filter(|value| value.is_object()) + .or_else(|| anchor.get("range").filter(|value| value.is_object())); + if let Some(range) = range { + let symbol = anchor["symbol"].as_str().unwrap_or_default(); + let start = range["start_line"].as_i64().unwrap_or_default(); + let end = range["end_line"].as_i64().unwrap_or_default(); + for line in start..=end { + let key = format!("{path}\u{1}{}\u{1}{name}", line + 1); + if seen_demand.insert(key.clone(), ()).is_none() { + demands.push((key, symbol.to_string())); + } + } + } + + // A state write is the one anchor kind with no event of its own: + // nothing is raised when an ivar is assigned, so the collector reads the + // member back and needs the member's own name to do it. + if anchor["kind"].as_str() != Some("STATE_WRITE") || name.is_empty() { + continue; + } + let Some(own) = anchor.get("range").filter(|value| value.is_object()) else { continue }; + let line = own["start_line"].as_i64().unwrap_or_default() + 1; + states.insert(format!("{path}\u{1}{line}\u{1}{name}"), format!("@{name}")); + } + + for (key, symbol) in demands { + lines.push(format!("d{FIELD}{key}{FIELD}{symbol}")); + } + for (key, ivar) in states { + lines.push(format!("s{FIELD}{key}{FIELD}{ivar}")); + } + for (key, sampled) in struct_fields(plan) { + lines.push(format!("f{FIELD}{key}{FIELD}{}", if sampled { "1" } else { "0" })); + } + for key in tlets(plan) { + lines.push(format!("l{FIELD}{key}")); + } + lines.join("\n") + "\n" +} + +fn struct_fields(plan: &Value) -> Vec<(String, bool)> { + plan.get("struct_fields") + .and_then(Value::as_object) + .into_iter() + .flatten() + .map(|(key, sampled)| (key.clone(), sampled.as_bool().unwrap_or(true))) + .collect() +} + +fn tlets(plan: &Value) -> Vec { + plan.get("tlets") + .and_then(Value::as_object) + .into_iter() + .flatten() + .map(|(key, _)| key.clone()) + .collect() +} + +fn absolute(path: &str, root: &Path) -> String { + if path.starts_with('/') { + return path.to_string(); + } + root.join(path).to_string_lossy().to_string() +} + +pub fn write(plan_path: &Path, output: &Path, target_dirs: &[String], root: &Path) -> Result<()> { + let raw = std::fs::read_to_string(plan_path) + .with_context(|| format!("unreadable plan {}", plan_path.display()))?; + let plan: Value = serde_json::from_str(&raw) + .with_context(|| format!("invalid plan {}", plan_path.display()))?; + std::fs::write(output, render(&plan, target_dirs, root)) + .with_context(|| format!("failed to write {}", output.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn lines(rendered: &str) -> Vec> { + rendered + .lines() + .map(|line| line.split(FIELD).map(str::to_string).collect()) + .collect() + } + + #[test] + fn a_demanded_range_becomes_one_coordinate_per_line() { + let plan = json!({"runtime_evidence": {"requests": [{ + "anchor": { + "relative_path": "lib/a.rb", "display_name": "size", "symbol": "sym-1", + "range": {"start_line": 4, "end_line": 6}, + }, + }]}}); + let rendered = render(&plan, &[], Path::new("/w")); + + // Ranges are zero-based and the collector reports one-based lines. + assert_eq!( + lines(&rendered), + vec![ + vec!["d", "/w/lib/a.rb\u{1}5\u{1}size", "sym-1"], + vec!["d", "/w/lib/a.rb\u{1}6\u{1}size", "sym-1"], + vec!["d", "/w/lib/a.rb\u{1}7\u{1}size", "sym-1"], + ] + ); + } + + #[test] + fn the_first_request_to_claim_a_coordinate_keeps_it() { + let request = |symbol: &str| { + json!({"anchor": { + "relative_path": "lib/a.rb", "display_name": "size", "symbol": symbol, + "range": {"start_line": 0, "end_line": 0}, + }}) + }; + let plan = json!({"runtime_evidence": {"requests": [request("first"), request("second")]}}); + + assert_eq!(lines(&render(&plan, &[], Path::new("/w")))[0][2], "first"); + } + + /// A state write raises no event of its own, so the collector reads the + /// member back and needs its ivar name. Only the anchor's own range names + /// the write; an execution range would name the statement around it. + #[test] + fn a_state_write_carries_the_member_to_read_back() { + let plan = json!({"runtime_evidence": {"requests": [{ + "execution_range": {"start_line": 0, "end_line": 9}, + "anchor": { + "relative_path": "lib/a.rb", "display_name": "count", "symbol": "sym-1", + "kind": "STATE_WRITE", "range": {"start_line": 3, "end_line": 3}, + }, + }]}}); + let rendered = render(&plan, &[], Path::new("/w")); + let state = lines(&rendered).into_iter().find(|line| line[0] == "s").expect("state record"); + + assert_eq!(state, vec!["s", "/w/lib/a.rb\u{1}4\u{1}count", "@count"]); + } + + #[test] + fn a_resolved_record_field_is_marked_unsampled() { + let plan = json!({ + "struct_fields": {"User\u{0}name": true, "User\u{0}id": false}, + "tlets": {"/w/lib/a.rb\u{0}12": true}, + }); + let rendered = lines(&render(&plan, &[], Path::new("/w"))); + + assert!(rendered.contains(&vec!["f".into(), "User\u{0}name".into(), "1".into()])); + assert!(rendered.contains(&vec!["f".into(), "User\u{0}id".into(), "0".into()])); + assert!(rendered.contains(&vec!["l".into(), "/w/lib/a.rb\u{0}12".into()])); + } +} diff --git a/gems/fact-mine/src/external_summary.rs b/gems/fact-mine/src/external_summary.rs index 9d606dac5..45bbd6445 100644 --- a/gems/fact-mine/src/external_summary.rs +++ b/gems/fact-mine/src/external_summary.rs @@ -6,21 +6,63 @@ use crate::profile::{summarize_call_resolution, ProfileOutput}; use anyhow::{bail, Context, Result}; +use flate2::read::GzDecoder; use serde::Deserialize; use std::collections::BTreeMap; use std::fs; +use std::io::Read; use std::path::Path; -const SCHEMA: &str = "fact-mine.external-complexity-summary.v1"; +const SCHEMA_V1: &str = "fact-mine.external-complexity-summary.v1"; +const SCHEMA_V2: &str = "fact-mine.external-complexity-summary.v2"; +const SCHEMA_V3: &str = "fact-mine.external-complexity-summary.v3"; +const ENVIRONMENT_SCHEMA_V1: &str = "fact-mine.semantic-environment.v1"; +include!(concat!(env!("OUT_DIR"), "/bundled_complexity_summaries.rs")); #[derive(Debug, Deserialize)] struct SummaryFile { schema: String, #[serde(default)] + producer: Option, + #[serde(default)] + source: Option, + #[serde(default)] + compatibility: Option, + #[serde(default)] symbols: BTreeMap, } #[derive(Debug, Deserialize)] +struct SummaryCompatibility { + #[serde(default)] + claims: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct SemanticEnvironmentFile { + schema: String, + #[serde(default)] + claims: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct SummaryProducer { + name: String, + version: String, +} + +#[derive(Debug, Deserialize)] +struct SummarySource { + profile_sha256: String, + method_count: usize, + complete_symbol_count: usize, + #[serde(default)] + indexer: Option, + #[serde(default)] + consumer_indexers: Vec, +} + +#[derive(Clone, Debug, Deserialize)] struct ComplexitySummary { time: String, space: String, @@ -43,26 +85,260 @@ fn default_bound_quality() -> String { } pub fn apply_file(output: &mut ProfileOutput, path: &Path) -> Result { - let source = fs::read_to_string(path) + let summary = read_file(path)?; + require_compatible(output, &summary) + .with_context(|| format!("incompatible complexity summary {}", path.display()))?; + apply_summary(output, &summary) +} + +pub fn apply_files(output: &mut ProfileOutput, paths: &[impl AsRef]) -> Result { + let mut summaries = Vec::with_capacity(paths.len()); + let mut costs_by_symbol: BTreeMap = BTreeMap::new(); + for path in paths { + let path = path.as_ref(); + let summary = read_file(path)?; + require_compatible(output, &summary) + .with_context(|| format!("incompatible complexity summary {}", path.display()))?; + for (symbol, cost) in &summary.symbols { + if let Some((time, space, first_path)) = costs_by_symbol.get(symbol) { + if time != &cost.time || space != &cost.space { + bail!( + "conflicting complexity summaries for {symbol}: {} has {time}/{space}, {} has {}/{}", + first_path, + path.display(), + cost.time, + cost.space + ); + } + } else { + costs_by_symbol.insert( + symbol.clone(), + ( + cost.time.clone(), + cost.space.clone(), + path.display().to_string(), + ), + ); + } + } + summaries.push(summary); + } + let mut applied = 0; + for summary in &summaries { + applied += apply_summary(output, summary)?; + } + Ok(applied) +} + +/// Apply reviewed, version-pinned summaries shipped with FactMine. Symbols +/// include the package version, so a bundle cannot match another toolchain or +/// dependency release accidentally. +pub fn apply_bundled(output: &mut ProfileOutput) -> Result { + let mut applied = 0; + for (name, bytes) in BUNDLED_SUMMARIES { + let source = + decode(Path::new(name), bytes).with_context(|| format!("failed to decode {name}"))?; + let summary: SummaryFile = serde_json::from_str(&source) + .with_context(|| format!("failed to parse bundled complexity summary {name}"))?; + validate(&summary) + .with_context(|| format!("failed to validate bundled complexity summary {name}"))?; + let producer_indexer = summary + .source + .as_ref() + .and_then(|source| source.indexer.as_deref()) + .with_context(|| { + format!( + "bundled complexity summary {name} must declare its exact compatible indexer" + ) + })?; + let source = summary.source.as_ref().expect("validated summary source"); + if !has_compatible_indexer(output, source, producer_indexer) { + continue; + } + if !is_compatible(output, &summary) { + continue; + } + applied += apply_summary(output, &summary)?; + } + Ok(applied) +} + +fn has_compatible_indexer( + output: &ProfileOutput, + source: &SummarySource, + producer_indexer: &str, +) -> bool { + output.semantic_indexes.iter().any(|index| { + let identity = format!("{}@{}", index.tool, index.version); + if source.consumer_indexers.is_empty() { + identity == producer_indexer + } else { + source + .consumer_indexers + .iter() + .any(|required| required == &identity) + } + }) +} + +fn read_file(path: &Path) -> Result { + let bytes = fs::read(path) .with_context(|| format!("failed to read complexity summary {}", path.display()))?; - apply_json(output, &source) - .with_context(|| format!("failed to apply complexity summary {}", path.display())) + let source = decode(path, &bytes) + .with_context(|| format!("failed to decode complexity summary {}", path.display()))?; + let summary: SummaryFile = serde_json::from_str(&source) + .with_context(|| format!("failed to parse complexity summary {}", path.display()))?; + validate(&summary) + .with_context(|| format!("failed to validate complexity summary {}", path.display()))?; + Ok(summary) +} + +fn decode(path: &Path, bytes: &[u8]) -> Result { + let compressed = bytes.starts_with(&[0x1f, 0x8b]) + || path.extension().and_then(|extension| extension.to_str()) == Some("gz"); + let mut decoded = Vec::new(); + if compressed { + GzDecoder::new(bytes).read_to_end(&mut decoded)?; + } else { + decoded.extend_from_slice(bytes); + } + String::from_utf8(decoded).context("complexity summary is not UTF-8 JSON") } pub fn apply_json(output: &mut ProfileOutput, source: &str) -> Result { let summary: SummaryFile = serde_json::from_str(source)?; - if summary.schema != SCHEMA { + validate(&summary)?; + require_compatible(output, &summary)?; + apply_summary(output, &summary) +} + +/// Attach semantic-environment sidecars to a profile. Claims are opaque to the +/// shared analyzer and merge only when identical. +pub fn apply_environment_files( + output: &mut ProfileOutput, + paths: &[impl AsRef], +) -> Result { + let mut applied = 0; + for path in paths { + let path = path.as_ref(); + let bytes = fs::read(path) + .with_context(|| format!("failed to read semantic environment {}", path.display()))?; + let source = decode(path, &bytes) + .with_context(|| format!("failed to decode semantic environment {}", path.display()))?; + let environment: SemanticEnvironmentFile = serde_json::from_str(&source) + .with_context(|| format!("failed to parse semantic environment {}", path.display()))?; + validate_environment(&environment) + .with_context(|| format!("invalid semantic environment {}", path.display()))?; + for (key, value) in environment.claims { + if let Some(existing) = output.semantic_environment.get(&key) { + if existing != &value { + bail!( + "conflicting semantic environment claim {key}: {existing:?} versus {value:?} from {}", + path.display() + ); + } + continue; + } + output.semantic_environment.insert(key, value); + applied += 1; + } + } + Ok(applied) +} + +fn validate_environment(environment: &SemanticEnvironmentFile) -> Result<()> { + if environment.schema != ENVIRONMENT_SCHEMA_V1 { bail!( - "unsupported complexity summary schema {}; expected {SCHEMA}", - summary.schema + "unsupported semantic environment schema {}; expected {ENVIRONMENT_SCHEMA_V1}", + environment.schema ); } + if environment.claims.is_empty() { + bail!("semantic environment must contain at least one claim"); + } + for (key, value) in &environment.claims { + if key.trim().is_empty() || value.trim().is_empty() { + bail!("semantic environment claim keys and values must be non-empty"); + } + } + Ok(()) +} + +fn is_compatible(output: &ProfileOutput, summary: &SummaryFile) -> bool { + summary + .compatibility + .as_ref() + .map(|compatibility| { + compatibility + .claims + .iter() + .all(|(key, value)| output.semantic_environment.get(key) == Some(value)) + }) + .unwrap_or(true) +} + +fn require_compatible(output: &ProfileOutput, summary: &SummaryFile) -> Result<()> { + let Some(compatibility) = summary.compatibility.as_ref() else { + return Ok(()); + }; + let mismatches = compatibility + .claims + .iter() + .filter_map(|(key, required)| { + let actual = output.semantic_environment.get(key); + (actual != Some(required)).then(|| { + format!( + "{key} requires {required:?}, profile has {}", + actual.map_or("".to_string(), |value| format!("{value:?}")) + ) + }) + }) + .collect::>(); + if !mismatches.is_empty() { + bail!( + "semantic environment does not satisfy summary compatibility: {}", + mismatches.join("; ") + ); + } + Ok(()) +} + +fn apply_summary(output: &mut ProfileOutput, summary: &SummaryFile) -> Result { + // A complete result is authoritative regardless of where it came from. Audit + // every overlap before mutating the profile so a generated/manual + // disagreement fails atomically instead of being hidden by application + // order. Incomplete results are deliberately replaceable by a complete + // generated result. + for call in &output.calls { + if call.target.is_some() || has_open_candidate_set(call) { + continue; + } + let Some(symbol) = call.semantic_symbol.as_deref() else { + continue; + }; + let Some(cost) = summary.symbols.get(symbol) else { + continue; + }; + let (Some(existing_time), Some(existing_space)) = ( + call.known_time_complexity.as_deref(), + call.known_space_complexity.as_deref(), + ) else { + continue; + }; + if existing_time != cost.time || existing_space != cost.space { + bail!( + "complete complexity conflict for {symbol}: existing {} has {existing_time}/{existing_space}, generated {} has {}/{}; fix the source analysis or fallback model instead of overriding either complete result", + call.complexity_provenance.as_deref().unwrap_or("unknown provenance"), + cost.provenance, + cost.time, + cost.space + ); + } + } + let mut applied = 0; for call in &mut output.calls { - if call.target.is_some() - || call.known_time_complexity.is_some() - || call.known_space_complexity.is_some() - { + if call.target.is_some() || has_open_candidate_set(call) { continue; } let Some(symbol) = call.semantic_symbol.as_deref() else { @@ -89,6 +365,9 @@ pub fn apply_json(output: &mut ProfileOutput, source: &str) -> Result { let raw_calls_not_normalized_inside_function = output .call_resolution_coverage .raw_calls_not_normalized_inside_function; + let source_export_eligible_methods_overlapping_raw_call_loss = output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss; let raw_calls_not_normalized_outside_function = output .call_resolution_coverage .raw_calls_not_normalized_outside_function; @@ -110,6 +389,10 @@ pub fn apply_json(output: &mut ProfileOutput, source: &str) -> Result { output .call_resolution_coverage .raw_calls_not_normalized_inside_function = raw_calls_not_normalized_inside_function; + output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss = + source_export_eligible_methods_overlapping_raw_call_loss; output .call_resolution_coverage .raw_calls_not_normalized_outside_function = raw_calls_not_normalized_outside_function; @@ -122,14 +405,98 @@ pub fn apply_json(output: &mut ProfileOutput, source: &str) -> Result { output .call_resolution_coverage .normalized_calls_without_raw_span = normalized_calls_without_raw_span; + crate::scip::apply_resolved_call_costs_to_contexts(output); } Ok(applied) } +fn has_open_candidate_set(call: &crate::profile::CallRecord) -> bool { + !call.consumer_closed_candidate_set + && (call.candidate_reason.is_some() || !call.candidate_targets.is_empty()) +} + +fn validate(summary: &SummaryFile) -> Result<()> { + match summary.schema.as_str() { + SCHEMA_V1 => {} + SCHEMA_V2 | SCHEMA_V3 => { + let producer = summary + .producer + .as_ref() + .context("versioned complexity summary is missing producer metadata")?; + if producer.name.trim().is_empty() || producer.version.trim().is_empty() { + bail!("versioned complexity summary producer name and version must be non-empty"); + } + let source = summary + .source + .as_ref() + .context("versioned complexity summary is missing source metadata")?; + let digest = source + .profile_sha256 + .strip_prefix("sha256:") + .unwrap_or_default(); + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("versioned complexity summary source.profile_sha256 must be a SHA-256 digest"); + } + if source.complete_symbol_count != summary.symbols.len() { + bail!( + "versioned complexity summary declares {} complete symbols but contains {}", + source.complete_symbol_count, + summary.symbols.len() + ); + } + if source.complete_symbol_count > 0 && source.method_count == 0 { + bail!( + "versioned complexity summary with exported symbols must analyze at least one method" + ); + } + if source + .consumer_indexers + .iter() + .any(|indexer| indexer.trim().is_empty() || !indexer.contains('@')) + { + bail!( + "versioned complexity summary consumer indexers must use non-empty tool@version identities" + ); + } + if summary.schema == SCHEMA_V3 { + let compatibility = summary + .compatibility + .as_ref() + .context("v3 complexity summary is missing compatibility metadata")?; + for (key, value) in &compatibility.claims { + if key.trim().is_empty() || value.trim().is_empty() { + bail!("v3 compatibility claim keys and values must be non-empty"); + } + } + } + } + other => bail!( + "unsupported complexity summary schema {other}; expected {SCHEMA_V1}, {SCHEMA_V2}, or {SCHEMA_V3}" + ), + } + for (symbol, cost) in &summary.symbols { + if symbol.trim().is_empty() { + bail!("complexity summary contains an empty compiler symbol"); + } + if cost.time.trim().is_empty() || cost.space.trim().is_empty() { + bail!("complexity summary for {symbol} must contain non-empty time and space bounds"); + } + if cost.provenance.trim().is_empty() || cost.bound_quality.trim().is_empty() { + bail!( + "complexity summary for {symbol} must contain non-empty provenance and bound quality" + ); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::profile::{CallRecord, ProfileOutput}; + use crate::profile::{CallRecord, ProfileOutput, SemanticIndex}; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; fn call(symbol: Option<&str>) -> CallRecord { CallRecord { @@ -142,6 +509,7 @@ mod tests { target_provenance: Some("scip".into()), candidate_targets: Vec::new(), candidate_reason: None, + consumer_closed_candidate_set: false, kind: "external_call".into(), owner: "Demo".into(), function: "run".into(), @@ -152,7 +520,10 @@ mod tests { lexical_symbol: None, lexical_symbol_origin: None, receiver_call_span: None, + selector_span: None, + execution_span: None, receiver_definition_call_spans: Vec::new(), + receiver_definition_sequence_projection: None, receiver_symbol: None, receiver_type: None, receiver_type_origin: None, @@ -171,6 +542,7 @@ mod tests { complexity_assumptions: Vec::new(), message: "read".into(), argument_count: 0, + arguments: Vec::new(), path: "Demo.java".into(), line: 1, span: [1, 0, 1, 10], @@ -181,6 +553,7 @@ mod tests { "dependency_or_stdlib_symbol_known_cost_unavailable".into(), ), empty_domain_cause: Some("external_declaration".into()), + runtime_evidence_observed: false, } } @@ -192,7 +565,7 @@ mod tests { ..ProfileOutput::default() }; let json = serde_json::json!({ - "schema": SCHEMA, + "schema": SCHEMA_V1, "symbols": { symbol: {"time": "O(N)", "space": "O(1)"} } @@ -207,6 +580,33 @@ mod tests { assert_eq!(output.calls[2].known_time_complexity, None); } + #[test] + fn does_not_close_an_observed_open_candidate_set() { + let symbol = "nil-kill-runtime ruby ruby 3.2.3 String#upcase()."; + let mut observed = call(Some(symbol)); + observed.target_provenance = Some("runtime_scip_observed".into()); + observed.candidate_targets = vec!["fn:observed".into()]; + observed.candidate_reason = Some("runtime_observed_candidate_set".into()); + observed.consumer_closed_candidate_set = false; + let mut output = ProfileOutput { + calls: vec![observed], + ..ProfileOutput::default() + }; + let json = serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + symbol: {"time": "O(1)", "space": "O(1)"} + } + }); + + assert_eq!(apply_json(&mut output, &json.to_string()).unwrap(), 0); + assert_eq!(output.calls[0].known_time_complexity, None); + assert_eq!( + output.calls[0].unresolved_reason.as_deref(), + Some("scip_external_symbol_unmodeled") + ); + } + #[test] fn refreshes_call_coverage_after_enrichment() { let symbol = "scip-java maven maven/acme/demo 1 acme/Demo#read()."; @@ -231,15 +631,49 @@ mod tests { local_complexity: 0.0, complexity_signals: BTreeMap::new(), params: Vec::new(), + callback_params: Vec::new(), + source_export_eligible: true, + generated_declaration: false, raw_source: "void run() {}".into(), normalized_source: "void run() {}".into(), untraceable_params: Vec::new(), source: serde_json::Value::Null, }); output.calls = vec![call(Some(symbol))]; + output.complexity_facts.push( + serde_json::from_value(serde_json::json!({ + "path": "Demo.java", + "owner": "Demo", + "function": "run", + "line": 1, + "span": [1, 0, 1, 10], + "parameters": [], + "collection_parameters": [], + "iterations": [], + "recursion": { + "calls": 0, + "shrinking_calls": 0, + "halving_calls": 0, + "loop_contained_shrinking_calls": 0, + "unknown_progress_calls": 0 + }, + "allocations": [], + "call_contexts": [{ + "line": 1, + "span": [1, 0, 1, 10], + "message": "read", + "execution_multiplicity": "O(1)", + "power": 0, + "parameter_arguments": [], + "argument_cardinality_relation": "same", + "evidence_gap": "unmodeled_typed_operation" + }] + })) + .unwrap(), + ); output.call_resolution_coverage.unresolved_call_sites = 1; let json = serde_json::json!({ - "schema": SCHEMA, + "schema": SCHEMA_V1, "symbols": { symbol: {"time": "O(N)", "space": "O(1)"} } @@ -259,5 +693,411 @@ mod tests { output.call_resolution_coverage.accounted_call_percent, 100.0 ); + assert_eq!( + output.complexity_facts[0].call_contexts[0] + .known_time_complexity + .as_deref(), + Some("O(N)") + ); + assert_eq!( + output.complexity_facts[0].call_contexts[0].evidence_gap, + None + ); + } + + #[test] + fn validates_v2_provenance_envelope() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let mut output = ProfileOutput { + calls: vec![call(Some(symbol))], + ..ProfileOutput::default() + }; + let valid = serde_json::json!({ + "schema": SCHEMA_V2, + "producer": {"name": "espalier", "version": "0.1.0"}, + "source": { + "profile_sha256": format!("sha256:{}", "a".repeat(64)), + "method_count": 1, + "complete_symbol_count": 1 + }, + "symbols": { + symbol: {"time": "O(N)", "space": "O(1)"} + } + }); + assert_eq!(apply_json(&mut output, &valid.to_string()).unwrap(), 1); + + let mut invalid = valid; + invalid["source"]["complete_symbol_count"] = serde_json::json!(2); + assert!( + apply_json(&mut ProfileOutput::default(), &invalid.to_string()) + .unwrap_err() + .to_string() + .contains("declares 2 complete symbols but contains 1") + ); + } + + #[test] + fn bundled_summary_may_name_a_distinct_consumer_indexer() { + let summary: SummaryFile = serde_json::from_value(serde_json::json!({ + "schema": SCHEMA_V3, + "producer": {"name": "espalier", "version": "0.1.0"}, + "source": { + "profile_sha256": format!("sha256:{}", "a".repeat(64)), + "method_count": 1, + "complete_symbol_count": 1, + "indexer": "scip-clang@0.4.0", + "consumer_indexers": ["scip-php@0.4.7"] + }, + "compatibility": {"claims": {}}, + "symbols": { + "scip-php composer php 8.4.21 strlen().": { + "time": "O(N)", + "space": "O(1)" + } + } + })) + .unwrap(); + validate(&summary).unwrap(); + let producer = ProfileOutput { + semantic_indexes: vec![SemanticIndex { + tool: "scip-clang".into(), + version: "0.4.0".into(), + }], + ..ProfileOutput::default() + }; + let consumer = ProfileOutput { + semantic_indexes: vec![SemanticIndex { + tool: "scip-php".into(), + version: "0.4.7".into(), + }], + ..ProfileOutput::default() + }; + let source = summary.source.as_ref().unwrap(); + assert!(!has_compatible_indexer( + &producer, + source, + "scip-clang@0.4.0" + )); + assert!(has_compatible_indexer( + &consumer, + source, + "scip-clang@0.4.0" + )); + assert_eq!(source.consumer_indexers, ["scip-php@0.4.7"]); + } + + #[test] + fn v3_summary_requires_exact_semantic_environment_claims() { + let symbol = "cxx . . std/vector#size()."; + let summary = serde_json::json!({ + "schema": SCHEMA_V3, + "producer": {"name": "espalier", "version": "0.1.0"}, + "source": { + "profile_sha256": format!("sha256:{}", "a".repeat(64)), + "method_count": 1, + "complete_symbol_count": 1 + }, + "compatibility": { + "claims": { + "cpp.stdlib": "libstdc++", + "cpp.stdlib.sha256": "sha256:toolchain" + } + }, + "symbols": { + symbol: {"time": "O(1)", "space": "O(1)"} + } + }); + let mut exact = ProfileOutput { + calls: vec![call(Some(symbol))], + semantic_environment: BTreeMap::from([ + ("cpp.stdlib".into(), "libstdc++".into()), + ("cpp.stdlib.sha256".into(), "sha256:toolchain".into()), + ]), + ..ProfileOutput::default() + }; + assert_eq!(apply_json(&mut exact, &summary.to_string()).unwrap(), 1); + + let error = apply_json(&mut ProfileOutput::default(), &summary.to_string()).unwrap_err(); + assert!(error.to_string().contains("cpp.stdlib")); + assert!(error.to_string().contains("")); + } + + #[test] + fn semantic_environment_sidecars_merge_identical_claims_and_reject_conflicts() { + let first = tempfile::NamedTempFile::new().unwrap(); + let second = tempfile::NamedTempFile::new().unwrap(); + fs::write( + first.path(), + serde_json::json!({ + "schema": ENVIRONMENT_SCHEMA_V1, + "claims": {"runtime": "ruby-3.2.3", "target": "x86_64-linux"} + }) + .to_string(), + ) + .unwrap(); + fs::write( + second.path(), + serde_json::json!({ + "schema": ENVIRONMENT_SCHEMA_V1, + "claims": {"runtime": "ruby-3.2.3", "abi": "gnu"} + }) + .to_string(), + ) + .unwrap(); + let mut output = ProfileOutput::default(); + assert_eq!( + apply_environment_files(&mut output, &[first.path(), second.path()]).unwrap(), + 3 + ); + assert_eq!(output.semantic_environment["abi"], "gnu"); + + fs::write( + second.path(), + serde_json::json!({ + "schema": ENVIRONMENT_SCHEMA_V1, + "claims": {"runtime": "ruby-3.3.0"} + }) + .to_string(), + ) + .unwrap(); + assert!(apply_environment_files(&mut output, &[second.path()]) + .unwrap_err() + .to_string() + .contains("conflicting semantic environment claim runtime")); + } + + #[test] + fn reads_gzip_semantic_environment_by_content() { + let json = serde_json::json!({ + "schema": ENVIRONMENT_SCHEMA_V1, + "claims": {"runtime": "ruby-3.2.3"} + }); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(json.to_string().as_bytes()).unwrap(); + let compressed = encoder.finish().unwrap(); + let file = tempfile::NamedTempFile::new().unwrap(); + fs::write(file.path(), compressed).unwrap(); + let mut output = ProfileOutput::default(); + + assert_eq!( + apply_environment_files(&mut output, &[file.path()]).unwrap(), + 1 + ); + assert_eq!(output.semantic_environment["runtime"], "ruby-3.2.3"); + } + + #[test] + fn reads_gzip_summary_by_content() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let json = serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + symbol: {"time": "O(N)", "space": "O(1)"} + } + }); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(json.to_string().as_bytes()).unwrap(); + let compressed = encoder.finish().unwrap(); + let file = tempfile::NamedTempFile::new().unwrap(); + fs::write(file.path(), compressed).unwrap(); + let mut output = ProfileOutput { + calls: vec![call(Some(symbol))], + ..ProfileOutput::default() + }; + + assert_eq!(apply_file(&mut output, file.path()).unwrap(), 1); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(N)") + ); + } + + #[test] + fn complete_generated_result_replaces_incomplete_existing_result() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let mut partial = call(Some(symbol)); + partial.known_time_complexity = Some("O(1)".into()); + partial.complexity_provenance = Some("incomplete_fallback".into()); + let mut output = ProfileOutput { + calls: vec![partial], + ..ProfileOutput::default() + }; + let json = serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + symbol: { + "time": "O(N)", + "space": "O(1)", + "provenance": "analyzed_source_summary" + } + } + }); + + assert_eq!(apply_json(&mut output, &json.to_string()).unwrap(), 1); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(N)") + ); + assert_eq!( + output.calls[0].known_space_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[0].complexity_provenance.as_deref(), + Some("analyzed_source_summary") + ); + } + + #[test] + fn equal_complete_generated_result_becomes_canonical() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let mut fallback = call(Some(symbol)); + fallback.known_time_complexity = Some("O(N)".into()); + fallback.known_space_complexity = Some("O(1)".into()); + fallback.complexity_provenance = Some("manual_fallback".into()); + let mut output = ProfileOutput { + calls: vec![fallback], + ..ProfileOutput::default() + }; + let json = serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + symbol: { + "time": "O(N)", + "space": "O(1)", + "provenance": "analyzed_source_summary" + } + } + }); + + assert_eq!(apply_json(&mut output, &json.to_string()).unwrap(), 1); + assert_eq!( + output.calls[0].complexity_provenance.as_deref(), + Some("analyzed_source_summary") + ); + } + + #[test] + fn rejects_complete_generated_manual_conflict_without_mutating_output() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let mut fallback = call(Some(symbol)); + fallback.known_time_complexity = Some("O(1)".into()); + fallback.known_space_complexity = Some("O(1)".into()); + fallback.complexity_provenance = Some("manual_fallback".into()); + let untouched = call(Some("scip-go gomod stdlib v1 bytes#Clone().")); + let mut output = ProfileOutput { + calls: vec![untouched, fallback], + ..ProfileOutput::default() + }; + let json = serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + "scip-go gomod stdlib v1 bytes#Clone().": { + "time": "O(N)", + "space": "O(N)" + }, + symbol: { + "time": "O(N)", + "space": "O(1)", + "provenance": "analyzed_source_summary" + } + } + }); + + let error = apply_json(&mut output, &json.to_string()).unwrap_err(); + assert!(error.to_string().contains("complete complexity conflict")); + assert!(error.to_string().contains(symbol)); + assert_eq!(output.calls[0].known_time_complexity, None); + assert_eq!( + output.calls[1].known_time_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[1].complexity_provenance.as_deref(), + Some("manual_fallback") + ); + } + + #[test] + fn rejects_conflicting_files_before_applying_either() { + let symbol = "scip-go gomod stdlib v1 strings#IndexByte()."; + let file_a = tempfile::NamedTempFile::new().unwrap(); + let file_b = tempfile::NamedTempFile::new().unwrap(); + for (file, time) in [(&file_a, "O(N)"), (&file_b, "O(1)")] { + fs::write( + file.path(), + serde_json::json!({ + "schema": SCHEMA_V1, + "symbols": { + symbol: {"time": time, "space": "O(1)"} + } + }) + .to_string(), + ) + .unwrap(); + } + let mut output = ProfileOutput { + calls: vec![call(Some(symbol))], + ..ProfileOutput::default() + }; + + let error = apply_files(&mut output, &[file_a.path(), file_b.path()]).unwrap_err(); + assert!(error + .to_string() + .contains("conflicting complexity summaries")); + assert_eq!(output.calls[0].known_time_complexity, None); + } + + #[test] + fn every_bundled_summary_applies_only_with_its_declared_indexer() { + assert!(!BUNDLED_SUMMARIES.is_empty()); + for (name, bytes) in BUNDLED_SUMMARIES { + let source = decode(Path::new(name), bytes).unwrap(); + let summary: SummaryFile = serde_json::from_str(&source).unwrap(); + validate(&summary).unwrap(); + let symbol = summary.symbols.keys().next().unwrap(); + let source = summary.source.as_ref().unwrap(); + let indexer = source + .consumer_indexers + .first() + .map(String::as_str) + .or(source.indexer.as_deref()) + .unwrap(); + let (tool, version) = indexer.split_once('@').unwrap(); + let semantic_environment = summary + .compatibility + .as_ref() + .map(|compatibility| compatibility.claims.clone()) + .unwrap_or_default(); + let mut exact = ProfileOutput { + calls: vec![call(Some(symbol))], + semantic_indexes: vec![SemanticIndex { + tool: tool.into(), + version: version.into(), + }], + semantic_environment: semantic_environment.clone(), + ..ProfileOutput::default() + }; + assert!( + apply_bundled(&mut exact).unwrap() > 0, + "{name} did not apply with {indexer}" + ); + assert_eq!( + exact.calls[0].complexity_provenance.as_deref(), + Some("analyzed_source_summary") + ); + + let mut wrong_indexer = ProfileOutput { + calls: vec![call(Some(symbol))], + semantic_indexes: vec![SemanticIndex { + tool: tool.into(), + version: format!("{version}-incompatible"), + }], + semantic_environment, + ..ProfileOutput::default() + }; + assert_eq!(apply_bundled(&mut wrong_indexer).unwrap(), 0); + assert_eq!(wrong_indexer.calls[0].known_time_complexity, None); + } } } diff --git a/gems/fact-mine/src/function_inventory.rs b/gems/fact-mine/src/function_inventory.rs new file mode 100644 index 000000000..c53546da4 --- /dev/null +++ b/gems/fact-mine/src/function_inventory.rs @@ -0,0 +1,418 @@ +//! Stable function identities and semantic fingerprints. +//! +//! An incremental collect asks two questions of a function: is it the same +//! function it was last time, and does the plan want anything observed inside +//! it. The first is a fingerprint of its normalized source, so reformatting is +//! not a change; the second is a lookup into the plan. +//! +//! Identity has to survive a file being edited around it, so it is the +//! (language, path, owner, name, kind) tuple plus how many identical ones +//! preceded it -- not a line number, which every edit above it moves. + +use anyhow::Result; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::Path; + +/// Plan fields that name a source coordinate the collector was asked to watch. +const DEMAND_FIELDS: [&str; 5] = [ + "runtime_call_sites", + "runtime_result_call_sites", + "runtime_collection_receiver_sites", + "loop_sites", + "state_write_sites", +]; + +pub fn build(methods: &[Value], plan: &Value, root: &Path) -> BTreeMap { + let mut occurrences: BTreeMap, usize> = BTreeMap::new(); + let mut functions = BTreeMap::new(); + for method in methods { + let path = relative(method["path"].as_str().unwrap_or_default(), root); + let span = method["span"].as_array().cloned().unwrap_or_default(); + let identity = ["language", "path", "owner", "name", "kind"] + .iter() + .map(|field| match *field { + "path" => path.clone(), + other => method[other].as_str().unwrap_or_default().to_string(), + }) + .collect::>(); + let occurrence = *occurrences.entry(identity.clone()).or_insert(0); + *occurrences.get_mut(&identity).expect("counted") += 1; + + let key = identity + .iter() + .cloned() + .chain(std::iter::once(occurrence.to_string())) + .collect::>() + .join("\u{0}"); + // Normalized source where there is any, so a reformat is not an edit. + let normalized = match method["normalized_source"].as_str() { + Some(source) if !source.is_empty() => source, + _ => method["raw_source"].as_str().unwrap_or_default(), + }; + let mut entry = Map::new(); + entry.insert("key".into(), json!(key)); + entry.insert("language".into(), json!(identity[0])); + entry.insert("path".into(), json!(path)); + entry.insert("owner".into(), json!(identity[2])); + entry.insert("name".into(), json!(identity[3])); + entry.insert("kind".into(), json!(identity[4])); + entry.insert("occurrence".into(), json!(occurrence)); + entry.insert("line".into(), json!(method["line"].as_i64().unwrap_or_default())); + entry.insert("span".into(), json!(span)); + entry.insert("fingerprint".into(), json!(fingerprint(normalized))); + entry.insert("runtime_demand".into(), json!(demanded(method, &span, plan, root))); + functions.insert(key, Value::Object(entry)); + } + functions +} + +fn fingerprint(source: &str) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(source.as_bytes())) +} + +fn bounds(span: &[Value]) -> (i64, i64) { + let first = span.first().and_then(Value::as_i64).unwrap_or_default(); + let last = span.get(2).and_then(Value::as_i64).unwrap_or_default(); + (first.min(last), first.max(last)) +} + +/// Whether the plan wants anything observed in this function: its own entry +/// asks for a sample or a frame, or some watched coordinate falls inside it. +fn demanded(method: &Value, span: &[Value], plan: &Value, root: &Path) -> bool { + let absolute = absolute(method["path"].as_str().unwrap_or_default(), root); + let method_key = [ + method["owner"].as_str().unwrap_or_default().to_string(), + method["name"].as_str().unwrap_or_default().to_string(), + method["kind"].as_str().unwrap_or_default().to_string(), + absolute.clone(), + method["line"].as_i64().unwrap_or_default().to_string(), + ] + .join("\u{0}"); + if let Some(entry) = plan["methods"].get(&method_key) { + if entry["sample"].as_bool().unwrap_or(false) || entry["frame"].as_bool().unwrap_or(false) { + return true; + } + } + let (first, last) = bounds(span); + DEMAND_FIELDS.iter().any(|field| { + plan[*field].as_object().into_iter().flatten().any(|(key, _)| { + let mut parts = key.splitn(3, '\u{0}'); + let path = parts.next().unwrap_or_default(); + let line = parts.next().and_then(|line| line.parse::().ok()).unwrap_or_default(); + path == absolute && line >= first && line <= last + }) + }) +} + +fn absolute(path: &str, root: &Path) -> String { + if path.starts_with('/') { + path.to_string() + } else { + root.join(path).to_string_lossy().to_string() + } +} + +fn relative(path: &str, root: &Path) -> String { + let absolute = absolute(path, root); + Path::new(&absolute) + .strip_prefix(root) + .map(|rest| rest.to_string_lossy().to_string()) + .unwrap_or(absolute) +} + +pub fn write(methods: &[Value], plan: &Value, root: &Path, output: &Path) -> Result { + let functions = build(methods, plan, root); + std::fs::write(output, serde_json::to_string(&functions)?)?; + Ok(functions.len()) +} + +// ------------------------------------------------------------- bookkeeping + +/// Which functions a shard exercised, and which callsites it reached. +/// +/// An incremental collect reruns a shard when a function it depended on +/// changed, so "depended on" has to be answered from what the shard actually +/// executed -- its function entries where it recorded them, and its line +/// coverage where it did not. +pub fn shard_bookkeeping( + inventory: &BTreeMap, + runtime_dir: &Path, + root: &Path, +) -> (Vec, Vec) { + let mut keys: Vec = Vec::new(); + + let entries = crate::trace_document::read_rows(runtime_dir, "function-entries"); + if entries.is_empty() { + for row in crate::trace_document::read_rows(runtime_dir, "coverage") { + let path = row["path"].as_str().unwrap_or_default(); + let lines = row["lines"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_i64) + .collect::>(); + for key in keys_for_coverage(inventory, path, &lines, root) { + push_unique(&mut keys, key); + } + } + } else { + for row in entries { + let found = key_for_entry( + inventory, + row["path"].as_str().unwrap_or_default(), + row["owner"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["kind"].as_str().unwrap_or_default(), + row["line"].as_i64(), + root, + ); + if let Some(key) = found { + push_unique(&mut keys, key); + } + } + } + keys.sort(); + + // Executed callsites where the shard recorded them, the calls themselves + // where it did not. + let mut rows = crate::trace_document::read_rows(runtime_dir, "executed-callsites"); + if rows.is_empty() { + rows = crate::trace_document::read_rows(runtime_dir, "runtime-calls"); + } + let mut sites: Vec = Vec::new(); + for row in rows { + let callsite = if row.get("callsite").is_some() { &row["callsite"] } else { &row }; + let site = json!([ + relative(callsite["path"].as_str().unwrap_or_default(), root), + callsite["line"].as_i64().unwrap_or_default(), + callsite["selector"].as_str().unwrap_or_default(), + ]); + if !sites.contains(&site) { + sites.push(site); + } + } + // Path, then line as a number, then selector -- a line sorts before a + // longer one, which sorting by text would get backwards. + sites.sort_by_cached_key(|site| { + ( + site[0].as_str().unwrap_or_default().to_string(), + site[1].as_i64().unwrap_or_default(), + site[2].as_str().unwrap_or_default().to_string(), + ) + }); + (keys, sites) +} + +fn push_unique(keys: &mut Vec, key: String) { + if !keys.contains(&key) { + keys.push(key); + } +} + +/// The functions whose span covers any executed line. +fn keys_for_coverage( + inventory: &BTreeMap, + path: &str, + lines: &[i64], + root: &Path, +) -> Vec { + let relative_path = relative(path, root); + inventory + .values() + .filter(|function| function["path"].as_str() == Some(relative_path.as_str())) + .filter_map(|function| { + let span = function["span"].as_array().cloned().unwrap_or_default(); + let (first, last) = bounds(&span); + lines + .iter() + .any(|line| *line >= first && *line <= last) + .then(|| function["key"].as_str().unwrap_or_default().to_string()) + }) + .collect() +} + +/// The function a recorded entry belongs to. Where a file holds more than one +/// with the same identity, the entry's line decides -- exactly, then by span. +fn key_for_entry( + inventory: &BTreeMap, + path: &str, + owner: &str, + name: &str, + kind: &str, + line: Option, + root: &Path, +) -> Option { + let relative_path = relative(path, root); + let candidates = inventory + .values() + .filter(|function| { + function["path"].as_str() == Some(relative_path.as_str()) + && function["owner"].as_str() == Some(owner) + && function["name"].as_str() == Some(name) + && function["kind"].as_str() == Some(kind) + }) + .collect::>(); + if candidates.len() < 2 { + return candidates + .first() + .and_then(|function| function["key"].as_str()) + .map(str::to_string); + } + let entry_line = line.unwrap_or_default(); + candidates + .iter() + .find(|function| function["line"].as_i64() == Some(entry_line)) + .or_else(|| { + candidates.iter().find(|function| { + let (first, last) = bounds(&function["span"].as_array().cloned().unwrap_or_default()); + entry_line >= first && entry_line <= last + }) + }) + .and_then(|function| function["key"].as_str()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn method(owner: &str, name: &str, line: i64, source: &str) -> Value { + json!({ + "language": "ruby", + "path": "lib/app.rb", + "owner": owner, + "name": name, + "kind": "instance", + "line": line, + "span": [line, 0, line + 2, 0], + "normalized_source": source, + }) + } + + fn root() -> &'static Path { + Path::new("/repo") + } + + #[test] + fn two_definitions_with_one_identity_stay_distinct() { + // A file may define the same method twice. Keying on the identity + // alone would collapse them and lose one of the two fingerprints, so + // an edit to the second would look like no change at all. + let methods = vec![ + method("App", "value", 2, "def value; 1; end"), + method("App", "value", 6, "def value; 2; end"), + ]; + + let built = build(&methods, &json!({}), root()); + + assert_eq!(built.len(), 2); + let occurrences: Vec = + built.values().filter_map(|f| f["occurrence"].as_i64()).collect(); + assert_eq!(occurrences, vec![0, 1]); + let fingerprints: Vec<&str> = + built.values().filter_map(|f| f["fingerprint"].as_str()).collect(); + assert_ne!(fingerprints[0], fingerprints[1]); + } + + #[test] + fn a_reformat_above_a_function_is_not_a_change_to_it() { + // Identity is the (language, path, owner, name, kind) tuple plus its + // occurrence, never the line -- every edit above a function moves that. + let before = build(&[method("App", "value", 2, "def value; 1; end")], &json!({}), root()); + let after = build(&[method("App", "value", 40, "def value; 1; end")], &json!({}), root()); + + assert_eq!(before.keys().collect::>(), after.keys().collect::>()); + let fingerprint = |set: &BTreeMap| { + set.values().next().unwrap()["fingerprint"].as_str().unwrap().to_string() + }; + assert_eq!(fingerprint(&before), fingerprint(&after)); + // The line still moves; it is recorded, just not part of identity. + assert_eq!(after.values().next().unwrap()["line"], json!(40)); + } + + #[test] + fn an_absolute_path_is_recorded_relative_to_the_root() { + let mut method = method("App", "value", 2, "def value; end"); + method["path"] = json!("/repo/lib/app.rb"); + + let built = build(&[method], &json!({}), root()); + + assert_eq!(built.values().next().unwrap()["path"], json!("lib/app.rb")); + } + + #[test] + fn demand_follows_the_plan_and_not_the_source() { + let plain = method("App", "value", 2, "def value; end"); + let unwatched = build(&[plain.clone()], &json!({}), root()); + assert_eq!(unwatched.values().next().unwrap()["runtime_demand"], json!(false)); + + // Asked for by name: the plan keys methods on the absolute path. + let key = ["App", "value", "instance", "/repo/lib/app.rb", "2"].join("\u{0}"); + let by_name = build(&[plain.clone()], &json!({"methods": {key: {"sample": true}}}), root()); + assert_eq!(by_name.values().next().unwrap()["runtime_demand"], json!(true)); + + // Asked for by coordinate: a watched line inside the function's span. + let inside = format!("/repo/lib/app.rb\u{0}3\u{0}call"); + let by_site = + build(&[plain.clone()], &json!({"runtime_call_sites": {inside: {}}}), root()); + assert_eq!(by_site.values().next().unwrap()["runtime_demand"], json!(true)); + + // A coordinate past the end of the span is some other function's. + let outside = format!("/repo/lib/app.rb\u{0}99\u{0}call"); + let beyond = + build(&[plain], &json!({"runtime_call_sites": {outside: {}}}), root()); + assert_eq!(beyond.values().next().unwrap()["runtime_demand"], json!(false)); + } + + #[test] + fn a_recorded_entry_resolves_to_the_definition_its_line_falls_in() { + let methods = vec![ + method("App", "value", 2, "def value; 1; end"), + method("App", "value", 6, "def value; 2; end"), + ]; + let built = build(&methods, &json!({}), root()); + let expected = |occurrence: i64| { + built + .values() + .find(|f| f["occurrence"] == json!(occurrence)) + .and_then(|f| f["key"].as_str()) + .map(str::to_string) + }; + + // Exactly on the definition line. + assert_eq!( + key_for_entry(&built, "lib/app.rb", "App", "value", "instance", Some(6), root()), + expected(1) + ); + // Inside the second definition's span but not on its first line. + assert_eq!( + key_for_entry(&built, "lib/app.rb", "App", "value", "instance", Some(7), root()), + expected(1) + ); + assert_eq!( + key_for_entry(&built, "lib/app.rb", "App", "value", "instance", Some(2), root()), + expected(0) + ); + assert_eq!( + key_for_entry(&built, "lib/app.rb", "Other", "value", "instance", Some(2), root()), + None + ); + } + + #[test] + fn coverage_attributes_an_executed_line_to_every_function_spanning_it() { + let methods = vec![ + method("App", "first", 2, "def first; end"), + method("App", "second", 10, "def second; end"), + ]; + let built = build(&methods, &json!({}), root()); + + let covered = keys_for_coverage(&built, "/repo/lib/app.rb", &[3], root()); + + assert_eq!(covered.len(), 1); + assert!(covered[0].contains("first"), "{covered:?}"); + assert!(keys_for_coverage(&built, "/repo/lib/app.rb", &[100], root()).is_empty()); + } +} diff --git a/gems/fact-mine/src/incremental.rs b/gems/fact-mine/src/incremental.rs index edc869ce3..1c01dc0ad 100644 --- a/gems/fact-mine/src/incremental.rs +++ b/gems/fact-mine/src/incremental.rs @@ -474,9 +474,10 @@ fn configuration_digest() -> Result { } fn stdlib_registry_digest() -> Result { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config/stdlib_complexity"); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config"); let mut files = Vec::new(); - collect_files(&root, &mut files)?; + collect_files(&root.join("stdlib_complexity"), &mut files)?; + collect_files(&root.join("complexity_summaries"), &mut files)?; files.sort(); let mut hasher = Sha256::new(); for file in files { diff --git a/gems/fact-mine/src/incremental_tests.rs b/gems/fact-mine/src/incremental_tests.rs index b738abb5b..741da2ef2 100644 --- a/gems/fact-mine/src/incremental_tests.rs +++ b/gems/fact-mine/src/incremental_tests.rs @@ -433,10 +433,20 @@ fn corrupt_json_identity_and_manifest_entries_are_misses() -> Result<()> { &cache_config, false, )?; - let shard_path = fs::read_dir(cache_config.directory.join("shards"))? - .next() - .expect("shard")? - .path(); + // Target this file's own shard, not `read_dir().next()`: a build may also + // emit stdlib shards, and directory iteration order is unspecified, so + // picking the first entry corrupted an arbitrary shard and made the later + // `candidate`-keyed assertions flake. + let candidate = candidate( + &file, + None, + Profile::Espalier, + directory.path(), + &stdlib_registry_digest()?, + &configuration_digest()?, + )?; + let shard_path = + ShardCache::new(cache_config.directory.clone()).shard_path(&candidate.cache_key); let mut bad_json = GzEncoder::new(Vec::new(), Compression::default()); bad_json.write_all(b"{}")?; @@ -451,14 +461,6 @@ fn corrupt_json_identity_and_manifest_entries_are_misses() -> Result<()> { )?; assert_eq!(json_miss.metrics.corrupt_entries, 1); - let candidate = candidate( - &file, - None, - Profile::Espalier, - directory.path(), - &stdlib_registry_digest()?, - &configuration_digest()?, - )?; let cache = ShardCache::new(cache_config.directory.clone()); let mut cached = match cache.load(&candidate)? { CacheRead::Hit(shard, _) => CachedShard { diff --git a/gems/fact-mine/src/lib.rs b/gems/fact-mine/src/lib.rs index aa781aabe..2bf48e5f8 100644 --- a/gems/fact-mine/src/lib.rs +++ b/gems/fact-mine/src/lib.rs @@ -4,12 +4,30 @@ mod architecture_test; mod ast; pub mod external_summary; +pub mod function_inventory; pub mod incremental; pub mod lsp_scip; pub mod lua_scip; pub mod parallel; pub mod profile; +pub mod runtime_decode; +pub mod runtime_evidence; +pub mod runtime_protocol; +pub mod runtime_trace; pub mod scip; +pub mod scip_emit; +pub mod shard_runner; +pub mod snapshot; +pub mod source_fingerprint; +pub mod sorbet_sig; +pub mod trace_document; +pub mod workload_plan; +pub mod trace_plan; +pub mod canonical_transaction; +pub mod collect; +pub mod collector_export; +pub mod collector_plan; +pub mod value_domain; pub mod syntax; pub mod syntax_oracle; pub mod type_inference; diff --git a/gems/fact-mine/src/lsp_scip.rs b/gems/fact-mine/src/lsp_scip.rs index 21191b80b..9ec31a1d6 100644 --- a/gems/fact-mine/src/lsp_scip.rs +++ b/gems/fact-mine/src/lsp_scip.rs @@ -697,7 +697,7 @@ fn parse_range(value: &Value) -> Option<[usize; 4]> { ]) } -fn path_to_file_uri(path: &Path) -> String { +pub(crate) fn path_to_file_uri(path: &Path) -> String { let path = path.to_string_lossy(); let encoded = path .bytes() @@ -784,6 +784,7 @@ mod tests { target_provenance: None, candidate_targets: Vec::new(), candidate_reason: None, + consumer_closed_candidate_set: false, kind: String::new(), owner: String::new(), function: String::new(), @@ -794,7 +795,10 @@ mod tests { lexical_symbol: None, lexical_symbol_origin: None, receiver_call_span: None, + selector_span: None, + execution_span: None, receiver_definition_call_spans: Vec::new(), + receiver_definition_sequence_projection: None, receiver_symbol: None, receiver_type: None, receiver_type_origin: None, @@ -813,6 +817,7 @@ mod tests { complexity_assumptions: Vec::new(), message: String::new(), argument_count: 0, + arguments: Vec::new(), path: String::new(), line: 1, span: [1, 0, 1, 0], @@ -821,6 +826,7 @@ mod tests { unresolved_reason: None, resolution_missing_proof: None, empty_domain_cause: None, + runtime_evidence_observed: false, } } } diff --git a/gems/fact-mine/src/main.rs b/gems/fact-mine/src/main.rs index 1d21adb54..1a636f159 100644 --- a/gems/fact-mine/src/main.rs +++ b/gems/fact-mine/src/main.rs @@ -27,11 +27,16 @@ fn main() -> Result<()> { fn run() -> Result<()> { let command = parse_args(std::env::args().skip(1).collect())?; match command { - Command::SyntaxFacts { language, files } => { + Command::SyntaxFacts { + language, + files, + fields, + } => { + let fields = fields.as_ref(); let facts = match language { Some(language) => { let language = Language::parse(&language)?; - syntax_oracle::project_files(&files, language) + syntax_oracle::project_selected_files(&files, language, fields) .with_context(|| "failed to project syntax facts")? } None => { @@ -55,7 +60,7 @@ fn run() -> Result<()> { let mut merged: Option = None; for (language_name, batch) in batches { let language = Language::parse(language_name)?; - let chunk = syntax_oracle::project_files(&batch, language) + let chunk = syntax_oracle::project_selected_files(&batch, language, fields) .with_context(|| "failed to project syntax facts")?; match merged.as_mut() { None => merged = Some(chunk), @@ -80,7 +85,9 @@ fn run() -> Result<()> { output, language_override, scip_indexes, + semantic_environments, complexity_summaries, + bundled_complexity_summaries, portable, incremental_cache, changed_files_only, @@ -101,6 +108,7 @@ fn run() -> Result<()> { && !changed_files_only && !portable && scip_indexes.is_empty() + && semantic_environments.is_empty() && complexity_summaries.is_empty() && !output.as_ref().is_some_and(|path| { path.extension().and_then(|value| value.to_str()) == Some("gz") @@ -128,8 +136,19 @@ fn run() -> Result<()> { for index in scip_indexes { fact_mine_rust::scip::apply_json_file(&mut merged, &index)?; } - for summary in complexity_summaries { - fact_mine_rust::external_summary::apply_file(&mut merged, &summary)?; + fact_mine_rust::external_summary::apply_environment_files( + &mut merged, + semantic_environments.as_slice(), + )?; + if bundled_complexity_summaries { + fact_mine_rust::external_summary::apply_bundled(&mut merged)?; + } + fact_mine_rust::external_summary::apply_files( + &mut merged, + complexity_summaries.as_slice(), + )?; + if profile == Profile::TracePlan { + profile::refresh_runtime_call_sites(&mut merged); } if let Some(metrics) = merged.incremental_metrics.as_mut() { metrics.external_enrichment_millis = @@ -162,7 +181,9 @@ fn run() -> Result<()> { language_override, format, scip_indexes, + semantic_environments, complexity_summaries, + bundled_complexity_summaries, } => { let language_override = language_override .as_deref() @@ -172,9 +193,17 @@ fn run() -> Result<()> { for index in scip_indexes { fact_mine_rust::scip::apply_json_file(&mut merged, &index)?; } - for summary in complexity_summaries { - fact_mine_rust::external_summary::apply_file(&mut merged, &summary)?; + fact_mine_rust::external_summary::apply_environment_files( + &mut merged, + semantic_environments.as_slice(), + )?; + if bundled_complexity_summaries { + fact_mine_rust::external_summary::apply_bundled(&mut merged)?; } + fact_mine_rust::external_summary::apply_files( + &mut merged, + complexity_summaries.as_slice(), + )?; let rendered = match format.as_str() { "json" => serde_json::to_string_pretty(&merged.call_resolution_coverage)?, "text" => render_call_resolution(&merged.call_resolution_coverage), @@ -246,6 +275,447 @@ fn run() -> Result<()> { println!("{}", rendered); } } + Command::NilKillTracePlan { + static_facts, + raw, + runtime_plan, + output, + root, + generated_at, + target_dirs, + exclude_dirs, + } => { + let facts: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&static_facts)?) + .with_context(|| format!("failed to parse {}", static_facts.display()))?; + let evidence = match runtime_plan { + Some(path) => serde_json::from_str(&std::fs::read_to_string(&path)?) + .with_context(|| format!("failed to parse {}", path.display()))?, + None => serde_json::Value::Null, + }; + let facts = if raw { + fact_mine_rust::trace_plan::reshape_static_facts(&facts, &root) + } else { + facts + }; + let plan = fact_mine_rust::trace_plan::TracePlan::build(&facts, &root); + let document = + plan.document(&generated_at, &target_dirs, &exclude_dirs, evidence); + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&output, serde_json::to_string_pretty(&document)?)?; + } + Command::NilKillCollect { commands, fast, continue_on_error, root } => { + let root = root.canonicalize().unwrap_or(root); + let mut config = fact_mine_rust::collect::Config::from_env(root, commands); + config.fast = fast; + config.continue_on_error = continue_on_error; + fact_mine_rust::collect::run(&config)?; + } + Command::NilKillCanonical { restore, state, paths } => { + if restore { + let raw = std::fs::read_to_string(&state) + .with_context(|| format!("unreadable {}", state.display()))?; + fact_mine_rust::canonical_transaction::restore(&serde_json::from_str(&raw)?)?; + } else { + let saved = fact_mine_rust::canonical_transaction::save( + &paths, + &state.with_extension("d"), + )?; + fs::write(&state, serde_json::to_string(&saved)?)?; + } + } + Command::NilKillWorkloadPlan { targets, command, output, root } => { + // Null when no runner is recognizable: the caller then keeps one + // opaque shard per command, which is correct rather than a + // fallback -- nothing about such a command says which part of it a + // source change affects. + let plan = fact_mine_rust::workload_plan::build(&targets, &command, &root); + fs::write(&output, serde_json::to_string(&plan)?)?; + } + Command::NilKillRunShards { plan, output } => { + // To a file, not stdout: the traced programs are writing there, + // and their output belongs to the person watching it. The caller + // needs the failures by name to mark the snapshot stale, which an + // exit code cannot carry. + let failed = fact_mine_rust::shard_runner::run_file(&plan)?; + fs::write(&output, serde_json::to_string(&serde_json::json!({"failed": failed}))?)?; + } + Command::NilKillSelectIncrement { input, output } => { + let raw = std::fs::read_to_string(&input) + .with_context(|| format!("unreadable {}", input.display()))?; + let request: serde_json::Value = serde_json::from_str(&raw)?; + let selection = fact_mine_rust::snapshot::select( + &fact_mine_rust::snapshot::Increment { + manifest: &request["manifest"], + current_hashes: &request["current_hashes"], + current_environment: &request["environment"], + functions: &request["functions"], + workload: &request["workload"], + trace_plan_digest: request["trace_plan_digest"].as_str().unwrap_or_default(), + }, + ); + fs::write(&output, serde_json::to_string(&selection)?)?; + } + Command::NilKillShardBookkeeping { inventory, shards, output, root } => { + let raw = std::fs::read_to_string(&inventory) + .with_context(|| format!("unreadable inventory {}", inventory.display()))?; + let inventory: std::collections::BTreeMap = + serde_json::from_str(&raw)?; + let mut answers = serde_json::Map::new(); + for shard in &shards { + let (dependencies, callsites) = + fact_mine_rust::function_inventory::shard_bookkeeping( + &inventory, shard, &root, + ); + let id = shard + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + answers.insert( + id, + serde_json::json!({"dependencies": dependencies, "callsites": callsites}), + ); + } + fs::write(&output, serde_json::to_string(&answers)?)?; + eprintln!("Bookkept {} shards", shards.len()); + } + Command::NilKillFunctionInventory { files, plan, output, root } => { + let plan = plan + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_else(|| serde_json::json!({})); + let files = canonical_runtime_sources(&files, &root)?; + let profile = build_profile(&files, None, Profile::TracePlan)?; + let methods = serde_json::to_value(&profile.methods)?; + let count = fact_mine_rust::function_inventory::write( + methods.as_array().map_or(&[][..], Vec::as_slice), + &plan, + &root, + &output, + )?; + eprintln!("Inventoried {count} functions"); + } + Command::NilKillMergeEvidence { inputs, output, plan } => { + let mut documents = inputs + .iter() + .map(|path| fact_mine_rust::runtime_protocol::read_runtime_evidence(path)) + .collect::>>()?; + // An incremental collect mixes shards stored under an older plan + // with ones just collected, so each is brought onto the plan the + // merged document will claim. + if let Some(plan) = plan { + let plan = fact_mine_rust::runtime_trace::read_plan(&plan)?; + documents = documents + .iter() + .map(|document| { + fact_mine_rust::runtime_trace::rebase_evidence(document, &plan) + }) + .collect(); + } + let merged = fact_mine_rust::runtime_trace::merge_evidence(&documents)?; + fact_mine_rust::runtime_trace::write_json( + &output, + &(fact_mine_rust::runtime_protocol::to_json_with_defaults(&merged)? + "\n"), + )?; + eprintln!("Merged {} evidence documents", inputs.len()); + } + Command::NilKillScipIndex { + runtime_dir, + evidence, + plan, + output, + attestation, + files, + environment, + root, + } => { + let raw = fact_mine_rust::runtime_protocol::read_json(&plan) + .with_context(|| format!("unreadable plan {}", plan.display()))?; + let document: serde_json::Value = serde_json::from_str(&raw)?; + let runtime_plan = document + .get("runtime_evidence") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or(document); + let environment = environment + .iter() + .filter_map(|claim| claim.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + + let emitted = fact_mine_rust::scip_emit::emit( + &root, + &runtime_dir, + &evidence, + &runtime_plan, + &files, + &environment, + |evidence_path, sources| { + Ok(runtime_scip_overlay(&root, sources, &plan, evidence_path)?.index) + }, + )?; + fs::write(&output, serde_json::to_string(&emitted.index)? + "\n")?; + fact_mine_rust::runtime_trace::write_json( + &attestation, + &(serde_json::to_string_pretty(&emitted.attestation)? + "\n"), + )?; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "index": output, + "attestation": attestation, + "events": emitted.events, + "inferred_events": emitted.inferred_events, + "documents": emitted.documents, + "occurrences": emitted.occurrences, + "invalid_events": emitted.invalid_events, + "excluded_events": 0, + "runtime_evidence": evidence, + "runtime_value_observations": emitted.observations, + }))? + ); + } + Command::NilKillTraceDocument { runtime_dirs, plan, root } => { + let raw = fact_mine_rust::runtime_protocol::read_json(&plan) + .with_context(|| format!("unreadable plan {}", plan.display()))?; + let plan: serde_json::Value = serde_json::from_str(&raw)?; + let digest = plan["runtime_evidence"]["plan_digest"] + .as_str() + .or_else(|| plan["plan_digest"].as_str()) + .unwrap_or_default() + .to_string(); + let built = fact_mine_rust::parallel::map_ordered(&runtime_dirs, |directory| { + // The runtime that observed, and the run it observed under, + // both come from the shard's own document. + let (runtime, run_id) = fact_mine_rust::trace_document::runtime_of(directory)?; + let run_ids = if run_id.is_empty() { vec![] } else { vec![run_id] }; + fact_mine_rust::trace_document::write( + &root, directory, &digest, &runtime, &run_ids, + )?; + Ok(1usize) + })?; + eprintln!("Built {} trace documents", built.iter().sum::()); + } + Command::NilKillCollectorPlan { plan, output, target_dirs, root } => { + fact_mine_rust::collector_plan::write(&plan, &output, &target_dirs, &root)?; + } + Command::NilKillCollectorExport { runtime_dirs, plan, source_roles, root } => { + let plan = plan + .as_deref() + .map(|path| -> Result { + let raw = fact_mine_rust::runtime_protocol::read_json(path) + .with_context(|| format!("unreadable plan {}", path.display()))?; + Ok(serde_json::from_str(&raw)?) + }) + .transpose()?; + let anchors = fact_mine_rust::collector_export::anchors_by_key(plan.as_ref(), &root); + let nonproduction = read_nonproduction(source_roles.as_deref(), &root); + let project_name = std::env::var("NIL_KILL_PROJECT_NAME").unwrap_or_else(|_| { + root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default() + }); + let project_version = std::env::var("NIL_KILL_PROJECT_VERSION") + .unwrap_or_else(|_| "workspace".to_string()); + let shaped = fact_mine_rust::parallel::map_ordered(&runtime_dirs, |directory| { + let mut written = 0; + let mut documents = std::fs::read_dir(directory) + .with_context(|| format!("unreadable shard {}", directory.display()))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name().is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("collector-raw-") && name.ends_with(".json.gz") + }) + }) + .collect::>(); + documents.sort(); + for path in documents { + let raw = fact_mine_rust::runtime_protocol::read_json(&path) + .with_context(|| format!("unreadable {}", path.display()))?; + let document: fact_mine_rust::collector_export::CollectorDocument = + serde_json::from_str(&raw) + .with_context(|| format!("invalid {}", path.display()))?; + fact_mine_rust::collector_export::Export::new( + &document, + &anchors, + nonproduction.clone(), + project_name.clone(), + project_version.clone(), + ) + .write(directory)?; + written += 1; + } + Ok(written) + })?; + eprintln!( + "Shaped {} collector documents across {} shards", + shaped.iter().sum::(), + runtime_dirs.len() + ); + } + Command::NilKillDeriveDomains { inputs, source_roles, root } => { + // Which files hold non-production code is a fact about the collect, + // not about the traced program, so it is read here rather than + // carried through every observation. + let nonproduction = + read_nonproduction(source_roles.as_deref(), &root).into_iter().collect::>(); + let derived = fact_mine_rust::parallel::map_ordered(&inputs, |path| { + let raw = fact_mine_rust::runtime_protocol::read_json(path) + .with_context(|| format!("unreadable collector document {}", path.display()))?; + let mut document: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("invalid collector document {}", path.display()))?; + let count = fact_mine_rust::value_domain::derive_document( + &mut document, + nonproduction.clone(), + ); + fact_mine_rust::runtime_trace::write_json( + path, + &serde_json::to_string(&document)?, + )?; + Ok(count) + })?; + eprintln!( + "Derived {} value domains across {} collector documents", + derived.iter().sum::(), + inputs.len() + ); + } + Command::NilKillDecodeCalls { input, root } => { + let text = std::fs::read_to_string(&input)?; + let rows: Vec = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .map(|event| fact_mine_rust::runtime_decode::call(&event, &root)) + .collect(); + println!("{}", serde_json::to_string(&rows)?); + } + Command::RuntimePlan { + files, + output, + root, + language_override, + } => { + let language_override = language_override + .as_deref() + .map(Language::parse) + .transpose()?; + let root = root + .unwrap_or(std::env::current_dir().context("failed to determine project root")?); + let files = canonical_runtime_sources(&files, &root)?; + let profile = build_profile(&files, language_override, Profile::TracePlan)?; + let plan = fact_mine_rust::runtime_protocol::build_trace_plan(&profile, &files, &root)?; + let json = fact_mine_rust::runtime_protocol::to_json(&plan)?; + let rendered = + serde_json::to_string_pretty(&serde_json::from_str::(&json)?)? + + "\n"; + write_text_artifact(&rendered, output.as_ref())?; + eprintln!( + "Runtime plan: {} documents, {} exact evidence anchors", + plan.documents.len(), + plan.requests.len() + ); + } + Command::RuntimeTrace { + plan, + traces, + output, + to_stdout, + merged, + root, + } => { + // The plan is parsed and digest-checked once however many traces are + // joined; paying that per shard cost more than the join itself. The + // shards themselves are independent, so they join concurrently -- + // this loop was the largest sequential stage of a collect. + // Stage timing, off unless asked for, so this command can be + // accounted for the same way the collector's stages are. + let timed = std::env::var("NIL_KILL_STAGE_TIMING").as_deref() == Ok("1"); + let mark = std::time::Instant::now(); + let plan = fact_mine_rust::runtime_trace::read_plan(&plan)?; + if timed { + eprintln!(" rust plan-read {:.2}s", mark.elapsed().as_secs_f64()); + } + let root = std::fs::canonicalize(&root).unwrap_or(root); + let mark = std::time::Instant::now(); + // Writing beside each trace is the default whatever the count. + // Making one trace behave differently from many meant a single-shard + // collect silently produced no evidence file at all. + let single = to_stdout; + let joined = fact_mine_rust::parallel::map_ordered(&traces, |path| { + let trace = fact_mine_rust::runtime_trace::read_trace(path)?; + let evidence = + fact_mine_rust::runtime_trace::build_evidence(&root, &plan, &trace)?; + match (&output, single) { + (Some(target), _) => { + fact_mine_rust::runtime_trace::write_json(target, &evidence)?; + Ok(None) + } + (None, true) => Ok(Some(evidence)), + (None, false) if merged.is_some() => { + let target = path.with_file_name("runtime-evidence.v1.json.gz"); + fact_mine_rust::runtime_trace::write_json(&target, &evidence)?; + Ok(Some(evidence)) + } + (None, false) => { + let target = path.with_file_name("runtime-evidence.v1.json.gz"); + fact_mine_rust::runtime_trace::write_json(&target, &evidence)?; + Ok(None) + } + } + })?; + if timed { + eprintln!(" rust join+write {:.2}s", mark.elapsed().as_secs_f64()); + } + // Merging here saves writing every shard's document only for the + // collector to read them all back and merge them in Ruby. + if let Some(target) = &merged { + let mark = std::time::Instant::now(); + let documents = joined + .iter() + .flatten() + .map(|text| { + fact_mine_rust::runtime_protocol::parse_runtime_evidence_json(text) + }) + .collect::>>()?; + if timed { + eprintln!(" rust merge-parse {:.2}s", mark.elapsed().as_secs_f64()); + } + let mark = std::time::Instant::now(); + let document = fact_mine_rust::runtime_trace::merge_evidence(&documents)?; + if timed { + eprintln!(" rust merge {:.2}s", mark.elapsed().as_secs_f64()); + } + let mark = std::time::Instant::now(); + fact_mine_rust::runtime_trace::write_json( + target, + &fact_mine_rust::runtime_protocol::to_json_with_defaults(&document)?, + )?; + if timed { + eprintln!(" rust canonical+write {:.2}s", mark.elapsed().as_secs_f64()); + } + } else { + for evidence in joined.into_iter().flatten() { + println!("{evidence}"); + } + } + eprintln!( + "Runtime trace joined: {} anchors over {} trace(s)", + plan.requests.len(), + traces.len() + ); + } + Command::RuntimeEvidenceValidate { plan, evidence } => { + let plan = fact_mine_rust::runtime_protocol::read_trace_plan(&plan)?; + let evidence = fact_mine_rust::runtime_protocol::read_runtime_evidence(&evidence)?; + fact_mine_rust::runtime_protocol::validate_runtime_evidence(&plan, &evidence)?; + eprintln!( + "Runtime evidence valid: {} runs, {} exact anchors", + evidence.runs.len(), + evidence.anchors.len() + ); + } Command::LuaScip { files, output, @@ -269,6 +739,63 @@ fn run() -> Result<()> { generated.stats.unresolved_calls, ); } + Command::RuntimeScip { + files, + plan, + evidence, + output, + root, + language_override, + } => { + let language_override = language_override + .as_deref() + .map(Language::parse) + .transpose()?; + let root = root + .unwrap_or(std::env::current_dir().context("failed to determine project root")?); + let files = canonical_runtime_sources(&files, &root)?; + let supplied_plan = fact_mine_rust::runtime_protocol::read_trace_plan(&plan)?; + let plan_files = supplied_plan + .documents + .iter() + .map(|document| root.join(&document.relative_path)) + .collect::>(); + let (mut profile, plan_profile) = + build_profile_pair(&files, &plan_files, language_override)?; + // Runtime discovery may add workspace callees to the analysis + // corpus after collection. Those files are useful declaration + // context, but were never evidence anchors and therefore must not + // alter the trace-plan digest. Rebuild anchor bindings from the + // exact document set named by the validated plan. + let rebuilt = fact_mine_rust::runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + &plan_files, + &root, + )?; + if supplied_plan.plan_digest != rebuilt.plan.plan_digest { + bail!("supplied runtime trace plan does not describe the current source snapshot"); + } + let evidence = fact_mine_rust::runtime_protocol::read_runtime_evidence(&evidence)?; + let overlay = fact_mine_rust::runtime_evidence::apply_protocol_to_profile( + &mut profile, + &rebuilt, + &evidence, + )?; + let rendered = serde_json::to_string_pretty(&overlay.index)? + "\n"; + if let Some(output) = output { + fs::write(&output, rendered) + .with_context(|| format!("failed to write {}", output.display()))?; + } else { + print!("{rendered}"); + } + eprintln!( + "Runtime SCIP: {} observed sites, {} inferred sites, {} typed receivers, {} occurrences", + overlay.stats.observed_call_sites, + overlay.stats.inferred_call_sites, + overlay.stats.typed_receivers, + overlay.stats.emitted_occurrences, + ); + } } Ok(()) } @@ -319,6 +846,26 @@ fn write_profile_artifact( Ok(()) } +fn write_text_artifact(contents: &str, destination: Option<&PathBuf>) -> Result<()> { + if let Some(path) = destination { + let file = fs::File::create(path) + .with_context(|| format!("failed to create {}", path.display()))?; + let buffered = BufWriter::new(file); + if path.extension().and_then(|extension| extension.to_str()) == Some("gz") { + let mut encoder = GzEncoder::new(buffered, Compression::fast()); + encoder.write_all(contents.as_bytes())?; + encoder.finish()?.flush()?; + } else { + let mut writer = buffered; + writer.write_all(contents.as_bytes())?; + writer.flush()?; + } + } else { + print!("{contents}"); + } + Ok(()) +} + fn write_profile_json( output: &profile::ProfileOutput, portable: bool, @@ -376,6 +923,150 @@ fn build_profile( Ok(output) } +/// The analysis profile and the trace-plan profile are extracted from the same +/// sources, so parse each file once and run both extractions over it. Building +/// them separately parsed the whole snapshot twice. +/// Which files this collect was told hold non-production code. A fact about the +/// collect, not about any traced program, so it is read once here. +fn read_nonproduction( + source_roles: Option<&std::path::Path>, + root: &std::path::Path, +) -> std::collections::BTreeSet { + source_roles + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|roles| { + roles["nonproduction"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry.as_str()) + .map(|entry| root.join(entry).to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// The overlay itself: parse the sources, rebuild the plan they describe, check +/// it still names the same snapshot, and lay the observed values over it. +fn runtime_scip_overlay( + root: &std::path::Path, + files: &[PathBuf], + plan: &std::path::Path, + evidence: &std::path::Path, +) -> Result { + let files = canonical_runtime_sources(files, root)?; + let supplied = fact_mine_rust::runtime_protocol::read_trace_plan(plan)?; + let plan_files = supplied + .documents + .iter() + .map(|document| root.join(&document.relative_path)) + .collect::>(); + let (mut profile, plan_profile) = build_profile_pair(&files, &plan_files, None)?; + let rebuilt = fact_mine_rust::runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + &plan_files, + root, + )?; + if supplied.plan_digest != rebuilt.plan.plan_digest { + bail!("supplied runtime trace plan does not describe the current source snapshot"); + } + let evidence = fact_mine_rust::runtime_protocol::read_runtime_evidence(evidence)?; + fact_mine_rust::runtime_evidence::apply_protocol_to_profile(&mut profile, &rebuilt, &evidence) +} + +fn build_profile_pair( + analysis_files: &[PathBuf], + plan_files: &[PathBuf], + language_override: Option, +) -> Result<(profile::ProfileOutput, profile::ProfileOutput)> { + let mut union = Vec::new(); + for file in analysis_files.iter().chain(plan_files) { + if !union.contains(file) { + union.push(file.clone()); + } + } + let wanted = |files: &[PathBuf], file: &PathBuf| files.contains(file); + let parsed = parallel::map_ordered(&union, |file| { + let language = if let Some(language) = language_override { + language + } else { + Language::for_path(file) + .with_context(|| format!("cannot detect language for {}", file.display()))? + }; + let document = syntax::parse_file(file.clone(), language)?; + let analysis = wanted(analysis_files, file) + .then(|| profile::extract_local(&document, Profile::Espalier)); + let plan = + wanted(plan_files, file).then(|| profile::extract_local(&document, Profile::TracePlan)); + Ok(( + analysis, + plan, + document.parse_recovered.then(|| profile::ParseRecovery { + path: file.to_string_lossy().to_string(), + spans: document.parse_recovery_spans, + }), + )) + })?; + + // Each profile is finalized over its own files, in its own order, so the + // result is exactly what building it alone would have produced. + let position = |file: &PathBuf| union.iter().position(|entry| entry == file); + let mut analysis_shards = Vec::with_capacity(analysis_files.len()); + let mut plan_shards = Vec::with_capacity(plan_files.len()); + for file in analysis_files { + if let Some(shard) = position(file).and_then(|at| parsed[at].0.clone()) { + analysis_shards.push(shard); + } + } + for file in plan_files { + if let Some(shard) = position(file).and_then(|at| parsed[at].1.clone()) { + plan_shards.push(shard); + } + } + let recoveries = |files: &[PathBuf]| { + files + .iter() + .filter_map(|file| position(file).and_then(|at| parsed[at].2.clone())) + .collect::>() + }; + let finalize = |selected: Profile, files: &[PathBuf], shards: Vec| { + let recovered = recoveries(files); + let mut output = profile::ProjectFactFinalizer::new(selected).finalize(shards); + output.input_coverage = profile::InputCoverage { + selected_files: files.len(), + parsed_files: files.len(), + parse_recovery_files: recovered + .iter() + .map(|recovery| recovery.path.clone()) + .collect(), + parse_recoveries: recovered, + }; + output + }; + Ok(( + finalize(Profile::Espalier, analysis_files, analysis_shards), + finalize(Profile::TracePlan, plan_files, plan_shards), + )) +} + +fn canonical_runtime_sources(files: &[PathBuf], root: &std::path::Path) -> Result> { + files + .iter() + .map(|file| { + let source = if file.is_absolute() { + file.clone() + } else { + root.join(file) + }; + source.canonicalize().with_context(|| { + format!("failed to canonicalize runtime source {}", file.display()) + }) + }) + .collect() +} + + fn build_requested_profile( files: &[PathBuf], language_override: Option, @@ -491,9 +1182,113 @@ fn percent(numerator: usize, denominator: usize) -> f64 { } enum Command { + /// Assemble the collector's instrumentation plan from static facts. + NilKillTracePlan { + static_facts: PathBuf, + /// True when the file is unreshaped `profile trace-plan` output. + raw: bool, + runtime_plan: Option, + output: PathBuf, + root: PathBuf, + generated_at: String, + target_dirs: Vec, + exclude_dirs: Vec, + }, + /// Collect runtime evidence: plan, trace, join, index. + NilKillCollect { + commands: Vec>, + fast: bool, + continue_on_error: bool, + root: PathBuf, + }, + /// Preserve a collect's canonical artifacts, or put them back. + NilKillCanonical { + restore: bool, + state: PathBuf, + paths: Vec, + }, + /// Split a workload into one shard per test file. + NilKillWorkloadPlan { + targets: Vec, + command: Vec, + output: PathBuf, + root: PathBuf, + }, + /// Run one traced program per shard, several at a time. + NilKillRunShards { + plan: PathBuf, + output: PathBuf, + }, + /// Which shards an incremental collect has to rerun. + NilKillSelectIncrement { + input: PathBuf, + output: PathBuf, + }, + /// Which functions each shard exercised, and which callsites it reached. + NilKillShardBookkeeping { + inventory: PathBuf, + shards: Vec, + output: PathBuf, + root: PathBuf, + }, + /// Stable function identities and fingerprints for an incremental collect. + NilKillFunctionInventory { + files: Vec, + plan: Option, + output: PathBuf, + root: PathBuf, + }, + /// Merge evidence documents into one canonical document. + NilKillMergeEvidence { + inputs: Vec, + output: PathBuf, + plan: Option, + }, + /// Emit the runtime SCIP index for a collect, and attest what it covers. + NilKillScipIndex { + runtime_dir: PathBuf, + evidence: PathBuf, + plan: PathBuf, + output: PathBuf, + attestation: PathBuf, + files: Vec, + environment: Vec, + root: PathBuf, + }, + /// Build each shard's trace document from the rows it holds. + NilKillTraceDocument { + runtime_dirs: Vec, + plan: PathBuf, + root: PathBuf, + }, + /// Write the flat plan a traced program reads. + NilKillCollectorPlan { + plan: PathBuf, + output: PathBuf, + target_dirs: Vec, + root: PathBuf, + }, + /// Shape the collector's documents into the rows the pipeline reads. + NilKillCollectorExport { + runtime_dirs: Vec, + plan: Option, + source_roles: Option, + root: PathBuf, + }, + /// Turn the collector's raw observations into value domains. + NilKillDeriveDomains { + inputs: Vec, + source_roles: Option, + root: PathBuf, + }, + NilKillDecodeCalls { + input: PathBuf, + root: PathBuf, + }, SyntaxFacts { language: Option, files: Vec, + fields: Option>, }, Profile { profile: String, @@ -501,7 +1296,9 @@ enum Command { output: Option, language_override: Option, scip_indexes: Vec, + semantic_environments: Vec, complexity_summaries: Vec, + bundled_complexity_summaries: bool, portable: bool, incremental_cache: Option, changed_files_only: bool, @@ -512,7 +1309,9 @@ enum Command { language_override: Option, format: String, scip_indexes: Vec, + semantic_environments: Vec, complexity_summaries: Vec, + bundled_complexity_summaries: bool, }, LuaScip { files: Vec, @@ -520,6 +1319,32 @@ enum Command { root: Option, server: Option, }, + RuntimeScip { + files: Vec, + plan: PathBuf, + evidence: PathBuf, + output: Option, + root: Option, + language_override: Option, + }, + RuntimePlan { + files: Vec, + output: Option, + root: Option, + language_override: Option, + }, + RuntimeEvidenceValidate { + plan: PathBuf, + evidence: PathBuf, + }, + RuntimeTrace { + plan: PathBuf, + traces: Vec, + output: Option, + to_stdout: bool, + merged: Option, + root: PathBuf, + }, } fn parse_args(args: Vec) -> Result { @@ -527,84 +1352,723 @@ fn parse_args(args: Vec) -> Result { let command = iter.next().unwrap_or_default(); match command.as_str() { - "scip-lua" => { + "nil-kill-trace-plan" => { + let mut static_facts = None; + let mut raw = false; + let mut runtime_plan = None; let mut output = None; let mut root = None; - let mut server = None; - let mut files = Vec::new(); + let mut generated_at = None; + let mut target_dirs = Vec::new(); + let mut exclude_dirs = Vec::new(); while let Some(arg) = iter.next() { + let mut take = |name: &str| -> Result { + iter.next().with_context(|| format!("{name} requires a value")) + }; match arg.as_str() { - "--output" => { - output = Some(PathBuf::from( - iter.next().with_context(|| "--output requires a value")?, - )); - } - other if other.starts_with("--output=") => { - output = Some(PathBuf::from(other.strip_prefix("--output=").unwrap())); - } - "--root" => { - root = Some(PathBuf::from( - iter.next().with_context(|| "--root requires a value")?, - )); - } - other if other.starts_with("--root=") => { - root = Some(PathBuf::from(other.strip_prefix("--root=").unwrap())); - } - "--lua-language-server" => { - server = Some(PathBuf::from( - iter.next() - .with_context(|| "--lua-language-server requires a value")?, - )); - } - other if other.starts_with("--lua-language-server=") => { - server = Some(PathBuf::from( - other.strip_prefix("--lua-language-server=").unwrap(), - )); + "--static-facts" => static_facts = Some(PathBuf::from(take("--static-facts")?)), + "--raw-facts" => { + static_facts = Some(PathBuf::from(take("--raw-facts")?)); + raw = true; } - other if other.starts_with("--") => bail!("unsupported option: {other}"), - path => files.push(PathBuf::from(path)), + "--runtime-plan" => runtime_plan = Some(PathBuf::from(take("--runtime-plan")?)), + "--output" => output = Some(PathBuf::from(take("--output")?)), + "--root" => root = Some(PathBuf::from(take("--root")?)), + "--generated-at" => generated_at = Some(take("--generated-at")?), + "--target-dir" => target_dirs.push(take("--target-dir")?), + "--exclude-dir" => exclude_dirs.push(take("--exclude-dir")?), + other => bail!("unsupported option: {other}"), } } - if files.is_empty() { - bail!("scip-lua requires at least one Lua file"); - } - Ok(Command::LuaScip { - files, - output, - root, - server, + Ok(Command::NilKillTracePlan { + static_facts: static_facts.context("--static-facts is required")?, + raw, + runtime_plan, + output: output.context("--output is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + generated_at: generated_at.unwrap_or_default(), + target_dirs, + exclude_dirs, }) } - "syntax-facts" => { - let mut language = None; - let mut files = Vec::new(); + "nil-kill-collect" => { + let mut commands = Vec::new(); + let mut fast = false; + let mut continue_on_error = false; + let mut root = None; + let mut glob = None; + let mut template = None; while let Some(arg) = iter.next() { match arg.as_str() { - "--language" => { - language = - Some(iter.next().with_context(|| "--language requires a value")?); + "--fast" => fast = true, + "--continue-on-error" => continue_on_error = true, + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + "--glob" => glob = Some(iter.next().context("--glob")?), + "--template" => template = Some(iter.next().context("--template")?), + "--cmd" => { + commands.push( + shell_words::split(&iter.next().context("--cmd")?) + .context("--cmd is not a valid command")?, + ); } - other if other.starts_with("--language=") => { - language = Some(other.strip_prefix("--language=").unwrap().to_string()); + // One command per line, so a workload too long for a + // command line can still be one collect. + "--commands" => { + let file = iter.next().context("--commands requires a file")?; + let text = std::fs::read_to_string(&file) + .with_context(|| format!("unreadable command file {file}"))?; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + commands.push( + shell_words::split(line) + .with_context(|| format!("{file}: {line} is not a command"))?, + ); + } } - other if other.starts_with("--") => bail!("unsupported option: {other}"), - path => files.push(PathBuf::from(path)), + "--" => { + commands.push(iter.by_ref().collect()); + break; + } + other => bail!("unsupported option: {other}"), } } - if files.is_empty() { - bail!("syntax-facts requires at least one file"); + // One command per matched file, which is what makes a shard per + // test possible without the caller writing them all out. + if let (Some(pattern), Some(template)) = (glob, template) { + let mut matched = glob::glob(&pattern) + .context("--glob is not a valid pattern")? + .filter_map(Result::ok) + .collect::>(); + matched.sort(); + for path in matched { + let filled = template.replace("{file}", &path.to_string_lossy()); + commands.push(shell_words::split(&filled).context("--template")?); + } } - Ok(Command::SyntaxFacts { language, files }) + Ok(Command::NilKillCollect { + commands, + fast, + continue_on_error, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) } - "profile" => { - let profile = iter + "nil-kill-canonical" => { + let mut restore = false; + let mut state = None; + let mut paths = Vec::new(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--restore" => restore = true, + "--state" => state = Some(PathBuf::from(iter.next().context("--state")?)), + "--path" => paths.push(PathBuf::from(iter.next().context("--path")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillCanonical { + restore, + state: state.context("--state is required")?, + paths, + }) + } + "nil-kill-workload-plan" => { + let mut targets = Vec::new(); + let mut command = Vec::new(); + let mut output = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--target" => targets.push(PathBuf::from(iter.next().context("--target")?)), + "--arg" => command.push(iter.next().context("--arg")?), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillWorkloadPlan { + targets, + command, + output: output.context("--output is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-run-shards" => { + let mut plan = None; + let mut output = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillRunShards { + plan: plan.context("--plan is required")?, + output: output.context("--output is required")?, + }) + } + "nil-kill-select-increment" => { + let mut input = None; + let mut output = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--input" => input = Some(PathBuf::from(iter.next().context("--input")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillSelectIncrement { + input: input.context("--input is required")?, + output: output.context("--output is required")?, + }) + } + "nil-kill-shard-bookkeeping" => { + let mut inventory = None; + let mut shards = Vec::new(); + let mut output = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--inventory" => { + inventory = Some(PathBuf::from(iter.next().context("--inventory")?)); + } + "--shard" => shards.push(PathBuf::from(iter.next().context("--shard")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillShardBookkeeping { + inventory: inventory.context("--inventory is required")?, + shards, + output: output.context("--output is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-function-inventory" => { + let mut files = Vec::new(); + let mut plan = None; + let mut output = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--file" => files.push(PathBuf::from(iter.next().context("--file")?)), + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillFunctionInventory { + files, + plan, + output: output.context("--output is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-merge-evidence" => { + let mut inputs = Vec::new(); + let mut output = None; + let mut plan = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--input" => inputs.push(PathBuf::from(iter.next().context("--input")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + other => bail!("unsupported option: {other}"), + } + } + if inputs.is_empty() { + bail!("nil-kill-merge-evidence requires at least one --input"); + } + Ok(Command::NilKillMergeEvidence { + inputs, + output: output.context("--output is required")?, + plan, + }) + } + "nil-kill-scip-index" => { + let mut runtime_dir = None; + let mut evidence = None; + let mut plan = None; + let mut output = None; + let mut attestation = None; + let mut files = Vec::new(); + let mut environment = Vec::new(); + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--runtime-dir" => { + runtime_dir = Some(PathBuf::from(iter.next().context("--runtime-dir")?)); + } + "--evidence" => { + evidence = Some(PathBuf::from(iter.next().context("--evidence")?)); + } + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--attestation" => { + attestation = Some(PathBuf::from(iter.next().context("--attestation")?)); + } + "--file" => files.push(PathBuf::from(iter.next().context("--file")?)), + "--environment" => environment.push(iter.next().context("--environment")?), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillScipIndex { + runtime_dir: runtime_dir.context("--runtime-dir is required")?, + evidence: evidence.context("--evidence is required")?, + plan: plan.context("--plan is required")?, + output: output.context("--output is required")?, + attestation: attestation.context("--attestation is required")?, + files, + environment, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-trace-document" => { + let mut runtime_dirs = Vec::new(); + let mut plan = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--runtime-dir" => { + runtime_dirs.push(PathBuf::from(iter.next().context("--runtime-dir")?)); + } + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillTraceDocument { + runtime_dirs, + plan: plan.context("--plan is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-collector-plan" => { + let mut plan = None; + let mut output = None; + let mut target_dirs = Vec::new(); + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--output" => output = Some(PathBuf::from(iter.next().context("--output")?)), + "--target-dir" => target_dirs.push(iter.next().context("--target-dir")?), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillCollectorPlan { + plan: plan.context("--plan is required")?, + output: output.context("--output is required")?, + target_dirs, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-collector-export" => { + let mut runtime_dirs = Vec::new(); + let mut plan = None; + let mut source_roles = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--runtime-dir" => { + runtime_dirs.push(PathBuf::from(iter.next().context("--runtime-dir")?)); + } + "--plan" => plan = Some(PathBuf::from(iter.next().context("--plan")?)), + "--source-roles" => { + source_roles = Some(PathBuf::from(iter.next().context("--source-roles")?)); + } + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + if runtime_dirs.is_empty() { + bail!("nil-kill-collector-export requires at least one --runtime-dir"); + } + Ok(Command::NilKillCollectorExport { + runtime_dirs, + plan, + source_roles, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-derive-domains" => { + let mut inputs = Vec::new(); + let mut source_roles = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--input" => inputs.push(PathBuf::from(iter.next().context("--input")?)), + "--source-roles" => { + source_roles = Some(PathBuf::from(iter.next().context("--source-roles")?)); + } + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + if inputs.is_empty() { + bail!("nil-kill-derive-domains requires at least one --input"); + } + Ok(Command::NilKillDeriveDomains { + inputs, + source_roles, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "nil-kill-decode-calls" => { + let mut input = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--input" => input = Some(PathBuf::from(iter.next().context("--input")?)), + "--root" => root = Some(PathBuf::from(iter.next().context("--root")?)), + other => bail!("unsupported option: {other}"), + } + } + Ok(Command::NilKillDecodeCalls { + input: input.context("--input is required")?, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "runtime-plan" => { + let mut output = None; + let mut root = None; + let mut language_override = None; + let mut files = Vec::new(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--output" => { + output = Some(PathBuf::from( + iter.next().with_context(|| "--output requires a value")?, + )); + } + other if other.starts_with("--output=") => { + output = Some(PathBuf::from(other.strip_prefix("--output=").unwrap())); + } + "--root" => { + root = Some(PathBuf::from( + iter.next().with_context(|| "--root requires a value")?, + )); + } + other if other.starts_with("--root=") => { + root = Some(PathBuf::from(other.strip_prefix("--root=").unwrap())); + } + "--language" => { + language_override = + Some(iter.next().with_context(|| "--language requires a value")?); + } + other if other.starts_with("--language=") => { + language_override = + Some(other.strip_prefix("--language=").unwrap().to_string()); + } + other if other.starts_with("--") => bail!("unsupported option: {other}"), + path => files.push(PathBuf::from(path)), + } + } + if files.is_empty() { + bail!("runtime-plan requires at least one source file"); + } + Ok(Command::RuntimePlan { + files, + output, + root, + language_override, + }) + } + "runtime-trace" => { + let mut plan = None; + let mut traces: Vec = Vec::new(); + let mut output = None; + let mut to_stdout = false; + let mut merged = None; + let mut root = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--plan" => { + plan = Some(PathBuf::from( + iter.next().with_context(|| "--plan requires a value")?, + )); + } + other if other.starts_with("--plan=") => { + plan = Some(PathBuf::from(other.strip_prefix("--plan=").unwrap())); + } + "--runtime-trace" => { + traces.push(PathBuf::from( + iter.next() + .with_context(|| "--runtime-trace requires a value")?, + )); + } + other if other.starts_with("--runtime-trace=") => { + traces.push(PathBuf::from( + other.strip_prefix("--runtime-trace=").unwrap(), + )); + } + "--stdout" => { + to_stdout = true; + } + "--merged-output" => { + merged = Some(PathBuf::from( + iter.next().with_context(|| "--merged-output requires a value")?, + )); + } + other if other.starts_with("--merged-output=") => { + merged = Some(PathBuf::from( + other.strip_prefix("--merged-output=").unwrap(), + )); + } + "--output" => { + output = Some(PathBuf::from( + iter.next().with_context(|| "--output requires a value")?, + )); + } + other if other.starts_with("--output=") => { + output = Some(PathBuf::from(other.strip_prefix("--output=").unwrap())); + } + "--root" => { + root = Some(PathBuf::from( + iter.next().with_context(|| "--root requires a value")?, + )); + } + other if other.starts_with("--root=") => { + root = Some(PathBuf::from(other.strip_prefix("--root=").unwrap())); + } + other => bail!("unsupported runtime-trace argument: {other}"), + } + } + if traces.is_empty() { + bail!("runtime-trace requires at least one --runtime-trace FILE"); + } + if (output.is_some() || to_stdout) && traces.len() > 1 { + bail!("--output/--stdout name one document; joining several writes each beside its own"); + } + Ok(Command::RuntimeTrace { + plan: plan.with_context(|| "runtime-trace requires --plan FILE")?, + traces, + output, + to_stdout, + merged, + root: root.unwrap_or_else(|| PathBuf::from(".")), + }) + } + "runtime-evidence" => { + let operation = iter + .next() + .with_context(|| "runtime-evidence requires an operation; use validate")?; + if operation != "validate" { + bail!("unsupported runtime-evidence operation {operation:?}; use validate"); + } + let mut plan = None; + let mut evidence = None; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--plan" => { + plan = Some(PathBuf::from( + iter.next().with_context(|| "--plan requires a value")?, + )); + } + other if other.starts_with("--plan=") => { + plan = Some(PathBuf::from(other.strip_prefix("--plan=").unwrap())); + } + "--evidence" => { + evidence = Some(PathBuf::from( + iter.next().with_context(|| "--evidence requires a value")?, + )); + } + other if other.starts_with("--evidence=") => { + evidence = + Some(PathBuf::from(other.strip_prefix("--evidence=").unwrap())); + } + other => bail!("unsupported runtime-evidence validate argument: {other}"), + } + } + Ok(Command::RuntimeEvidenceValidate { + plan: plan.with_context(|| "runtime-evidence validate requires --plan FILE")?, + evidence: evidence + .with_context(|| "runtime-evidence validate requires --evidence FILE")?, + }) + } + "runtime-scip" => { + let mut output = None; + let mut plan = None; + let mut evidence = None; + let mut root = None; + let mut language_override = None; + let mut files = Vec::new(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--output" => { + output = Some(PathBuf::from( + iter.next().with_context(|| "--output requires a value")?, + )); + } + other if other.starts_with("--output=") => { + output = Some(PathBuf::from(other.strip_prefix("--output=").unwrap())); + } + "--runtime-evidence" => { + evidence = Some(PathBuf::from( + iter.next() + .with_context(|| "--runtime-evidence requires a value")?, + )); + } + other if other.starts_with("--runtime-evidence=") => { + evidence = Some(PathBuf::from( + other.strip_prefix("--runtime-evidence=").unwrap(), + )); + } + "--trace-plan" => { + plan = Some(PathBuf::from( + iter.next().with_context(|| "--trace-plan requires a value")?, + )); + } + other if other.starts_with("--trace-plan=") => { + plan = Some(PathBuf::from( + other.strip_prefix("--trace-plan=").unwrap(), + )); + } + "--root" => { + root = Some(PathBuf::from( + iter.next().with_context(|| "--root requires a value")?, + )); + } + other if other.starts_with("--root=") => { + root = Some(PathBuf::from(other.strip_prefix("--root=").unwrap())); + } + "--language" => { + language_override = + Some(iter.next().with_context(|| "--language requires a value")?); + } + other if other.starts_with("--language=") => { + language_override = + Some(other.strip_prefix("--language=").unwrap().to_string()); + } + other if other.starts_with("--") => bail!("unsupported option: {other}"), + path => files.push(PathBuf::from(path)), + } + } + if files.is_empty() { + bail!("runtime-scip requires at least one source file"); + } + let evidence = + evidence.with_context(|| "runtime-scip requires --runtime-evidence FILE")?; + let plan = plan.with_context(|| "runtime-scip requires --trace-plan FILE")?; + Ok(Command::RuntimeScip { + files, + plan, + evidence, + output, + root, + language_override, + }) + } + "scip-lua" => { + let mut output = None; + let mut root = None; + let mut server = None; + let mut files = Vec::new(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--output" => { + output = Some(PathBuf::from( + iter.next().with_context(|| "--output requires a value")?, + )); + } + other if other.starts_with("--output=") => { + output = Some(PathBuf::from(other.strip_prefix("--output=").unwrap())); + } + "--root" => { + root = Some(PathBuf::from( + iter.next().with_context(|| "--root requires a value")?, + )); + } + other if other.starts_with("--root=") => { + root = Some(PathBuf::from(other.strip_prefix("--root=").unwrap())); + } + "--lua-language-server" => { + server = Some(PathBuf::from( + iter.next() + .with_context(|| "--lua-language-server requires a value")?, + )); + } + other if other.starts_with("--lua-language-server=") => { + server = Some(PathBuf::from( + other.strip_prefix("--lua-language-server=").unwrap(), + )); + } + other if other.starts_with("--") => bail!("unsupported option: {other}"), + path => files.push(PathBuf::from(path)), + } + } + if files.is_empty() { + bail!("scip-lua requires at least one Lua file"); + } + Ok(Command::LuaScip { + files, + output, + root, + server, + }) + } + "syntax-facts" => { + let mut language = None; + let mut fields: Option> = None; + let mut files = Vec::new(); + let add_fields = |value: &str, fields: &mut Option<_>| { + let selected: std::collections::BTreeSet = value + .split(',') + .map(str::trim) + .filter(|field| !field.is_empty()) + .map(str::to_string) + .collect(); + *fields = Some(selected); + }; + while let Some(arg) = iter.next() { + match arg.as_str() { + "--language" => { + language = + Some(iter.next().with_context(|| "--language requires a value")?); + } + other if other.starts_with("--language=") => { + language = Some(other.strip_prefix("--language=").unwrap().to_string()); + } + "--fields" => { + let value = iter.next().with_context(|| "--fields requires a value")?; + add_fields(&value, &mut fields); + } + other if other.starts_with("--fields=") => { + add_fields(other.strip_prefix("--fields=").unwrap(), &mut fields); + } + other if other.starts_with("--") => bail!("unsupported option: {other}"), + path => files.push(PathBuf::from(path)), + } + } + if files.is_empty() { + bail!("syntax-facts requires at least one file"); + } + if fields.as_ref().is_some_and(|fields| fields.is_empty()) { + bail!("--fields requires at least one field name"); + } + Ok(Command::SyntaxFacts { + language, + files, + fields, + }) + } + "profile" => { + let profile = iter .next() .with_context(|| "usage: fact-mine-rust profile {espalier|nil-kill} FILE...")?; let mut output = None; let mut language_override = None; let mut files = Vec::new(); let mut scip_indexes = Vec::new(); + let mut semantic_environments = Vec::new(); let mut complexity_summaries = Vec::new(); + let mut bundled_complexity_summaries = true; let mut portable = false; let mut incremental_cache = None; let mut changed_files_only = false; @@ -636,6 +2100,17 @@ fn parse_args(args: Vec) -> Result { scip_indexes .push(PathBuf::from(other.strip_prefix("--scip-index=").unwrap())); } + "--semantic-environment" => { + semantic_environments.push(PathBuf::from( + iter.next() + .with_context(|| "--semantic-environment requires a value")?, + )); + } + other if other.starts_with("--semantic-environment=") => { + semantic_environments.push(PathBuf::from( + other.strip_prefix("--semantic-environment=").unwrap(), + )); + } "--complexity-summary" => { complexity_summaries.push(PathBuf::from( iter.next() @@ -647,6 +2122,9 @@ fn parse_args(args: Vec) -> Result { other.strip_prefix("--complexity-summary=").unwrap(), )); } + "--no-bundled-complexity-summaries" => { + bundled_complexity_summaries = false; + } "--portable" => { portable = true; } @@ -683,7 +2161,9 @@ fn parse_args(args: Vec) -> Result { output, language_override, scip_indexes, + semantic_environments, complexity_summaries, + bundled_complexity_summaries, portable, incremental_cache, changed_files_only, @@ -695,7 +2175,9 @@ fn parse_args(args: Vec) -> Result { let mut format = "text".to_string(); let mut files = Vec::new(); let mut scip_indexes = Vec::new(); + let mut semantic_environments = Vec::new(); let mut complexity_summaries = Vec::new(); + let mut bundled_complexity_summaries = true; while let Some(arg) = iter.next() { match arg.as_str() { "--output" => { @@ -724,6 +2206,17 @@ fn parse_args(args: Vec) -> Result { scip_indexes .push(PathBuf::from(other.strip_prefix("--scip-index=").unwrap())); } + "--semantic-environment" => { + semantic_environments.push(PathBuf::from( + iter.next() + .with_context(|| "--semantic-environment requires a value")?, + )); + } + other if other.starts_with("--semantic-environment=") => { + semantic_environments.push(PathBuf::from( + other.strip_prefix("--semantic-environment=").unwrap(), + )); + } "--complexity-summary" => { complexity_summaries.push(PathBuf::from( iter.next() @@ -735,6 +2228,9 @@ fn parse_args(args: Vec) -> Result { other.strip_prefix("--complexity-summary=").unwrap(), )); } + "--no-bundled-complexity-summaries" => { + bundled_complexity_summaries = false; + } "--format" => { format = iter.next().with_context(|| "--format requires a value")?; } @@ -754,11 +2250,13 @@ fn parse_args(args: Vec) -> Result { language_override, format, scip_indexes, + semantic_environments, complexity_summaries, + bundled_complexity_summaries, }) } other => bail!( - "usage: fact-mine-rust {{syntax-facts|profile|call-resolution|scip-lua}} FILE... (got: {other})" + "usage: fact-mine-rust {{syntax-facts|profile|call-resolution|runtime-plan|runtime-evidence|runtime-scip|scip-lua}} FILE... (got: {other})" ), } } @@ -819,6 +2317,182 @@ mod tests { .is_err()); } + #[test] + fn stdlib_producer_can_disable_bundled_summaries() { + let parsed = parse_args(vec![ + "profile".to_string(), + "espalier".to_string(), + "--no-bundled-complexity-summaries".to_string(), + "stdlib.go".to_string(), + ]) + .expect("parse stdlib producer profile"); + match parsed { + Command::Profile { + bundled_complexity_summaries, + .. + } => assert!(!bundled_complexity_summaries), + _ => panic!("expected profile command"), + } + } + + #[test] + fn profile_accepts_semantic_environment_sidecars() { + let parsed = parse_args(vec![ + "profile".to_string(), + "espalier".to_string(), + "--semantic-environment=runtime.json".to_string(), + "--semantic-environment".to_string(), + "target.json".to_string(), + "example.cpp".to_string(), + ]) + .expect("parse semantic environment profile"); + match parsed { + Command::Profile { + semantic_environments, + files, + .. + } => { + assert_eq!( + semantic_environments, + vec![PathBuf::from("runtime.json"), PathBuf::from("target.json")] + ); + assert_eq!(files, vec![PathBuf::from("example.cpp")]); + } + _ => panic!("expected profile command"), + } + } + + #[test] + fn runtime_scip_requires_evidence_and_accepts_language_neutral_sources() { + let parsed = parse_args(vec![ + "runtime-scip".to_string(), + "--trace-plan=runtime-plan.json".to_string(), + "--runtime-evidence=runtime-evidence.v1.json.gz".to_string(), + "--output".to_string(), + "runtime.scip.json".to_string(), + "one.rb".to_string(), + "two.py".to_string(), + ]) + .expect("runtime SCIP command"); + match parsed { + Command::RuntimeScip { + files, + plan, + evidence, + output, + root, + language_override, + } => { + assert_eq!( + files, + vec![PathBuf::from("one.rb"), PathBuf::from("two.py")] + ); + assert_eq!(plan, PathBuf::from("runtime-plan.json")); + assert_eq!(evidence, PathBuf::from("runtime-evidence.v1.json.gz")); + assert_eq!(output, Some(PathBuf::from("runtime.scip.json"))); + assert_eq!(root, None); + assert_eq!(language_override, None); + } + _ => panic!("expected runtime SCIP"), + } + assert!(parse_args(vec!["runtime-scip".to_string(), "one.rb".to_string()]).is_err()); + } + + #[test] + fn runtime_scip_rebuilds_anchor_bindings_from_plan_documents_not_discovered_callees() { + let directory = tempfile::tempdir().expect("tempdir"); + let planned = directory.path().join("planned.rb"); + let discovered = directory.path().join("discovered.rb"); + std::fs::write( + &planned, + "class Planned\n def run(value)\n value.size\n end\nend\n", + ) + .expect("planned source"); + std::fs::write( + &discovered, + "class Discovered\n def helper\n 1\n end\nend\n", + ) + .expect("discovered source"); + let planned_profile = + build_profile(std::slice::from_ref(&planned), None, Profile::TracePlan) + .expect("planned profile"); + let supplied = fact_mine_rust::runtime_protocol::build_trace_plan_with_bindings( + &planned_profile, + std::slice::from_ref(&planned), + directory.path(), + ) + .expect("supplied plan"); + + // `discovered` exists and participates in the full analysis corpus, but + // only `planned` owns evidence anchors. Both profiles come out of one + // parse of the union, so this also proves the shared parse does not let + // the wider corpus reach the plan. + let plan_files = supplied + .plan + .documents + .iter() + .map(|document| directory.path().join(&document.relative_path)) + .collect::>(); + let (analysis, plan_profile) = build_profile_pair( + &[planned.clone(), discovered.clone()], + &plan_files, + None, + ) + .expect("profiles"); + assert_eq!(analysis.input_coverage.selected_files, 2); + assert_eq!(plan_profile.input_coverage.selected_files, 1); + let rebuilt = fact_mine_rust::runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + &plan_files, + directory.path(), + ) + .expect("rebuild"); + + assert_eq!(rebuilt.plan.plan_digest, supplied.plan.plan_digest); + assert_eq!(rebuilt.plan.documents.len(), 1); + assert_eq!(rebuilt.plan.documents[0].relative_path, "planned.rb"); + assert!(discovered.exists()); + } + + #[test] + fn runtime_commands_canonicalize_relative_sources_before_binding_calls() { + let directory = tempfile::tempdir().expect("tempdir"); + let source = directory.path().join("worker.rb"); + std::fs::write( + &source, + "class Worker\n def run(value)\n value.size\n end\nend\n", + ) + .expect("source"); + let files = canonical_runtime_sources(&[PathBuf::from("worker.rb")], directory.path()) + .expect("canonical runtime sources"); + assert_eq!( + files, + vec![source.canonicalize().expect("canonical source")] + ); + + let plan_profile = + build_profile(&files, None, Profile::TracePlan).expect("trace-plan profile"); + let built = fact_mine_rust::runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + &files, + directory.path(), + ) + .expect("plan"); + let overlay_profile = + build_profile(&files, None, Profile::Espalier).expect("overlay profile"); + let overlay_call_ids = overlay_profile + .calls + .iter() + .map(|call| call.id.as_str()) + .collect::>(); + + assert!(built.bindings.values().all(|binding| match binding { + fact_mine_rust::runtime_protocol::AnchorBinding::Call { call_id } => + overlay_call_ids.contains(call_id.as_str()), + _ => true, + })); + } + #[test] fn requested_incremental_profile_emits_cache_scope() { let directory = tempfile::tempdir().expect("tempdir"); diff --git a/gems/fact-mine/src/profile.rs b/gems/fact-mine/src/profile.rs index cac0117cc..5c2569648 100644 --- a/gems/fact-mine/src/profile.rs +++ b/gems/fact-mine/src/profile.rs @@ -132,6 +132,15 @@ pub struct IncrementalMetrics { pub peak_resident_bytes: Option, } +/// Producer identity copied from SCIP metadata. Bundled external facts can +/// require an exact indexer build when the symbol scheme itself does not carry +/// a dependency or toolchain version. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, Ord, PartialEq, PartialOrd)] +pub struct SemanticIndex { + pub tool: String, + pub version: String, +} + /// The enriched output matching what Ruby's EspalierProfile::Builder.build returns. #[derive(Clone, Debug, Deserialize, Serialize, Default)] pub struct ProfileOutput { @@ -147,7 +156,18 @@ pub struct ProfileOutput { #[serde(default, skip_serializing_if = "InputCoverage::is_empty")] pub input_coverage: InputCoverage, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub semantic_indexes: Vec, + /// Opaque compatibility claims supplied by the build/index environment. + /// + /// The shared external-summary join compares these exactly. Language-owned + /// manifests decide how runtime digests, targets, sysroots, and ABI choices + /// are represented; shared analysis never interprets their keys. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub semantic_environment: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub owners: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dispatch_impls: Vec, pub methods: Vec, pub fields: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -177,6 +197,11 @@ pub struct ProfileOutput { /// after all files have been merged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub calls: Vec, + /// Function-like preprocessor definitions retained across file shards. + /// An absent cost means the language adapter could not bound that body; + /// project propagation requires every same-name definition to converge. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub preprocessor_definition_costs: Vec, /// Denominator-aware coverage of exact project call targets. This is a /// pure reduction over the final merged call records; it never resolves or /// reconstructs a target itself. @@ -187,6 +212,25 @@ pub struct ProfileOutput { pub state_accesses: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub complexity_facts: Vec, + /// Minimal, generic data-demand plan for runtime value collection. It is + /// emitted by TracePlan so tracers can avoid collecting values FactMine's + /// CFG/DFG will never consume. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_call_sites: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_result_call_sites: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_collection_receiver_sites: Vec, + /// Branch-local runtime capability predicates emitted from normalized + /// syntax. A tracer reports only the predicate's observed Boolean result; + /// the generic runtime overlay applies the branch and CFG/DFG relation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_capability_guards: Vec, + /// Branch-local proof that a simple value condition has reached its + /// truthy path. Adapters recognize native syntax; the runtime overlay + /// joins it only through exact CFG reaching definitions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_truthiness_guards: Vec, // NilKill-only fields #[serde(default, skip_serializing_if = "Vec::is_empty")] pub flow_local_types: Vec, @@ -248,6 +292,18 @@ pub struct ProfileOutput { pub imports: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PreprocessorDefinitionCost { + pub id: String, + pub language: String, + pub name: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space: Option, +} + #[derive(Clone, Debug, Deserialize, Serialize, Default)] pub struct InputCoverage { pub selected_files: usize, @@ -273,6 +329,48 @@ pub struct ParseRecovery { pub spans: Vec<[usize; 4]>, } +/// A language-neutral source range for which a runtime tracer needs a value +/// domain. FactMine owns the semantic demand; a tracer merely matches this +/// anchor to execution and serializes a generic runtime observation. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct RuntimeValueCaptureSite { + pub path: String, + pub span: [usize; 4], + /// Smallest normalized executable region whose first line event is + /// guaranteed to precede this capture. Runtime providers may use it to + /// activate expensive event sources only while this expression executes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activation_span: Option<[usize; 4]>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +/// A normalized predicate whose true and false branches respectively prove +/// that `subject` does or does not support `member`. The predicate call ID is +/// the semantic anchor used to join a tracer's Boolean observation; no +/// runtime provider needs to encode source-language control flow. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct RuntimeCapabilityGuard { + pub source: String, + pub subject: String, + pub member: String, + pub condition_call_id: String, + pub condition_span: [usize; 4], + #[serde(skip_serializing_if = "Option::is_none")] + pub member_available_span: Option<[usize; 4]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub member_unavailable_span: Option<[usize; 4]>, +} + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct RuntimeTruthinessGuard { + pub source: String, + pub subject: String, + pub condition_span: [usize; 4], + #[serde(skip_serializing_if = "Option::is_none")] + pub truthy_span: Option<[usize; 4]>, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct OwnerRecord { pub id: String, @@ -287,6 +385,81 @@ pub struct OwnerRecord { pub symbol: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub supertypes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requirements: Vec, +} + +/// A concrete type that can stand in for an abstract-dispatch type at runtime. +/// Espalier resolves an interface method's cost to the worst-case over these. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DispatchImpl { + pub interface: String, + pub implementer: String, + pub language: String, + /// "structural" (method-set superset) or "nominal" (declared conformance). + pub basis: String, +} + +/// For every abstract-dispatch type, find the concrete types that satisfy it - +/// structurally (method set superset, e.g. Go) or nominally (declared +/// conformance via supertypes, e.g. Java/Rust). This is the whole-program +/// satisfaction relation that drives worst-case interface costing. +fn compute_dispatch_impls(owners: &[OwnerRecord], methods: &[MethodRecord]) -> Vec { + // An interface carries no method bodies, so its method-set is empty and it + // never becomes a spurious structural implementer of another interface. + let mut methodset: BTreeMap<(&str, &str), BTreeSet<&str>> = BTreeMap::new(); + for method in methods { + methodset + .entry((method.language.as_str(), method.owner.as_str())) + .or_default() + .insert(method.dispatch_name.as_str()); + } + let mut edges = Vec::new(); + for iface in owners { + let required = iface + .requirements + .iter() + .map(String::as_str) + .collect::>(); + for concrete in owners { + if concrete.language != iface.language || concrete.name == iface.name { + continue; + } + // Structural: the concrete method-set is a superset of what the + // interface requires (Go, TypeScript). + let structural = !required.is_empty() + && methodset + .get(&(concrete.language.as_str(), concrete.name.as_str())) + .is_some_and(|set| required.iter().all(|method| set.contains(method))); + // Nominal: the concrete declares conformance (Java/Rust/Swift/... via + // `implements` / `impl` / `: P`, canonicalized into supertypes). + let nominal = concrete.supertypes.iter().any(|supertype| { + supertype == &iface.name + || supertype.rsplit(['.', ':']).next() == Some(iface.name.as_str()) + }); + if structural || nominal { + edges.push(DispatchImpl { + interface: iface.name.clone(), + implementer: concrete.name.clone(), + language: iface.language.clone(), + basis: if structural { "structural" } else { "nominal" }.to_string(), + }); + } + } + } + // A type declared across several files (or matching structurally and + // nominally) yields duplicate edges; keep one per (interface, implementer). + edges.sort_by(|a, b| { + (&a.language, &a.interface, &a.implementer).cmp(&( + &b.language, + &b.interface, + &b.implementer, + )) + }); + edges.dedup_by(|a, b| { + a.language == b.language && a.interface == b.interface && a.implementer == b.implementer + }); + edges } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -319,6 +492,10 @@ pub struct CallRecord { pub candidate_targets: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub candidate_reason: Option, + /// True only when compiler/language semantics prove that downstream + /// consumers cannot add another implementation to this candidate set. + #[serde(default)] + pub consumer_closed_candidate_set: bool, pub kind: String, pub owner: String, pub function: String, @@ -340,10 +517,25 @@ pub struct CallRecord { /// Exact normalized span of a direct call used as this call's receiver. #[serde(skip_serializing_if = "Option::is_none")] pub receiver_call_span: Option<[usize; 4]>, + /// Exact callable selector span, retained independently from the full + /// invocation span when a callback body makes that invocation multiline. + #[serde(skip_serializing_if = "Option::is_none")] + pub selector_span: Option<[usize; 4]>, + /// Complete executable expression range supplied to runtime collectors. + /// For calls with attached callbacks this includes the callback body while + /// `span` remains the semantic call span used by CFG/DFG analysis. + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_span: Option<[usize; 4]>, /// Exact producer call spans for every reaching definition of a local /// receiver. Empty means at least one definition was not a direct call. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub receiver_definition_call_spans: Vec<[usize; 4]>, + /// Shared normalized CFG/DFG projection applied to every reaching + /// call-result definition. This is populated for destructured bindings + /// such as `left, right = values()` only when every reaching definition + /// selects the same sequence position. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_definition_sequence_projection: Option, /// Adapter-proven canonical receiver type for cross-file resolution. #[serde(skip_serializing_if = "Option::is_none")] pub receiver_symbol: Option, @@ -393,6 +585,11 @@ pub struct CallRecord { pub complexity_assumptions: Vec, pub message: String, pub argument_count: usize, + /// Argument spellings at the call site, so a caller can link a callback + /// argument (a named function reference) to its definition and substitute + /// its cost for the callee's callback C. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub arguments: Vec, pub path: String, pub line: usize, pub span: [usize; 4], @@ -410,6 +607,12 @@ pub struct CallRecord { /// cases where the retained evidence cannot distinguish them. #[serde(skip_serializing_if = "Option::is_none")] pub empty_domain_cause: Option, + /// True when a runtime-evidence event matched this normalized source call. + /// This is diagnostic provenance only: it neither closes a candidate set + /// nor licenses a cost model. It lets coverage reports distinguish a + /// missing runtime path from a failed semantic join. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub runtime_evidence_observed: bool, } #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] @@ -457,6 +660,11 @@ pub struct CallResolutionCoverage { /// are retained separately so a source-scope policy is not misreported as /// a function extractor defect. pub raw_calls_not_normalized_inside_function: usize, + /// Export-eligible methods that still overlap an unmatched parser call. + /// Extraction must revoke eligibility for every such method; a non-zero + /// value is a soundness bug, not a diagnostic sampling condition. + #[serde(default)] + pub source_export_eligible_methods_overlapping_raw_call_loss: usize, /// Raw parser calls outside every extracted executable function. pub raw_calls_not_normalized_outside_function: usize, /// Grammar-node kinds for the raw-call subset with no normalized call at @@ -598,6 +806,20 @@ pub struct MethodRecord { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub complexity_signals: BTreeMap, pub params: Vec, + /// Parameter names invoked as callbacks in the body. Such a function has a + /// cost parametric in that callback (C); a caller can substitute the passed + /// callable's cost. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub callback_params: Vec, + /// True only when this declaration has an executable source body and every + /// parser call inside that body reached normalized call evidence. + #[serde(default)] + pub source_export_eligible: bool, + /// A callable manufactured by a source declaration macro. It participates + /// in target and cost resolution but is not an independently authored + /// production function for coverage denominators. + #[serde(default)] + pub generated_declaration: bool, /// Exact source covered by the parser's function span. Consumers that need /// function bodies must use this projection rather than re-parsing files. pub raw_source: String, @@ -746,6 +968,7 @@ pub struct ArrayShape { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct StructDeclaration { + pub language: String, pub path: String, pub class: String, pub fields: Vec, @@ -839,7 +1062,16 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { .collect::>(); let owners = extract_owners(document, &language, &path); - let methods = extract_methods(&lines, document, &language, &path); + let mut methods = extract_methods(&lines, document, &language, &path); + for recovery in &document.parse_recovery_spans { + for method in methods.iter_mut().filter(|method| { + method + .span + .is_some_and(|method_span| spans_overlap(method_span, *recovery)) + }) { + method.source_export_eligible = false; + } + } let fields = extract_fields(document, &language, &path); let (state_types, mut state_type_records) = extract_state_types(document, &language, &path); let (state_protocols, state_protocol_records) = @@ -853,15 +1085,21 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { if trace_plan { let mut struct_declarations = extract_struct_declarations(document, &language, &path); let mut tlet_sites = Vec::new(); - if let Ok((root, _)) = crate::ast::parse(std::path::Path::new(&path)) { + let mut runtime_capability_guards = Vec::new(); + let mut runtime_truthiness_guards = Vec::new(); + if let Ok((root, _)) = + crate::ast::parse_with_language(std::path::Path::new(&path), document.language) + { let behavior = crate::syntax::normalized_behavior::behavior(document.language); collect_struct_declarations( &root, + &language, &path, &mut Vec::new(), &mut struct_declarations, behavior, ); + merge_struct_declarations(&mut struct_declarations); crate::type_inference::collect_tlet_sites(&root, &path, &mut tlet_sites); // The field inventory keeps one representative write per state // slot, which may be an earlier untyped setter. Preserve the @@ -875,6 +1113,12 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { &mut Vec::new(), &mut ivar_tlet_types, ); + // Capability syntax is adapter-owned, but the branch relation and + // later runtime join belong to FactMine. Retain only the opaque + // predicate anchors in the trace plan. + let calls = extract_calls(document, &language, &path); + runtime_capability_guards = extract_runtime_capability_guards(&root, behavior, &calls); + runtime_truthiness_guards = extract_runtime_truthiness_guards(&root, behavior, &calls); state_type_records.extend(ivar_tlet_types.into_iter().map( |((owner, field), declared_type)| { let field = field.trim_start_matches('@').to_string(); @@ -892,6 +1136,24 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { }, )); } + // Runtime value collection is demand-driven. These are the only two + // cases where the generic overlay consumes a container shape or a + // call result; all other runtime calls need just their receiver type. + let calls = extract_calls(document, &language, &path); + let flow_local_types = extract_flow_local_types(document); + let state_accesses = extract_state_accesses(document, &language, &path); + let (mut runtime_result_call_sites, runtime_collection_receiver_sites) = + extract_runtime_value_capture_sites(document, &flow_local_types, &calls); + runtime_result_call_sites.extend(runtime_capability_guards.iter().map(|guard| { + RuntimeValueCaptureSite { + path: path.clone(), + span: guard.condition_span, + activation_span: runtime_activation_span(document, guard.condition_span), + selector: None, + } + })); + runtime_result_call_sites.sort(); + runtime_result_call_sites.dedup(); return LocalFactShard::new( profile, ProfileOutput { @@ -903,7 +1165,14 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { signatures, type_definitions, declaration_type_pressures, + calls, + state_accesses, tlet_sites, + runtime_call_sites: Vec::new(), + runtime_result_call_sites, + runtime_collection_receiver_sites, + runtime_capability_guards, + runtime_truthiness_guards, ..ProfileOutput::default() }, ); @@ -912,7 +1181,7 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { let mut hash_shapes = extract_hash_shapes(&lines, &language, &path); let mut array_shapes = extract_array_shapes(&lines, &language, &path); - let root_node = crate::ast::parse(std::path::Path::new(&path)) + let root_node = crate::ast::parse_with_language(std::path::Path::new(&path), document.language) .ok() .map(|(r, _)| r); if let Some(ref root) = root_node { @@ -921,23 +1190,32 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { } let mut struct_declarations = extract_struct_declarations(document, &language, &path); - let behavior = crate::syntax::normalized_behavior::behavior( - crate::syntax::Language::parse(document.language.as_str()) - .unwrap_or(crate::syntax::Language::Ruby), - ); + let behavior = crate::syntax::normalized_behavior::behavior(document.language); if let Some(ref root) = root_node { collect_struct_declarations( root, + &language, &path, &mut Vec::new(), &mut struct_declarations, behavior, ); } + merge_struct_declarations(&mut struct_declarations); let state_type_edges = extract_state_type_edges(document, &language, &path); + let preprocessor_definition_costs = + extract_preprocessor_definition_costs(document, &language, &path, behavior); let calls = extract_calls(document, &language, &path); let state_accesses = extract_state_accesses(document, &language, &path); let complexity_facts = syntax::complexity_facts::facts(document); + let runtime_capability_guards = root_node + .as_ref() + .map(|root| extract_runtime_capability_guards(root, behavior, &calls)) + .unwrap_or_default(); + let runtime_truthiness_guards = root_node + .as_ref() + .map(|root| extract_runtime_truthiness_guards(root, behavior, &calls)) + .unwrap_or_default(); let mut tlet_sites = Vec::new(); let mut dead_nil_checks = Vec::new(); @@ -1225,10 +1503,40 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { .any(|function| span_contains(function.span, *span)), }) .collect(); + for gap in &raw_calls_not_normalized { + if let Some(method) = methods + .iter_mut() + .filter(|method| method.span.is_some_and(|span| span_contains(span, *gap))) + .min_by_key(|method| { + let span = method.span.unwrap_or([0, 0, usize::MAX, usize::MAX]); + (span[2].saturating_sub(span[0]), span[3].abs_diff(span[1])) + }) + { + method.source_export_eligible = false; + } + } + let source_export_eligible_methods_overlapping_raw_call_loss = raw_calls_not_normalized + .iter() + .filter_map(|gap| { + methods + .iter() + .filter(|method| { + method + .span + .is_some_and(|method_span| span_contains(method_span, *gap)) + }) + .min_by_key(|method| { + let span = method.span.unwrap_or([0, 0, usize::MAX, usize::MAX]); + (span[2].saturating_sub(span[0]), span[3].abs_diff(span[1])) + }) + }) + .filter(|method| method.source_export_eligible) + .count(); let call_resolution_coverage = CallResolutionCoverage { raw_parser_call_sites: raw_call_spans.len(), raw_calls_not_normalized: raw_calls_not_normalized.len(), raw_calls_not_normalized_inside_function, + source_export_eligible_methods_overlapping_raw_call_loss, raw_calls_not_normalized_outside_function: raw_calls_not_normalized.len() - raw_calls_not_normalized_inside_function, raw_calls_not_normalized_by_kind, @@ -1243,7 +1551,10 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { artifact_scope: None, incremental_metrics: None, input_coverage: InputCoverage::default(), + semantic_indexes: Vec::new(), + semantic_environment: BTreeMap::new(), owners, + dispatch_impls: Vec::new(), methods, fields, struct_declarations, @@ -1261,9 +1572,15 @@ pub fn extract_local(document: &Document, profile: Profile) -> LocalFactShard { state_type_edges, call_graph_edges: Vec::new(), calls, + preprocessor_definition_costs, call_resolution_coverage, state_accesses, complexity_facts, + runtime_call_sites: Vec::new(), + runtime_result_call_sites: Vec::new(), + runtime_collection_receiver_sites: Vec::new(), + runtime_capability_guards, + runtime_truthiness_guards, flow_local_types, type_dependencies, collection_index_lookups, @@ -1343,8 +1660,24 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { &output.type_definitions, &mut output.calls, ); + apply_generated_record_costs( + &output.struct_declarations, + &output.methods, + &mut output.calls, + ); + reapply_generated_callable_costs(&mut output); + apply_merged_preprocessor_definition_costs( + &output.preprocessor_definition_costs, + &output.methods, + &mut output.calls, + ); apply_merged_declared_callback_costs(&output.fields, &output.methods, &mut output.calls); + apply_injected_state_callback_costs(&output.state_param_origin_records, &mut output.calls); + if profile == Profile::TracePlan { + output.runtime_call_sites = runtime_call_capture_sites(&output.calls, Some(document)); + } output.call_graph_edges = extract_call_graph_edges(&output.calls); + output.dispatch_impls = compute_dispatch_impls(&output.owners, &output.methods); output } @@ -1352,6 +1685,10 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { let mut output = merge_local(outputs, profile); finalize_project_output(&mut output); + if profile == Profile::TracePlan { + refresh_runtime_call_sites(&mut output); + } + output.dispatch_impls = compute_dispatch_impls(&output.owners, &output.methods); output } @@ -1376,8 +1713,14 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { let mut array_shapes = Vec::new(); let mut state_type_edges = Vec::new(); let mut calls = Vec::new(); + let mut preprocessor_definition_costs = Vec::new(); let mut state_accesses = Vec::new(); let mut complexity_facts = Vec::new(); + let mut runtime_call_sites = Vec::new(); + let mut runtime_result_call_sites = Vec::new(); + let mut runtime_collection_receiver_sites = Vec::new(); + let mut runtime_capability_guards = Vec::new(); + let mut runtime_truthiness_guards = Vec::new(); let mut flow_local_types = Vec::new(); let mut type_dependencies = Vec::new(); let mut collection_index_lookups = Vec::new(); @@ -1409,6 +1752,7 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { let mut raw_parser_call_sites = 0usize; let mut raw_calls_not_normalized = 0usize; let mut raw_calls_not_normalized_inside_function = 0usize; + let mut source_export_eligible_methods_overlapping_raw_call_loss = 0usize; let mut raw_calls_not_normalized_outside_function = 0usize; let mut raw_calls_not_normalized_by_kind = BTreeMap::new(); let mut raw_call_normalization_gap_samples = Vec::new(); @@ -1422,6 +1766,9 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { raw_calls_not_normalized_inside_function += output .call_resolution_coverage .raw_calls_not_normalized_inside_function; + source_export_eligible_methods_overlapping_raw_call_loss += output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss; raw_calls_not_normalized_outside_function += output .call_resolution_coverage .raw_calls_not_normalized_outside_function; @@ -1463,8 +1810,16 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { array_shapes.extend(output.array_shapes); state_type_edges.extend(output.state_type_edges); calls.extend(output.calls); + preprocessor_definition_costs.extend(output.preprocessor_definition_costs); state_accesses.extend(output.state_accesses); complexity_facts.extend(output.complexity_facts); + if trace_plan { + runtime_call_sites.extend(output.runtime_call_sites); + runtime_result_call_sites.extend(output.runtime_result_call_sites); + runtime_collection_receiver_sites.extend(output.runtime_collection_receiver_sites); + } + runtime_capability_guards.extend(output.runtime_capability_guards); + runtime_truthiness_guards.extend(output.runtime_truthiness_guards); if nil_kill || trace_plan { tlet_sites.extend(output.tlet_sites); } @@ -1512,7 +1867,10 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { artifact_scope: None, incremental_metrics: None, input_coverage: InputCoverage::default(), + semantic_indexes: Vec::new(), + semantic_environment: BTreeMap::new(), owners, + dispatch_impls: Vec::new(), methods, fields, struct_declarations, @@ -1530,10 +1888,12 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { state_type_edges, call_graph_edges: Vec::new(), calls, + preprocessor_definition_costs, call_resolution_coverage: CallResolutionCoverage { raw_parser_call_sites, raw_calls_not_normalized, raw_calls_not_normalized_inside_function, + source_export_eligible_methods_overlapping_raw_call_loss, raw_calls_not_normalized_outside_function, raw_calls_not_normalized_by_kind, raw_call_normalization_gap_samples, @@ -1542,6 +1902,11 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { }, state_accesses, complexity_facts, + runtime_call_sites, + runtime_result_call_sites, + runtime_collection_receiver_sites, + runtime_capability_guards, + runtime_truthiness_guards, flow_local_types, type_dependencies, collection_index_lookups, @@ -1573,6 +1938,62 @@ fn merge_local(outputs: Vec, profile: Profile) -> ProfileOutput { } } +fn apply_merged_preprocessor_definition_costs( + definitions: &[PreprocessorDefinitionCost], + methods: &[MethodRecord], + calls: &mut [CallRecord], +) { + let source_languages = methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + let mut grouped = BTreeMap::<(&str, &str), Vec<&PreprocessorDefinitionCost>>::new(); + for definition in definitions { + grouped + .entry((&definition.language, &definition.name)) + .or_default() + .push(definition); + } + let converged = grouped + .into_iter() + .filter_map(|(key, definitions)| { + let first = definitions.first()?; + let time = first.time.as_deref()?; + let space = first.space.as_deref()?; + definitions + .iter() + .all(|definition| { + definition.time.as_deref() == Some(time) + && definition.space.as_deref() == Some(space) + }) + .then(|| (key, (time.to_string(), space.to_string()))) + }) + .collect::>(); + for call in calls.iter_mut().filter(|call| { + call.preprocessor_callable + && call.known_time_complexity.is_none() + && call.known_space_complexity.is_none() + }) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some((time, space)) = converged.get(&(language, call.message.as_str())) else { + continue; + }; + call.known_time_complexity = Some(time.clone()); + call.known_space_complexity = Some(space.clone()); + call.complexity_provenance = Some("merged_source_preprocessor_definition".to_string()); + call.complexity_bound_quality = + Some("upper_bound_merged_source_preprocessor_definition".to_string()); + call.complexity_candidates = vec![call.message.clone()]; + call.complexity_assumptions = vec![format!( + "all analyzed source definitions of `{}` converge on this bounded cost", + call.message + )]; + call.complexity_missing_kind = None; + } +} + fn finalize_project_output(output: &mut ProfileOutput) { resolve_project_calls( &output.owners, @@ -1580,7 +2001,19 @@ fn finalize_project_output(output: &mut ProfileOutput) { &output.type_definitions, &mut output.calls, ); + apply_generated_record_costs( + &output.struct_declarations, + &output.methods, + &mut output.calls, + ); + reapply_generated_callable_costs(output); + apply_merged_preprocessor_definition_costs( + &output.preprocessor_definition_costs, + &output.methods, + &mut output.calls, + ); apply_merged_declared_callback_costs(&output.fields, &output.methods, &mut output.calls); + apply_injected_state_callback_costs(&output.state_param_origin_records, &mut output.calls); output.call_graph_edges = extract_call_graph_edges(&output.calls); output.owners.sort_by(|a, b| a.id.cmp(&b.id)); output.owners.dedup_by(|a, b| a.id == b.id); @@ -1592,6 +2025,12 @@ fn finalize_project_output(output: &mut ProfileOutput) { }); output.calls.sort_by(|a, b| a.id.cmp(&b.id)); output.calls.dedup_by(|a, b| a.id == b.id); + output + .preprocessor_definition_costs + .sort_by(|a, b| a.id.cmp(&b.id)); + output + .preprocessor_definition_costs + .dedup_by(|a, b| a.id == b.id); annotate_call_resolution_proofs(&output.owners, &output.methods, &mut output.calls); let raw_coverage = std::mem::take(&mut output.call_resolution_coverage); let mut coverage = summarize_call_resolution(&output.owners, &output.methods, &output.calls); @@ -1599,6 +2038,8 @@ fn finalize_project_output(output: &mut ProfileOutput) { coverage.raw_calls_not_normalized = raw_coverage.raw_calls_not_normalized; coverage.raw_calls_not_normalized_inside_function = raw_coverage.raw_calls_not_normalized_inside_function; + coverage.source_export_eligible_methods_overlapping_raw_call_loss = + raw_coverage.source_export_eligible_methods_overlapping_raw_call_loss; coverage.raw_calls_not_normalized_outside_function = raw_coverage.raw_calls_not_normalized_outside_function; coverage.raw_calls_not_normalized_by_kind = raw_coverage.raw_calls_not_normalized_by_kind; @@ -1610,7 +2051,7 @@ fn finalize_project_output(output: &mut ProfileOutput) { .then_with(|| left.kind.cmp(&right.kind)) }); samples.dedup(); - samples.truncate(64); + samples.truncate(1024); coverage.raw_call_normalization_gap_samples = samples; coverage.normalized_calls_without_raw_span = raw_coverage.normalized_calls_without_raw_span; output.call_resolution_coverage = coverage; @@ -1724,122 +2165,468 @@ fn apply_merged_declared_callback_costs( pub(crate) fn reapply_declared_callback_costs(output: &mut ProfileOutput) { apply_merged_declared_callback_costs(&output.fields, &output.methods, &mut output.calls); + apply_injected_state_callback_costs(&output.state_param_origin_records, &mut output.calls); } -/// Immutable lookup tables shared by proof annotation and coverage. The -/// previous implementation rebuilt and rescanned corpus-wide method/call -/// vectors for every unresolved call. -struct CallResolutionIndex<'a> { - methods_by_id: HashMap<&'a str, &'a MethodRecord>, - methods_by_message: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, - methods_by_lexical: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, - methods_by_symbol_owner: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, - methods_by_owner: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, - methods_by_owner_dispatch: HashMap<(&'a str, &'a str, &'a str, &'a str), Vec<&'a MethodRecord>>, - calls_by_site: HashMap<(&'a str, &'a str, [usize; 4]), &'a CallRecord>, -} +/// A state slot assigned from a constructor/method parameter is an injected +/// dispatch boundary. Its implementation can vary outside the analyzed +/// project, but one source call still invokes it at most once. Preserve that +/// uncertainty as a parametric callback cost instead of requiring the test +/// double or deployment implementation to be part of the current corpus. +fn apply_injected_state_callback_costs( + origins: &[StateParamOriginRecord], + calls: &mut [CallRecord], +) { + let injected = origins + .iter() + .map(|origin| (origin.owner.as_str(), origin.field.trim_start_matches('@'))) + .collect::>(); + let (time, space) = crate::syntax::parametric_call_complexity("callback_once") + .expect("callback_once is a built-in parametric cost"); -impl<'a> CallResolutionIndex<'a> { - fn new(methods: &'a [MethodRecord], calls: &'a [CallRecord]) -> Self { - let mut index = Self { - methods_by_id: HashMap::new(), - methods_by_message: HashMap::new(), - methods_by_lexical: HashMap::new(), - methods_by_symbol_owner: HashMap::new(), - methods_by_owner: HashMap::new(), - methods_by_owner_dispatch: HashMap::new(), - calls_by_site: HashMap::new(), - }; - for method in methods { - let language = method.language.as_str(); - index.methods_by_id.insert(method.id.as_str(), method); - index - .methods_by_message - .entry((language, method.dispatch_name.as_str())) - .or_default() - .push(method); - if let Some(symbol) = method.lexical_symbol.as_deref() { - index - .methods_by_lexical - .entry((language, symbol)) - .or_default() - .push(method); - } - if let Some(owner) = method.symbol_owner.as_deref() { - index - .methods_by_symbol_owner - .entry((language, owner)) - .or_default() - .push(method); - } - let mut owners = vec![method.owner.as_str()]; - if let Some(symbol_owner) = method.symbol_owner.as_deref() { - if symbol_owner != method.owner { - owners.push(symbol_owner); - } - } - for owner in owners.into_iter().filter(|owner| !owner.is_empty()) { - index - .methods_by_owner - .entry((language, owner)) - .or_default() - .push(method); - let short = owner - .rsplit([':', '.']) - .find(|part| !part.is_empty()) - .unwrap_or(owner); - if short != owner { - index - .methods_by_owner - .entry((language, short)) - .or_default() - .push(method); - } - index - .methods_by_owner_dispatch - .entry(( - language, - owner, - method.kind.as_str(), - method.dispatch_name.as_str(), - )) - .or_default() - .push(method); - if short != owner { - index - .methods_by_owner_dispatch - .entry(( - language, - short, - method.kind.as_str(), - method.dispatch_name.as_str(), - )) - .or_default() - .push(method); - } - } - } - for call in calls { - index - .calls_by_site - .insert((call.source.as_str(), call.path.as_str(), call.span), call); + for call in calls.iter_mut().filter(|call| { + call.state_receiver + && (call.known_time_complexity.is_none() || call.known_space_complexity.is_none()) + }) { + let receiver = call + .receiver + .strip_prefix("self.") + .or_else(|| call.receiver.strip_prefix("this.")) + .unwrap_or(&call.receiver) + .trim_start_matches('@') + .split('.') + .next() + .unwrap_or_default(); + if !injected.contains(&(call.owner.as_str(), receiver)) { + continue; } - index + + call.callback_receiver = true; + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = Some("parametric_injected_state_contract".to_string()); + call.complexity_bound_quality = Some("upper_bound_parametric_callback_once".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; } } -/// Summarize final call records without changing their resolution outcome. -/// This must run after project merge so exact cross-file targets are already -/// present and so consumers observe one authoritative denominator. -pub fn summarize_call_resolution( - owners: &[OwnerRecord], +/// Price operations proven by a language adapter's normalized declarative +/// record contract. This is deliberately language-neutral: adapters decide +/// which constructs are records, their fields, and which class operations are +/// constant; the shared join only matches those emitted facts. +fn apply_generated_record_costs( + declarations: &[StructDeclaration], methods: &[MethodRecord], - calls: &[CallRecord], -) -> CallResolutionCoverage { - let index = CallResolutionIndex::new(methods, calls); - let mut coverage = CallResolutionCoverage { - total_call_sites: calls.len(), - owners_with_supertypes: owners + calls: &mut [CallRecord], +) { + let source_languages = methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + + for call in calls.iter_mut().filter(|call| { + call.known_time_complexity.is_none() || call.known_space_complexity.is_none() + }) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let semantic_receiver = call.semantic_symbol.as_deref().and_then(|symbol| { + crate::syntax::normalized_behavior::behavior_for_name(language)? + .external_symbol_owner(symbol) + }); + let receiver = call + .receiver_type + .as_deref() + .or(call.receiver_symbol.as_deref()) + .or(semantic_receiver.as_deref()) + .unwrap_or(call.receiver.as_str()); + let matching_contracts = declarations + .iter() + .filter(|declaration| declaration.language == language) + .filter(|declaration| owner_name_matches(&declaration.class, receiver)) + .filter(|declaration| { + declaration + .fields + .iter() + .any(|field| field == &call.message) + || (call.receiver_kind == "type" + && declaration + .constant_operations + .iter() + .any(|operation| operation == &call.message)) + }) + .map(|declaration| declaration.class.as_str()) + .collect::>(); + if matching_contracts.len() != 1 { + continue; + } + + call.known_time_complexity = Some("O(1)".to_string()); + call.known_space_complexity = Some("O(1)".to_string()); + call.complexity_provenance = Some("generated_record_contract".to_string()); + call.complexity_bound_quality = + Some("upper_bound_normalized_declaration_contract".to_string()); + call.complexity_candidates = matching_contracts + .iter() + .map(|contract| (*contract).to_string()) + .collect(); + if call.receiver_symbol.is_none() { + call.receiver_symbol = matching_contracts + .iter() + .next() + .map(|contract| (*contract).to_string()); + call.receiver_symbol_origin = call + .receiver_symbol + .as_ref() + .map(|_| "semantic_generated_record_contract".to_string()); + } + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } +} + +pub(crate) fn reapply_generated_record_costs(output: &mut ProfileOutput) { + apply_generated_record_costs( + &output.struct_declarations, + &output.methods, + &mut output.calls, + ); +} + +pub(crate) fn reapply_generated_callable_costs(output: &mut ProfileOutput) { + let methods_by_id = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + // A resolver may already have proved the exact project target before a + // runtime/compiler symbol is available. Generated declarations are + // complete language-owned contracts, so preserve that stronger exact + // proof rather than requiring a second external-symbol-shaped join. + for call in output.calls.iter_mut().filter(|call| call.target.is_some()) { + let Some(method) = call + .target + .as_deref() + .and_then(|target| methods_by_id.get(target).copied()) + else { + continue; + }; + if !method.generated_declaration { + continue; + } + let Some(complexity) = + crate::syntax::normalized_behavior::behavior_for_name(&method.language).and_then( + |behavior| behavior.generated_callable_complexity(&method.raw_source, &method.name), + ) + else { + continue; + }; + call.known_time_complexity = Some(complexity.time.to_string()); + call.known_space_complexity = Some(complexity.space.to_string()); + call.complexity_provenance = Some("generated_callable_declaration".to_string()); + call.complexity_bound_quality = + Some("upper_bound_normalized_declaration_contract".to_string()); + call.complexity_candidates = vec![method.id.clone()]; + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } + + let source_languages = output + .methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + for call in output.calls.iter_mut().filter(|call| { + call.target.is_none() + && call.semantic_symbol.is_some() + && (call.known_time_complexity.is_none() || call.known_space_complexity.is_none()) + }) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let Some(owner) = call + .semantic_symbol + .as_deref() + .and_then(|symbol| behavior.external_symbol_owner(symbol)) + else { + continue; + }; + let candidates = output + .methods + .iter() + .filter(|method| method.language == language) + .filter(|method| { + method.owner == owner || method.symbol_owner.as_deref() == Some(owner.as_str()) + }) + .filter(|method| method.dispatch_name == call.message) + .filter_map(|method| { + behavior + .generated_callable_complexity(&method.raw_source, &method.name) + .map(|complexity| (method, complexity)) + }) + .collect::>(); + if candidates.len() != 1 { + continue; + } + let (method, complexity) = candidates[0]; + call.target = Some(method.id.clone()); + call.kind = "resolved_call".to_string(); + call.target_provenance = Some("semantic_generated_declaration".to_string()); + call.external_symbol_scope = None; + call.known_time_complexity = Some(complexity.time.to_string()); + call.known_space_complexity = Some(complexity.space.to_string()); + call.complexity_provenance = Some("generated_callable_declaration".to_string()); + call.complexity_bound_quality = + Some("upper_bound_normalized_declaration_contract".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } +} + +/// Runtime evidence can close an otherwise unknown receiver to exact project +/// declarations without furnishing compiler symbols. Keep the runtime-world +/// boundary intact for ordinary bodies, but a language-owned generated +/// declaration (reader/property/accessor) is already a complete normalized +/// contract. Join a closed set only when *every* project alternative carries +/// the same generated contract; no language-specific identity parsing occurs +/// here. +pub(crate) fn reapply_runtime_generated_candidate_costs(output: &mut ProfileOutput) { + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + for call in output.calls.iter_mut().filter(|call| { + call.target.is_none() + && call.consumer_closed_candidate_set + && !call.candidate_targets.is_empty() + && matches!( + call.candidate_reason.as_deref(), + Some("runtime_modeled_observed_candidate_set") + | Some("runtime_modeled_mixed_candidate_set") + ) + }) { + let candidate_methods = call + .candidate_targets + .iter() + .filter_map(|id| methods.get(id.as_str()).copied()) + .collect::>(); + if candidate_methods.len() != call.candidate_targets.len() { + continue; + } + let complexities = candidate_methods + .iter() + .filter_map(|method| { + crate::syntax::normalized_behavior::behavior_for_name(&method.language) + .and_then(|behavior| { + behavior.generated_callable_complexity(&method.raw_source, &method.name) + }) + .map(|complexity| (complexity.time.to_string(), complexity.space.to_string())) + }) + .collect::>(); + if complexities.len() != candidate_methods.len() { + continue; + } + let Some((time, space)) = complexities.first().cloned() else { + continue; + }; + if complexities + .iter() + .any(|complexity| complexity != &(time.clone(), space.clone())) + { + continue; + } + + let mixed = call.candidate_reason.as_deref() == Some("runtime_modeled_mixed_candidate_set"); + if mixed { + // The external side has already supplied a conservative upper + // bound. Generated accessor contracts are O(1), so retaining that + // existing bound is conservative for the full closed union. + if time != "O(1)" + || space != "O(1)" + || call.known_time_complexity.is_none() + || call.known_space_complexity.is_none() + { + continue; + } + call.complexity_provenance = Some( + "runtime_scip_modeled:mixed_project_external_candidate_max+generated_accessor" + .to_string(), + ); + call.complexity_bound_quality = Some("upper_bound_closed_candidate_max".to_string()); + } else { + call.known_time_complexity = Some(time); + call.known_space_complexity = Some(space); + call.complexity_provenance = + Some("generated_callable_declaration_candidate_max".to_string()); + call.complexity_bound_quality = Some("upper_bound_closed_candidate_max".to_string()); + } + // A closed mixed set retains an external alternative even if its + // project-owned side happens to contain one generated declaration. + // Its max-bound is sound, but naming that declaration as the exact + // target would falsely erase the observed external member of the + // domain. + if !mixed && candidate_methods.len() == 1 { + let method = candidate_methods[0]; + call.target = Some(method.id.clone()); + call.kind = "resolved_call".to_string(); + call.target_provenance = Some("runtime_unique_generated_declaration".to_string()); + call.external_symbol_scope = None; + call.candidate_targets.clear(); + call.candidate_reason = None; + } + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } +} + +pub(crate) fn reapply_direct_call_result_costs(output: &mut ProfileOutput) { + let mut by_dispatch: BTreeMap<(&str, &str, &str), Vec<&MethodRecord>> = BTreeMap::new(); + for method in &output.methods { + let Some(owner) = method.symbol_owner.as_deref() else { + continue; + }; + by_dispatch + .entry((owner, method.dispatch_name.as_str(), method.kind.as_str())) + .or_default() + .push(method); + } + resolve_direct_call_result_calls( + &output.methods, + &output.type_definitions, + &mut output.calls, + &by_dispatch, + ); +} + +/// Immutable lookup tables shared by proof annotation and coverage. The +/// previous implementation rebuilt and rescanned corpus-wide method/call +/// vectors for every unresolved call. +struct CallResolutionIndex<'a> { + methods_by_id: HashMap<&'a str, &'a MethodRecord>, + methods_by_message: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, + methods_by_lexical: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, + methods_by_symbol_owner: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, + methods_by_owner: HashMap<(&'a str, &'a str), Vec<&'a MethodRecord>>, + methods_by_owner_dispatch: HashMap<(&'a str, &'a str, &'a str, &'a str), Vec<&'a MethodRecord>>, + calls_by_site: HashMap<(&'a str, &'a str, [usize; 4]), &'a CallRecord>, +} + +impl<'a> CallResolutionIndex<'a> { + fn new(methods: &'a [MethodRecord], calls: &'a [CallRecord]) -> Self { + let mut index = Self { + methods_by_id: HashMap::new(), + methods_by_message: HashMap::new(), + methods_by_lexical: HashMap::new(), + methods_by_symbol_owner: HashMap::new(), + methods_by_owner: HashMap::new(), + methods_by_owner_dispatch: HashMap::new(), + calls_by_site: HashMap::new(), + }; + for method in methods { + let language = method.language.as_str(); + index.methods_by_id.insert(method.id.as_str(), method); + index + .methods_by_message + .entry((language, method.dispatch_name.as_str())) + .or_default() + .push(method); + if let Some(symbol) = method.lexical_symbol.as_deref() { + index + .methods_by_lexical + .entry((language, symbol)) + .or_default() + .push(method); + } + if let Some(owner) = method.symbol_owner.as_deref() { + index + .methods_by_symbol_owner + .entry((language, owner)) + .or_default() + .push(method); + } + let mut owners = vec![method.owner.as_str()]; + if let Some(symbol_owner) = method.symbol_owner.as_deref() { + if symbol_owner != method.owner { + owners.push(symbol_owner); + } + } + for owner in owners.into_iter().filter(|owner| !owner.is_empty()) { + index + .methods_by_owner + .entry((language, owner)) + .or_default() + .push(method); + let short = owner + .rsplit([':', '.']) + .find(|part| !part.is_empty()) + .unwrap_or(owner); + if short != owner { + index + .methods_by_owner + .entry((language, short)) + .or_default() + .push(method); + } + index + .methods_by_owner_dispatch + .entry(( + language, + owner, + method.kind.as_str(), + method.dispatch_name.as_str(), + )) + .or_default() + .push(method); + if short != owner { + index + .methods_by_owner_dispatch + .entry(( + language, + short, + method.kind.as_str(), + method.dispatch_name.as_str(), + )) + .or_default() + .push(method); + } + } + } + for call in calls { + index + .calls_by_site + .insert((call.source.as_str(), call.path.as_str(), call.span), call); + } + index + } +} + +/// Summarize final call records without changing their resolution outcome. +/// This must run after project merge so exact cross-file targets are already +/// present and so consumers observe one authoritative denominator. +pub fn summarize_call_resolution( + owners: &[OwnerRecord], + methods: &[MethodRecord], + calls: &[CallRecord], +) -> CallResolutionCoverage { + let index = CallResolutionIndex::new(methods, calls); + let mut coverage = CallResolutionCoverage { + total_call_sites: calls.len(), + owners_with_supertypes: owners .iter() .filter(|owner| !owner.supertypes.is_empty()) .count(), @@ -2328,7 +3115,12 @@ fn inherited_target_ids( if call.constructor_target.is_some() { return BTreeSet::new(); } - let start = if let Some(symbol) = call.receiver_symbol.as_deref() { + let behavior = crate::syntax::normalized_behavior::behavior_for_name(&source.language); + let start = if behavior + .is_some_and(|behavior| behavior.inherited_lookup_uses_source_owner(call.implicit_receiver)) + { + Some(source.owner.clone()) + } else if let Some(symbol) = call.receiver_symbol.as_deref() { Some(symbol.to_string()) } else if let Some(receiver_type) = call.receiver_type.as_deref() { declared_dispatch_owner_name_from_type(receiver_type, source.language.as_str()) @@ -2430,6 +3222,10 @@ fn span_contains(outer: [usize; 4], inner: [usize; 4]) -> bool { (outer[0], outer[1]) <= (inner[0], inner[1]) && (inner[2], inner[3]) <= (outer[2], outer[3]) } +fn spans_overlap(left: [usize; 4], right: [usize; 4]) -> bool { + (left[0], left[1]) <= (right[2], right[3]) && (right[0], right[1]) <= (left[2], left[3]) +} + /// Match calls only through an origin recorded during normalization. A /// normalized call can deliberately retain a callable-access span /// (`receiver.member`) rather than its parser invocation (`receiver.member()`). @@ -2500,6 +3296,8 @@ fn resolve_project_calls( type_definitions: &[TypeDefinition], calls: &mut [CallRecord], ) { + apply_merged_alias_costs(methods, type_definitions, calls); + apply_static_direct_call_result_contracts(methods, calls); let source_languages = methods .iter() .map(|method| (method.id.as_str(), method.language.as_str())) @@ -2520,8 +3318,30 @@ fn resolve_project_calls( .or_default() .push(method); } - for call in calls.iter_mut().filter(|call| call.target.is_none()) { - let Some(symbol) = call.lexical_symbol.as_deref() else { + // Some adapters can prove a fallback reconciliation key when declaration + // and call namespaces use different external coordinates. Bind only a + // unique key; collisions remain unresolved. + let mut by_reconciliation_key = BTreeMap::<(String, String, String), Vec<&MethodRecord>>::new(); + for method in methods { + let Some(behavior) = + crate::syntax::normalized_behavior::behavior_for_name(&method.language) + else { + continue; + }; + let Some(symbol) = method.lexical_symbol.as_deref() else { + continue; + }; + let Some((scope, name)) = behavior.project_function_reconciliation_key(symbol) else { + continue; + }; + by_reconciliation_key + .entry((method.language.clone(), scope, name)) + .or_default() + .push(method); + } + resolve_relative_scoped_calls(calls, &by_lexical, &source_languages); + for call in calls.iter_mut().filter(|call| call.target.is_none()) { + let Some(symbol) = call.lexical_symbol.as_deref() else { continue; }; let candidates = by_lexical @@ -2545,8 +3365,45 @@ fn resolve_project_calls( call.unresolved_reason = None; } + // Reconcile calls whose exact lexical coordinate did not match. + for call in calls.iter_mut().filter(|call| call.target.is_none()) { + if matches!( + call.lexical_symbol_origin.as_deref(), + Some("explicit_import" | "function_local_import") + ) { + continue; + } + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let Some(symbol) = call.lexical_symbol.as_deref() else { + continue; + }; + let Some((scope, name)) = behavior.project_function_reconciliation_key(symbol) else { + continue; + }; + let candidates = by_reconciliation_key + .get(&(language.to_string(), scope, name)) + .map(Vec::as_slice) + .unwrap_or_default(); + if let Some(candidate) = unique_call_candidate(candidates, call, Some(language)) { + call.target = Some(candidate.id.clone()); + call.kind = if call.owner == candidate.owner { + "internal_call".to_string() + } else { + "resolved_call".to_string() + }; + call.confidence = "high".to_string(); + call.unresolved_reason = None; + } + } + resolve_same_namespace_static_calls(methods, calls); resolve_same_namespace_declared_receiver_calls(methods, calls, &by_dispatch); + resolve_relative_type_receiver_calls(methods, calls, &source_languages); for call in calls.iter_mut().filter(|call| call.target.is_none()) { let Some(owner) = call.receiver_symbol.as_deref() else { @@ -2582,13 +3439,483 @@ fn resolve_project_calls( } resolve_inherited_calls(owners, methods, calls); + resolve_fallback_lexical_calls(owners, methods, calls, &by_lexical, &source_languages); + annotate_project_candidate_sets(owners, methods, calls, &by_lexical, &by_dispatch); resolve_direct_call_result_calls(methods, type_definitions, calls, &by_dispatch); for call in calls.iter_mut().filter(|call| call.target.is_some()) { call.candidate_targets.clear(); call.candidate_reason = None; } - annotate_project_candidate_sets(owners, methods, calls, &by_lexical, &by_dispatch); +} + +/// Reuse an adapter's language-guaranteed return contract for a direct call +/// that is immediately consumed as another call's receiver. This is the +/// static counterpart to runtime result evidence: the shared join correlates +/// normalized spans and CFG/DFG producer sets, while each adapter alone owns +/// which native calls have a guaranteed return type. It never guesses a +/// project method's return type or attempts to interpret source text. +fn apply_static_direct_call_result_contracts(methods: &[MethodRecord], calls: &mut [CallRecord]) { + let source_languages = methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + + loop { + let static_returns = calls + .iter() + .filter_map(|call| { + let language = source_languages.get(call.source.as_str()).copied()?; + let behavior = crate::syntax::normalized_behavior::behavior_for_name(language)?; + let receiver_type = call.receiver_type.as_deref(); + let return_type = call + .constructor_target + .as_ref() + .and_then(|_| { + call.receiver_symbol + .clone() + .or_else(|| call.receiver_type.clone()) + .or_else(|| { + (call.receiver_kind == "type").then(|| call.receiver.clone()) + }) + }) + .or_else(|| { + behavior + .static_argument_dependent_return_type(&call.message, &call.arguments) + }) + .or_else(|| { + call.implicit_receiver + .then(|| behavior.known_return_type(&call.message)) + .flatten() + }) + .or_else(|| behavior.static_return_type(&call.message, receiver_type)) + .or_else(|| { + behavior.propagated_collection_return_type(&call.message, receiver_type) + })?; + Some(( + (call.source.as_str(), call.path.as_str(), call.span), + return_type, + )) + }) + .fold( + BTreeMap::<(&str, &str, [usize; 4]), BTreeSet>::new(), + |mut rows, (key, return_type)| { + rows.entry(key).or_default().insert(return_type); + rows + }, + ); + let mut updates = Vec::new(); + for (index, call) in calls.iter().enumerate().filter(|(_, call)| { + (call.receiver_type.is_none() || !call.receiver_definition_call_spans.is_empty()) + && (call.known_time_complexity.is_none() || call.known_space_complexity.is_none()) + }) { + let receiver_spans = call + .receiver_call_span + .into_iter() + .chain(call.receiver_definition_call_spans.iter().copied()) + .collect::>(); + if receiver_spans.is_empty() { + continue; + } + let return_types = receiver_spans + .iter() + .map(|span| static_returns.get(&(call.source.as_str(), call.path.as_str(), *span))) + .collect::>>(); + let Some(return_types) = return_types else { + continue; + }; + let distinct = return_types + .into_iter() + .filter(|types| types.len() == 1) + .flat_map(|types| types.iter().cloned()) + .collect::>(); + if distinct.len() != 1 { + continue; + } + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) + else { + continue; + }; + let mut receiver_type = distinct.into_iter().next().expect("one static return type"); + if call.receiver_definition_sequence_projection.is_some() { + let Some(projected) = + projected_sequence_result_type(behavior, &receiver_type, language) + else { + continue; + }; + receiver_type = projected; + } + let normalized = TypeExpr::parse(&receiver_type, language); + let complexity = behavior.call_complexity(&normalized, &call.message); + let parametric = complexity + .is_none() + .then(|| { + behavior + .parametric_call_cost(&normalized, &call.message) + .and_then(|kind| crate::syntax::parametric_call_complexity(&kind)) + }) + .flatten(); + if call.receiver_type.as_deref() == Some(receiver_type.as_str()) + && complexity.is_none() + && parametric.is_none() + { + continue; + } + updates.push((index, receiver_type, complexity, parametric)); + } + if updates.is_empty() { + break; + } + for (index, receiver_type, complexity, parametric) in updates { + let call = &mut calls[index]; + call.receiver_type = Some(receiver_type); + call.receiver_type_origin = Some("static_call_result_contract".to_string()); + if let Some(complexity) = complexity { + call.known_time_complexity = Some(complexity.time.to_string()); + call.known_space_complexity = Some(complexity.space.to_string()); + call.complexity_provenance = Some("static_call_result_contract".to_string()); + call.complexity_bound_quality = + Some("upper_bound_language_return_contract".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } else if let Some((time, space)) = parametric { + call.callback_receiver = true; + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = Some("static_call_result_contract".to_string()); + call.complexity_bound_quality = + Some("upper_bound_language_return_contract".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } + } + } +} + +/// Join adapter-owned lexical type/module receiver candidates against exact +/// project declarations. The language adapter supplies the candidate order; +/// this shared resolver never invents a namespace and accepts a target only +/// when the resulting declaration set is unique. +fn resolve_relative_type_receiver_calls( + methods: &[MethodRecord], + calls: &mut [CallRecord], + source_languages: &BTreeMap<&str, &str>, +) { + let mut by_owner_dispatch = BTreeMap::<(&str, &str, &str), Vec<&MethodRecord>>::new(); + for method in methods { + by_owner_dispatch + .entry(( + method.owner.as_str(), + method.dispatch_name.as_str(), + method.kind.as_str(), + )) + .or_default() + .push(method); + } + for call in calls.iter_mut().filter(|call| { + call.target.is_none() + && call.receiver_kind == "type" + && call.receiver_symbol.is_none() + && !call.receiver.is_empty() + }) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let dispatch = if call.constructor_target.is_some() { + "instance" + } else { + "class" + }; + let message = call + .constructor_target + .as_deref() + .unwrap_or(call.message.as_str()); + let mut resolved = None; + for owner in behavior.relative_type_receiver_candidates(&call.receiver, &call.owner) { + let candidates = by_owner_dispatch + .get(&(owner.as_str(), message, dispatch)) + .into_iter() + .flatten() + .copied() + .filter(|method| method.language == language) + .collect::>(); + if !candidates.is_empty() { + resolved = Some((owner, candidates)); + break; + } + } + let Some((owner, candidates)) = resolved else { + continue; + }; + call.receiver_symbol = Some(owner); + call.receiver_symbol_origin = Some("adapter_relative_type_receiver_lookup".to_string()); + if let Some(candidate) = unique_call_candidate(&candidates, call, Some(language)) { + call.target = Some(candidate.id.clone()); + call.kind = "resolved_call".to_string(); + call.confidence = "high".to_string(); + call.unresolved_reason = None; + call.resolution_missing_proof = None; + } else { + call.candidate_targets = candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect::>() + .into_iter() + .collect(); + call.candidate_reason = Some("relative_type_receiver_candidate_set".to_string()); + call.unresolved_reason = + Some("closed_project_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + } + } +} + +/// Continue adapter-owned unqualified lookup only after member and inheritance +/// resolution failed. The shared resolver binds an exact declaration or keeps +/// a closed candidate set; adapters supply only ordered lexical identities. +fn resolve_fallback_lexical_calls( + owners: &[OwnerRecord], + methods: &[MethodRecord], + calls: &mut [CallRecord], + by_lexical: &BTreeMap<&str, Vec<&MethodRecord>>, + source_languages: &BTreeMap<&str, &str>, +) { + let sources = methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + for call in calls.iter_mut().filter(|call| call.target.is_none()) { + if matches!( + call.lexical_symbol_origin.as_deref(), + Some("explicit_import" | "function_local_import") + ) { + continue; + } + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let Some(source) = sources.get(call.source.as_str()).copied() else { + continue; + }; + // An ambiguous inherited member still hides namespace functions. + if !conservative_inherited_target_ids(owners, methods, call, source).is_empty() { + continue; + } + let namespace = call.symbol_namespace.as_deref().unwrap_or_default(); + let mut resolved = None; + for symbol in + behavior.fallback_lexical_candidates(&call.message, namespace, call.implicit_receiver) + { + let candidates = by_lexical + .get(symbol.as_str()) + .into_iter() + .flatten() + .copied() + .filter(|method| method.language == language) + .collect::>(); + if !candidates.is_empty() { + resolved = Some((symbol, candidates)); + break; + } + } + let Some((symbol, candidates)) = resolved else { + continue; + }; + call.lexical_symbol = Some(symbol); + call.lexical_symbol_origin = Some("adapter_fallback_lexical_lookup".to_string()); + if let Some(candidate) = unique_call_candidate(&candidates, call, Some(language)) { + call.target = Some(candidate.id.clone()); + call.kind = "resolved_call".to_string(); + call.confidence = "high".to_string(); + call.unresolved_reason = None; + } else { + call.candidate_targets = candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect::>() + .into_iter() + .collect(); + call.candidate_reason = Some("fallback_lexical_candidate_set".to_string()); + call.unresolved_reason = + Some("closed_project_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + } + } +} + +fn apply_merged_alias_costs( + methods: &[MethodRecord], + type_definitions: &[TypeDefinition], + calls: &mut [CallRecord], +) { + let source_languages = methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + let aliases = type_definitions + .iter() + .filter(|definition| definition.kind == "type_alias") + .filter_map(|definition| { + Some(( + (definition.language.as_str(), definition.name.as_str()), + definition.target.as_deref()?, + )) + }) + .fold( + BTreeMap::<(&str, &str), BTreeSet<&str>>::new(), + |mut aliases, (name, target)| { + aliases.entry(name).or_default().insert(target); + aliases + }, + ); + for call in calls + .iter_mut() + .filter(|call| call.known_time_complexity.is_none()) + { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let Some((alias_name, constructor_alias)) = behavior.merged_alias_call_name( + &call.message, + call.receiver_type.as_deref(), + call.implicit_receiver, + call.target.is_none(), + ) else { + continue; + }; + let Some(targets) = aliases.get(&(language, alias_name.as_str())) else { + continue; + }; + let normalized = targets + .iter() + .map(|target| TypeExpr::parse(target, language)) + .collect::>(); + if normalized.len() != 1 { + continue; + } + let receiver = normalized.into_iter().next().expect("one alias target"); + let constructor_target = constructor_alias + .then(|| targets.iter().next().copied()) + .flatten(); + let known = constructor_target + .and_then(|target| behavior.intrinsic_call_complexity(None, target)) + .or_else(|| behavior.call_complexity(&receiver, &call.message)); + let parametric = known + .is_none() + .then(|| behavior.parametric_call_cost(&receiver, &call.message)) + .flatten(); + let parametric_complexity = parametric + .as_deref() + .and_then(crate::syntax::parametric_call_complexity); + let Some(time) = known + .map(|cost| cost.time) + .or_else(|| parametric_complexity.map(|cost| cost.0)) + else { + continue; + }; + let space = known + .map(|cost| cost.space) + .or_else(|| parametric_complexity.map(|cost| cost.1)) + .expect("time and space contracts are paired"); + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = Some(if known.is_some() { + "merged_project_type_alias_registry".to_string() + } else { + "parametric_merged_project_type_alias_contract".to_string() + }); + call.complexity_bound_quality = Some( + known + .map(|_| "upper_bound_declared_receiver".to_string()) + .or_else(|| { + parametric + .as_ref() + .map(|kind| format!("upper_bound_parametric_{kind}")) + }) + .expect("known or parametric cost"), + ); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } +} + +/// Resolve adapter-provided relative qualified identities. The first scope +/// containing project declarations wins; multiple declarations remain a +/// closed candidate set for the complexity aggregator. +fn resolve_relative_scoped_calls( + calls: &mut [CallRecord], + by_lexical: &BTreeMap<&str, Vec<&MethodRecord>>, + source_languages: &BTreeMap<&str, &str>, +) { + for call in calls.iter_mut().filter(|call| call.target.is_none()) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { + continue; + }; + let Some(symbol) = call.lexical_symbol.as_deref() else { + continue; + }; + let namespace = call.symbol_namespace.as_deref().unwrap_or_default(); + let Some((resolved_symbol, candidates)) = behavior + .relative_lexical_candidates(symbol, namespace) + .into_iter() + .find_map(|candidate| { + let declarations = by_lexical + .get(candidate.as_str()) + .into_iter() + .flatten() + .copied() + .filter(|method| method.language == language) + .collect::>(); + (!declarations.is_empty()).then_some((candidate, declarations)) + }) + else { + continue; + }; + call.lexical_symbol = Some(resolved_symbol); + call.lexical_symbol_origin = Some("adapter_relative_lexical_lookup".to_string()); + if let Some(candidate) = unique_call_candidate(&candidates, call, Some(language)) { + call.target = Some(candidate.id.clone()); + call.kind = if call.owner == candidate.owner { + "internal_call".to_string() + } else { + "resolved_call".to_string() + }; + call.confidence = "high".to_string(); + call.unresolved_reason = None; + } else { + call.candidate_targets = candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect::>() + .into_iter() + .collect(); + call.candidate_reason = Some("relative_lexical_candidate_set".to_string()); + call.unresolved_reason = + Some("closed_project_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + } + } } /// Bind an unqualified declared receiver type only when the merged project @@ -2625,17 +3952,21 @@ fn resolve_same_namespace_declared_receiver_calls( if nominal.contains(['.', ':']) { continue; } - let expected_dot = format!("{namespace}.{nominal}"); - let expected_scope = format!("{namespace}::{nominal}"); - let candidates = [expected_dot.as_str(), expected_scope.as_str()] + // Declarations carry the canonical owner symbol built by + // `canonical_symbol_owner`, which normalizes the namespace separator to + // ".". Build the same form here so a cross-file receiver in the same + // namespace matches its type's methods (a raw "::ns" would never hit). + let canonical_namespace = namespace.replace("::", "."); + let expected = if canonical_namespace.is_empty() { + nominal.clone() + } else { + format!("{canonical_namespace}.{nominal}") + }; + let candidates = by_dispatch + .get(&(expected.as_str(), call.message.as_str(), "instance")) .into_iter() - .flat_map(|owner| { - by_dispatch - .get(&(owner, call.message.as_str(), "instance")) - .into_iter() - .flatten() - .copied() - }) + .flatten() + .copied() .filter(|method| method.language == source.language) .collect::>(); let Some(candidate) = @@ -2670,6 +4001,11 @@ fn annotate_project_candidate_sets( let Some(source) = sources.get(call.source.as_str()).copied() else { continue; }; + let Some(behavior) = + crate::syntax::normalized_behavior::behavior_for_name(&source.language) + else { + continue; + }; let mut reason = None; let mut candidates = BTreeSet::new(); if let Some(symbol) = call.lexical_symbol.as_deref() { @@ -2680,7 +4016,10 @@ fn annotate_project_candidate_sets( .flatten() .filter(|method| method.language == source.language) .filter(|method| { - source.language != "java" || method.params.len() == call.argument_count + behavior.project_call_candidate_compatible( + call.argument_count, + method.params.len(), + ) }) .map(|method| method.id.clone()), ); @@ -2693,6 +4032,11 @@ fn annotate_project_candidate_sets( .then_some(source.symbol_owner.as_deref()) .flatten() }) { + let excludes_self = call.constructor_target.is_some() + && crate::syntax::Language::parse(&source.language).is_ok_and(|language| { + crate::syntax::normalized_behavior::behavior(language) + .constructor_delegation_excludes_self() + }); let dispatch = if call.implicit_receiver { source.kind.as_str() } else if call.receiver_kind == "type" { @@ -2706,8 +4050,12 @@ fn annotate_project_candidate_sets( .into_iter() .flatten() .filter(|method| method.language == source.language) + .filter(|method| !excludes_self || method.id != call.source) .filter(|method| { - source.language != "java" || method.params.len() == call.argument_count + behavior.project_call_candidate_compatible( + call.argument_count, + method.params.len(), + ) }) .map(|method| method.id.clone()), ); @@ -2730,19 +4078,34 @@ fn unique_call_candidate<'a>( call: &CallRecord, source_language: Option<&str>, ) -> Option<&'a MethodRecord> { + let excludes_self = call.constructor_target.is_some() + && source_language + .and_then(|language| crate::syntax::Language::parse(language).ok()) + .is_some_and(|language| { + crate::syntax::normalized_behavior::behavior(language) + .constructor_delegation_excludes_self() + }); + let candidates = candidates + .iter() + .copied() + .filter(|candidate| !excludes_self || candidate.id != call.source) + .collect::>(); if candidates.len() == 1 { - return Some(candidates[0]); + return candidates.first().copied(); } - if source_language != Some("java") { - return None; - } - let arity = call.argument_count; - let matches = candidates + let behavior = + source_language.and_then(crate::syntax::normalized_behavior::behavior_for_name)?; + let compatible = candidates .iter() .copied() - .filter(|candidate| candidate.params.len() == arity) + .filter(|candidate| { + behavior.project_call_candidate_compatible(call.argument_count, candidate.params.len()) + }) .collect::>(); - (matches.len() == 1).then(|| matches[0]) + if compatible.len() == candidates.len() { + return None; + } + (compatible.len() == 1).then(|| compatible[0]) } fn resolve_inherited_calls( @@ -2761,13 +4124,8 @@ fn resolve_inherited_calls( return None; } let source = sources.get(call.source.as_str()).copied()?; - // These adapters expose nominal inheritance or language-defined - // method promotion. Other languages keep the normalized edge facts - // for measurement until their dispatch rules have exact oracles. - if !matches!( - source.language.as_str(), - "java" | "csharp" | "python" | "go" - ) { + let behavior = crate::syntax::normalized_behavior::behavior_for_name(&source.language)?; + if !behavior.resolves_inherited_project_calls() { return None; } let targets = conservative_inherited_target_ids(owners, methods, call, source); @@ -2797,7 +4155,13 @@ fn conservative_inherited_target_ids( if call.constructor_target.is_some() { return BTreeSet::new(); } - let start = if let Some(symbol) = call.receiver_symbol.as_deref() { + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(&source.language) + else { + return BTreeSet::new(); + }; + let start = if behavior.inherited_lookup_uses_source_owner(call.implicit_receiver) { + Some(source.owner.clone()) + } else if let Some(symbol) = call.receiver_symbol.as_deref() { Some(symbol.to_string()) } else if let Some(receiver_type) = call.receiver_type.as_deref() { declared_dispatch_owner_name_from_type(receiver_type, source.language.as_str()) @@ -2823,9 +4187,68 @@ fn conservative_inherited_target_ids( if exact.len() == 1 { return exact.into_iter().next(); } - if exact.len() > 1 || identity.contains(['.', ':']) { + if exact.len() > 1 { + return None; + } + let by_name = owners + .iter() + .filter(|owner| owner.language == source.language && owner.name == identity) + .collect::>(); + if by_name.len() == 1 { + return by_name.into_iter().next(); + } + if by_name.len() > 1 { return None; } + let normalized = owners + .iter() + .filter(|owner| owner.language == source.language) + .filter(|owner| { + behavior.inherited_owner_identity_matches( + identity, + &owner.name, + owner.symbol.as_deref(), + ) + }) + .collect::>(); + if behavior.inherited_identity_prefers_specialization(identity) { + let specializations = normalized + .iter() + .copied() + .filter(|owner| owner.name != identity && owner.name.contains(['<', '['])) + .collect::>(); + if specializations.len() == 1 { + return specializations.into_iter().next(); + } + if specializations.len() > 1 { + return None; + } + } + if normalized.len() == 1 { + return normalized.into_iter().next(); + } + if normalized.len() > 1 { + return None; + } + if identity.contains(['.', ':']) { + // A package-qualified supertype (e.g. a Go embed `bytes.Buffer`) + // carries only the import-leaf `package.Type`, while the declaring + // owner's canonical symbol prefixes the namespace directory. Match + // the identity as that symbol's trailing `.package.Type` suffix, + // binding only a unique owner. + let suffix = format!(".{}", identity.replace("::", ".")); + let qualified = owners + .iter() + .filter(|owner| owner.language == source.language) + .filter(|owner| { + owner + .symbol + .as_deref() + .is_some_and(|symbol| symbol.ends_with(&suffix)) + }) + .collect::>(); + return (qualified.len() == 1).then(|| qualified.into_iter().next().unwrap()); + } if let Some(namespace) = context .and_then(|owner| owner.symbol.as_deref()) @@ -2852,11 +4275,7 @@ fn conservative_inherited_target_ids( return None; } } - let by_name = owners - .iter() - .filter(|owner| owner.language == source.language && owner.name == identity) - .collect::>(); - (by_name.len() == 1).then(|| by_name[0]) + None }; let Some(start_owner) = resolve_owner(&start, None) else { @@ -2899,7 +4318,7 @@ fn conservative_inherited_target_ids( }) .filter(|method| method.dispatch_name == call.message && method.kind == dispatch) .filter(|method| { - source.language != "java" || method.params.len() == call.argument_count + behavior.project_call_candidate_compatible(call.argument_count, method.params.len()) }) .map(|method| method.id.clone()) .collect::>(); @@ -2923,45 +4342,36 @@ fn resolve_same_namespace_static_calls(methods: &[MethodRecord], calls: &mut [Ca .iter() .map(|method| (method.id.as_str(), method.language.as_str())) .collect::>(); - let mut candidates = BTreeMap::<(String, String, String), Vec<&MethodRecord>>::new(); - for method in methods - .iter() - .filter(|method| method.language == "java" && method.kind == "class") - { - let Some(symbol_owner) = method.symbol_owner.as_deref() else { + for call in calls.iter_mut().filter(|call| call.target.is_none()) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { continue; }; - let Some((namespace, owner)) = symbol_owner.rsplit_once('.') else { + let Some(behavior) = crate::syntax::normalized_behavior::behavior_for_name(language) else { continue; }; - candidates - .entry(( - namespace.to_string(), - owner.to_string(), - method.dispatch_name.clone(), - )) - .or_default() - .push(method); - } - for call in calls.iter_mut().filter(|call| call.target.is_none()) { - if source_languages.get(call.source.as_str()).copied() != Some("java") - || call.receiver_binding_kind != "unbound" - || call.receiver.contains(['.', ':', '(', ')', '[', ']']) + if call.receiver_binding_kind != "unbound" + || !behavior.unbound_receiver_may_name_project_type(&call.receiver) { continue; } let Some(namespace) = call.symbol_namespace.as_deref() else { continue; }; - let matches = candidates - .get(&( - namespace.to_string(), - call.receiver.clone(), - call.message.clone(), - )) - .map(Vec::as_slice) - .unwrap_or_default(); - let Some(candidate) = unique_call_candidate(matches, call, Some("java")) else { + let matches = methods + .iter() + .filter(|method| method.language == language && method.kind == "class") + .filter(|method| method.dispatch_name == call.message) + .filter(|method| { + method.symbol_owner.as_deref().is_some_and(|symbol_owner| { + symbol_owner + .rsplit_once('.') + .is_some_and(|(owner_namespace, owner)| { + owner_namespace == namespace && owner == call.receiver + }) + }) + }) + .collect::>(); + let Some(candidate) = unique_call_candidate(&matches, call, Some(language)) else { continue; }; call.target = Some(candidate.id.clone()); @@ -3020,17 +4430,24 @@ fn resolve_direct_call_result_calls( let inner_targets = calls .iter() .filter_map(|call| { - Some(( + let targets = call + .target + .as_deref() + .into_iter() + .chain(call.candidate_targets.iter().map(String::as_str)) + .collect::>(); + (!targets.is_empty()).then_some(( (call.source.as_str(), call.path.as_str(), call.span), - call.target.as_deref()?, + targets, )) }) .collect::>(); let mut resolved = Vec::new(); + let mut costed = Vec::new(); for (index, call) in calls .iter() .enumerate() - .filter(|(_, call)| call.target.is_none()) + .filter(|(_, call)| call.target.is_none() && call.known_time_complexity.is_none()) { let receiver_spans = call .receiver_call_span @@ -3040,28 +4457,91 @@ fn resolve_direct_call_result_calls( if receiver_spans.is_empty() { continue; } - let producer_facts = receiver_spans - .iter() - .filter_map(|receiver_span| { - let inner_target = inner_targets.get(&( - call.source.as_str(), - call.path.as_str(), - *receiver_span, - ))?; - let inner_method = methods_by_id.get(inner_target).copied()?; - let return_fact = return_facts.get(&( + let mut producer_facts = Vec::new(); + let mut producer_set_complete = true; + for receiver_span in &receiver_spans { + let Some(candidate_targets) = + inner_targets.get(&(call.source.as_str(), call.path.as_str(), *receiver_span)) + else { + producer_set_complete = false; + break; + }; + for inner_target in candidate_targets { + let Some(inner_method) = methods_by_id.get(inner_target).copied() else { + producer_set_complete = false; + break; + }; + let Some(return_fact) = return_facts.get(&( inner_method.language.as_str(), inner_method.path.as_str(), inner_method.owner.as_str(), inner_method.name.as_str(), inner_method.line, - ))?; - Some((inner_method, *return_fact)) - }) - .collect::>(); - if producer_facts.len() != receiver_spans.len() { + )) else { + producer_set_complete = false; + break; + }; + producer_facts.push((inner_method, *return_fact)); + } + if !producer_set_complete { + break; + } + } + if !producer_set_complete || producer_facts.is_empty() { continue; } + let return_types = producer_facts + .iter() + .filter_map(|(_, fact)| fact.return_type.clone()) + .collect::>(); + if return_types.len() == 1 + && producer_facts + .iter() + .all(|(_, fact)| fact.return_type.is_some()) + { + let receiver_type = return_types.into_iter().next().expect("one return type"); + let Some(source) = methods_by_id.get(call.source.as_str()).copied() else { + continue; + }; + let Ok(language) = crate::syntax::Language::parse(&source.language) else { + continue; + }; + let behavior = crate::syntax::normalized_behavior::behavior(language); + if let Some(complexity) = behavior.call_complexity(&receiver_type, &call.message) { + costed.push((index, receiver_type, complexity)); + continue; + } + let parametric_costs = producer_facts + .iter() + .filter_map(|(method, fact)| { + let behavior = crate::syntax::normalized_behavior::behavior_for_name( + &method.language, + )?; + behavior.call_result_parametric_cost(fact.return_type.as_ref()?) + }) + .collect::>(); + let unique_parametric_costs = + parametric_costs.iter().cloned().collect::>(); + if parametric_costs.len() == producer_facts.len() + && unique_parametric_costs.len() == 1 + { + let kind = unique_parametric_costs + .into_iter() + .next() + .expect("one parametric result cost"); + if let Some((time, space)) = crate::syntax::parametric_call_complexity(&kind) { + costed.push(( + index, + receiver_type, + crate::syntax::normalized_behavior::NormalizedCallComplexity { + time, + space, + }, + )); + continue; + } + } + } let symbols = producer_facts .iter() .filter_map(|(_, fact)| fact.return_symbol.as_deref()) @@ -3110,78 +4590,607 @@ fn resolve_direct_call_result_calls( resolved.push((index, candidate.id.clone(), receiver_symbol)); } } - if resolved.is_empty() { - break; + if resolved.is_empty() && costed.is_empty() { + break; + } + for (index, receiver_type, complexity) in costed { + let call = &mut calls[index]; + call.receiver_type = Some(receiver_type.to_string()); + call.receiver_type_origin = Some("declared_call_result_candidate_join".to_string()); + call.known_time_complexity = Some(complexity.time.to_string()); + call.known_space_complexity = Some(complexity.space.to_string()); + call.complexity_provenance = Some("declared_call_result_candidate_join".to_string()); + call.complexity_bound_quality = Some("upper_bound_closed_return_type_join".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } + for (index, target, receiver_symbol) in resolved { + let call = &mut calls[index]; + call.target = Some(target); + call.receiver_kind = "value".to_string(); + call.receiver_symbol = receiver_symbol; + call.receiver_type_origin = Some("declared_call_result".to_string()); + call.receiver_symbol_origin = call + .receiver_symbol + .as_ref() + .map(|_| "declared_call_result".to_string()); + call.kind = "resolved_call".to_string(); + call.confidence = "high".to_string(); + call.unresolved_reason = None; + } + } +} + +fn extract_flow_local_types(document: &Document) -> Vec { + let places = document + .places + .iter() + .map(|place| (place.id.as_str(), place)) + .collect::>(); + let nodes = document + .control_flow_nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let definitions = document + .reaching_definitions + .iter() + .map(|fact| { + ( + (fact.node_id.as_str(), fact.place_id.as_str()), + &fact.definitions, + ) + }) + .collect::>(); + let effects = document + .node_effects + .iter() + .map(|effect| (effect.node_id.as_str(), effect)) + .collect::>(); + let callback_bindings = document.callback_bindings.iter().fold( + BTreeMap::<(&str, &str), Vec<_>>::new(), + |mut rows, binding| { + rows.entry((binding.node_id.as_str(), binding.place_id.as_str())) + .or_default() + .push(binding); + rows + }, + ); + let mut rows = document + .flow_types + .iter() + .flat_map(|fact| { + let place = places.get(fact.place_id.as_str())?; + let node = nodes.get(fact.node_id.as_str())?; + let resolved_types = fact + .types + .iter() + .filter_map(|hint| TypeExpr::from_flow_hint(hint, document.language.as_str())) + .collect::>(); + let reaching = definitions + .get(&(fact.node_id.as_str(), fact.place_id.as_str())) + .cloned() + .cloned() + .unwrap_or_default(); + let definition_call_sources = reaching + .iter() + .filter_map(|definition| { + let effect = effects.get(definition.as_str())?; + effect + .write_call_source_sets + .get(&fact.place_id) + .cloned() + .or_else(|| { + effect + .write_call_sources + .get(&fact.place_id) + .copied() + .map(|span| vec![span]) + }) + .map(|spans| (definition.clone(), spans)) + }) + .collect::>(); + let definition_sequence_projections = reaching + .iter() + .filter_map(|definition| { + let effect = effects.get(definition.as_str())?; + effect + .write_sequence_projections + .get(&fact.place_id) + .copied() + .map(|position| (definition.clone(), position)) + }) + .collect::>(); + let bindings = callback_bindings + .get(&(fact.node_id.as_str(), fact.place_id.as_str())) + .cloned() + .unwrap_or_default(); + let rows = if bindings.is_empty() { + vec![(fact.node_id.clone(), node.span, None)] + } else { + bindings + .into_iter() + .map(|binding| { + ( + format!( + "{}:callback-binding:{}:{}:{}:{}", + binding.node_id, + binding.span[0], + binding.span[1], + binding.position, + place.name + ), + binding.span, + Some(binding.position), + ) + }) + .collect() + }; + Some(rows.into_iter().map(move |(node_id, span, position)| { + let callback_definition = position.is_some(); + json!({ + "file": document.file, + "function": fact.function, + "owner": fact.owner, + "name": place.name, + "place_id": fact.place_id, + "node_id": node_id, + "line": span[0], + "span": span, + "types": fact.types, + "resolved_types": resolved_types, + "complete": fact.complete, + // A callback parameter is a fresh definition for its + // normalized callback region. Reusing the enclosing CFG + // node's reaching set leaks an earlier same-named local + // or a sibling callback binding into this scope. + "reaching_definitions": if callback_definition { + Vec::::new() + } else { + reaching.clone() + }, + "definition_call_sources": if callback_definition { + BTreeMap::>::new() + } else { + definition_call_sources.clone() + }, + "definition_sequence_projections": if callback_definition { + BTreeMap::::new() + } else { + definition_sequence_projections.clone() + }, + "callback_binding_position": position, + }) + })) + }) + .flatten() + .collect::>(); + let existing = document + .flow_types + .iter() + .map(|flow| (flow.node_id.clone(), flow.place_id.clone())) + .collect::>(); + for binding in &document.callback_bindings { + if existing.contains(&(binding.node_id.clone(), binding.place_id.clone())) { + continue; + } + let Some(place) = places.get(binding.place_id.as_str()) else { + continue; + }; + if !nodes.contains_key(binding.node_id.as_str()) { + continue; + } + rows.push(json!({ + "file": document.file, + "function": binding.function, + "owner": binding.owner, + "name": place.name, + "place_id": binding.place_id, + "node_id": format!( + "{}:callback-binding:{}:{}:{}:{}", + binding.node_id, + binding.span[0], + binding.span[1], + binding.position, + place.name + ), + "line": binding.span[0], + "span": binding.span, + "types": [], + "resolved_types": [], + "complete": false, + "reaching_definitions": [], + "definition_call_sources": {}, + "definition_sequence_projections": {}, + "callback_binding_position": binding.position, + })); + } + rows +} + +/// Reduce normalized CFG/DFG facts to the opaque source anchors a runtime +/// collector needs. This deliberately carries no source-language expression +/// or flow rule: the collector only records a value when this plan says that +/// FactMine will consume it. +fn extract_runtime_value_capture_sites( + document: &Document, + flow_local_types: &[serde_json::Value], + calls: &[CallRecord], +) -> (Vec, Vec) { + let mut result_spans = BTreeSet::new(); + for row in flow_local_types { + for span in row + .get("definition_call_sources") + .and_then(serde_json::Value::as_object) + .into_iter() + .flat_map(|sources| sources.values()) + .flat_map(|value| { + serde_json::from_value::>(value.clone()) + .ok() + .or_else(|| { + serde_json::from_value::<[usize; 4]>(value.clone()) + .ok() + .map(|span| vec![span]) + }) + .unwrap_or_default() + }) + { + result_spans.insert(span); + } + } + // A callback or other compound expression may normalize to one CFG node + // even though it contains `value = call(); consume(value)`. Reaching + // definitions describe the state on entry to that node, so the ordinary + // cross-node reduction above cannot see this intra-node producer. The + // normalized effect already records both the exact producer span and the + // fact that the written place is read in the same node; retain that + // demand without teaching the runtime collector source-flow semantics. + for effect in &document.node_effects { + for read in &effect.reads { + if let Some(span) = effect.write_call_sources.get(read) { + result_spans.insert(*span); + } + if let Some(spans) = effect.write_call_source_sets.get(read) { + result_spans.extend(spans.iter().copied()); + } } - for (index, target, receiver_symbol) in resolved { - let call = &mut calls[index]; - call.target = Some(target); - call.receiver_kind = "value".to_string(); - call.receiver_symbol = receiver_symbol; - call.receiver_type_origin = Some("declared_call_result".to_string()); - call.receiver_symbol_origin = call - .receiver_symbol - .as_ref() - .map(|_| "declared_call_result".to_string()); - call.kind = "resolved_call".to_string(); - call.confidence = "high".to_string(); - call.unresolved_reason = None; + } + // Direct chained calls have no named local definition, but the normalized + // receiver projection proves that their result is immediately consumed. + // Named receivers are already covered by the CFG/DFG definition sources + // above; duplicating every receiver-definition projection here would make + // the runtime plan pay for the same evidence twice. + for call in calls { + if let Some(span) = call.receiver_call_span { + result_spans.insert(span); } } + + let result_sites = result_spans + .into_iter() + .flat_map(|span| runtime_value_capture_sites_for_span(document, calls, span)) + .collect(); + let receiver_sites = document + .call_sites + .iter() + .filter(|call| call.block) + .flat_map(|call| runtime_value_capture_sites_for_span(document, calls, call.span)) + .collect::>() + .into_iter() + .collect(); + (result_sites, receiver_sites) } -fn extract_flow_local_types(document: &Document) -> Vec { - let places = document - .places +fn runtime_value_capture_sites_for_span( + document: &Document, + calls: &[CallRecord], + span: [usize; 4], +) -> Vec { + let selectors = calls .iter() - .map(|place| (place.id.as_str(), place)) - .collect::>(); - let nodes = document - .control_flow_nodes + .filter(|call| call.path == document.file && call.span == span) + .map(|call| call.message.clone()) + .collect::>(); + let selectors = if selectors.is_empty() { + vec![None] + } else { + selectors.into_iter().map(Some).collect() + }; + selectors + .into_iter() + .map(|selector| RuntimeValueCaptureSite { + path: document.file.clone(), + span, + activation_span: runtime_activation_span(document, span), + selector, + }) + .collect() +} + +/// Runtime target observation is only useful when static resolution left the +/// normalized call without an exact target, semantic identity, or intrinsic +/// cost. Keeping that filter in FactMine prevents a tracer from paying a +/// per-call synchronization cost for facts the static profile has already +/// proven. +fn runtime_call_capture_sites( + calls: &[CallRecord], + document: Option<&Document>, +) -> Vec { + calls .iter() - .map(|node| (node.id.as_str(), node)) - .collect::>(); - let definitions = document - .reaching_definitions + .filter(|call| { + call.target.is_none() + && call.semantic_symbol.is_none() + && call.known_time_complexity.is_none() + && call.known_space_complexity.is_none() + }) + .map(|call| RuntimeValueCaptureSite { + path: call.path.clone(), + span: call.span, + activation_span: document + .and_then(|document| runtime_activation_span(document, call.span)), + selector: Some(call.message.clone()), + }) + .collect::>() + .into_iter() + .collect() +} + +/// Recompute runtime target demands after SCIP, semantic environments, and +/// complexity summaries have enriched the final call set. A trace plan built +/// from pre-enrichment calls would ask collectors to observe identities and +/// values that the static pipeline has already proven. +pub fn refresh_runtime_call_sites(output: &mut ProfileOutput) { + let activation_spans = output + .runtime_call_sites .iter() - .map(|fact| { + .map(|site| { ( - (fact.node_id.as_str(), fact.place_id.as_str()), - &fact.definitions, + (site.path.clone(), site.span, site.selector.clone()), + site.activation_span, ) }) .collect::>(); + output.runtime_call_sites = runtime_call_capture_sites(&output.calls, None); + for site in &mut output.runtime_call_sites { + site.activation_span = activation_spans + .get(&(site.path.clone(), site.span, site.selector.clone())) + .copied() + .flatten(); + } +} + +fn runtime_activation_span(document: &Document, capture: [usize; 4]) -> Option<[usize; 4]> { + let statements = document + .local_methods + .iter() + .flat_map(|method| &method.statements) + .filter(|statement| span_contains(statement.span, capture)) + .collect::>(); + let mut preceding = Vec::new(); + for method in &document.local_methods { + collect_preceding_enclosing_spans(&method.node, capture, &mut preceding); + } + if let Some(span) = preceding.into_iter().min_by_key(|span| { + ( + span[2].saturating_sub(span[0]), + span[3].saturating_sub(span[1]), + ) + }) { + return Some(span); + } + + if let Some(statement) = statements + .iter() + .filter(|statement| statement.span[0] < capture[0]) + .min_by_key(|statement| { + ( + statement.span[2].saturating_sub(statement.span[0]), + statement.span[3].saturating_sub(statement.span[1]), + ) + }) + { + return Some(statement.span); + } + // When a normalized statement starts on the capture line, a provider can + // activate there; method/scope ancestors must not shift the generic + // execution region to an unrelated earlier line. + if !statements.is_empty() { + return Some(capture); + } + document - .flow_types + .control_flow_nodes .iter() - .filter_map(|fact| { - let place = places.get(fact.place_id.as_str())?; - let node = nodes.get(fact.node_id.as_str())?; - let resolved_types = fact - .types - .iter() - .filter_map(|hint| TypeExpr::from_flow_hint(hint, document.language.as_str())) - .collect::>(); - Some(json!({ - "file": document.file, - "function": fact.function, - "owner": fact.owner, - "name": place.name, - "place_id": fact.place_id, - "node_id": fact.node_id, - "line": node.line, - "span": node.span, - "types": fact.types, - "resolved_types": resolved_types, - "complete": fact.complete, - "reaching_definitions": definitions - .get(&(fact.node_id.as_str(), fact.place_id.as_str())) - .cloned() - .cloned() - .unwrap_or_default(), - })) + .filter(|node| !matches!(node.kind.as_str(), "entry" | "exit")) + .filter(|node| span_contains(node.span, capture)) + .min_by_key(|node| { + ( + node.span[2].saturating_sub(node.span[0]), + node.span[3].saturating_sub(node.span[1]), + ) }) - .collect() + .map(|node| node.span) +} + +fn collect_preceding_enclosing_spans( + node: &crate::ast::Node, + capture: [usize; 4], + spans: &mut Vec<[usize; 4]>, +) { + let node_span = [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ]; + if !span_contains(node_span, capture) { + return; + } + if node_span[0] < capture[0] + && !matches!( + node.r#type.as_str(), + "SCOPE" | "BLOCK" | "DEFN" | "DEFS" | "CLASS" | "MODULE" + ) + { + spans.push(node_span); + } + for child in node.children.iter().filter_map(crate::ast::node) { + collect_preceding_enclosing_spans(child, capture, spans); + } +} + +/// Convert adapter-recognized capability predicates into opaque, stable +/// profile facts. This walker deliberately knows only the normalized `IF` / +/// `UNLESS` child layout; native predicate spelling remains behind the +/// `NormalizedLanguageBehavior` boundary. +fn extract_runtime_capability_guards( + root: &crate::ast::Node, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + calls: &[CallRecord], +) -> Vec { + fn normalized_span(node: &crate::ast::Node) -> [usize; 4] { + [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ] + } + + fn visit( + node: &crate::ast::Node, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + calls: &[CallRecord], + guards: &mut BTreeSet, + ) { + if matches!(node.r#type.as_str(), "IF" | "UNLESS") { + if let Some(condition) = node.children.first().and_then(crate::ast::node) { + if let Some(capability) = behavior.runtime_capability_guard(condition) { + let condition_span = normalized_span(condition); + let true_span = node + .children + .get(1) + .and_then(crate::ast::node) + .map(normalized_span); + let false_span = node + .children + .get(2) + .and_then(crate::ast::node) + .map(normalized_span); + let (member_available_span, member_unavailable_span) = + if node.r#type == "UNLESS" { + (false_span, true_span) + } else { + (true_span, false_span) + }; + for condition_call in calls.iter().filter(|call| { + call.span == condition_span && call.receiver == capability.subject + }) { + guards.insert(RuntimeCapabilityGuard { + source: condition_call.source.clone(), + subject: capability.subject.clone(), + member: capability.member.clone(), + condition_call_id: condition_call.id.clone(), + condition_span, + member_available_span, + member_unavailable_span, + }); + } + } + } + } + for child in node.children.iter().filter_map(crate::ast::node) { + visit(child, behavior, calls, guards); + } + } + + let mut guards = BTreeSet::new(); + visit(root, behavior, calls, &mut guards); + guards.into_iter().collect() +} + +/// Extract adapter-recognized bare-value branch conditions into an opaque, +/// language-neutral CFG fact. The adapter decides which source condition has +/// native truthiness semantics; this shared pass associates it only with the +/// exact enclosing method and selected truthy branch span. +fn extract_runtime_truthiness_guards( + root: &crate::ast::Node, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + calls: &[CallRecord], +) -> Vec { + fn normalized_span(node: &crate::ast::Node) -> [usize; 4] { + [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ] + } + + fn visit( + node: &crate::ast::Node, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + calls: &[CallRecord], + guards: &mut BTreeSet, + ) { + if matches!(node.r#type.as_str(), "IF" | "UNLESS") { + if let Some(condition) = node.children.first().and_then(crate::ast::node) { + let mut truthy_subjects = Vec::new(); + collect_truthy_subjects(condition, behavior, &mut truthy_subjects); + for truthiness in truthy_subjects { + let body_span = node + .children + .get(if node.r#type == "UNLESS" { 2 } else { 1 }) + .and_then(crate::ast::node) + .map(normalized_span); + let node_span = normalized_span(node); + let sources = calls + .iter() + .filter(|call| span_contains(node_span, call.span)) + .map(|call| call.source.clone()) + .collect::>(); + for source in sources { + guards.insert(RuntimeTruthinessGuard { + source, + subject: truthiness.subject.clone(), + condition_span: normalized_span(condition), + truthy_span: body_span, + }); + } + } + } + } + for child in node.children.iter().filter_map(crate::ast::node) { + visit(child, behavior, calls, guards); + } + } + + fn collect_truthy_subjects( + condition: &crate::ast::Node, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + subjects: &mut Vec, + ) { + if let Some(subject) = behavior.runtime_truthiness_guard(condition) { + subjects.push(subject); + return; + } + // A true normalized conjunction proves every operand truthy. An OR, + // negation, comparison, or arbitrary call does not. This is a generic + // Boolean fact over normalized AST roles; adapters still decide which + // leaf spellings carry native truthiness semantics. + if condition.r#type == "AND" { + for child in condition.children.iter().filter_map(crate::ast::node) { + collect_truthy_subjects(child, behavior, subjects); + } + } + } + + let mut guards = BTreeSet::new(); + visit(root, behavior, calls, &mut guards); + guards.into_iter().collect() } fn extract_type_dependencies( @@ -3582,79 +5591,6 @@ fn header_before_body_brace(header: &str) -> &str { header } -fn is_param_untraceable(sig_text: &str, param: &str) -> bool { - let bytes = sig_text.as_bytes(); - let p_bytes = param.as_bytes(); - if p_bytes.is_empty() { - return false; - } - let mut pos = 0; - while let Some(idx) = sig_text[pos..].find(param) { - let abs_idx = pos + idx; - pos = abs_idx + param.len(); - - if abs_idx + param.len() < bytes.len() { - let next_char = bytes[abs_idx + param.len()] as char; - if next_char.is_alphanumeric() || next_char == '_' { - continue; - } - } - - if abs_idx > 0 { - let prev1 = bytes[abs_idx - 1] as char; - if prev1 == '*' { - if abs_idx > 1 && bytes[abs_idx - 2] as char == '*' { - if abs_idx > 2 { - let prev3 = bytes[abs_idx - 3] as char; - if !prev3.is_alphanumeric() && prev3 != '_' { - return true; - } - } else { - return true; - } - } else { - if abs_idx > 1 { - let prev2 = bytes[abs_idx - 2] as char; - if !prev2.is_alphanumeric() && prev2 != '_' { - return true; - } - } else { - return true; - } - } - } else if prev1 == '&' { - if abs_idx > 1 { - let prev2 = bytes[abs_idx - 2] as char; - if !prev2.is_alphanumeric() && prev2 != '_' { - return true; - } - } else { - return true; - } - } - } - } - false -} - -fn extract_untraceable_params( - lines: &[String], - fn_def: &syntax::FunctionDef, - language: &str, -) -> Vec { - if language != "ruby" { - return Vec::new(); - } - let sig_text = get_def_header(lines, fn_def.line); - let mut untraceable = Vec::new(); - for param in &fn_def.params { - if is_param_untraceable(&sig_text, param) { - untraceable.push(param.clone()); - } - } - untraceable -} - fn extract_methods( lines: &[String], document: &Document, @@ -3681,6 +5617,14 @@ fn extract_methods( let complexity = document .local_complexity_scores .get(&format!("{}#{}", owner, name)); + let template_types = document + .method_template_types + .get(&format!( + "{}\0{}\0{}", + fn_def.owner, fn_def.name, fn_def.line + )) + .cloned() + .unwrap_or_default(); // dispatch_kind "top" means owner is only the file-stem // fallback, not a real enclosing type - resolving it by name @@ -3726,9 +5670,16 @@ fn extract_methods( .map(|row| row.signals.clone()) .unwrap_or_default(), params: fn_def.params.clone(), + callback_params: fn_def.callback_params.clone(), + source_export_eligible: fn_def.source_export_eligible + && behavior.source_body_implicit_work_is_modeled(&raw_source, &template_types), + generated_declaration: fn_def.body.kind == "SYNTHETIC_ACCESSOR", raw_source, normalized_source, - untraceable_params: extract_untraceable_params(lines, fn_def, language), + untraceable_params: behavior.untraceable_profile_parameters( + &get_def_header(lines, fn_def.line), + &fn_def.params, + ), source, } }) @@ -3736,6 +5687,10 @@ fn extract_methods( } fn extract_owners(document: &Document, language: &str, path: &str) -> Vec { + let behavior = syntax::Language::parse(language) + .ok() + .map(crate::syntax::normalized_behavior::behavior); + let source = std::fs::read_to_string(path).unwrap_or_default(); let mut owners = document .owner_defs .iter() @@ -3753,10 +5708,17 @@ fn extract_owners(document: &Document, language: &str, path: &str) -> Vec>(); @@ -3770,7 +5732,9 @@ fn extract_owners(document: &Document, language: &str, path: &str) -> Vec Vec { - let sig = ruby_signature_before_line(lines, fn_def.line); - if sig.starts_with("sig ") { - return sig; - } - String::new() - } - "python" | "typescript" | "javascript" => source_signature_for(lines, fn_def), - // Typed adapters may keep FunctionDef.signature as display text - // (`name (arg)`), which loses return annotations required by CFG/DFG. - // Their declaration header is the source of truth for static facts. - "c" | "cpp" | "csharp" | "go" | "java" | "kotlin" | "php" | "rust" | "swift" | "zig" => { - header_before_body_brace(&get_def_header(lines, fn_def.line)) - .split_whitespace() - .collect::>() - .join(" ") - } - _ => { - let params = fn_def.params.join(", "); - if params.is_empty() { - fn_def.name.clone() - } else { - format!("{} ({})", fn_def.name, params) - } - } - } -} - -/// Ruby: scan backwards from the def line to find a `sig { ... }` block. -fn ruby_signature_before_line(lines: &[String], line: usize) -> String { - let mut idx = line.saturating_sub(2); - if idx >= lines.len() { - return String::new(); - } - // Skip blank lines going backward - while idx > 0 && lines[idx].trim().is_empty() { - idx = idx.saturating_sub(1); - } - if lines[idx].trim().starts_with("sig ") { - return lines[idx].trim().to_string(); + let behavior = crate::syntax::normalized_behavior::behavior_for_name(language); + if let Some(header) = + behavior.and_then(|behavior| behavior.complete_declaration_header(lines, fn_def.line)) + { + return header_before_body_brace(&header) + .split_whitespace() + .collect::>() + .join(" "); } - let mut start = idx; - loop { - if start == 0 { - break; - } - let text = lines[start].trim(); - if text.starts_with("sig ") { - // Join lines from start to idx - let joined: String = lines[start..=idx] - .iter() - .map(|l| l.trim()) - .collect::>() - .join(" "); - // Normalize whitespace - let normalized: String = joined.split_whitespace().collect::>().join(" "); - return normalized; - } - if text.starts_with("def ") || text.starts_with("class ") || text.starts_with("module ") { - return String::new(); - } - start = start.saturating_sub(1); + if behavior.is_some_and(|behavior| behavior.uses_source_declaration_header()) { + return header_before_body_brace(&get_def_header(lines, fn_def.line)) + .split_whitespace() + .collect::>() + .join(" "); } - String::new() -} -/// Python/TypeScript: the raw def line IS the signature. -fn source_signature_for(lines: &[String], fn_def: &syntax::FunctionDef) -> String { - let idx = fn_def.line.saturating_sub(1); - if idx >= lines.len() { - return String::new(); + if let Some(signature) = + behavior.and_then(|behavior| behavior.source_profile_signature(lines, fn_def)) + { + return signature; + } + let params = fn_def.params.join(", "); + if params.is_empty() { + fn_def.name.clone() + } else { + format!("{} ({})", fn_def.name, params) } - lines[idx].trim().to_string() } fn method_source(signature: &str, language: &str) -> serde_json::Value { @@ -3951,7 +5868,8 @@ fn method_source(signature: &str, language: &str) -> serde_json::Value { return serde_json::Value::Object(Default::default()); } let mut source = serde_json::Map::new(); - if language == "ruby" && signature.starts_with("sig ") { + let behavior = crate::syntax::normalized_behavior::behavior_for_name(language); + if behavior.is_some_and(|behavior| behavior.profile_signature_is_annotation(signature)) { source.insert( "sig".to_string(), serde_json::Value::String(signature.to_string()), @@ -3962,7 +5880,12 @@ fn method_source(signature: &str, language: &str) -> serde_json::Value { ); source.insert( "type_system".to_string(), - serde_json::Value::String("sorbet".to_string()), + serde_json::Value::String( + behavior + .map(|behavior| behavior.profile_type_system()) + .unwrap_or("native") + .to_string(), + ), ); source.insert( "source".to_string(), @@ -3975,26 +5898,21 @@ fn method_source(signature: &str, language: &str) -> serde_json::Value { ); source.insert( "type_system".to_string(), - serde_json::Value::String(language_type_system(language).to_string()), + serde_json::Value::String( + behavior + .map(|behavior| behavior.profile_type_system()) + .unwrap_or("native") + .to_string(), + ), ); } serde_json::Value::Object(source) } -fn language_type_system(language: &str) -> &str { - match language { - "ruby" => "sorbet", - "python" => "python-typing", - "typescript" => "typescript", - "javascript" => "typescript", - "go" => "go-types", - "rust" => "rust-types", - "java" => "java-types", - "kotlin" => "kotlin-types", - "swift" => "swift-types", - "csharp" => "csharp-types", - _ => "native", - } +fn profile_type_system(language: &str) -> &'static str { + crate::syntax::normalized_behavior::behavior_for_name(language) + .map(|behavior| behavior.profile_type_system()) + .unwrap_or("native") } // --------------------------------------------------------------------------- @@ -4038,11 +5956,9 @@ fn extract_fields(document: &Document, language: &str, path: &str) -> Vec = if is_static { + let requires_declared_owner = crate::syntax::normalized_behavior::behavior_for_name(language) + .is_some_and(|behavior| behavior.state_writes_require_declared_owner()); + let valid_owners: BTreeSet = if requires_declared_owner { document.owner_defs.iter().map(|o| o.name.clone()).collect() } else { document @@ -4362,7 +6278,7 @@ fn extract_type_definitions( if clean_name.starts_with("self.") { clean_name = clean_name.strip_prefix("self.").unwrap().to_string(); } - let ts = language_type_system(language); + let ts = profile_type_system(language); out.push(TypeDefinition { id: [ language, @@ -4394,7 +6310,7 @@ fn extract_type_definitions( // Type aliases from Document type_aliases map for (name, target) in &document.type_aliases { - let ts = language_type_system(language); + let ts = profile_type_system(language); let (owner, short_name) = AliasResolver::resolve(name); let line = document.type_alias_lines.get(name).copied().unwrap_or(0); out.push(TypeDefinition { @@ -4432,7 +6348,7 @@ fn extract_type_definitions( Some(t) if !t.is_empty() => t.clone(), _ => continue, }; - let ts = language_type_system(language); + let ts = profile_type_system(language); out.push(TypeDefinition { id: [ language, @@ -4478,7 +6394,7 @@ fn extract_type_definitions( .map(|fd| fd.line) .unwrap_or(0) }); - let ts = language_type_system(language); + let ts = profile_type_system(language); let params: Vec = param_types .iter() .map(|(pname, ptype)| { @@ -4666,81 +6582,31 @@ struct SignatureParser; impl SignatureParser { fn parse(sig: &str, language: &str) -> (Option, Vec>) { - match language { - "ruby" => parse_sorbet_signature(sig), - "python" => parse_python_signature(sig), - "typescript" | "javascript" => parse_typescript_signature(sig), - "c" | "cpp" | "csharp" | "java" => parse_c_family_signature(sig), - _ => parse_generic_signature(sig), - } + let signature = crate::syntax::normalized_behavior::behavior_for_name(language) + .map(|behavior| behavior.parse_signature(sig)) + .unwrap_or_default(); + let params = signature + .params + .into_iter() + .filter(|(name, declared)| !name.is_empty() && !declared.is_empty()) + .map(|(name, declared)| { + BTreeMap::from([("name".to_string(), name), ("type".to_string(), declared)]) + }) + .collect(); + (signature.return_type, params) } } struct AliasResolver; -impl AliasResolver { - fn resolve(name: &str) -> (String, String) { - if let Some(idx) = name.rfind("::") { - (name[..idx].to_string(), name[idx + 2..].to_string()) - } else { - (String::new(), name.to_string()) - } - } -} - -/// Sorbet sig: sig { params(name: Type).returns(ReturnType) } -fn parse_sorbet_signature(sig: &str) -> (Option, Vec>) { - let sig = sig.trim(); - if !sig.starts_with("sig") { - return (None, Vec::new()); - } - - let return_type = sorbet_extract(sig, ".returns(").or_else(|| sorbet_extract(sig, "returns(")); - let params = sorbet_extract_params(sig); - (return_type, params) -} - -fn sorbet_extract(sig: &str, marker: &str) -> Option { - let start = sig.find(marker)?; - let inner = &sig[start + marker.len()..]; - let mut depth = 1u32; - let mut end = 0usize; - for (i, c) in inner.char_indices() { - match c { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - end = i; - break; - } - } - _ => {} - } - } - if end > 0 { - Some(inner[..end].trim().to_string()) - } else { - None - } -} - -fn sorbet_extract_params(sig: &str) -> Vec> { - let params_str = - match sorbet_extract(sig, ".params(").or_else(|| sorbet_extract(sig, "params(")) { - Some(p) => p, - None => return Vec::new(), - }; - let mut out = Vec::new(); - for entry in split_top_level_params(¶ms_str) { - if let Some((name, type_part)) = entry.split_once(':') { - let mut map = BTreeMap::new(); - map.insert("name".to_string(), name.trim().to_string()); - map.insert("type".to_string(), type_part.trim().to_string()); - out.push(map); +impl AliasResolver { + fn resolve(name: &str) -> (String, String) { + if let Some(idx) = name.rfind("::") { + (name[..idx].to_string(), name[idx + 2..].to_string()) + } else { + (String::new(), name.to_string()) } } - out } pub(crate) fn split_top_level_params(params: &str) -> Vec { @@ -4765,232 +6631,13 @@ pub(crate) fn split_top_level_params(params: &str) -> Vec { out } -fn parse_python_signature(sig: &str) -> (Option, Vec>) { - let sig = sig.trim(); - let paren_open = match sig.find('(') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let paren_close = match sig.rfind(')') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let params_str = &sig[paren_open + 1..paren_close]; - let return_type = sig[paren_close + 1..].trim().strip_prefix("->").map(|s| { - let mut cleaned = s.trim(); - if cleaned.ends_with(": ...") { - cleaned = cleaned[..cleaned.len() - 5].trim(); - } - if cleaned.ends_with(':') { - cleaned = cleaned[..cleaned.len() - 1].trim(); - } - cleaned.to_string() - }); - - let params: Vec> = params_str - .split(',') - .filter_map(|entry| { - let entry = entry.trim(); - if entry.is_empty() || entry == "self" || entry == "cls" { - return None; - } - let (name, type_part) = if let Some((name, rest)) = entry.split_once(':') { - let name = name.trim().trim_end_matches('='); - (name.to_string(), rest.trim().to_string()) - } else { - return None; - }; - if type_part.is_empty() { - return None; - } - let mut map = BTreeMap::new(); - map.insert("name".to_string(), name); - map.insert("type".to_string(), type_part); - Some(map) - }) - .collect(); - - (return_type, params) -} - -fn parse_typescript_signature(sig: &str) -> (Option, Vec>) { - let sig = sig.trim(); - let paren_open = match sig.find('(') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let paren_close = match sig.rfind(')') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let params_str = &sig[paren_open + 1..paren_close]; - let return_type = sig[paren_close + 1..].trim().strip_prefix(':').map(|s| { - s.trim() - .trim_end_matches(';') - .trim_end_matches('{') - .trim() - .to_string() - }); - - let params: Vec> = params_str - .split(',') - .filter_map(|entry| { - let entry = entry.trim(); - if entry.is_empty() { - return None; - } - let entry = entry.trim_start_matches("..."); - let (name, type_part) = if let Some((name, rest)) = entry.split_once(':') { - let name = name.trim().trim_end_matches('?'); - (name.to_string(), rest.trim().to_string()) - } else { - return None; - }; - if type_part.is_empty() { - return None; - } - let mut map = BTreeMap::new(); - map.insert("name".to_string(), name); - map.insert("type".to_string(), type_part); - Some(map) - }) - .collect(); - - (return_type, params) -} - -fn parse_generic_signature(sig: &str) -> (Option, Vec>) { - let sig = sig.trim(); - let paren_open = match sig.find('(') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let paren_close = match sig.rfind(')') { - Some(p) => p, - None => return (None, Vec::new()), - }; - let params_str = &sig[paren_open + 1..paren_close]; - let after_paren = sig[paren_close + 1..].trim(); - - let mut return_type = None; - if let Some(ret) = after_paren.strip_prefix("->") { - return_type = Some( - ret.trim() - .trim_end_matches('{') - .trim_end_matches(';') - .trim() - .to_string(), - ); - } else if let Some(ret) = after_paren.strip_prefix(':') { - return_type = Some( - ret.trim() - .trim_end_matches('{') - .trim_end_matches(';') - .trim() - .to_string(), - ); - } else if !after_paren.is_empty() && after_paren != "{" && after_paren != ";" { - return_type = Some( - after_paren - .trim() - .trim_end_matches('{') - .trim_end_matches(';') - .trim() - .to_string(), - ); - } - - let params: Vec> = params_str - .split(',') - .filter_map(|entry| { - let entry = entry.trim(); - if entry.is_empty() || entry == "self" || entry == "this" { - return None; - } - let mut name = String::new(); - let mut ty = String::new(); - if let Some((n, t)) = entry.split_once(':') { - name = n.trim().to_string(); - ty = t.trim().to_string(); - } else { - let parts: Vec<&str> = entry.split_whitespace().collect(); - if parts.len() >= 2 { - // Go style "name Type" or Java style "Type name" - // If the first looks like a standard type or has uppercase, it's Java style, but simpler to check the last word - let last = parts.last().unwrap(); - if last.chars().next().unwrap_or(' ').is_ascii_lowercase() { - // Java/C: "Type name" - name = last.to_string(); - ty = parts[0..parts.len() - 1].join(" "); - } else { - // Go: "name Type" - name = parts[0].to_string(); - ty = parts[1..].join(" "); - } - } - } - if !name.is_empty() && !ty.is_empty() { - let mut map = BTreeMap::new(); - map.insert("name".to_string(), name); - map.insert("type".to_string(), ty); - Some(map) - } else { - None - } - }) - .collect(); - - (return_type, params) -} - -fn parse_c_family_signature(sig: &str) -> (Option, Vec>) { - let (mut return_type, params) = parse_generic_signature(sig); - if return_type.is_some() { - return (return_type, params); - } - - let Some(paren_open) = sig.find('(') else { - return (None, params); - }; - let prefix = sig[..paren_open].trim(); - let mut words = prefix.split_whitespace().collect::>(); - let _method_name = words.pop(); - while words.first().is_some_and(|word| { - matches!( - *word, - "public" - | "private" - | "protected" - | "internal" - | "static" - | "virtual" - | "override" - | "abstract" - | "sealed" - | "partial" - | "async" - | "extern" - | "unsafe" - | "readonly" - | "inline" - | "const" - ) - }) { - words.remove(0); - } - if !words.is_empty() { - return_type = Some(words.join(" ")); - } - (return_type, params) -} - // --------------------------------------------------------------------------- // Struct declarations // --------------------------------------------------------------------------- fn extract_struct_declarations( document: &Document, - _language: &str, + language: &str, path: &str, ) -> Vec { // `immutable_struct_readers` intentionally contains only Sorbet `const` @@ -5024,6 +6671,7 @@ fn extract_struct_declarations( .into_iter() .collect(); StructDeclaration { + language: language.to_string(), path: path.to_string(), class: class_name, fields, @@ -5035,6 +6683,97 @@ fn extract_struct_declarations( .collect() } +fn merge_struct_declarations(declarations: &mut Vec) { + let mut groups = BTreeMap::<(String, String, String), Vec>::new(); + for declaration in std::mem::take(declarations) { + groups + .entry(( + declaration.language.clone(), + declaration.path.clone(), + owner_type_name(&declaration.class).to_string(), + )) + .or_default() + .push(declaration); + } + + for (_, group) in groups { + let distinct_classes = group + .iter() + .map(|declaration| declaration.class.as_str()) + .collect::>(); + let longest = distinct_classes + .iter() + .map(|class| class.len()) + .max() + .unwrap_or_default(); + let canonical = distinct_classes + .iter() + .filter(|class| class.len() == longest) + .copied() + .collect::>(); + if canonical.len() != 1 { + declarations.extend(group); + continue; + } + + let mut merged = group + .iter() + .find(|declaration| declaration.class == canonical[0]) + .cloned() + .expect("canonical record declaration belongs to its group"); + let mut fields = merged.fields.clone(); + let mut seen_fields = fields.iter().cloned().collect::>(); + let mut operations = merged.constant_operations.clone(); + let mut seen_operations = operations.iter().cloned().collect::>(); + let mut field_types = BTreeMap::new(); + let mut conflicting_fields = BTreeSet::new(); + let mut lines = Vec::new(); + for declaration in group { + for field in declaration.fields { + if seen_fields.insert(field.clone()) { + fields.push(field); + } + } + for operation in declaration.constant_operations { + if seen_operations.insert(operation.clone()) { + operations.push(operation); + } + } + for (field, declared_type) in declaration.field_types { + if conflicting_fields.contains(&field) { + continue; + } + match field_types.get(&field) { + Some(existing) if existing != &declared_type => { + // Conflicting complete declarations are not safe to + // collapse into one record contract. + field_types.remove(&field); + conflicting_fields.insert(field); + } + None => { + field_types.insert(field, declared_type); + } + _ => {} + } + } + if declaration.line > 0 { + lines.push(declaration.line); + } + } + merged.fields = fields; + merged.constant_operations = operations; + merged.field_types = field_types; + merged.line = lines.into_iter().min().unwrap_or_default(); + declarations.push(merged); + } + declarations.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.class.cmp(&right.class)) + .then_with(|| left.line.cmp(&right.line)) + }); +} + // --------------------------------------------------------------------------- // State type edges // --------------------------------------------------------------------------- @@ -5421,6 +7160,7 @@ fn collect_hash_shapes_from_ast( fn collect_struct_declarations( node: &crate::ast::Node, + language: &str, path: &str, namespace: &mut Vec, struct_declarations: &mut Vec, @@ -5439,6 +7179,7 @@ fn collect_struct_declarations( .unwrap_or(&owner.name) .to_string(); struct_declarations.push(StructDeclaration { + language: language.to_string(), path: path.to_string(), class: owner.name.clone(), fields, @@ -5448,7 +7189,14 @@ fn collect_struct_declarations( }); namespace.push(simple_name); for child in child_nodes(node) { - collect_struct_declarations(child, path, namespace, struct_declarations, behavior); + collect_struct_declarations( + child, + language, + path, + namespace, + struct_declarations, + behavior, + ); } namespace.pop(); } else { @@ -5459,7 +7207,14 @@ fn collect_struct_declarations( pushed = true; } for child in child_nodes(node) { - collect_struct_declarations(child, path, namespace, struct_declarations, behavior); + collect_struct_declarations( + child, + language, + path, + namespace, + struct_declarations, + behavior, + ); } if pushed { namespace.pop(); @@ -5474,86 +7229,48 @@ fn count_lines(_lines: &[String], _start_line: usize, code: &str) -> usize { fn infer_literal_type(value: &str, language: &str) -> String { let value = value.trim(); - let lang = language.to_lowercase(); + let behavior = crate::syntax::normalized_behavior::behavior_for_name(language); if value.is_empty() { - return if lang == "javascript" || lang == "typescript" { - "any".to_string() - } else if lang == "python" { - "Any".to_string() - } else { - "T.untyped".to_string() - }; + return behavior + .map(|behavior| behavior.untyped_type()) + .unwrap_or_else(|| "T.untyped".to_string()); + } + if let Some(native) = behavior.and_then(|behavior| behavior.native_profile_literal_type(value)) + { + return native; } if value.starts_with('"') || value.starts_with('\'') { return "String".to_string(); } - if value.starts_with(':') { - return "Symbol".to_string(); - } if value == "true" || value == "false" { - return if lang == "javascript" || lang == "typescript" { - "boolean".to_string() - } else { - "T::Boolean".to_string() - }; + return "T::Boolean".to_string(); } if value == "nil" || value == "null" || value == "None" { - return if lang == "javascript" || lang == "typescript" { - "null".to_string() - } else { - "NilClass".to_string() - }; + return "NilClass".to_string(); } if value.parse::().is_ok() || value.parse::().is_ok() { - return if lang == "javascript" || lang == "typescript" || lang == "lua" { - "number".to_string() - } else if value.parse::().is_ok() { + return if value.parse::().is_ok() { "Integer".to_string() } else { "Float".to_string() }; } - if value.starts_with('[') - || value.starts_with("%i") - || value.starts_with("%I") - || value.starts_with("%w") - || value.starts_with("%W") - { - return match lang.as_str() { - "python" => "List[Any]".to_string(), - "typescript" | "javascript" => "any[]".to_string(), - "go" => "[]any".to_string(), - "rust" => "Vec".to_string(), - "java" | "kotlin" => "List".to_string(), - _ => "T::Array[T.untyped]".to_string(), - }; + if value.starts_with('[') { + return behavior + .map(|behavior| behavior.untyped_array_type()) + .unwrap_or_else(|| "T::Array[T.untyped]".to_string()); } if value.starts_with('{') { - return match lang.as_str() { - "python" => "Dict[Any, Any]".to_string(), - "typescript" | "javascript" => "Record".to_string(), - "go" => "map[string]any".to_string(), - "rust" => "HashMap".to_string(), - "java" | "kotlin" => "Map".to_string(), - _ => "T::Hash[T.untyped, T.untyped]".to_string(), - }; - } - if value.starts_with("%q") || value.starts_with("%Q") { - return "String".to_string(); - } - if value.starts_with("%s") { - return "Symbol".to_string(); + return behavior + .map(|behavior| behavior.untyped_hash_type()) + .unwrap_or_else(|| "T::Hash[T.untyped, T.untyped]".to_string()); } if value.chars().next().is_some_and(|c| c.is_uppercase()) { return value.to_string(); } - if lang == "javascript" || lang == "typescript" { - "any".to_string() - } else if lang == "python" { - "Any".to_string() - } else { - "T.untyped".to_string() - } + behavior + .map(|behavior| behavior.untyped_type()) + .unwrap_or_else(|| "T.untyped".to_string()) } // --------------------------------------------------------------------------- @@ -5654,24 +7371,6 @@ fn declaration_namespace(document: &Document, span: [usize; 4]) -> Option<&str> }) } -fn cpp_symbol_without_template_arguments(name: &str) -> String { - let mut output = String::with_capacity(name.len()); - let mut depth = 0usize; - for character in name.chars() { - match character { - '<' => depth += 1, - '>' if depth > 0 => depth -= 1, - _ if depth == 0 => output.push(character), - _ => {} - } - } - if depth == 0 { - output - } else { - name.to_string() - } -} - fn canonical_symbol_owner( document: &Document, owner: &str, @@ -5770,7 +7469,18 @@ fn canonical_declared_type_origin(document: &Document, name: &str) -> Option Option { - let mut name = name.strip_prefix("declared:").unwrap_or(name).trim(); + let base = name.strip_prefix("declared:").unwrap_or(name).trim(); + // Pointer/reference sigils (Go `*T`/`&T`, C/C++ `T*`, Rust `&T`/`&mut T`) + // do not change which type owns a method, so strip them before resolving + // the dispatch owner. A pointer-typed value (e.g. a constructor result like + // `*os.File`) must resolve the same owner as the base type. No-op for + // languages without pointer spelling. + let mut name = base + .trim_start_matches(['*', '&']) + .trim_start_matches("mut ") + .trim_start_matches(['*', '&']) + .trim_end_matches(['*', '&']) + .trim(); let mut visited = BTreeSet::new(); while let Some(target) = document.type_aliases.get(name) { if !visited.insert(name.to_string()) { @@ -5817,6 +7527,46 @@ fn declared_receiver_type( .cloned() } +fn declared_type_is_template_dependent( + document: &Document, + definition: Option<&syntax::FunctionDef>, + declared_type: &str, +) -> bool { + let Some(definition) = definition else { + return false; + }; + let key = format!( + "{}\0{}\0{}", + definition.owner, definition.name, definition.line + ); + let Some(parameters) = document.method_template_types.get(&key) else { + return false; + }; + let mut pending = declared_type + .split(|character: char| character != '_' && !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .collect::>(); + let mut visited = BTreeSet::new(); + while let Some(token) = pending.pop() { + if parameters.contains(&token) { + return true; + } + if !visited.insert(token.clone()) { + continue; + } + if let Some(target) = document.type_aliases.get(&token) { + pending.extend( + target + .split(|character: char| character != '_' && !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_string), + ); + } + } + false +} + fn declared_state_receiver_type( document: &Document, owner: &str, @@ -5877,13 +7627,33 @@ fn flow_receiver_type( let exact = (types.len() == 1) .then(|| types.into_iter().next()) .flatten(); - if exact.is_some() || document.language.as_str() != "java" { + if exact.is_some() { return exact; } - // Java locals retain their declared type across assignments. CFG flow - // may be incomplete at a branch node even though the declaration fact is - // present on the same normalized place. + let behavior = crate::syntax::normalized_behavior::behavior(document.language); + // Some adapters can prove that their local flow facts are invariant for a + // binding even when the normalized call node does not retain the receiver + // as an AST child. Only accept the fallback when every complete fact for + // the binding agrees, so a reassignment or branch disagreement remains + // unresolved. + if behavior.complexity_uses_invariant_flow_types() { + let invariant = document + .flow_types + .iter() + .filter(|fact| fact.complete && place_ids.contains(fact.place_id.as_str())) + .flat_map(|fact| fact.types.iter()) + .map(|name| name.strip_prefix("declared:").unwrap_or(name).trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if invariant.len() == 1 { + return invariant.into_iter().next(); + } + } + + // Adapters may prove that a declaration remains authoritative even when + // the exact CFG join at this call site is incomplete. let declared = document .flow_types .iter() @@ -5891,7 +7661,7 @@ fn flow_receiver_type( .flat_map(|fact| fact.types.iter()) .filter_map(|name| name.strip_prefix("declared:")) .map(str::trim) - .filter(|name| valid_java_declared_local_type(name)) + .filter(|name| behavior.declared_flow_type_fallback(name)) .map(str::to_string) .collect::>(); (declared.len() == 1) @@ -5899,23 +7669,198 @@ fn flow_receiver_type( .flatten() } -fn valid_java_declared_local_type(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .next() - .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) - && !name.contains(['=', '(', ')', ';', '\n']) - && !name.contains("//") - && !name.contains("&&") +/// Resolve the type of a `base.field` (or `base.field[i]`) receiver expression: +/// resolve the base's type, then look up `field` in that type's declared field +/// table. Language-neutral - reads `state_declarations`, which every adapter +/// populates for struct/class fields. Composes with the other receiver +/// resolvers and recurses for nested access (`outer.inner.field`). This is what +/// lets `h.field.method()` resolve even though `h.field` is not a call the +/// direct-call-result linker can see. +fn field_access_receiver_type( + document: &Document, + definition: Option<&syntax::FunctionDef>, + function: &str, + owner: &str, + receiver: &str, + call_span: [usize; 4], + language: &str, +) -> Option { + let dot = receiver.rfind('.').map(|index| (index, 1usize)); + let arrow = receiver.rfind("->").map(|index| (index, 2usize)); + let (separator, width) = match (dot, arrow) { + (Some(dot), Some(arrow)) => { + if dot.0 > arrow.0 { + dot + } else { + arrow + } + } + (Some(dot), None) => dot, + (None, Some(arrow)) => arrow, + (None, None) => return None, + }; + let base = &receiver[..separator]; + let field_expr = &receiver[separator + width..]; + let base = base.trim(); + let field_expr = field_expr.trim(); + // Strip a trailing index (`fs[0]`) and remember we must return the element + // type of the field rather than the field type itself. + let (field, indexed) = match field_expr.find('[') { + Some(bracket) => (field_expr[..bracket].trim(), true), + None => (field_expr, false), + }; + if base.is_empty() + || field.is_empty() + || base.contains(['(', ')', '[']) + || field.contains(['(', ')']) + { + return None; + } + let base_type = declared_receiver_type(document, definition, base) + .or_else(|| flow_receiver_type(document, function, base, call_span)) + .or_else(|| declared_state_receiver_type(document, owner, base)) + .or_else(|| { + field_access_receiver_type( + document, definition, function, owner, base, call_span, language, + ) + })?; + let normalized_base_type = normalized_declared_alias(document, &base_type); + let behavior = crate::syntax::normalized_behavior::behavior(document.language); + let member_owner_type = definition + .and_then(|definition| { + behavior.pointer_member_receiver_type( + &definition.body.text, + base, + field, + &normalized_base_type, + ) + }) + .unwrap_or(normalized_base_type); + let base_owner = declared_dispatch_owner_name_from_type(&member_owner_type, language)?; + let field_types = + inherited_field_types(document, &base_owner, field, language, &mut BTreeSet::new()); + let field_type = (field_types.len() == 1) + .then(|| field_types.into_iter().next()) + .flatten()?; + if indexed { + collection_element_type(&field_type, language) + } else { + Some(field_type) + } +} + +fn inherited_field_types( + document: &Document, + owner: &str, + field: &str, + language: &str, + visited: &mut BTreeSet, +) -> BTreeSet { + let owner_name = owner_type_name(owner).to_string(); + if !visited.insert(owner_name.clone()) { + return BTreeSet::new(); + } + let direct = document + .state_declarations + .iter() + .filter(|declaration| { + declaration.field == field && owner_name_matches(&declaration.owner, &owner_name) + }) + .filter_map(|declaration| declaration.r#type.clone()) + .collect::>(); + if !direct.is_empty() { + return direct; + } + document + .owner_defs + .iter() + .filter(|definition| owner_name_matches(&definition.name, &owner_name)) + .flat_map(|definition| definition.supertypes.iter()) + .flat_map(|supertype| { + let supertype = declared_dispatch_owner_name_from_type(supertype, language) + .unwrap_or_else(|| owner_type_name(supertype).to_string()); + inherited_field_types(document, &supertype, field, language, visited) + }) + .collect() +} + +/// The element type of an indexed collection field (`[]T`, `List`, `[T]`, +/// `Vec`), via the language-aware type parser. Returns None for non-arrays. +fn collection_element_type(type_name: &str, language: &str) -> Option { + match TypeExpr::parse(type_name, language) { + TypeExpr::Array(inner) | TypeExpr::Set(inner) => match *inner { + TypeExpr::Primitive(name) => Some(name), + _ => None, + }, + TypeExpr::Hash { value, .. } => match *value { + TypeExpr::Primitive(name) => Some(name), + _ => None, + }, + _ => None, + } +} + +fn projected_sequence_result_type( + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + type_name: &str, + language: &str, +) -> Option { + collection_element_type(type_name, language) + .or_else(|| behavior.indexed_collection_result_type(type_name)) +} + +fn indexed_receiver_type( + document: &Document, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + declared_type: String, + language: &str, +) -> Option { + let normalized = normalized_declared_alias(document, &declared_type); + let result = collection_element_type(&normalized, language) + .or_else(|| behavior.indexed_collection_result_type(&normalized))?; + Some(normalized_declared_alias(document, &result)) +} + +fn inferred_collection_element_receiver_type( + document: &Document, + definition: Option<&syntax::FunctionDef>, + function: &str, + owner: &str, + receiver: &str, + message: &str, + call_span: [usize; 4], + language: &str, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) -> Option { + let definition = definition?; + let collection = behavior.collection_element_binding(&definition.body.text, receiver)?; + let collection_type = declared_receiver_type(document, Some(definition), &collection) + .or_else(|| flow_receiver_type(document, function, &collection, call_span)) + .or_else(|| declared_state_receiver_type(document, owner, &collection)) + .or_else(|| { + field_access_receiver_type( + document, + Some(definition), + function, + owner, + &collection, + call_span, + language, + ) + })?; + let normalized_collection = normalized_declared_alias(document, &collection_type); + let element_type = collection_element_type(&normalized_collection, language)?; + behavior + .pointer_member_receiver_type(&definition.body.text, receiver, message, &element_type) + .or(Some(element_type)) } -fn reaching_call_result_spans( +fn reaching_call_results( document: &Document, function: &str, receiver: &str, call_span: [usize; 4], -) -> Vec<[usize; 4]> { +) -> (Vec<[usize; 4]>, Option) { let place_ids = document .places .iter() @@ -5923,7 +7868,7 @@ fn reaching_call_result_spans( .map(|place| place.id.as_str()) .collect::>(); if place_ids.is_empty() { - return Vec::new(); + return (Vec::new(), None); } let mut candidates = document @@ -5965,20 +7910,61 @@ fn reaching_call_result_spans( if reaching.definitions.is_empty() { continue; } - let mut spans = BTreeSet::new(); - let complete = reaching.definitions.iter().all(|definition| { - document - .node_effects - .iter() - .find(|definition_effect| definition_effect.node_id == *definition) - .and_then(|definition_effect| { - definition_effect.write_call_sources.get(*place_id) - }) - .map(|span| spans.insert(*span)) - .is_some() + let non_null_at_read = document.nullable_states.iter().any(|state| { + state.node_id == node.id + && state.place_id == **place_id + && state.complete + && state.state == "definitely_non_null" }); + let mut spans = BTreeSet::new(); + let mut projections = BTreeSet::new(); + let mut projected_definitions = 0usize; + let mut considered_definitions = 0usize; + let complete = reaching + .definitions + .iter() + .filter(|definition| { + !non_null_at_read + || !document.node_effects.iter().any(|effect| { + effect.node_id == **definition + && effect + .write_value_hints + .get(*place_id) + .is_some_and(|value| value == "nil" || value == "null") + }) + }) + .all(|definition| { + considered_definitions += 1; + document + .node_effects + .iter() + .find(|definition_effect| definition_effect.node_id == *definition) + .and_then(|definition_effect| { + if let Some(position) = definition_effect + .write_sequence_projections + .get(*place_id) + .copied() + { + projected_definitions += 1; + projections.insert(position); + } + definition_effect.write_call_sources.get(*place_id) + }) + .map(|span| spans.insert(*span)) + .is_some() + }); if complete && !spans.is_empty() { - proven_sets.insert(spans.into_iter().collect::>()); + let projection = if projected_definitions == 0 { + Some(None) + } else if projected_definitions == considered_definitions && projections.len() == 1 + { + Some(projections.into_iter().next()) + } else { + None + }; + if let Some(projection) = projection { + proven_sets.insert((spans.into_iter().collect::>(), projection)); + } } } if !proven_sets.is_empty() { @@ -6038,12 +8024,31 @@ fn source_function<'a>( } fn owner_type_name(value: &str) -> &str { - let value = value.trim().trim_start_matches('*'); - let value = value.split(['[', '<']).next().unwrap_or(value); - value - .rsplit([':', '.']) - .find(|part| !part.is_empty()) - .unwrap_or(value) + let value = value + .trim() + .strip_prefix("const ") + .unwrap_or(value.trim()) + .trim_start_matches('*') + .trim_end_matches(|character: char| { + character.is_whitespace() || matches!(character, '&' | '*') + }); + let mut generic_depth = 0usize; + let mut leaf_start = 0usize; + for (index, character) in value.char_indices() { + match character { + '<' => generic_depth += 1, + '>' => generic_depth = generic_depth.saturating_sub(1), + // SCIP symbol owners use `/` between nested declarations, while + // source adapters commonly retain the language spelling (`::`, + // `.`, or `:`). Owner comparison is shared identity plumbing, + // so normalize the portable SCIP separator here instead of + // teaching individual language adapters about one another. + ':' | '.' | '/' if generic_depth == 0 => leaf_start = index + character.len_utf8(), + _ => {} + } + } + let leaf = value[leaf_start..].trim(); + leaf.split(['[', '<']).next().unwrap_or(leaf).trim() } fn owner_name_matches(left: &str, right: &str) -> bool { @@ -6052,10 +8057,33 @@ fn owner_name_matches(left: &str, right: &str) -> bool { /// Follow declared state projections to the selected field. The language /// adapter proves whether the final native declared type is callable. +/// A method call on a receiver whose static type is an abstract dispatch type +/// (interface / trait / protocol / abstract class) has no single body: it is a +/// callback whose per-call cost is set by the eventual implementation. Price it +/// as `callback_once` so the enclosing function's bound is parametric in that +/// cost, exactly like an injected function value. +fn abstract_dispatch_callback_cost( + document: &Document, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + receiver_type: &str, +) -> Option { + let nominal = declared_dispatch_owner_name_from_type(receiver_type, document.language.as_str()) + .unwrap_or_else(|| receiver_type.to_string()); + document + .owner_defs + .iter() + .any(|owner| { + owner_name_matches(&owner.name, &nominal) + && behavior.type_kind_is_abstract_dispatch(&owner.kind) + }) + .then(|| "callback_once".to_string()) +} + fn declared_field_callback_cost( document: &Document, behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, call: &syntax::CallSite, + definition: Option<&syntax::FunctionDef>, ) -> Option { let mut owner = call.owner.clone(); let receiver_fields = call @@ -6065,10 +8093,20 @@ fn declared_field_callback_cost( .filter(|part| !part.is_empty()) .skip_while(|part| matches!(*part, "self" | "this")) .collect::>(); - for field in receiver_fields { + let mut start = 0; + if let Some(first) = receiver_fields.first() { + if let Some(declared) = declared_receiver_type(document, definition, first) { + // `item.callback()` selects a callable field on the parameter/local + // type. The first segment is a binding, not a field of the current + // method owner; begin projection from its declared type. + owner = declared; + start = 1; + } + } + for field in &receiver_fields[start..] { let declaration = document.state_declarations.iter().find(|declaration| { owner_name_matches(&declaration.owner, &owner) - && declaration.field.trim_start_matches('@') == field + && declaration.field.trim_start_matches('@') == *field })?; owner = declaration.r#type.clone()?; } @@ -6090,6 +8128,54 @@ fn declared_field_callback_cost( .flatten() } +fn source_preprocessor_call_complexity( + document: &Document, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, + message: &str, +) -> Option { + let definitions = document + .symbol_scope + .preprocessor_definitions + .get(message)?; + let costs = definitions + .iter() + .filter_map(|definition| behavior.preprocessor_definition_call_complexity(definition)) + .collect::>(); + (costs.len() == definitions.len() + && costs.first().is_some() + && costs + .windows(2) + .all(|pair| pair[0].time == pair[1].time && pair[0].space == pair[1].space)) + .then(|| costs.into_iter().next()) + .flatten() +} + +fn extract_preprocessor_definition_costs( + document: &Document, + language: &str, + path: &str, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) -> Vec { + document + .symbol_scope + .preprocessor_definitions + .iter() + .flat_map(|(name, definitions)| { + definitions.iter().map(move |definition| { + let cost = behavior.preprocessor_definition_call_complexity(definition); + PreprocessorDefinitionCost { + id: stable_id("preprocessor", &[language, path, name, definition]), + language: language.to_string(), + name: name.clone(), + path: path.to_string(), + time: cost.as_ref().map(|cost| cost.time.to_string()), + space: cost.as_ref().map(|cost| cost.space.to_string()), + } + }) + }) + .collect() +} + fn extract_calls(document: &Document, language: &str, path: &str) -> Vec { let behavior = crate::syntax::normalized_behavior::behavior(document.language); let receiver_call_spans = document @@ -6097,7 +8183,17 @@ fn extract_calls(document: &Document, language: &str, path: &str) -> Vec>(); - document + let selector_spans = document + .call_selector_projections + .iter() + .map(|projection| (projection.call_span, projection.selector_span)) + .collect::>(); + let execution_spans = document + .call_execution_projections + .iter() + .map(|projection| (projection.call_span, projection.execution_span)) + .collect::>(); + let mut calls = document .call_sites .iter() .map(|call| { @@ -6111,6 +8207,9 @@ fn extract_calls(document: &Document, language: &str, path: &str) -> Vec Vec Vec>(); + (supertypes.len() == 1) + .then(|| { + behavior.super_constructor_call_complexity( + supertypes.into_iter().next().expect("one supertype"), + ) + }) + .flatten() }); let parametric_cost = known_complexity .is_none() @@ -6187,18 +8424,156 @@ fn extract_calls(document: &Document, language: &str, path: &str) -> Vec Vec Vec Vec Vec Vec Vec Vec Vec Vec>(); + remove_binding_receiver_pseudo_calls(&mut calls); + propagate_direct_call_result_receiver_types(&mut calls, language, behavior); + propagate_callback_argument_parameter_types(&mut calls, document, language, behavior); + propagate_collection_callback_parameter_types(&mut calls, document, language, behavior); + calls +} + +fn remove_binding_receiver_pseudo_calls(calls: &mut Vec) { + let binding_receivers = calls + .iter() + .filter(|call| { + matches!( + call.receiver_binding_kind.as_str(), + "parameter" | "local" | "state" + ) + }) + .filter_map(|call| { + Some(( + call.source.clone(), + call.path.clone(), + call.receiver_call_span?, + call.receiver.clone(), + )) + }) + .collect::>(); + if binding_receivers.is_empty() { + return; + } + + let pseudo_ids = calls + .iter() + .filter(|call| call.implicit_receiver && call.argument_count == 0) + .filter(|call| { + binding_receivers.contains(&( + call.source.clone(), + call.path.clone(), + call.span, + call.message.clone(), + )) + }) + .map(|call| call.id.clone()) + .collect::>(); + if pseudo_ids.is_empty() { + return; + } + + for call in calls.iter_mut() { + if call.receiver_call_span.is_some_and(|span| { + binding_receivers.contains(&( + call.source.clone(), + call.path.clone(), + span, + call.receiver.clone(), + )) + }) { + call.receiver_call_span = None; + } + } + calls.retain(|call| !pseudo_ids.contains(&call.id)); +} + +fn propagate_direct_call_result_receiver_types( + calls: &mut [CallRecord], + language: &str, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) { + loop { + let producers = calls + .iter() + .filter_map(|call| { + let producer_receiver_type = call + .receiver_type + .as_deref() + .or(call.receiver_symbol.as_deref()) + .or_else(|| { + (call.receiver_kind == "type" && !call.receiver.is_empty()) + .then_some(call.receiver.as_str()) + }); + let result_type = behavior + .static_argument_dependent_return_type(&call.message, &call.arguments) + .or_else(|| behavior.static_return_type(&call.message, producer_receiver_type)) + .or_else(|| { + behavior.propagated_collection_return_type( + &call.message, + producer_receiver_type, + ) + })?; + Some(( + (call.source.clone(), call.path.clone(), call.span), + result_type, + )) + }) + .collect::>(); + let mut changed = false; + for call in calls.iter_mut().filter(|call| call.receiver_type.is_none()) { + let receiver_spans = call + .receiver_call_span + .into_iter() + .chain(call.receiver_definition_call_spans.iter().copied()) + .collect::>(); + if receiver_spans.is_empty() { + continue; } + let Some(result_types) = receiver_spans + .iter() + .map(|span| { + producers + .get(&(call.source.clone(), call.path.clone(), *span)) + .cloned() + }) + .collect::>>() + else { + continue; + }; + let result_types = result_types.into_iter().collect::>(); + if result_types.len() != 1 { + continue; + } + let mut receiver_type = result_types + .into_iter() + .next() + .expect("one static producer result type"); + if call.receiver_definition_sequence_projection.is_some() { + let Some(projected) = + projected_sequence_result_type(behavior, &receiver_type, language) + else { + continue; + }; + receiver_type = projected; + } + let parsed = TypeExpr::parse(&receiver_type, language); + let known = behavior.call_complexity(&parsed, &call.message); + let parametric = known + .is_none() + .then(|| behavior.parametric_call_cost(&parsed, &call.message)) + .flatten(); + let parametric_complexity = parametric + .as_deref() + .and_then(crate::syntax::parametric_call_complexity); + call.receiver_type = Some(receiver_type); + call.receiver_type_origin = Some("static_call_result_contract".to_string()); + call.known_time_complexity = known + .map(|cost| cost.time.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.0.to_string())); + call.known_space_complexity = known + .map(|cost| cost.space.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.1.to_string())); + call.complexity_provenance = known + .map(|_| "language_stdlib_registry".to_string()) + .or_else(|| { + parametric_complexity + .map(|_| "parametric_declared_receiver_contract".to_string()) + }); + call.complexity_bound_quality = known + .map(|_| "upper_bound_declared_receiver".to_string()) + .or_else(|| { + parametric + .as_ref() + .map(|kind| format!("upper_bound_parametric_{kind}")) + }); + changed = true; + } + if !changed { + break; + } + } +} + +fn propagate_callback_argument_parameter_types( + calls: &mut [CallRecord], + document: &Document, + language: &str, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) { + let block_calls = document + .call_sites + .iter() + .filter(|site| site.block) + .collect::>(); + let nodes = document + .control_flow_nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let places = document + .places + .iter() + .map(|place| (place.id.as_str(), place)) + .collect::>(); + let parameter_counts = document.callback_bindings.iter().fold( + BTreeMap::<(&str, [usize; 4]), usize>::new(), + |mut counts, binding| { + counts + .entry((binding.node_id.as_str(), binding.span)) + .and_modify(|count| *count = (*count).max(binding.position + 1)) + .or_insert(binding.position + 1); + counts + }, + ); + let mut proven = Vec::new(); + for binding in &document.callback_bindings { + let Some(node) = nodes.get(binding.node_id.as_str()) else { + continue; + }; + let Some(place) = places.get(binding.place_id.as_str()) else { + continue; + }; + let candidates = block_calls + .iter() + .filter(|site| site.function == binding.function) + .filter(|site| span_contains(node.span, site.span)) + .filter(|site| site.span[0] == node.span[0]) + .collect::>(); + if candidates.len() != 1 { + continue; + } + let site = candidates[0]; + let outer_calls = calls + .iter() + .filter(|call| { + call.function == site.function + && call.message == site.message + && call.span == site.span + }) + .collect::>(); + if outer_calls.len() != 1 { + continue; + } + let outer = outer_calls[0]; + let parameter_count = parameter_counts + .get(&(binding.node_id.as_str(), binding.span)) + .copied() + .unwrap_or(0); + let Some(receiver_type) = behavior.callback_argument_parameter_type( + &outer.receiver, + outer.receiver_type.as_deref(), + &outer.message, + binding.position, + parameter_count, + &outer.arguments, + ) else { + continue; + }; + proven.push(( + binding.function.as_str(), + binding.span, + place.name.as_str(), + receiver_type, + )); + } + + for call in calls.iter_mut().filter(|call| call.receiver_type.is_none()) { + let receiver_types = proven + .iter() + .filter(|(function, span, name, _)| { + *function == call.function + && *name == call.receiver + && span_contains(*span, call.span) + }) + .map(|(_, _, _, receiver_type)| receiver_type.as_str()) + .collect::>(); + if receiver_types.len() != 1 { + continue; + } + let receiver_type = receiver_types + .into_iter() + .next() + .expect("one callback argument type"); + let parsed = TypeExpr::parse(receiver_type, language); + let known = behavior.call_complexity(&parsed, &call.message); + let parametric = known + .is_none() + .then(|| behavior.parametric_call_cost(&parsed, &call.message)) + .flatten(); + let parametric_complexity = parametric + .as_deref() + .and_then(crate::syntax::parametric_call_complexity); + call.receiver_type = Some(receiver_type.to_string()); + call.receiver_type_origin = Some("callback_argument".to_string()); + call.known_time_complexity = known + .map(|cost| cost.time.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.0.to_string())); + call.known_space_complexity = known + .map(|cost| cost.space.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.1.to_string())); + call.complexity_provenance = known + .map(|_| "language_stdlib_registry".to_string()) + .or_else(|| { + parametric_complexity.map(|_| "parametric_declared_receiver_contract".to_string()) + }); + call.complexity_bound_quality = known + .map(|_| "upper_bound_declared_receiver".to_string()) + .or_else(|| { + parametric + .as_ref() + .map(|kind| format!("upper_bound_parametric_{kind}")) + }); + } +} + +fn propagate_collection_callback_parameter_types( + calls: &mut [CallRecord], + document: &Document, + language: &str, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) { + let contextual_types = calls + .iter() + .filter(|call| call.receiver_type.is_none()) + .filter_map(|call| { + let definition = document.function_defs.iter().find(|definition| { + definition.name == call.function + && definition + .params + .iter() + .any(|parameter| parameter == &call.receiver) + && span_contains(definition.span, call.span) + })?; + let element_types = calls + .iter() + .filter(|outer| outer.source != call.source) + .filter(|outer| span_contains(outer.span, definition.span)) + .filter(|outer| behavior.collection_callback_parameter(&outer.message)) + .filter_map(|outer| { + collection_element_type(outer.receiver_type.as_deref()?, language) + }) + .collect::>(); + (element_types.len() == 1).then(|| { + ( + call.id.clone(), + element_types.into_iter().next().expect("one element type"), + ) + }) }) - .collect() + .collect::>(); + for call in calls.iter_mut() { + let Some(receiver_type) = contextual_types.get(&call.id) else { + continue; + }; + let parsed = TypeExpr::parse(receiver_type, language); + let known = behavior.call_complexity(&parsed, &call.message); + let parametric = known + .is_none() + .then(|| behavior.parametric_call_cost(&parsed, &call.message)) + .flatten(); + let parametric_complexity = parametric + .as_deref() + .and_then(crate::syntax::parametric_call_complexity); + call.receiver_type = Some(receiver_type.clone()); + call.receiver_type_origin = Some("collection_callback_parameter".to_string()); + call.known_time_complexity = known + .map(|cost| cost.time.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.0.to_string())); + call.known_space_complexity = known + .map(|cost| cost.space.to_string()) + .or_else(|| parametric_complexity.map(|cost| cost.1.to_string())); + call.complexity_provenance = known + .map(|_| "language_stdlib_registry".to_string()) + .or_else(|| { + parametric_complexity.map(|_| "parametric_declared_receiver_contract".to_string()) + }); + call.complexity_bound_quality = known + .map(|_| "upper_bound_declared_receiver".to_string()) + .or_else(|| { + parametric + .as_ref() + .map(|kind| format!("upper_bound_parametric_{kind}")) + }); + } } fn extract_state_accesses( @@ -6584,6 +9411,7 @@ pub(crate) mod tests { visibility: Some("public".to_string()), params: vec!["name".to_string()], callback_params: Vec::new(), + source_export_eligible: true, signature: "def hello(name)".to_string(), }], owner_defs: vec![syntax::OwnerDef { @@ -6592,11 +9420,14 @@ pub(crate) mod tests { kind: "class".to_string(), reopenable: false, supertypes: Vec::new(), + requirements: Vec::new(), line: 1, span: [1, 0, 1, 16], }], normalization_call_origins: Vec::new(), call_raw_origin_projections: Vec::new(), + call_selector_projections: Vec::new(), + call_execution_projections: Vec::new(), state_declarations: vec![syntax::StateDeclaration { field: "@name".to_string(), owner: "Greeter".to_string(), @@ -6642,6 +9473,7 @@ pub(crate) mod tests { def_use: vec![], liveness: vec![], flow_types: vec![], + callback_bindings: vec![], protocol_method_effects: vec![], protocol_call_paths: vec![], clone_candidates: vec![], @@ -6657,85 +9489,515 @@ pub(crate) mod tests { type_alias_lines: Default::default(), method_param_types: Default::default(), method_local_types: Default::default(), + method_template_types: Default::default(), hazard_sites: vec![], imports: vec![], } } #[test] - fn owner_receivers_are_not_state_fields() { - let document = test_document(); + fn owner_receivers_are_not_state_fields() { + let document = test_document(); + + assert_eq!(receiver_state_field("self", &document), None); + assert_eq!(receiver_state_field("this", &document), None); + assert_eq!( + receiver_state_field("self.name", &document), + Some("name".to_string()) + ); + assert_eq!( + receiver_state_field("this.name", &document), + Some("name".to_string()) + ); + } + + #[test] + fn cpp_owner_name_matching_ignores_template_spacing() { + assert!(owner_name_matches( + "ScopedRemover &&", + "ScopedRemover < DispatcherType, Enable >" + )); + assert!(owner_name_matches( + "Node", + "CallbackListBase< ReturnType (Args...), PoliciesType >::Node" + )); + assert!(!owner_name_matches( + "CallbackListBase", + "CallbackListBase< ReturnType (Args...), PoliciesType >::Node" + )); + } + + #[test] + fn owner_name_matching_accepts_portable_scip_nested_owner_separator() { + assert!(owner_name_matches( + "SlopCop::CoverageData::Dataset", + "SlopCop/CoverageData/Dataset" + )); + assert!(!owner_name_matches( + "SlopCop::CoverageData::Dataset", + "SlopCop/CoverageData/FileCoverage" + )); + } + + #[test] + fn raw_call_loss_is_grouped_by_parser_node_kind_and_survives_merge() { + let mut document = test_document(); + let span = [4, 2, 4, 9]; + document.raw_call_sites = vec![crate::ast::RawCallSite { + span, + kind: "call_expression".to_string(), + }]; + + let output = extract(&document, Profile::Espalier); + assert_eq!(output.call_resolution_coverage.raw_calls_not_normalized, 1); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_outside_function, + 1 + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_by_kind + .get("call_expression"), + Some(&1) + ); + assert_eq!( + output + .call_resolution_coverage + .raw_call_normalization_gap_samples, + vec![RawCallNormalizationGap { + path: "test.rb".to_string(), + language: "ruby".to_string(), + span, + kind: "call_expression".to_string(), + inside_executable_function: false, + }] + ); + + let merged = merge(vec![output], Profile::Espalier); + assert_eq!( + merged + .call_resolution_coverage + .raw_calls_not_normalized_by_kind + .get("call_expression"), + Some(&1) + ); + assert_eq!( + merged + .call_resolution_coverage + .raw_call_normalization_gap_samples + .len(), + 1 + ); + } + + #[test] + fn raw_call_loss_revokes_only_the_innermost_source_export_body() { + let mut document = test_document(); + let mut outer = document.function_defs[0].clone(); + outer.name = "outer".to_string(); + outer.span = [1, 0, 3, 0]; + document.function_defs.push(outer); + document.raw_call_sites = vec![crate::ast::RawCallSite { + span: [1, 2, 1, 8], + kind: "call_expression".to_string(), + }]; + + let output = extract(&document, Profile::Espalier); + assert_eq!(output.methods.len(), 2); + assert!( + !output + .methods + .iter() + .find(|method| method.name == "hello") + .unwrap() + .source_export_eligible + ); + assert!( + output + .methods + .iter() + .find(|method| method.name == "outer") + .unwrap() + .source_export_eligible + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 1 + ); + assert_eq!( + output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss, + 0 + ); + } + + #[test] + fn parser_recovery_revokes_every_overlapping_source_export_body() { + let mut document = test_document(); + document.parse_recovered = true; + document.parse_recovery_spans = vec![[1, 1, 1, 5]]; + + let output = extract(&document, Profile::Espalier); + assert_eq!(output.methods.len(), 1); + assert!(!output.methods[0].source_export_eligible); + } + + #[test] + fn trace_plan_exports_only_cfg_dfg_value_capture_demands() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"class RuntimePlanFixture + def run(rows) + ignored = ["items"].fetch(0) + selected = rows.resolve_items + selected.map { |row| row.to_s } + end +end +"#, + ) + .expect("write Ruby source"); + let documents = syntax::parse_files(&[file.path().to_path_buf()], Language::Ruby) + .expect("parse Ruby source"); + + let output = extract(&documents[0], Profile::TracePlan); + + assert!( + output.runtime_call_sites.iter().any(|site| { + site.span[0] == 4 + && site.activation_span.map(|span| span[0]) == Some(4) + && site.selector.as_deref() == Some("resolve_items") + }), + "a single-line unresolved call must activate on its own line" + ); + assert!( + output + .runtime_call_sites + .iter() + .all(|site| site.span[0] != 3), + "a statically costed call must not request runtime target observation" + ); + assert!( + output.runtime_result_call_sites.iter().any(|site| { + site.span[0] == 4 && site.selector.as_deref() == Some("resolve_items") + }), + "a call that defines a later receiver must request its result" + ); + assert!( + output + .runtime_collection_receiver_sites + .iter() + .any(|site| site.span[0] == 5 && site.selector.as_deref() == Some("map")), + "a collection callback must retain its receiver element domain" + ); + } + + #[test] + fn trace_plan_retains_intra_callback_call_result_demands() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"class RuntimePlanFixture + def run(rows) + rows.filter_map do |row| + fact = row[:fact] + fact.normalize + fact + end + end +end +"#, + ) + .expect("write Ruby source"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); - assert_eq!(receiver_state_field("self", &document), None); - assert_eq!(receiver_state_field("this", &document), None); - assert_eq!( - receiver_state_field("self.name", &document), - Some("name".to_string()) + let output = extract(&document, Profile::TracePlan); + + assert!( + output.runtime_result_call_sites.iter().any(|site| { + site.span[0] == 4 && site.selector.as_deref() == Some("[]") + }), + "a call result written and consumed inside one normalized callback node must be requested" ); + } + + #[test] + fn trace_plan_drops_runtime_target_demands_after_static_enrichment() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"class RuntimePlanFixture + def run(provider) + provider.resolve_items + end +end +"#, + ) + .expect("write Ruby source"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = extract(&document, Profile::TracePlan); + assert_eq!(output.runtime_call_sites.len(), 1); + + let call = output + .calls + .iter_mut() + .find(|call| call.message == "resolve_items") + .expect("call"); + call.known_time_complexity = Some("O(1)".to_string()); + call.known_space_complexity = Some("O(1)".to_string()); + refresh_runtime_call_sites(&mut output); + + assert!(output.runtime_call_sites.is_empty()); + } + + #[test] + fn trace_plan_activates_runtime_events_at_the_enclosing_multiline_statement() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"class RuntimePlanFixture + def run(provider) + { + items: provider.resolve_items + } + end +end +"#, + ) + .expect("write Ruby source"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let output = extract(&document, Profile::TracePlan); + let site = output + .runtime_call_sites + .iter() + .find(|site| site.selector.as_deref() == Some("resolve_items")) + .expect("runtime call site"); + + assert_eq!(site.span[0], 4); + assert_eq!(site.activation_span.expect("activation span")[0], 3); + } + + #[test] + fn generated_record_reader_targets_have_constant_cost_without_runtime_evidence() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"Index = Struct.new(:facts, keyword_init: true) +class Index + def file_default(key) + facts[key] + end +end +"#, + ) + .expect("write Ruby source"); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby) + .expect("parse Ruby source"); + + let mut output = extract(&document, Profile::Espalier); + let reader_id = output + .methods + .iter() + .find(|method| method.name == "facts" && method.generated_declaration) + .expect("synthetic reader declaration") + .id + .clone(); + let reader = output + .calls + .iter_mut() + .find(|call| call.function == "file_default" && call.message == "facts") + .expect("generated reader call"); + reader.target = Some(reader_id); + reader.kind = "resolved_call".to_string(); + reader.known_time_complexity = None; + reader.known_space_complexity = None; + + reapply_generated_callable_costs(&mut output); + + let reader = output + .calls + .iter() + .find(|call| call.function == "file_default" && call.message == "facts") + .expect("generated reader call"); + assert_eq!(reader.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(reader.known_space_complexity.as_deref(), Some("O(1)")); assert_eq!( - receiver_state_field("this.name", &document), - Some("name".to_string()) + reader.complexity_provenance.as_deref(), + Some("generated_callable_declaration") ); } #[test] - fn raw_call_loss_is_grouped_by_parser_node_kind_and_survives_merge() { - let mut document = test_document(); - let span = [4, 2, 4, 9]; - document.raw_call_sites = vec![crate::ast::RawCallSite { - span, - kind: "call_expression".to_string(), - }]; + fn generated_mutable_record_writers_have_constant_cost_but_data_has_no_writer() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"MutableRow = Struct.new(:value) +ImmutableRow = Data.define(:value) +"#, + ) + .expect("write Ruby source"); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby) + .expect("parse Ruby source"); + let output = extract(&document, Profile::Espalier); + + let writer = output + .methods + .iter() + .find(|method| method.name == "value=") + .unwrap_or_else(|| { + panic!( + "synthetic Struct writer declaration; generated={:?}", + output + .methods + .iter() + .filter(|method| method.generated_declaration) + .map(|method| (&method.owner, &method.name)) + .collect::>() + ) + }); + assert!(writer.owner.ends_with("::MutableRow")); + assert!(writer.generated_declaration); + assert_eq!(writer.params, vec!["value"]); + assert!(!output + .methods + .iter() + .any(|method| method.owner.ends_with("::ImmutableRow") && method.name == "value=")); + } + + #[test] + fn generated_record_constructor_contract_does_not_price_instance_index_methods() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"Widget = Struct.new(:value) +class Widget + def [](key) + key.to_s + end +end + +class Caller + def run(widget) + widget[:key] + end +end +"#, + ) + .expect("write Ruby source"); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby) + .expect("parse Ruby source"); + + let mut output = extract(&document, Profile::Espalier); + let index = output + .calls + .iter_mut() + .find(|call| call.function == "run" && call.message == "[]") + .expect("instance index call"); + index.receiver_type = Some("Widget".to_string()); + index.known_time_complexity = None; + index.known_space_complexity = None; + + reapply_generated_record_costs(&mut output); + + let index = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "[]") + .expect("instance index call"); + assert!(index.known_time_complexity.is_none()); + assert!(index.known_space_complexity.is_none()); + } + + #[test] + fn static_receiver_contracts_close_literal_and_direct_call_chains() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"class Sample + def word_member? + %w[while until for].include?("while") + end + + def converted_empty?(value) + Array(value).empty? + end + + def rendered_empty?(value) + value.to_s.empty? + end + + def source_file + File.expand_path(__FILE__) + end +end +"#, + ) + .expect("write Ruby source"); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby) + .expect("parse Ruby source"); let output = extract(&document, Profile::Espalier); - assert_eq!(output.call_resolution_coverage.raw_calls_not_normalized, 1); - assert_eq!( - output - .call_resolution_coverage - .raw_calls_not_normalized_inside_function, - 0 - ); - assert_eq!( - output - .call_resolution_coverage - .raw_calls_not_normalized_outside_function, - 1 - ); - assert_eq!( - output - .call_resolution_coverage - .raw_calls_not_normalized_by_kind - .get("call_expression"), - Some(&1) - ); - assert_eq!( - output - .call_resolution_coverage - .raw_call_normalization_gap_samples, - vec![RawCallNormalizationGap { - path: "test.rb".to_string(), - language: "ruby".to_string(), - span, - kind: "call_expression".to_string(), - inside_executable_function: false, - }] + let word = output + .calls + .iter() + .find(|call| call.function == "word_member?" && call.message == "include?") + .expect("word-array membership call"); + assert_eq!(word.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(word.known_space_complexity.as_deref(), Some("O(1)")); + assert!( + !output.calls.iter().any(|call| call.message == "__FILE__"), + "Ruby's lexical __FILE__ pseudo-constant must not be normalized as self.__FILE__()" ); - let merged = merge(vec![output], Profile::Espalier); - assert_eq!( - merged - .call_resolution_coverage - .raw_calls_not_normalized_by_kind - .get("call_expression"), - Some(&1) - ); - assert_eq!( - merged - .call_resolution_coverage - .raw_call_normalization_gap_samples - .len(), - 1 - ); + for (function, receiver_type) in [ + ("converted_empty?", "T::Array[T.untyped]"), + ("rendered_empty?", "String"), + ] { + let call = output + .calls + .iter() + .find(|call| call.function == function && call.message == "empty?") + .unwrap_or_else(|| panic!("{function} chained empty? call")); + assert_eq!(call.receiver_type.as_deref(), Some(receiver_type)); + assert_eq!( + call.receiver_type_origin.as_deref(), + Some("static_call_result_contract") + ); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(1)")); + } } #[test] @@ -6804,6 +10066,145 @@ pub(crate) mod tests { assert_eq!(unmatched_normalized, vec![[1, 0, 1, 6]]); } + #[test] + fn ruby_block_calls_retain_exact_raw_call_origins() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("temporary Ruby source"); + file.write_all( + br#"module BlockCallFixture + module_function + def run(rows, file_coverage, branch_arms) + branch_arm_coverage(file_coverage, branch_arms).each_with_object(Hash.new(0)) do |row, out| + next if row.covered + + out[row.arm.line] += 1 + end + rows.filter_map do |row| + row.to_s if row + end + rows.map { |row| row.to_s } + index = rows.each_with_object( + {} + ) do |row, out| + out[row] = true + end + index[rows.size] = false + buffer = [] + buffer[rows.size] = true + defaults = Hash.new { |hash, key| hash[key] = [] } + defaults + rows.value = rows.size + end +end +"#, + ) + .expect("write Ruby source"); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby) + .expect("parse Ruby source"); + + let output = extract(&document, Profile::Espalier); + assert_eq!( + output.call_resolution_coverage.raw_calls_not_normalized, + 0, + "{:?}", + output + .call_resolution_coverage + .raw_call_normalization_gap_samples + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0, + "{:?}", + output + .call_resolution_coverage + .raw_call_normalization_gap_samples + ); + assert!( + output.calls.iter().any(|call| call.message == "value="), + "attribute writers are executable Ruby calls" + ); + assert!( + output + .calls + .iter() + .any(|call| call.receiver == "Hash" && call.message == "new"), + "constructor calls nested in block-call arguments must be retained" + ); + assert_eq!( + output + .calls + .iter() + .filter(|call| call.message == "each_with_object") + .map(|call| call.arguments.len()) + .collect::>(), + vec![1, 1], + "wrapped block-call arguments must be structurally retained without duplication" + ); + let accumulator_writes = output + .calls + .iter() + .filter(|call| call.receiver == "out" && call.message == "[]=") + .collect::>(); + assert_eq!(accumulator_writes.len(), 1); + assert!(accumulator_writes.iter().all(|call| { + call.receiver_type + .as_deref() + .is_some_and(|receiver| receiver.contains("Hash")) + && call.receiver_type_origin.as_deref() == Some("callback_argument") + && call.known_time_complexity.as_deref() == Some("O(1)") + && call.known_space_complexity.as_deref() == Some("O(1)") + })); + let accumulator_result_write = output + .calls + .iter() + .find(|call| call.receiver == "index" && call.message == "[]=") + .expect("writer on an argument-dependent call result"); + assert_eq!( + accumulator_result_write.receiver_type.as_deref(), + Some("T::Hash[T.untyped, T.untyped]") + ); + assert_eq!( + accumulator_result_write.receiver_type_origin.as_deref(), + Some("static_call_result_contract") + ); + assert_eq!( + accumulator_result_write.known_time_complexity.as_deref(), + Some("O(1)") + ); + let array_write = output + .calls + .iter() + .find(|call| call.receiver == "buffer" && call.message == "[]=") + .expect("array writer"); + assert_eq!( + array_write.receiver_type.as_deref(), + Some("T::Array[T.untyped]") + ); + assert_eq!(array_write.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(array_write.known_space_complexity.as_deref(), Some("O(N)")); + let default_hash_write = output + .calls + .iter() + .find(|call| call.receiver == "hash" && call.message == "[]=") + .expect("Hash default callback writer"); + assert_eq!( + default_hash_write.receiver_type.as_deref(), + Some("T::Hash[T.untyped, T.untyped]") + ); + assert_eq!( + default_hash_write.receiver_type_origin.as_deref(), + Some("callback_argument") + ); + assert_eq!( + default_hash_write.known_time_complexity.as_deref(), + Some("O(1)") + ); + } + #[test] fn compact_call_resolution_evidence_matches_full_espalier_resolution() { let mut file = tempfile::Builder::new() @@ -6891,6 +10292,7 @@ end visibility: Some("public".to_string()), params: Vec::new(), callback_params: Vec::new(), + source_export_eligible: true, signature: "def helper".to_string(), }); let output = extract(&doc, Profile::Espalier); @@ -6947,7 +10349,7 @@ end pub(crate) fn test_python_signature_parsing_impl() { let sig = "def my_func(a: int, b: str = 'hello') -> str:"; - let (return_type, params) = parse_python_signature(sig); + let (return_type, params) = SignatureParser::parse(sig, "python"); assert_eq!(return_type, Some("str".to_string())); assert_eq!(params.len(), 2); assert_eq!(params[0].get("name").unwrap(), "a"); @@ -6955,20 +10357,20 @@ end assert_eq!(params[1].get("name").unwrap(), "b"); assert_eq!(params[1].get("type").unwrap(), "str = 'hello'"); - let (r, p) = parse_python_signature("def no_paren"); + let (r, p) = SignatureParser::parse("def no_paren", "python"); assert!(r.is_none()); assert!(p.is_empty()); - let (r, _p) = parse_python_signature("def my_func(a: int"); + let (r, _p) = SignatureParser::parse("def my_func(a: int", "python"); assert!(r.is_none()); - let (_r, p) = parse_python_signature("def my_func(self, cls, , a, b: ) -> str:"); + let (_r, p) = SignatureParser::parse("def my_func(self, cls, , a, b: ) -> str:", "python"); assert_eq!(p.len(), 0); } pub(crate) fn test_typescript_signature_parsing_impl() { let sig = "(a: number, b?: string, ...c: any[]): void;"; - let (return_type, params) = parse_typescript_signature(sig); + let (return_type, params) = SignatureParser::parse(sig, "typescript"); assert_eq!(return_type, Some("void".to_string())); assert_eq!(params.len(), 3); assert_eq!(params[0].get("name").unwrap(), "a"); @@ -6978,14 +10380,14 @@ end assert_eq!(params[2].get("name").unwrap(), "c"); assert_eq!(params[2].get("type").unwrap(), "any[]"); - let (r, p) = parse_typescript_signature("no_paren"); + let (r, p) = SignatureParser::parse("no_paren", "typescript"); assert!(r.is_none()); assert!(p.is_empty()); - let (r, _p) = parse_typescript_signature("(a: number"); + let (r, _p) = SignatureParser::parse("(a: number", "typescript"); assert!(r.is_none()); - let (_r, p) = parse_typescript_signature("( , a, b: ): void"); + let (_r, p) = SignatureParser::parse("( , a, b: ): void", "typescript"); assert_eq!(p.len(), 0); } @@ -7061,6 +10463,7 @@ def py_fn(a: int) -> str: visibility: Some("public".to_string()), params: vec!["x".to_string()], callback_params: Vec::new(), + source_export_eligible: true, signature: "".to_string(), }); @@ -7083,6 +10486,7 @@ def py_fn(a: int) -> str: visibility: Some("public".to_string()), params: vec!["x".to_string()], callback_params: Vec::new(), + source_export_eligible: true, signature: "sig { .params(x: Integer).returns(String) }".to_string(), }); @@ -7105,6 +10509,7 @@ def py_fn(a: int) -> str: visibility: Some("public".to_string()), params: vec![], callback_params: Vec::new(), + source_export_eligible: true, signature: "def top_level_fn".to_string(), }); @@ -7243,6 +10648,7 @@ def py_fn(a: int) -> str: kind: "class".to_string(), reopenable: false, supertypes: Vec::new(), + requirements: Vec::new(), line: 1, span: [1, 0, 1, 15], }); @@ -7482,28 +10888,30 @@ def py_fn(a: int) -> str: visibility: None, params: vec!["a".to_string()], callback_params: Vec::new(), + source_export_eligible: true, signature: "".to_string(), }); extract(&doc_py, Profile::Espalier); } pub(crate) fn test_sorbet_signature_parsing_impl() { - let (r, _p) = parse_sorbet_signature("def foo"); + let (r, _p) = SignatureParser::parse("def foo", "ruby"); assert!(r.is_none()); - let (r, p) = parse_sorbet_signature("sig { .params(x: Integer).returns(String) }"); + let (r, p) = SignatureParser::parse("sig { .params(x: Integer).returns(String) }", "ruby"); assert_eq!(r, Some("String".to_string())); assert_eq!(p.len(), 1); assert_eq!(p[0].get("name").unwrap(), "x"); assert_eq!(p[0].get("type").unwrap(), "Integer"); - let (r, p) = parse_sorbet_signature( + let (r, p) = SignatureParser::parse( "sig { .params(x: T::Array[Integer], y: T::Hash[Symbol, String]).returns(String) }", + "ruby", ); assert_eq!(r, Some("String".to_string())); assert_eq!(p.len(), 2); - let (r, _p) = parse_sorbet_signature("sig { .params(x: Integer"); + let (r, _p) = SignatureParser::parse("sig { .params(x: Integer", "ruby"); assert!(r.is_none()); } @@ -7532,17 +10940,17 @@ def py_fn(a: int) -> str: } pub(crate) fn test_language_type_system_impl() { - assert_eq!(language_type_system("ruby"), "sorbet"); - assert_eq!(language_type_system("python"), "python-typing"); - assert_eq!(language_type_system("typescript"), "typescript"); - assert_eq!(language_type_system("javascript"), "typescript"); - assert_eq!(language_type_system("go"), "go-types"); - assert_eq!(language_type_system("rust"), "rust-types"); - assert_eq!(language_type_system("java"), "java-types"); - assert_eq!(language_type_system("kotlin"), "kotlin-types"); - assert_eq!(language_type_system("swift"), "swift-types"); - assert_eq!(language_type_system("csharp"), "csharp-types"); - assert_eq!(language_type_system("unknown"), "native"); + assert_eq!(profile_type_system("ruby"), "sorbet"); + assert_eq!(profile_type_system("python"), "python-typing"); + assert_eq!(profile_type_system("typescript"), "typescript"); + assert_eq!(profile_type_system("javascript"), "typescript"); + assert_eq!(profile_type_system("go"), "go-types"); + assert_eq!(profile_type_system("rust"), "rust-types"); + assert_eq!(profile_type_system("java"), "java-types"); + assert_eq!(profile_type_system("kotlin"), "kotlin-types"); + assert_eq!(profile_type_system("swift"), "swift-types"); + assert_eq!(profile_type_system("csharp"), "csharp-types"); + assert_eq!(profile_type_system("unknown"), "native"); } pub(crate) fn test_profile_extra_coverage_impl() { @@ -7557,7 +10965,7 @@ def py_fn(a: int) -> str: assert_eq!(s_name, "SimpleName"); // 3. sorbet_extract nested parentheses - let (res_type, params) = parse_sorbet_signature("sig { .returns(Nested(Type)) }"); + let (res_type, params) = SignatureParser::parse("sig { .returns(Nested(Type)) }", "ruby"); assert_eq!(res_type, Some("Nested(Type)".to_string())); assert!(params.is_empty()); @@ -7581,6 +10989,7 @@ def py_fn(a: int) -> str: visibility: None, params: vec!["a".to_string(), "b".to_string()], callback_params: Vec::new(), + source_export_eligible: true, signature: "".to_string(), }; let sig = method_signature(&lines, &fn_def, "unknown"); @@ -7816,12 +11225,14 @@ def py_fn(a: int) -> str: target_provenance: None, candidate_targets: Vec::new(), candidate_reason: None, + consumer_closed_candidate_set: false, kind: "internal_call".into(), owner: "Demo".into(), function: "a".into(), receiver: "self".into(), message: "b".into(), argument_count: 0, + arguments: Vec::new(), path: "demo.rb".into(), line: 2, receiver_kind: "value".into(), @@ -7830,7 +11241,10 @@ def py_fn(a: int) -> str: lexical_symbol: None, lexical_symbol_origin: None, receiver_call_span: None, + selector_span: None, + execution_span: None, receiver_definition_call_spans: Vec::new(), + receiver_definition_sequence_projection: None, receiver_symbol: None, receiver_type: None, receiver_type_origin: None, @@ -7853,6 +11267,7 @@ def py_fn(a: int) -> str: unresolved_reason: None, resolution_missing_proof: None, empty_domain_cause: None, + runtime_evidence_observed: false, }); output.state_accesses.push(StateAccessRecord { id: "edge:state".into(), @@ -8374,6 +11789,13 @@ impl<'a> StateParamVisitor<'a> { } fn normalize_string(s: &str, root: &std::path::Path) -> String { + // A prose blocker reads "unknown return expression ARGS at :13", so + // the root can sit anywhere in the string rather than at its start. Strip + // it wherever it appears before the structural passes below. + let root_prefix = format!("{}/", root.to_string_lossy()); + if s.contains(&root_prefix) { + return normalize_string(&s.replace(&root_prefix, ""), root); + } if s.contains('\x00') { let parts: Vec = s .split('\x00') @@ -8406,20 +11828,19 @@ fn normalize_string(s: &str, root: &std::path::Path) -> String { } pub fn normalize_paths(v: &mut serde_json::Value, root: &std::path::Path) { + // Any string carrying the checkout root is machine-specific, whatever + // holds it. Allow-listing keys meant identities kept working while + // `domain_id`, `symbol_owner`, and bare strings inside `requirements` and + // `blockers` arrays carried one developer's home directory into the + // committed oracles -- portable everywhere except the machine that had to + // run them. match v { + serde_json::Value::String(s) => { + *v = serde_json::Value::String(normalize_string(s, root)); + } serde_json::Value::Object(map) => { - for (key, val) in map.iter_mut() { - // Stable identities can embed paths in a compound key (for - // example hidden-enum local keys use NUL-separated fields). - // Normalize those exactly as IDs so profile oracles remain - // portable across checkouts. - if key == "path" || key == "file" || key == "id" || key == "key" { - if let serde_json::Value::String(s) = val { - *val = serde_json::Value::String(normalize_string(s, root)); - } - } else { - normalize_paths(val, root); - } + for (_key, val) in map.iter_mut() { + normalize_paths(val, root); } } serde_json::Value::Array(arr) => { diff --git a/gems/fact-mine/src/runtime_decode.rs b/gems/fact-mine/src/runtime_decode.rs new file mode 100644 index 000000000..99883d3f6 --- /dev/null +++ b/gems/fact-mine/src/runtime_decode.rs @@ -0,0 +1,807 @@ +//! Reading what a language runtime observed into the shape evidence expects. +//! +//! A native collector writes what it saw in the terms its own VM uses: a class +//! object's name, a receiver's type, the file a method was defined in. Evidence +//! is written in SCIP's terms: descriptors, package coordinates, source roles, +//! paths relative to the repository. This module is the translation, and it is +//! mechanical -- it renames and regroups, and infers nothing. Flow analysis +//! belongs to FactMine. +//! +//! Ported from nil-kill's `runtime_value_evidence.rb`. Nothing here touches a +//! VM: the input is files the collector already wrote, so the translation does +//! not need to run inside the traced process, and once it does not, the traced +//! process needs no library code beyond the native collector itself. +//! +//! The same split works for any language: only how a type is named and how a +//! container is enumerated are language-specific, and both are decided inside +//! the collector, where the VM is. + +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +fn text(value: Option<&Value>) -> String { + match value { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(b)) => b.to_string(), + _ => String::new(), + } +} + +fn integer(value: Option<&Value>) -> i64 { + value + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .unwrap_or(0) +} + +fn array(value: Option<&Value>) -> &[Value] { + value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) +} + +fn lexically_absolute(path: &str, root: &Path) -> PathBuf { + let candidate = Path::new(path); + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.join(candidate) + }; + // Resolve `.` and `..` without touching the filesystem: the collector may + // report a path that no longer exists by the time evidence is read. + let mut parts: Vec = Vec::new(); + for component in joined.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if matches!(parts.last(), Some(Component::Normal(_))) { + parts.pop(); + } else { + parts.push(component); + } + } + other => parts.push(other), + } + } + parts.iter().collect() +} + +/// The repository-relative spelling, or the original text when the path lies +/// outside the repository entirely. +pub fn relative_path(path: &str, root: &Path) -> String { + if path.is_empty() { + return String::new(); + } + let absolute = lexically_absolute(path, root); + match absolute.strip_prefix(root) { + Ok(relative) => relative.to_string_lossy().into_owned(), + Err(_) => path.to_string(), + } +} + +fn inside_root(path: &Path, root: &Path) -> bool { + path == root || path.starts_with(root) +} + +/// A path under a test directory, or named like a test file, is test code +/// whatever mechanism implements it. +fn nonproduction_path(path: &str, root: &Path) -> bool { + if path.is_empty() { + return false; + } + let absolute = lexically_absolute(path, root); + let Ok(relative) = absolute.strip_prefix(root) else { + return false; + }; + let components: Vec = relative + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + if components + .iter() + .any(|part| matches!(part.as_str(), "test" | "tests" | "spec" | "specs")) + { + return true; + } + components + .last() + .is_some_and(|last| last.ends_with("_test.rb") || last.ends_with("_spec.rb")) +} + +/// SCIP escapes any word that is not plainly alphanumeric. +pub fn symbol_word(value: &str) -> String { + if value.is_empty() { + return ".".to_string(); + } + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '+' | '@' | '/' | '-')) + { + return value.to_string(); + } + format!("`{}`", value.replace('`', "``")) +} + +/// SCIP's canonical descriptor escaping. Question marks, bangs, equals signs +/// and most Ruby operators are not legal bare names. +pub fn descriptor_name(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '$' | '-')) + { + return value.to_string(); + } + format!("`{}`", value.replace('`', "``")) +} + +pub fn descriptor_owner(value: &str) -> String { + value + .split("::") + .filter(|part| !part.is_empty()) + .map(descriptor_name) + .collect::>() + .join("/") +} + +pub fn runtime_type_symbol(type_name: &str, runtime_version: &str) -> String { + format!( + "nil-kill-runtime ruby ruby {runtime_version} {}#", + descriptor_owner(type_name) + ) +} + +pub fn runtime_singleton_symbol(type_name: &str, runtime_version: &str) -> String { + format!( + "nil-kill-runtime ruby ruby {runtime_version} {}.", + descriptor_owner(type_name) + ) +} + +fn runtime_symbol(callee: &Value, selector: &str) -> String { + let word = |key: &str, fallback: &str| { + let raw = text(callee.get(key)); + symbol_word(if raw.is_empty() { fallback } else { &raw }) + }; + let manager = word("package_manager", "runtime"); + let package = word("package", "ruby"); + let version = word("version", "workspace"); + let owner_raw = { + let owner = text(callee.get("owner")); + if owner.is_empty() { + let receiver = text(callee.get("receiver_type")); + if receiver.is_empty() { "ruby".to_string() } else { receiver } + } else { + owner + } + }; + let separator = if text(callee.get("kind")) == "class" { "." } else { "#" }; + format!( + "nil-kill-runtime {manager} {package} {version} {}{separator}{}().", + descriptor_owner(&owner_raw), + descriptor_name(selector) + ) +} + +/// Where a call target came from. Provenance dominates mechanism: a C-backed +/// Struct defined in a test is test code, not standard library. +fn target_source_role(callee: &Value, root: &Path) -> String { + let package = text(callee.get("package")); + if text(callee.get("source_role")) == "nonproduction" { + return "NON_PRODUCTION".to_string(); + } + if matches!(package.as_str(), "minitest" | "mocha" | "rspec-mocks" | "rr") { + return "NON_PRODUCTION".to_string(); + } + if nonproduction_path(&text(callee.get("path")), root) { + return "NON_PRODUCTION".to_string(); + } + let manager = text(callee.get("package_manager")); + if manager == "workspace" { + return "PRODUCTION".to_string(); + } + if manager == "ruby" { + return "STANDARD_LIBRARY".to_string(); + } + if !package.is_empty() { + return "DEPENDENCY".to_string(); + } + "UNKNOWN_SOURCE".to_string() +} + +fn normalized_range(range: Option<&Value>) -> Option { + let range = range?.as_array()?; + match range.len() { + 4 => Some(Value::Array(range.clone())), + 3 => Some(json!([range[0], range[1], range[0], range[2]])), + _ => None, + } +} + +fn strings(values: Option<&Value>) -> Vec { + let mut out: Vec = array(values) + .iter() + .map(|v| text(Some(v))) + .filter(|s| !s.is_empty()) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// A shape is either a bare class name or a nested container description. +fn normalize_shape(shape: &Value) -> Option { + if let Some(name) = shape.as_str() { + return Some(json!({ "kind": "class", "name": name })); + } + let object = shape.as_object()?; + let kind = text(object.get("kind")); + if kind.is_empty() { + return Some(json!({ "kind": "unknown" })); + } + let mut normalized = Map::new(); + normalized.insert("kind".to_string(), Value::String(kind)); + let name = text(object.get("name")); + if !name.is_empty() { + normalized.insert("name".to_string(), Value::String(name)); + } + for key in ["elements", "keys", "values"] { + let children: Vec = array(object.get(key)) + .iter() + .filter_map(normalize_shape) + .collect(); + if !children.is_empty() { + normalized.insert(key.to_string(), Value::Array(children)); + } + } + let members: Map = object + .get("members") + .and_then(Value::as_object) + .map(|members| { + members + .iter() + .filter_map(|(name, child)| Some((name.clone(), normalize_shape(child)?))) + .collect() + }) + .unwrap_or_default(); + if !members.is_empty() { + normalized.insert("members".to_string(), Value::Object(members)); + } + Some(Value::Object(normalized)) +} + +/// `T.untyped` marks absence of identity, not a runtime alternative. Where a +/// shape supplies an exact record identity, it replaces that marker. +fn reconcile_record_slot(domain: &mut Map, slot: &str, shapes: &[Value]) { + let current = strings(domain.get(slot)); + if !current.iter().any(|s| s == "T.untyped") { + return; + } + let record_names: Vec = shapes + .iter() + .filter(|shape| text(shape.get("kind")) == "record") + .map(|shape| text(shape.get("name"))) + .filter(|name| !name.is_empty()) + .collect(); + if record_names.is_empty() { + return; + } + let mut merged: Vec = current.into_iter().filter(|s| s != "T.untyped").collect(); + for name in record_names { + if !merged.contains(&name) { + merged.push(name); + } + } + merged.sort(); + domain.insert( + slot.to_string(), + Value::Array(merged.into_iter().map(Value::String).collect()), + ); +} + +pub fn domain( + types: Option<&Value>, + singletons: Option<&Value>, + elements: Option<&Value>, + keys: Option<&Value>, + values: Option<&Value>, + shapes: Option<&Value>, +) -> Value { + let mut normalized_shapes: Vec = Vec::new(); + for shape in array(shapes) { + if let Some(shape) = normalize_shape(shape) { + if !normalized_shapes.contains(&shape) { + normalized_shapes.push(shape); + } + } + } + let to_value = |v: Vec| Value::Array(v.into_iter().map(Value::String).collect()); + let mut out = Map::new(); + out.insert("types".to_string(), to_value(strings(types))); + out.insert("singletons".to_string(), to_value(strings(singletons))); + out.insert("elements".to_string(), to_value(strings(elements))); + out.insert("keys".to_string(), to_value(strings(keys))); + out.insert("values".to_string(), to_value(strings(values))); + out.insert("shapes".to_string(), Value::Array(normalized_shapes.clone())); + + reconcile_record_slot(&mut out, "types", &normalized_shapes); + let nested = |key: &str| -> Vec { + normalized_shapes + .iter() + .flat_map(|shape| array(shape.get(key)).to_vec()) + .collect() + }; + reconcile_record_slot(&mut out, "elements", &nested("elements")); + reconcile_record_slot(&mut out, "keys", &nested("keys")); + reconcile_record_slot(&mut out, "values", &nested("values")); + Value::Object(out) +} + +fn normalized_domain_payload(payload: Option<&Value>) -> Option { + let payload = payload?.as_object()?; + Some(domain( + payload.get("types"), + payload.get("singletons"), + payload.get("elements"), + payload.get("keys"), + payload.get("values"), + payload.get("shapes"), + )) +} + +/// Translate one observed call into the evidence row shape. +pub fn call(event: &Value, root: &Path) -> Value { + let caller = event.get("caller").cloned().unwrap_or(Value::Null); + let callsite = event.get("callsite").cloned().unwrap_or(Value::Null); + let callee = event.get("callee").cloned().unwrap_or(Value::Null); + + let callee_name = text(callee.get("name")); + // A constructor is observed as `initialize` but is dispatched as `new`. + let selector = if callee_name == "initialize" { "new".to_string() } else { callee_name.clone() }; + + let word = |key: &str, fallback: &str| { + let raw = text(callee.get(key)); + symbol_word(if raw.is_empty() { fallback } else { &raw }) + }; + let source_role = target_source_role(&callee, root); + let mut target = Map::new(); + target.insert("symbol".to_string(), Value::String(runtime_symbol(&callee, &selector))); + target.insert("owner".to_string(), Value::String(text(callee.get("owner")))); + target.insert("name".to_string(), Value::String(selector.clone())); + target.insert("kind".to_string(), Value::String(text(callee.get("kind")))); + target.insert( + "receiver_type".to_string(), + Value::String(text(callee.get("receiver_type"))), + ); + target.insert("source_role".to_string(), Value::String(source_role.clone())); + target.insert("package_manager".to_string(), Value::String(word("package_manager", "runtime"))); + target.insert("package_name".to_string(), Value::String(word("package", "ruby"))); + target.insert("package_version".to_string(), Value::String(word("version", "workspace"))); + + let callee_path = text(callee.get("path")); + let native = callee.get("native").and_then(Value::as_bool).unwrap_or(false); + if !native && !callee_path.is_empty() { + let absolute = lexically_absolute(&callee_path, root); + if inside_root(&absolute, root) { + target.insert( + "definition".to_string(), + json!({ + "language": "ruby", + "path": relative_path(&callee_path, root), + "owner": text(callee.get("owner")), + "name": callee_name, + "kind": text(callee.get("kind")), + "line": integer(callee.get("line")), + }), + ); + } + } + + let mut callsite_row = Map::new(); + callsite_row.insert( + "path".to_string(), + Value::String(relative_path(&text(callsite.get("path")), root)), + ); + callsite_row.insert("line".to_string(), json!(integer(callsite.get("line")))); + if let Some(range) = normalized_range(callsite.get("range")) { + callsite_row.insert("range".to_string(), range); + } + let selector_text = { + let raw = text(callsite.get("selector")); + if raw.is_empty() { text(callee.get("name")) } else { raw } + }; + callsite_row.insert("selector".to_string(), Value::String(selector_text)); + callsite_row.insert( + "anchor_symbol".to_string(), + Value::String(text(callsite.get("anchor_symbol"))), + ); + + // False sorts before true: a call observed both ways reports the falsy + // witness first, which is the one that matters for nil analysis. + let mut truths: Vec = array(event.get("result_truths")) + .iter() + .filter_map(Value::as_bool) + .collect(); + truths.dedup(); + let mut unique: Vec = Vec::new(); + for truth in truths { + if !unique.contains(&truth) { + unique.push(truth); + } + } + unique.sort_by_key(|t| i32::from(*t)); + + let mut row = Map::new(); + row.insert("language".to_string(), Value::String("ruby".to_string())); + row.insert( + "caller".to_string(), + json!({ + "language": "ruby", + "path": relative_path(&text(caller.get("path")), root), + "owner": text(caller.get("class")), + "name": text(caller.get("method")), + "kind": text(caller.get("kind")), + "line": integer(caller.get("line")), + }), + ); + row.insert("callsite".to_string(), Value::Object(callsite_row)); + row.insert("target".to_string(), Value::Object(target)); + if let Some(receiver) = normalized_domain_payload(event.get("receiver_domain")) { + row.insert("receiver_domain".to_string(), receiver); + } + if let Some(result) = normalized_domain_payload(event.get("result_domain")) { + row.insert("result_domain".to_string(), result); + } + row.insert( + "result_truths".to_string(), + Value::Array(unique.into_iter().map(Value::Bool).collect()), + ); + // Receiver and target describe the same dispatch. Giving the receiver a + // weaker role would let a test double's values contaminate production flow + // after FactMine correctly filters its NON_PRODUCTION target. + row.insert( + "receiver_source_role".to_string(), + Value::String(if source_role == "NON_PRODUCTION" { + "NON_PRODUCTION".to_string() + } else { + "UNKNOWN_SOURCE".to_string() + }), + ); + row.insert("count".to_string(), json!(integer(event.get("count")))); + Value::Object(row) +} + +/// Fuse observations of the same slot, unioning their domains and adding their +/// counts, then order them so the output does not depend on file read order. +pub fn merge_observations(rows: Vec) -> Vec { + let mut grouped: BTreeMap = BTreeMap::new(); + let mut order: Vec = Vec::new(); + for row in rows { + let group = format!( + "{}\u{0}{}\u{0}{}\u{0}{}", + text(row.get("kind")), + serde_json::to_string(row.get("scope").unwrap_or(&Value::Null)).unwrap_or_default(), + text(row.get("slot")), + text(row.get("slot_kind")) + ); + match grouped.get_mut(&group) { + None => { + order.push(group.clone()); + grouped.insert(group, row); + } + Some(first) => { + let addition = integer(row.get("count")); + for field in ["types", "singletons", "elements", "keys", "values", "shapes"] { + let mut merged = array(first.pointer(&format!("/domain/{field}"))).to_vec(); + for value in array(row.pointer(&format!("/domain/{field}"))) { + if !merged.contains(value) { + merged.push(value.clone()); + } + } + if let Some(domain) = first.get_mut("domain").and_then(Value::as_object_mut) { + domain.insert(field.to_string(), Value::Array(merged)); + } + } + let total = integer(first.get("count")) + addition; + if let Some(object) = first.as_object_mut() { + object.insert("count".to_string(), json!(total)); + } + } + } + } + let mut out: Vec = order + .into_iter() + .filter_map(|group| grouped.remove(&group)) + .collect(); + out.sort_by_key(|row| { + let scope = row.get("scope").cloned().unwrap_or(Value::Null); + ( + text(scope.get("language")), + text(scope.get("path")), + text(scope.get("owner")), + text(scope.get("function")), + integer(scope.get("line")), + text(row.get("kind")), + text(row.get("slot")), + ) + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> &'static Path { + Path::new("/repo") + } + + #[test] + fn a_path_inside_the_repository_is_reported_relative_to_it() { + assert_eq!(relative_path("/repo/lib/a.rb", root()), "lib/a.rb"); + assert_eq!(relative_path("lib/a.rb", root()), "lib/a.rb"); + assert_eq!(relative_path("", root()), ""); + } + + #[test] + fn a_path_outside_the_repository_keeps_its_own_spelling() { + assert_eq!(relative_path("/gems/dep/lib/a.rb", root()), "/gems/dep/lib/a.rb"); + } + + #[test] + fn test_directories_and_test_filenames_are_both_nonproduction() { + assert!(nonproduction_path("test/a_test.rb", root())); + assert!(nonproduction_path("spec/thing_spec.rb", root())); + assert!(nonproduction_path("lib/nested/foo_test.rb", root())); + assert!(!nonproduction_path("lib/foo.rb", root())); + assert!(!nonproduction_path("", root())); + } + + #[test] + fn a_test_directory_named_like_one_but_deeper_still_counts() { + assert!(nonproduction_path("lib/tests/helper.rb", root())); + assert!(!nonproduction_path("lib/testing/helper.rb", root())); + } + + #[test] + fn descriptors_escape_exactly_what_scip_forbids() { + assert_eq!(descriptor_name("valid_name"), "valid_name"); + assert_eq!(descriptor_name("valid-name+1$"), "valid-name+1$"); + assert_eq!(descriptor_name("empty?"), "`empty?`"); + assert_eq!(descriptor_name("save!"), "`save!`"); + assert_eq!(descriptor_name("=="), "`==`"); + assert_eq!(descriptor_name("[]"), "`[]`"); + assert_eq!(descriptor_name("a`b"), "`a``b`"); + assert_eq!(descriptor_name(""), "``"); + } + + #[test] + fn a_namespaced_owner_becomes_a_descriptor_path() { + assert_eq!(descriptor_owner("Foo::Bar::Baz"), "Foo/Bar/Baz"); + assert_eq!(descriptor_owner("::Foo"), "Foo"); + assert_eq!(descriptor_owner("Foo::Bar?"), "Foo/`Bar?`"); + } + + #[test] + fn symbol_words_allow_more_punctuation_than_descriptors() { + assert_eq!(symbol_word("rubygems"), "rubygems"); + assert_eq!(symbol_word("1.2.3"), "1.2.3"); + assert_eq!(symbol_word("a/b@c"), "a/b@c"); + assert_eq!(symbol_word(""), "."); + assert_eq!(symbol_word("with space"), "`with space`"); + } + + #[test] + fn an_instance_call_and_a_class_call_differ_only_in_their_separator() { + let instance = json!({ "owner": "Foo", "kind": "instance", "package_manager": "workspace" }); + let class = json!({ "owner": "Foo", "kind": "class", "package_manager": "workspace" }); + assert!(runtime_symbol(&instance, "bar").ends_with("Foo#bar().")); + assert!(runtime_symbol(&class, "bar").ends_with("Foo.bar().")); + } + + #[test] + fn a_callee_without_an_owner_falls_back_to_its_receiver_then_to_ruby() { + let with_receiver = json!({ "receiver_type": "Bar", "kind": "instance" }); + assert!(runtime_symbol(&with_receiver, "x").ends_with("Bar#x().")); + let bare = json!({ "kind": "instance" }); + assert!(runtime_symbol(&bare, "x").ends_with("ruby#x().")); + } + + #[test] + fn source_role_puts_provenance_ahead_of_mechanism() { + let cases = [ + (json!({ "source_role": "nonproduction" }), "NON_PRODUCTION"), + (json!({ "package": "minitest" }), "NON_PRODUCTION"), + (json!({ "path": "test/a_test.rb", "package_manager": "ruby" }), "NON_PRODUCTION"), + (json!({ "package_manager": "workspace" }), "PRODUCTION"), + (json!({ "package_manager": "ruby" }), "STANDARD_LIBRARY"), + (json!({ "package": "nokogiri" }), "DEPENDENCY"), + (json!({}), "UNKNOWN_SOURCE"), + ]; + for (callee, expected) in cases { + assert_eq!(target_source_role(&callee, root()), expected, "{callee}"); + } + } + + #[test] + fn a_constructor_is_observed_as_initialize_but_dispatched_as_new() { + let event = json!({ + "caller": { "path": "lib/a.rb", "class": "A", "method": "run", "line": 2 }, + "callsite": { "path": "lib/a.rb", "line": 3 }, + "callee": { "name": "initialize", "owner": "B", "kind": "instance", + "package_manager": "workspace" }, + "count": 1 + }); + let row = call(&event, root()); + assert_eq!(row["target"]["name"], json!("new")); + assert!(row["target"]["symbol"].as_str().unwrap().ends_with("B#new().")); + assert_eq!(row["callsite"]["selector"], json!("initialize"), "the site is as observed"); + } + + #[test] + fn a_definition_is_recorded_only_for_non_native_code_inside_the_repository() { + let base = |extra: Value| { + let mut callee = json!({ "name": "x", "owner": "B", "kind": "instance", "line": 9 }); + for (k, v) in extra.as_object().unwrap() { + callee[k] = v.clone(); + } + json!({ + "caller": {}, "callsite": {}, "callee": callee, "count": 1 + }) + }; + let inside = call(&base(json!({ "path": "lib/b.rb" })), root()); + assert_eq!(inside["target"]["definition"]["path"], json!("lib/b.rb")); + assert_eq!(inside["target"]["definition"]["line"], json!(9)); + + let native = call(&base(json!({ "path": "lib/b.rb", "native": true })), root()); + assert!(native["target"].get("definition").is_none()); + + let outside = call(&base(json!({ "path": "/elsewhere/b.rb" })), root()); + assert!(outside["target"].get("definition").is_none()); + } + + #[test] + fn a_receiver_inherits_only_the_nonproduction_role() { + let event = |role: &str| { + json!({ + "caller": {}, "callsite": {}, + "callee": { "name": "x", "source_role": role, "package_manager": "workspace" }, + "count": 1 + }) + }; + let test_double = call(&event("nonproduction"), root()); + assert_eq!(test_double["receiver_source_role"], json!("NON_PRODUCTION")); + let production = call(&event(""), root()); + assert_eq!(production["target"]["source_role"], json!("PRODUCTION")); + assert_eq!( + production["receiver_source_role"], + json!("UNKNOWN_SOURCE"), + "the receiver's own values are not claimed to be production" + ); + } + + #[test] + fn a_three_element_range_is_expanded_to_four() { + assert_eq!(normalized_range(Some(&json!([1, 2, 9]))), Some(json!([1, 2, 1, 9]))); + assert_eq!(normalized_range(Some(&json!([1, 2, 3, 4]))), Some(json!([1, 2, 3, 4]))); + assert_eq!(normalized_range(Some(&json!([1, 2]))), None); + assert_eq!(normalized_range(None), None); + } + + #[test] + fn a_falsy_witness_is_reported_before_a_truthy_one() { + let event = |truths: Value| { + json!({ "caller": {}, "callsite": {}, "callee": { "name": "x" }, + "result_truths": truths, "count": 1 }) + }; + assert_eq!(call(&event(json!([true, false])), root())["result_truths"], json!([false, true])); + assert_eq!(call(&event(json!([true, true])), root())["result_truths"], json!([true])); + } + + #[test] + fn a_domain_is_sorted_deduplicated_and_stripped_of_blanks() { + let d = domain( + Some(&json!(["B", "A", "A", ""])), + None, None, None, None, None, + ); + assert_eq!(d["types"], json!(["A", "B"])); + assert_eq!(d["singletons"], json!([])); + } + + #[test] + fn a_record_shape_replaces_the_untyped_marker_it_identifies() { + let d = domain( + Some(&json!(["T.untyped"])), + None, None, None, None, + Some(&json!([{ "kind": "record", "name": "Point" }])), + ); + assert_eq!(d["types"], json!(["Point"]), "the exact identity wins"); + } + + #[test] + fn an_untyped_marker_with_no_record_identity_is_left_alone() { + let d = domain( + Some(&json!(["T.untyped"])), + None, None, None, None, + Some(&json!([{ "kind": "array" }])), + ); + assert_eq!(d["types"], json!(["T.untyped"])); + } + + #[test] + fn nested_record_identities_reconcile_their_own_slot() { + let d = domain( + None, None, + Some(&json!(["T.untyped"])), + None, None, + Some(&json!([{ + "kind": "array", + "elements": [{ "kind": "record", "name": "Row" }] + }])), + ); + assert_eq!(d["elements"], json!(["Row"])); + } + + #[test] + fn a_bare_string_shape_becomes_a_class_shape() { + assert_eq!( + normalize_shape(&json!("String")), + Some(json!({ "kind": "class", "name": "String" })) + ); + assert_eq!(normalize_shape(&json!({})), Some(json!({ "kind": "unknown" }))); + assert_eq!(normalize_shape(&json!(7)), None); + } + + #[test] + fn a_shape_keeps_only_the_children_it_actually_has() { + let shape = normalize_shape(&json!({ + "kind": "hash", "name": "", "keys": ["Symbol"], "values": [], "members": {} + })) + .expect("shape"); + assert_eq!(shape["keys"], json!([{ "kind": "class", "name": "Symbol" }])); + assert!(shape.get("values").is_none(), "empty children are omitted"); + assert!(shape.get("members").is_none()); + assert!(shape.get("name").is_none(), "an empty name is omitted"); + } + + #[test] + fn observations_of_one_slot_fuse_their_domains_and_add_their_counts() { + let row = |types: Value, count: i64| { + json!({ + "kind": "parameter", + "scope": { "language": "ruby", "path": "a.rb", "owner": "A", + "function": "run", "line": 1 }, + "slot": "x", "slot_kind": "", + "domain": { "types": types, "singletons": [], "elements": [], + "keys": [], "values": [], "shapes": [] }, + "count": count + }) + }; + let merged = merge_observations(vec![row(json!(["A"]), 2), row(json!(["B", "A"]), 3)]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0]["domain"]["types"], json!(["A", "B"])); + assert_eq!(merged[0]["count"], json!(5)); + } + + #[test] + fn different_slots_stay_separate_and_come_out_in_a_stable_order() { + let row = |path: &str, function: &str, slot: &str| { + json!({ + "kind": "parameter", + "scope": { "language": "ruby", "path": path, "owner": "A", + "function": function, "line": 1 }, + "slot": slot, "slot_kind": "", + "domain": { "types": ["X"] }, "count": 1 + }) + }; + let merged = merge_observations(vec![ + row("b.rb", "z", "q"), + row("a.rb", "y", "p"), + row("a.rb", "y", "a"), + ]); + let order: Vec = merged + .iter() + .map(|r| format!("{}:{}", r["scope"]["path"].as_str().unwrap(), r["slot"].as_str().unwrap())) + .collect(); + assert_eq!(order, vec!["a.rb:a", "a.rb:p", "b.rb:q"]); + } +} diff --git a/gems/fact-mine/src/runtime_evidence.rs b/gems/fact-mine/src/runtime_evidence.rs new file mode 100644 index 000000000..d4feaad54 --- /dev/null +++ b/gems/fact-mine/src/runtime_evidence.rs @@ -0,0 +1,7460 @@ +//! Language-neutral runtime value evidence. +//! +//! Tracers own observation. FactMine owns every relation between an observed +//! value and normalized source/CFG/DFG facts. In particular, this schema must +//! never grow assignments, AST nodes, block-binding rules, or source-language +//! expressions: those would duplicate FactMine's analysis in each tracer. + +use anyhow::{bail, Context, Result}; +#[cfg(test)] +use flate2::read::GzDecoder; +use protobuf::Enum; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs; +#[cfg(test)] +use std::io::Read; +use std::path::Path; + +use crate::profile::{CallRecord, MethodRecord, ProfileOutput}; +#[cfg(test)] +use crate::runtime_protocol::CaptureStatus; +use crate::runtime_protocol::{self, runtime_value, AnchorBinding, BuiltTracePlan, EvidenceKind}; +use crate::type_inference::TypeExpr; + +// Private normalized facts used by the CFG/DFG overlay. This is deliberately +// not a wire schema: the only public runtime contract is runtime_protocol. +const INTERNAL_FACTS_SCHEMA: &str = "fact-mine.normalized-runtime-facts"; +#[cfg(test)] +const SCHEMA: &str = INTERNAL_FACTS_SCHEMA; +const RUNTIME_RECORD_ACCESSOR_SYMBOL_PREFIX: &str = "fact-mine-runtime runtime-contract v1 Record#"; + +fn runtime_record_accessor_symbol(member: &str) -> String { + format!("{RUNTIME_RECORD_ACCESSOR_SYMBOL_PREFIX}{member}().") +} + +pub(crate) fn is_runtime_record_accessor_symbol(symbol: &str, member: &str) -> bool { + symbol == runtime_record_accessor_symbol(member) +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct RuntimeValueEvidence { + pub schema: String, + #[serde(default)] + pub authority: String, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub runs: Vec, + #[serde(default)] + pub observations: Vec, + #[serde(default)] + pub calls: Vec, + /// Runtime dispatch facts whose exact normalized callsite is not known. + /// + /// A correlation group can still prove that a receiver type and selector + /// dispatched to a particular semantic target even when a native tracer + /// cannot distinguish two same-line source calls. FactMine may use that + /// fact as a target catalog entry, but only its CFG/DFG may join it back + /// to a normalized call. + #[serde(default)] + pub target_catalog: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct MethodLocator { + pub language: String, + pub path: String, + #[serde(default)] + pub owner: String, + pub name: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub line: usize, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct ValueScope { + pub language: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub owner: String, + #[serde(default)] + pub function: String, + #[serde(default)] + pub line: usize, + /// FactMine-only exact binding. Runtime collectors never serialize + /// normalized profile IDs. + #[serde(skip)] + pub method_id: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct SourceAnchor { + pub path: String, + pub line: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option<[usize; 4]>, + #[serde(default)] + pub selector: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct ValueDomain { + #[serde(default)] + pub types: BTreeSet, + /// Exact identities of module/class/function singleton values. These are + /// refinements of a nominal runtime type such as `Module`, not additional + /// union alternatives. + #[serde(default)] + pub singletons: BTreeSet, + #[serde(default)] + pub elements: BTreeSet, + #[serde(default)] + pub keys: BTreeSet, + #[serde(default)] + pub values: BTreeSet, + #[serde(default)] + pub shapes: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +struct ValueShape { + pub kind: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub elements: Vec, + #[serde(default)] + pub keys: Vec, + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub members: BTreeMap, +} + +impl ValueDomain { + fn is_empty(&self) -> bool { + self.types.is_empty() + && self.singletons.is_empty() + && self.elements.is_empty() + && self.keys.is_empty() + && self.values.is_empty() + && self.shapes.is_empty() + } + + fn validate(&self, context: &str) -> Result<()> { + for value in self + .types + .iter() + .chain(&self.singletons) + .chain(&self.elements) + .chain(&self.keys) + .chain(&self.values) + { + if value.trim().is_empty() { + bail!("{context} contains an empty runtime type identity"); + } + } + for shape in &self.shapes { + shape.validate(context)?; + } + Ok(()) + } +} + +impl ValueShape { + fn validate(&self, context: &str) -> Result<()> { + if !matches!( + self.kind.as_str(), + "class" | "array" | "hash" | "set" | "tuple" | "record" | "unknown" + ) { + bail!("{context} contains unsupported value shape {:?}", self.kind); + } + if self.kind == "class" && self.name.is_empty() { + bail!("{context} contains a class shape without a name"); + } + for child in self + .elements + .iter() + .chain(&self.keys) + .chain(&self.values) + .chain(self.members.values()) + { + child.validate(context)?; + } + Ok(()) + } +} + +/// A value observation attached to a semantic storage boundary. +/// +/// Supported kinds are deliberately storage-oriented rather than +/// language-oriented: +/// - `parameter`: method `scope` + `slot` +/// - `return`: method `scope` +/// - `state`: `scope.language` + `scope.owner` + `slot` +/// - `collection`: `scope` + `slot`, where `slot_kind` identifies parameter, +/// return, state, or another tracer-addressable boundary. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct ValueObservation { + pub kind: String, + pub scope: ValueScope, + #[serde(default)] + pub slot: String, + #[serde(default)] + pub slot_kind: String, + pub domain: ValueDomain, + #[serde(default)] + pub count: u64, + /// FactMine-only exact state-access binding. + #[serde(skip)] + pub state_access_id: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct SemanticTarget { + /// Canonical SCIP symbol. Keeping the symbol in evidence means FactMine + /// never needs to understand a tracer or package manager's identity + /// grammar. + pub symbol: String, + #[serde(default)] + pub owner: String, + pub name: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub receiver_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct ObservedCall { + pub language: String, + pub caller: MethodLocator, + pub callsite: SourceAnchor, + pub targets: Vec, + #[serde(default = "default_true")] + pub target_observation_complete: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_domain: Option, + /// Truth values observed for this call's result. This is intentionally a + /// language-neutral runtime fact: providers decide whether a native value + /// is Boolean, while FactMine joins it to normalized branch predicates. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub result_truths: BTreeSet, + #[serde(default)] + pub count: u64, + /// FactMine-only exact call binding regenerated from the validated plan. + #[serde(skip)] + pub call_id: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct RuntimeTargetObservation { + pub language: String, + pub selector: String, + pub targets: Vec, + pub receiver_domain: ValueDomain, +} + +fn default_true() -> bool { + true +} + +impl RuntimeValueEvidence { + #[cfg(test)] + fn from_path(path: &Path) -> Result { + let bytes = fs::read(path) + .with_context(|| format!("failed to read runtime evidence {}", path.display()))?; + let mut json = String::new(); + if bytes.starts_with(&[0x1f, 0x8b]) { + GzDecoder::new(bytes.as_slice()) + .read_to_string(&mut json) + .with_context(|| { + format!("failed to decompress runtime evidence {}", path.display()) + })?; + } else { + json = String::from_utf8(bytes).with_context(|| { + format!("runtime evidence {} is not valid UTF-8", path.display()) + })?; + } + Self::from_json(&json) + .with_context(|| format!("invalid runtime evidence {}", path.display())) + } + + #[cfg(test)] + fn from_json(json: &str) -> Result { + let evidence: Self = serde_json::from_str(json)?; + evidence.validate()?; + Ok(evidence) + } + + fn validate(&self) -> Result<()> { + if self.schema != INTERNAL_FACTS_SCHEMA { + bail!("invalid normalized runtime facts schema {:?}", self.schema); + } + if self.authority.is_empty() { + bail!("runtime evidence authority must not be empty"); + } + for (index, observation) in self.observations.iter().enumerate() { + if observation.scope.language.is_empty() { + bail!("observations[{index}].scope requires language"); + } + if !matches!( + observation.kind.as_str(), + "parameter" | "return" | "state" | "collection" + ) { + bail!( + "observations[{index}] has unsupported kind {:?}", + observation.kind + ); + } + if matches!( + observation.kind.as_str(), + "parameter" | "state" | "collection" + ) && observation.slot.is_empty() + { + bail!("observations[{index}] requires a slot"); + } + if matches!(observation.kind.as_str(), "parameter" | "return") + && (observation.scope.path.is_empty() + || observation.scope.function.is_empty() + || observation.scope.line == 0) + { + bail!("observations[{index}] requires a path, function, and positive line"); + } + if observation.kind == "state" && observation.scope.owner.is_empty() { + bail!("observations[{index}] state evidence requires an owner"); + } + observation + .domain + .validate(&format!("observations[{index}].domain"))?; + if observation.domain.is_empty() { + bail!("observations[{index}] has an empty value domain"); + } + } + for (index, call) in self.calls.iter().enumerate() { + if call.language.is_empty() { + bail!("calls[{index}].language must not be empty"); + } + validate_method(&call.caller, &format!("calls[{index}].caller"))?; + if call.callsite.path.is_empty() || call.callsite.line == 0 { + bail!("calls[{index}].callsite requires path and positive line"); + } + if call.targets.is_empty() + && (call.target_observation_complete + || call + .receiver_domain + .as_ref() + .is_none_or(ValueDomain::is_empty) + && call + .result_domain + .as_ref() + .is_none_or(ValueDomain::is_empty) + && call.result_truths.is_empty()) + { + bail!( + "calls[{index}] without targets requires an incomplete target observation and exact receiver/result evidence" + ); + } + for (target_index, target) in call.targets.iter().enumerate() { + if target.symbol.is_empty() || target.name.is_empty() { + bail!("calls[{index}].targets[{target_index}] requires symbol and name"); + } + if let Some(definition) = &target.definition { + validate_method( + definition, + &format!("calls[{index}].targets[{target_index}].definition"), + )?; + } + } + if let Some(domain) = &call.receiver_domain { + domain.validate(&format!("calls[{index}].receiver_domain"))?; + if domain.is_empty() { + bail!("calls[{index}].receiver_domain must not be empty"); + } + } + if let Some(domain) = &call.result_domain { + domain.validate(&format!("calls[{index}].result_domain"))?; + if domain.is_empty() { + bail!("calls[{index}].result_domain must not be empty"); + } + } + } + for (index, observation) in self.target_catalog.iter().enumerate() { + if observation.language.is_empty() || observation.selector.is_empty() { + bail!("target_catalog[{index}] requires a language and selector"); + } + if observation.targets.is_empty() || observation.receiver_domain.is_empty() { + bail!("target_catalog[{index}] requires targets and a receiver domain"); + } + observation + .receiver_domain + .validate(&format!("target_catalog[{index}].receiver_domain"))?; + for (target_index, target) in observation.targets.iter().enumerate() { + if target.symbol.is_empty() || target.name.is_empty() { + bail!( + "target_catalog[{index}].targets[{target_index}] requires symbol and name" + ); + } + if target.name != observation.selector { + bail!( + "target_catalog[{index}].targets[{target_index}] selector does not match" + ); + } + if let Some(definition) = &target.definition { + validate_method( + definition, + &format!("target_catalog[{index}].targets[{target_index}].definition"), + )?; + } + } + } + Ok(()) + } +} + +fn validate_method(method: &MethodLocator, context: &str) -> Result<()> { + if method.language.is_empty() || method.path.is_empty() || method.name.is_empty() { + bail!("{context} requires language, path, and name"); + } + Ok(()) +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OverlayStats { + pub observed_call_sites: usize, + pub inferred_call_sites: usize, + pub typed_receivers: usize, + pub emitted_occurrences: usize, +} + +#[derive(Clone, Debug)] +pub struct RuntimeScipOverlay { + pub index: Value, + pub stats: OverlayStats, +} + +/// Validate and overlay canonical runtime evidence through FactMine's exact +/// trace-plan bindings. +/// +/// The collector supplies only SCIP-like source/value/target identities. This +/// conversion deliberately introduces normalized profile IDs *after* +/// validation, from the binding table FactMine regenerated from the current +/// source snapshot. No path/name/line fallback is available on this path. +pub fn apply_protocol_to_profile( + output: &mut ProfileOutput, + built: &BuiltTracePlan, + evidence: &runtime_protocol::RuntimeEvidence, +) -> Result { + runtime_protocol::validate_runtime_evidence(&built.plan, evidence)?; + let internal = protocol_evidence(output, built, evidence)?; + apply_to_profile(output, &internal) +} + +fn protocol_evidence( + output: &ProfileOutput, + built: &BuiltTracePlan, + evidence: &runtime_protocol::RuntimeEvidence, +) -> Result { + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let calls = output + .calls + .iter() + .map(|call| (call.id.as_str(), call)) + .collect::>(); + let accesses = output + .state_accesses + .iter() + .map(|access| (access.id.as_str(), access)) + .collect::>(); + let anchor_methods = built + .bindings + .iter() + .filter_map(|(anchor, binding)| { + let method_id = match binding { + AnchorBinding::Parameter { method_id, .. } + | AnchorBinding::Return { method_id } => method_id, + AnchorBinding::Call { call_id } => &calls.get(call_id.as_str())?.source, + AnchorBinding::State { access_id } => { + &accesses.get(access_id.as_str())?.function_id + } + }; + Some((anchor.as_str(), method_id.as_str())) + }) + .collect::>(); + + let mut internal = RuntimeValueEvidence { + schema: INTERNAL_FACTS_SCHEMA.to_string(), + authority: "modeled-runs".to_string(), + environment: evidence + .environment + .iter() + .map(|claim| (claim.key.clone(), claim.value.clone())) + .collect(), + runs: evidence.runs.iter().map(|run| run.id.clone()).collect(), + ..RuntimeValueEvidence::default() + }; + + for row in &evidence.anchors { + let Some(capture) = row.capture.as_ref() else { + continue; + }; + let complete = capture + .complete_kinds + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + .collect::>(); + if complete.is_empty() { + continue; + } + let binding = built.bindings.get(&row.anchor_symbol).with_context(|| { + format!( + "validated anchor {:?} lacks a FactMine binding", + row.anchor_symbol + ) + })?; + match binding { + AnchorBinding::Parameter { + method_id, + ordinal: _, + name, + } => { + if !complete.contains(&EvidenceKind::PARAMETER_VALUE.value()) { + continue; + } + let method = methods.get(method_id.as_str()).with_context(|| { + format!("parameter anchor refers to missing method {method_id:?}") + })?; + for bucket in &row.executions { + let value = bucket.value.as_ref().with_context(|| { + format!("parameter anchor {:?} lacks its value", row.anchor_symbol) + })?; + let domain = protocol_value_set_domain(value, &method.language)?; + if domain.is_empty() { + continue; + } + internal.observations.push(protocol_observation( + "parameter", + method, + name, + domain, + bucket.count, + Some(method_id.clone()), + None, + )); + } + } + AnchorBinding::Return { method_id } => { + if !complete.contains(&EvidenceKind::RETURN_VALUE.value()) { + continue; + } + let method = methods.get(method_id.as_str()).with_context(|| { + format!("return anchor refers to missing method {method_id:?}") + })?; + for bucket in &row.executions { + let value = bucket.value.as_ref().with_context(|| { + format!("return anchor {:?} lacks its value", row.anchor_symbol) + })?; + let domain = protocol_value_set_domain(value, &method.language)?; + if domain.is_empty() { + continue; + } + internal.observations.push(protocol_observation( + "return", + method, + "", + domain, + bucket.count, + Some(method_id.clone()), + None, + )); + } + } + AnchorBinding::State { access_id } => { + if !complete.contains(&EvidenceKind::STATE_VALUE.value()) { + continue; + } + let access = accesses.get(access_id.as_str()).with_context(|| { + format!("state anchor refers to missing access {access_id:?}") + })?; + let method = methods.get(access.function_id.as_str()).with_context(|| { + format!( + "state access refers to missing method {:?}", + access.function_id + ) + })?; + for bucket in &row.executions { + let value = bucket.value.as_ref().with_context(|| { + format!("state anchor {:?} lacks its value", row.anchor_symbol) + })?; + let domain = protocol_value_set_domain(value, &method.language)?; + if domain.is_empty() { + continue; + } + internal.observations.push(protocol_observation( + "state", + method, + &access.field, + domain, + bucket.count, + Some(method.id.clone()), + Some(access_id.clone()), + )); + } + } + AnchorBinding::Call { call_id } => { + let call = calls + .get(call_id.as_str()) + .with_context(|| format!("call anchor refers to missing call {call_id:?}"))?; + let method = methods + .get(call.source.as_str()) + .with_context(|| format!("call refers to missing method {:?}", call.source))?; + for bucket in &row.executions { + if let Some(observation) = protocol_call_observation( + call, + method, + call_id, + bucket, + &complete, + &methods, + &built.bindings, + &anchor_methods, + )? { + internal.calls.push(observation); + } + } + } + } + } + let method_index = MethodIndex::new(&output.methods); + let points = flow_points(output, &method_index); + let requests = built + .plan + .requests + .iter() + .filter_map(|request| { + Some(( + request.anchor.as_ref()?.symbol.as_str(), + request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + .collect::>(), + )) + }) + .collect::>(); + for correlation in &evidence.correlations { + let Some(capture) = correlation.capture.as_ref() else { + continue; + }; + let complete = capture + .complete_kinds + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + .collect::>(); + let candidates = correlation + .candidate_anchor_symbols + .iter() + .filter_map(|anchor| match built.bindings.get(anchor) { + Some(AnchorBinding::Call { call_id }) => { + let call = calls.get(call_id.as_str()).copied()?; + let method = methods.get(call.source.as_str()).copied()?; + Some((anchor.as_str(), call_id, call, method)) + } + _ => None, + }) + .collect::>(); + if candidates.len() != correlation.candidate_anchor_symbols.len() { + bail!( + "validated correlation {:?} lacks an exact FactMine call binding", + correlation.group_id + ); + } + for bucket in &correlation.executions { + // Candidate ownership is intentionally unresolved here. The + // receiver/selector/target dispatch fact itself is nevertheless + // exact and can seed FactMine's generic target catalog. Only the + // normalized CFG/DFG overlay below may connect this catalog fact + // to one or more source calls. + if complete.contains(&EvidenceKind::RECEIVER_VALUE.value()) + && complete.contains(&EvidenceKind::CALL_TARGET.value()) + { + let (_, _, representative_call, representative_method) = candidates[0]; + if let (Some(receiver), Some(target)) = + (bucket.receiver.as_ref(), bucket.target.as_ref()) + { + let receiver_domain = + protocol_value_set_domain(receiver, &representative_method.language)?; + if !receiver_domain.is_empty() { + if let Some(target) = protocol_target( + target, + representative_call, + representative_method, + &methods, + &built.bindings, + &anchor_methods, + Some(receiver), + )? { + internal.target_catalog.push(RuntimeTargetObservation { + language: representative_method.language.clone(), + selector: representative_call.message.clone(), + targets: vec![target], + receiver_domain, + }); + } + } + } + } + let resolved = protocol_correlation_candidates(&candidates, bucket, &points)?; + for (anchor, call_id, call, method) in resolved { + let candidate_complete = complete + .intersection(requests.get(anchor).with_context(|| { + format!("correlation candidate {anchor:?} lacks a trace-plan request") + })?) + .copied() + .collect::>(); + if candidate_complete.is_empty() { + continue; + } + if let Some(observation) = protocol_call_observation( + call, + method, + call_id, + bucket, + &candidate_complete, + &methods, + &built.bindings, + &anchor_methods, + )? { + internal.calls.push(observation); + } + } + } + } + Ok(internal) +} + +fn protocol_call_observation( + call: &CallRecord, + method: &MethodRecord, + call_id: &str, + bucket: &runtime_protocol::ExecutionBucket, + complete: &BTreeSet, + methods: &BTreeMap<&str, &MethodRecord>, + bindings: &BTreeMap, + anchor_methods: &BTreeMap<&str, &str>, +) -> Result> { + let target = if complete.contains(&EvidenceKind::CALL_TARGET.value()) { + bucket + .target + .as_ref() + .map(|target| { + protocol_target( + target, + call, + method, + methods, + bindings, + anchor_methods, + bucket.receiver.as_ref(), + ) + }) + .transpose()? + .flatten() + } else { + None + }; + // A completely captured runtime target may still be unusable as a + // production target (for example, a test double). Keep the receiver + // evidence, but do not close production dispatch with the filtered target. + let target_observation_complete = target.is_some(); + let receiver_domain = if complete.contains(&EvidenceKind::RECEIVER_VALUE.value()) + || complete.contains(&EvidenceKind::COLLECTION_VALUE.value()) + { + bucket + .receiver + .as_ref() + .map(|value| protocol_value_set_domain(value, &method.language)) + .transpose()? + .filter(|domain| !domain.is_empty()) + } else { + None + }; + let result_domain = if complete.contains(&EvidenceKind::RESULT_VALUE.value()) { + bucket + .result + .as_ref() + .map(|value| protocol_value_set_domain(value, &method.language)) + .transpose()? + .filter(|domain| !domain.is_empty()) + } else { + None + }; + let result_truths = if complete.contains(&EvidenceKind::BOOLEAN_RESULT.value()) { + bucket.boolean_result.into_iter().collect() + } else { + BTreeSet::new() + }; + // A canonical bucket can be complete for a requested field yet contain + // no usable production fact after provenance filtering (for example, a + // test-double target with no captured receiver/result). Preserve no + // synthetic call row: the exact anchor remains represented by its + // capture status, and an empty normalized row cannot inform CFG/DFG. + if target.is_none() + && receiver_domain.is_none() + && result_domain.is_none() + && result_truths.is_empty() + { + return Ok(None); + } + Ok(Some(ObservedCall { + language: method.language.clone(), + caller: method_locator(method), + callsite: SourceAnchor { + path: call.path.clone(), + line: call.line, + range: Some(call.span), + selector: call.message.clone(), + }, + targets: target.into_iter().collect(), + target_observation_complete, + receiver_domain, + result_domain, + result_truths, + count: bucket.count, + call_id: Some(call_id.to_string()), + })) +} + +fn protocol_correlation_candidates<'a>( + candidates: &'a [(&str, &String, &'a CallRecord, &'a MethodRecord)], + bucket: &runtime_protocol::ExecutionBucket, + points: &[FlowPoint], +) -> Result> { + if correlation_candidates_share_reaching_value(candidates, points) { + return Ok(candidates.to_vec()); + } + let Some(receiver) = bucket.receiver.as_ref() else { + return Ok(Vec::new()); + }; + let observed = protocol_value_set_domain(receiver, &candidates[0].3.language)?; + let mut compatible = Vec::new(); + for candidate in candidates { + let source_type = candidate + .2 + .receiver_type + .as_deref() + .or(candidate.2.receiver_symbol.as_deref()); + let Some(source_type) = source_type else { + return Ok(Vec::new()); + }; + let domain = domain_from_type_source(source_type, &candidate.3.language); + if domain.types.is_empty() { + return Ok(Vec::new()); + } + if !domain.types.is_disjoint(&observed.types) { + compatible.push(*candidate); + } + } + Ok((compatible.len() == 1) + .then_some(compatible) + .unwrap_or_default()) +} + +fn correlation_candidates_share_reaching_value( + candidates: &[(&str, &String, &CallRecord, &MethodRecord)], + points: &[FlowPoint], +) -> bool { + correlation_calls_share_reaching_value( + &candidates + .iter() + .map(|(_, _, call, _)| *call) + .collect::>(), + points, + ) +} + +fn correlation_calls_share_reaching_value(calls: &[&CallRecord], points: &[FlowPoint]) -> bool { + let identities = calls + .iter() + .map(|call| { + points + .iter() + .filter(|point| point.source == call.source && point.name == call.receiver) + .filter(|point| { + span_contains(call.span, point.span) || span_contains(point.span, call.span) + }) + .map(|point| { + ( + point.place_id.clone(), + point + .reaching_definitions + .iter() + .cloned() + .collect::>(), + ) + }) + .collect::>() + }) + .collect::>(); + identities.first().is_some_and(|first| { + !first.is_empty() && identities.iter().all(|identity| identity == first) + }) +} + +fn protocol_observation( + kind: &str, + method: &MethodRecord, + slot: &str, + domain: ValueDomain, + count: u64, + method_id: Option, + state_access_id: Option, +) -> ValueObservation { + ValueObservation { + kind: kind.to_string(), + scope: ValueScope { + language: method.language.clone(), + path: method.path.clone(), + owner: method.owner.clone(), + function: method.name.clone(), + line: method.line, + method_id, + }, + slot: slot.to_string(), + slot_kind: String::new(), + domain, + count, + state_access_id, + } +} + +fn method_locator(method: &MethodRecord) -> MethodLocator { + MethodLocator { + language: method.language.clone(), + path: method.path.clone(), + owner: method.owner.clone(), + name: method.name.clone(), + kind: method.kind.clone(), + line: method.line, + } +} + +fn protocol_target( + target: &runtime_protocol::RuntimeTarget, + call: &CallRecord, + caller: &MethodRecord, + methods: &BTreeMap<&str, &MethodRecord>, + bindings: &BTreeMap, + anchor_methods: &BTreeMap<&str, &str>, + receiver: Option<&runtime_protocol::ValueSet>, +) -> Result> { + if target.source_role.enum_value_or_default() == runtime_protocol::SourceRole::NON_PRODUCTION { + return Ok(None); + } + let definition_method = target.definition.as_ref().and_then(|definition| { + (!definition.anchor_symbol.is_empty()) + .then(|| { + anchor_methods + .get(definition.anchor_symbol.as_str()) + .copied() + }) + .flatten() + .or_else(|| { + bindings + .get(&definition.anchor_symbol) + .and_then(|binding| match binding { + AnchorBinding::Parameter { method_id, .. } + | AnchorBinding::Return { method_id } => Some(method_id.as_str()), + _ => None, + }) + }) + .and_then(|method_id| methods.get(method_id).copied()) + .or_else(|| protocol_definition_method(definition, methods)) + }); + let receiver_type = receiver + .map(|value| protocol_value_set_domain(value, &caller.language)) + .transpose()? + .and_then(|domain| { + let language = crate::syntax::Language::parse(&caller.language).ok()?; + let behavior = crate::syntax::normalized_behavior::behavior(language); + behavior.runtime_value_domain_type( + &domain.types.into_iter().collect::>(), + &domain.elements.into_iter().collect::>(), + &domain.keys.into_iter().collect::>(), + &domain.values.into_iter().collect::>(), + ) + }) + .unwrap_or_default(); + Ok(Some(SemanticTarget { + symbol: target.symbol.clone(), + owner: definition_method + .map(|method| method.owner.clone()) + .unwrap_or_default(), + name: call.message.clone(), + kind: definition_method + .map(|method| method.kind.clone()) + .unwrap_or_else(|| { + if call.receiver_kind == "type" { + "class".to_string() + } else { + "instance".to_string() + } + }), + receiver_type, + definition: definition_method.map(method_locator), + })) +} + +fn protocol_definition_method<'a>( + definition: &runtime_protocol::RuntimeDefinition, + methods: &'a BTreeMap<&str, &MethodRecord>, +) -> Option<&'a MethodRecord> { + if definition.relative_path.is_empty() { + return None; + } + let relative = definition.relative_path.replace('\\', "/"); + let line = definition.range.as_ref()?.start_line as usize + 1; + let candidates = methods + .values() + .copied() + .filter(|method| { + let path = method.path.replace('\\', "/"); + (path == relative || path.ends_with(&format!("/{relative}"))) + && (method.line == line + || method + .span + .is_some_and(|span| span[0] <= line && line <= span[2])) + }) + .collect::>(); + if candidates.len() == 1 { + candidates.into_iter().next() + } else { + None + } +} + +fn protocol_runtime_type(value: &runtime_protocol::RuntimeValue, language: &str) -> Result { + let language = crate::syntax::Language::parse(language)?; + let behavior = crate::syntax::normalized_behavior::behavior(language); + behavior + .runtime_value_type_from_symbol(&value.type_symbol) + .with_context(|| { + format!( + "{language:?} runtime adapter cannot decode type SCIP symbol {:?}", + value.type_symbol + ) + }) +} + +fn protocol_value_domain( + value: &runtime_protocol::RuntimeValue, + language: &str, +) -> Result { + let owner = protocol_runtime_type(value, language)?; + let mut domain = ValueDomain { + types: BTreeSet::from([owner.clone()]), + singletons: if value.singleton_symbol.is_empty() { + BTreeSet::new() + } else { + let language = crate::syntax::Language::parse(language)?; + let behavior = crate::syntax::normalized_behavior::behavior(language); + BTreeSet::from([behavior + .runtime_value_singleton_from_symbol(&value.singleton_symbol) + .with_context(|| { + format!( + "{language:?} runtime adapter cannot decode singleton SCIP symbol {:?}", + value.singleton_symbol + ) + })?]) + }, + ..ValueDomain::default() + }; + match value.shape.as_ref() { + Some(runtime_value::Shape::Sequence(shape)) => { + merge_protocol_value_set(&mut domain.elements, shape.elements.as_ref(), language)?; + domain.shapes.push(ValueShape { + kind: "array".to_string(), + elements: protocol_value_set_shapes(shape.elements.as_ref(), language)?, + ..ValueShape::default() + }); + } + Some(runtime_value::Shape::Mapping(shape)) => { + let mut keys = Vec::new(); + let mut values = Vec::new(); + for entry in &shape.entries { + if let Some(key) = entry.key.as_ref() { + domain.keys.insert(protocol_runtime_type(key, language)?); + keys.push(protocol_value_shape(key, language)?); + } + if let Some(value) = entry.value.as_ref() { + domain + .values + .insert(protocol_runtime_type(value, language)?); + values.push(protocol_value_shape(value, language)?); + } + } + keys.sort(); + keys.dedup(); + values.sort(); + values.dedup(); + domain.shapes.push(ValueShape { + kind: "hash".to_string(), + keys, + values, + ..ValueShape::default() + }); + } + Some(runtime_value::Shape::Record(shape)) => { + let mut members = BTreeMap::new(); + for member in &shape.members { + let alternatives = protocol_value_set_shapes(member.values.as_ref(), language)?; + members.insert( + member.name.clone(), + alternatives.into_iter().next().unwrap_or(ValueShape { + kind: "unknown".to_string(), + ..ValueShape::default() + }), + ); + } + domain.shapes.push(ValueShape { + kind: "record".to_string(), + name: owner, + members, + ..ValueShape::default() + }); + } + Some(runtime_value::Shape::Tuple(shape)) => { + let elements = shape + .elements + .iter() + .flat_map(|set| protocol_value_set_shapes(Some(set), language)) + .flatten() + .collect(); + domain.shapes.push(ValueShape { + kind: "tuple".to_string(), + elements, + ..ValueShape::default() + }); + } + None => {} + } + Ok(domain) +} + +fn protocol_value_set_domain( + values: &runtime_protocol::ValueSet, + language: &str, +) -> Result { + let mut domain = ValueDomain::default(); + for alternative in &values.alternatives { + let value = alternative + .value + .as_ref() + .context("validated runtime value alternative is missing")?; + if value.source_role.enum_value_or_default() == runtime_protocol::SourceRole::NON_PRODUCTION + { + continue; + } + merge_domain(&mut domain, &protocol_value_domain(value, language)?); + } + Ok(domain) +} + +fn merge_protocol_value_set( + destination: &mut BTreeSet, + values: Option<&runtime_protocol::ValueSet>, + language: &str, +) -> Result<()> { + for weighted in values.into_iter().flat_map(|set| &set.alternatives) { + if let Some(value) = weighted.value.as_ref() { + if value.source_role.enum_value_or_default() + == runtime_protocol::SourceRole::NON_PRODUCTION + { + continue; + } + destination.insert(protocol_runtime_type(value, language)?); + } + } + Ok(()) +} + +fn protocol_value_set_shapes( + values: Option<&runtime_protocol::ValueSet>, + language: &str, +) -> Result> { + values + .into_iter() + .flat_map(|set| &set.alternatives) + .filter_map(|weighted| weighted.value.as_ref()) + .filter(|value| { + value.source_role.enum_value_or_default() + != runtime_protocol::SourceRole::NON_PRODUCTION + }) + .map(|value| protocol_value_shape(value, language)) + .collect() +} + +fn protocol_value_shape( + value: &runtime_protocol::RuntimeValue, + language: &str, +) -> Result { + let name = protocol_runtime_type(value, language)?; + Ok(match value.shape.as_ref() { + Some(runtime_value::Shape::Sequence(shape)) => ValueShape { + kind: "array".to_string(), + name, + elements: protocol_value_set_shapes(shape.elements.as_ref(), language)?, + ..ValueShape::default() + }, + Some(runtime_value::Shape::Mapping(shape)) => { + let mut keys = shape + .entries + .iter() + .filter_map(|entry| entry.key.as_ref()) + .map(|key| protocol_value_shape(key, language)) + .collect::>>()?; + let mut values = shape + .entries + .iter() + .filter_map(|entry| entry.value.as_ref()) + .map(|value| protocol_value_shape(value, language)) + .collect::>>()?; + keys.sort(); + keys.dedup(); + values.sort(); + values.dedup(); + ValueShape { + kind: "hash".to_string(), + name, + keys, + values, + ..ValueShape::default() + } + } + Some(runtime_value::Shape::Record(shape)) => { + let mut members = BTreeMap::new(); + for member in &shape.members { + let alternatives = protocol_value_set_shapes(member.values.as_ref(), language)?; + members.insert( + member.name.clone(), + alternatives.into_iter().next().unwrap_or(ValueShape { + kind: "unknown".to_string(), + ..ValueShape::default() + }), + ); + } + ValueShape { + kind: "record".to_string(), + name, + members, + ..ValueShape::default() + } + } + Some(runtime_value::Shape::Tuple(shape)) => ValueShape { + kind: "tuple".to_string(), + name, + elements: shape + .elements + .iter() + .flat_map(|set| protocol_value_set_shapes(Some(set), language)) + .flatten() + .collect(), + ..ValueShape::default() + }, + None => ValueShape { + kind: "class".to_string(), + name, + ..ValueShape::default() + }, + }) +} + +/// Overlay runtime observations on FactMine's normalized source/CFG/DFG facts +/// and emit the resulting identities as an ordinary runtime-authority SCIP +/// index. All propagation in this function operates on normalized facts; it +/// never parses a source-language expression. +fn apply_to_profile( + output: &mut ProfileOutput, + evidence: &RuntimeValueEvidence, +) -> Result { + evidence.validate()?; + let method_index = MethodIndex::new(&output.methods); + let call_index = CallIndex::new(&output.calls); + let return_domains = observed_return_domains(evidence, &method_index); + let mut receiver_domains = + observed_receiver_domains(output, &call_index, evidence, &method_index); + seed_fact_mine_receiver_domains(output, &method_index, &mut receiver_domains); + let (exact_receiver_domains, exact_result_domains) = + matched_observed_value_domains(&call_index, evidence, &method_index); + for (call_id, domain) in exact_receiver_domains { + merge_domain(receiver_domains.entry(call_id).or_default(), &domain); + } + let observed = match_observed_calls(&call_index, evidence, &method_index); + let flow_points = flow_points(output, &method_index); + let flow = FlowIndex::new(&flow_points); + seed_observed_call_receivers(&observed, &mut receiver_domains); + let mut selected = observed.clone(); + let catalog = target_catalog(evidence); + + loop { + let before_domains = receiver_domains.clone(); + let before_selected = selected.clone(); + propagate_call_results( + output, + &method_index, + &return_domains, + &exact_result_domains, + &selected, + &mut receiver_domains, + ); + propagate_cfg_dfg_domains( + output, + evidence, + &method_index, + &flow, + &return_domains, + &exact_result_domains, + &selected, + &mut receiver_domains, + ); + let capability_narrowed = + runtime_capability_narrowed_domains( + output, + &call_index, + evidence, + &method_index, + &receiver_domains, + ); + let narrowed_receiver_domains = + runtime_truthiness_narrowed_domains(output, &method_index, &capability_narrowed); + infer_runtime_receiver_targets( + output, + &method_index, + &narrowed_receiver_domains, + &mut selected, + ); + infer_targets( + output, + &method_index, + &catalog, + &narrowed_receiver_domains, + &mut selected, + ); + infer_runtime_stdlib_targets( + output, + evidence, + &method_index, + &narrowed_receiver_domains, + &mut selected, + ); + if receiver_domains == before_domains && selected == before_selected { + break; + } + } + let capability_narrowed = runtime_capability_narrowed_domains( + output, + &call_index, + evidence, + &method_index, + &receiver_domains, + ); + let narrowed_receiver_domains = + runtime_truthiness_narrowed_domains(output, &method_index, &capability_narrowed); + infer_runtime_record_accessors(output, &narrowed_receiver_domains, &mut selected); + let mut stats = OverlayStats { + observed_call_sites: observed.len(), + inferred_call_sites: selected + .keys() + .filter(|id| !observed.contains_key(*id)) + .count(), + ..OverlayStats::default() + }; + for call in &mut output.calls { + call.runtime_evidence_observed |= observed.contains_key(&call.id); + let Some(domain) = narrowed_receiver_domains.get(&call.id) else { + continue; + }; + if call.receiver_type.is_none() && domain.types.len() == 1 { + call.receiver_type = domain.types.iter().next().cloned(); + call.receiver_type_origin = Some("runtime_value_evidence_cfg_dfg".to_string()); + stats.typed_receivers += 1; + } + } + + let index = build_scip_index( + output, + evidence, + &observed, + &selected, + &method_index, + &mut stats, + )?; + if !selected.is_empty() { + crate::scip::apply_value(output, &index)?; + } + Ok(RuntimeScipOverlay { index, stats }) +} + +/// A `record` value-shape is a tracer-owned structural observation. When every +/// observed receiver alternative is a record exposing the same member, emit an +/// ordinary synthetic SCIP target for that constant-time accessor. The target +/// is intentionally language-neutral: language providers only serialize the +/// observed shape, while FactMine owns the join to a normalized call and the +/// portable SCIP export. +fn infer_runtime_record_accessors( + output: &ProfileOutput, + receiver_domains: &BTreeMap, + selected: &mut BTreeMap>, +) { + for call in &output.calls { + if call.target.is_some() || selected.contains_key(&call.id) { + continue; + } + let Some(domain) = receiver_domains.get(&call.id) else { + continue; + }; + if !runtime_record_domain_exposes(domain, &call.message) { + continue; + } + selected.insert( + call.id.clone(), + vec![runtime_record_accessor_target(&call.message)], + ); + } +} + +/// Return true only when every observed runtime type is represented by one or +/// more record shapes, and that member occurs on every such shape. This keeps +/// a heterogeneous observed domain closed and avoids treating an unshaped +/// dynamic receiver as a record merely because another receiver was one. +fn runtime_record_domain_exposes(domain: &ValueDomain, member: &str) -> bool { + if domain.types.is_empty() { + return false; + } + domain + .types + .iter() + .all(|runtime_type| runtime_record_type_exposes(domain, runtime_type, member)) +} + +fn runtime_record_type_exposes(domain: &ValueDomain, runtime_type: &str, member: &str) -> bool { + let shapes = domain + .shapes + .iter() + .filter(|shape| shape.kind == "record" && shape.name == runtime_type) + .collect::>(); + !shapes.is_empty() + && shapes + .iter() + .all(|shape| shape.members.contains_key(member)) +} + +/// The flow points, plus the grouping by (method, local) that the seeding and +/// the final receiver join both key on. Each of those used to rescan every +/// point for every call. +struct FlowIndex<'p> { + points: &'p [FlowPoint], + by_slot: HashMap<&'p str, HashMap<&'p str, Vec<&'p FlowPoint>>>, +} + +impl<'p> FlowIndex<'p> { + fn new(points: &'p [FlowPoint]) -> Self { + let mut by_slot: HashMap<&'p str, HashMap<&'p str, Vec<&'p FlowPoint>>> = HashMap::new(); + for point in points { + by_slot + .entry(point.source.as_str()) + .or_default() + .entry(point.name.as_str()) + .or_default() + .push(point); + } + Self { points, by_slot } + } + + fn in_slot(&self, source: &str, name: &str) -> &[&'p FlowPoint] { + self.by_slot + .get(source) + .and_then(|slots| slots.get(name)) + .map_or(&[][..], Vec::as_slice) + } +} + +/// What a flow node holds, keyed by (method, CFG node, place). The key borrows +/// from the flow points rather than owning three copies of each string. +type FlowNodeDomains<'p> = HashMap<(&'p str, &'p str, &'p str), ValueDomain>; + +/// Whether `qualified` names `suffix` nested inside some enclosing scope -- +/// what `qualified.ends_with(&format!("::{suffix}"))` said, without the +/// allocation it charged for every method compared. +fn nested_under(qualified: &str, suffix: &str) -> bool { + qualified.len() >= suffix.len() + 2 + && qualified.ends_with(suffix) + && qualified.as_bytes()[qualified.len() - suffix.len() - 2..][..2] == *b"::" +} + +/// The calls, grouped by the keys every join actually uses. Each join used to +/// rescan the whole call list, so a corpus cost `calls * observations` where +/// `calls + observations` is enough. +struct CallIndex<'a> { + by_id: HashMap<&'a str, Vec<&'a CallRecord>>, + by_source: HashMap<&'a str, Vec<&'a CallRecord>>, + by_message: HashMap<&'a str, Vec<&'a CallRecord>>, +} + +impl<'a> CallIndex<'a> { + fn new(calls: &'a [CallRecord]) -> Self { + let mut by_id: HashMap<&'a str, Vec<&'a CallRecord>> = HashMap::new(); + let mut by_source: HashMap<&'a str, Vec<&'a CallRecord>> = HashMap::new(); + let mut by_message: HashMap<&'a str, Vec<&'a CallRecord>> = HashMap::new(); + for call in calls { + by_id.entry(call.id.as_str()).or_default().push(call); + by_source.entry(call.source.as_str()).or_default().push(call); + by_message.entry(call.message.as_str()).or_default().push(call); + } + Self { by_id, by_source, by_message } + } + + fn with_id(&self, id: &str) -> &[&'a CallRecord] { + self.by_id.get(id).map_or(&[][..], Vec::as_slice) + } + + fn from_source(&self, source: &str) -> &[&'a CallRecord] { + self.by_source.get(source).map_or(&[][..], Vec::as_slice) + } + + fn with_message(&self, message: &str) -> &[&'a CallRecord] { + self.by_message.get(message).map_or(&[][..], Vec::as_slice) + } +} + +struct MethodIndex<'a> { + methods: &'a [MethodRecord], + by_id: BTreeMap<&'a str, &'a MethodRecord>, + // Both spellings a locator may arrive under, so a lookup by name does not + // have to walk every method in the corpus. + by_name: HashMap<&'a str, Vec<&'a MethodRecord>>, +} + +impl<'a> MethodIndex<'a> { + fn new(methods: &'a [MethodRecord]) -> Self { + Self { + methods, + by_id: methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect(), + by_name: { + let mut by_name: HashMap<&'a str, Vec<&'a MethodRecord>> = HashMap::new(); + for method in methods { + by_name.entry(method.name.as_str()).or_default().push(method); + if method.dispatch_name != method.name { + by_name + .entry(method.dispatch_name.as_str()) + .or_default() + .push(method); + } + } + by_name + }, + } + } + + /// Every method spelled `name`, in corpus order. Each method is filed + /// under its source spelling and, when they differ, its dispatch selector, + /// so no bucket repeats a method. + fn named(&self, name: &str) -> &[&'a MethodRecord] { + self.by_name.get(name).map_or(&[][..], Vec::as_slice) + } + + fn locate(&self, locator: &MethodLocator) -> Vec<&'a MethodRecord> { + let named = self.named(&locator.name); + let scoped = named + .iter() + .copied() + .filter(|method| method.language == locator.language) + // A method record preserves its source spelling for reporting + // (`self.render` in Ruby, qualified declarations in other + // languages), while runtime tracers report the dispatch selector + // (`render`). `dispatch_name` is the language-normalized bridge + // between those two representations; retain `name` for sources + // that already use the selector spelling. + .filter(|method| method.name == locator.name || method.dispatch_name == locator.name) + .filter(|method| locator.line == 0 || method.line == locator.line) + .filter(|method| { + locator.owner.is_empty() + || method.owner == locator.owner + || nested_under(&method.owner, &locator.owner) + || nested_under(&locator.owner, &method.owner) + }) + .filter(|method| path_matches(&method.path, &locator.path)) + .collect::>(); + if !scoped.is_empty() { + return scoped; + } + let unscoped = named + .iter() + .copied() + .filter(|method| method.language == locator.language) + .filter(|method| method.name == locator.name || method.dispatch_name == locator.name) + .filter(|method| locator.line == 0 || method.line == locator.line) + .filter(|method| path_matches(&method.path, &locator.path)) + .collect::>(); + (unscoped.len() == 1) + .then_some(unscoped) + .unwrap_or_default() + } + + fn locate_scope(&self, scope: &ValueScope) -> Vec<&'a MethodRecord> { + if let Some(method_id) = scope.method_id.as_deref() { + return self.by_id.get(method_id).copied().into_iter().collect(); + } + let candidates = if scope.function.is_empty() { + self.methods.iter().collect::>() + } else { + self.named(&scope.function).to_vec() + }; + candidates + .into_iter() + .filter(|method| method.language == scope.language) + .filter(|method| scope.line == 0 || method.line == scope.line) + .filter(|method| { + scope.owner.is_empty() + || method.owner == scope.owner + || nested_under(&method.owner, &scope.owner) + || nested_under(&scope.owner, &method.owner) + }) + .filter(|method| scope.path.is_empty() || path_matches(&method.path, &scope.path)) + .collect() + } +} + +fn seed_observed_call_receivers( + selected: &BTreeMap>, + receiver_domains: &mut BTreeMap, +) { + for (call_id, targets) in selected { + let types = targets + .iter() + .filter_map(|target| { + (!target.receiver_type.is_empty()) + .then_some(target.receiver_type.clone()) + .or_else(|| (!target.owner.is_empty()).then_some(target.owner.clone())) + }) + .collect::>(); + if !types.is_empty() { + receiver_domains + .entry(call_id.clone()) + .or_default() + .types + .extend(types); + } + } +} + +fn seed_fact_mine_receiver_domains( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + receiver_domains: &mut BTreeMap, +) { + for call in &output.calls { + let Some(method) = methods.by_id.get(call.source.as_str()) else { + continue; + }; + let Some(source_type) = call + .receiver_type + .as_deref() + .or(call.receiver_symbol.as_deref()) + .or_else(|| { + (call.receiver_kind == "type" && !call.receiver.is_empty()) + .then_some(call.receiver.as_str()) + }) + .or_else(|| { + matches!(call.receiver.as_str(), "" | "self" | "this") + .then_some(method.owner.as_str()) + }) + else { + continue; + }; + let domain = domain_from_type_source(source_type, &method.language); + if !domain.is_empty() { + merge_domain( + receiver_domains.entry(call.id.clone()).or_default(), + &domain, + ); + } + } +} + +#[derive(Clone, Debug)] +struct FlowPoint { + source: String, + name: String, + node_id: String, + place_id: String, + reaching_definitions: Vec, + definition_call_sources: BTreeMap>, + definition_sequence_projections: BTreeMap, + callback_binding_position: Option, + static_domain: ValueDomain, + span: [usize; 4], +} + +fn propagate_cfg_dfg_domains( + output: &ProfileOutput, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, + flow: &FlowIndex<'_>, + return_domains: &BTreeMap, + exact_result_domains: &BTreeMap, + selected: &BTreeMap>, + receiver_domains: &mut BTreeMap, +) { + let points = flow.points; + if points.is_empty() { + return; + } + let mut node_domains = FlowNodeDomains::new(); + for point in points { + if !point.static_domain.is_empty() { + merge_domain( + node_domains + .entry(( + point.source.as_str(), + point.node_id.as_str(), + point.place_id.as_str(), + )) + .or_default(), + &point.static_domain, + ); + } + } + + // Runtime parameter observations seed the CFG entry definition, rather + // than every same-spelled local. Reaching definitions then decide which + // uses may inherit the observed domain. + for observation in evidence.observations.iter().filter(|row| { + row.kind == "parameter" || (row.kind == "collection" && row.slot_kind == "method_param") + }) { + for method in methods.locate_scope(&observation.scope) { + for point in flow.in_slot(&method.id, &observation.slot) { + for definition in point + .reaching_definitions + .iter() + .filter(|definition| definition.contains(":entry:")) + { + merge_domain( + node_domains + .entry(( + point.source.as_str(), + definition.as_str(), + point.place_id.as_str(), + )) + .or_default(), + &observation.domain, + ); + } + } + } + } + + // Existing call domains are facts at their CFG use sites. This includes + // runtime state observations and compiler/type-analyzer receiver facts. + seed_flow_nodes_from_calls(output, flow, receiver_domains, &mut node_domains); + + // A normalized iteration relation binds collection value domains to block + // locals. The source-language adapter decides whether a call is an + // iteration; this layer only projects the already-normalized relation. + seed_collection_callback_nodes( + output, + methods, + flow, + receiver_domains, + &mut node_domains, + ); + seed_call_result_definitions( + output, + methods, + points, + return_domains, + exact_result_domains, + selected, + receiver_domains, + &mut node_domains, + ); + + loop { + let mut changed = false; + for point in points { + let reaching = point + .reaching_definitions + .iter() + .filter_map(|definition| { + node_domains.get(&( + point.source.as_str(), + definition.as_str(), + point.place_id.as_str(), + )) + }) + .collect::>(); + if let Some(domain) = joined_domain(&reaching) { + changed |= merge_domain( + node_domains + .entry(( + point.source.as_str(), + point.node_id.as_str(), + point.place_id.as_str(), + )) + .or_default(), + &domain, + ); + } + } + if !changed { + break; + } + } + + for call in &output.calls { + let domains = flow + .in_slot(&call.source, &call.receiver) + .iter() + .filter(|point| span_contains_line(point.span, call.line)) + .filter_map(|point| { + node_domains.get(&( + point.source.as_str(), + point.node_id.as_str(), + point.place_id.as_str(), + )) + }) + .collect::>(); + if let Some(domain) = joined_domain(&domains) { + merge_domain( + receiver_domains.entry(call.id.clone()).or_default(), + &domain, + ); + } + } +} + +fn flow_points(output: &ProfileOutput, methods: &MethodIndex<'_>) -> Vec { + output + .flow_local_types + .iter() + .filter_map(|flow| { + let file = flow["file"].as_str()?; + let owner = flow["owner"].as_str()?; + let function = flow["function"].as_str()?; + let name = flow["name"].as_str()?; + let node_id = flow["node_id"].as_str()?; + let place_id = flow["place_id"].as_str()?; + let span = serde_json::from_value::<[usize; 4]>(flow["span"].clone()).ok()?; + let candidates = methods + .methods + .iter() + .filter(|method| method.name == function && path_matches(&method.path, file)) + .collect::>(); + let owner_candidates = candidates + .iter() + .copied() + .filter(|method| method.owner == owner) + .collect::>(); + let method = if owner_candidates.len() == 1 { + owner_candidates[0] + } else if owner_candidates.is_empty() && candidates.len() == 1 { + candidates[0] + } else { + return None; + }; + let reaching_definitions = flow["reaching_definitions"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + let definition_sequence_projections = + serde_json::from_value::>( + flow["definition_sequence_projections"].clone(), + ) + .unwrap_or_default(); + let mut static_domain = ValueDomain::default(); + for value in flow["resolved_types"].as_array().into_iter().flatten() { + if let Ok(value) = serde_json::from_value::(value.clone()) { + merge_domain( + &mut static_domain, + &domain_from_type_expr_source_language(&value, &method.language), + ); + } + } + if static_domain.is_empty() { + for hint in flow["types"].as_array().into_iter().flatten() { + if let Some(hint) = hint.as_str() { + merge_domain( + &mut static_domain, + &domain_from_type_source(hint, &method.language), + ); + } + } + } + let projections = reaching_definitions + .iter() + .filter_map(|definition| definition_sequence_projections.get(definition)) + .copied() + .collect::>(); + if !reaching_definitions.is_empty() + && definition_sequence_projections.len() == reaching_definitions.len() + && projections.len() == 1 + { + static_domain = sequence_position_domain( + &static_domain, + *projections.iter().next().expect("one sequence projection"), + ); + } + Some(FlowPoint { + source: method.id.clone(), + name: name.to_string(), + node_id: node_id.to_string(), + place_id: place_id.to_string(), + reaching_definitions, + definition_call_sources: serde_json::from_value( + flow["definition_call_sources"].clone(), + ) + .unwrap_or_default(), + definition_sequence_projections, + callback_binding_position: flow["callback_binding_position"] + .as_u64() + .map(|position| position as usize), + static_domain, + span, + }) + }) + .collect() +} + +fn seed_flow_nodes_from_calls<'p>( + output: &ProfileOutput, + points: &FlowIndex<'p>, + receiver_domains: &BTreeMap, + node_domains: &mut FlowNodeDomains<'p>, +) { + for call in &output.calls { + let Some(domain) = receiver_domains.get(&call.id) else { + continue; + }; + for point in points + .in_slot(&call.source, &call.receiver) + .iter() + .filter(|point| span_contains_line(point.span, call.line)) + { + merge_domain( + node_domains + .entry(( + point.source.as_str(), + point.node_id.as_str(), + point.place_id.as_str(), + )) + .or_default(), + domain, + ); + } + } +} + +fn seed_collection_callback_nodes<'p>( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + flow: &FlowIndex<'p>, + receiver_domains: &BTreeMap, + node_domains: &mut FlowNodeDomains<'p>, +) { + let points = flow.points; + for call in &output.calls { + let Some(receiver_domain) = receiver_domains.get(&call.id) else { + continue; + }; + let Some(method) = methods.by_id.get(call.source.as_str()) else { + continue; + }; + let Ok(language) = crate::syntax::Language::parse(&method.language) else { + continue; + }; + let behavior = crate::syntax::normalized_behavior::behavior(language); + let static_receiver_type = call + .receiver_type + .as_deref() + .map(|value| TypeExpr::parse(value, &method.language)); + // An untyped source parameter frequently becomes a concrete runtime + // collection only after observation. Ask the language adapter about + // that normalized runtime identity before deciding whether its block + // binds collection values; otherwise an `each` call can never seed + // its callback local merely because the source declaration was open. + let runtime_receiver_type = behavior.runtime_value_domain_type( + &receiver_domain.types.iter().cloned().collect::>(), + &receiver_domain.elements.iter().cloned().collect::>(), + &receiver_domain.keys.iter().cloned().collect::>(), + &receiver_domain.values.iter().cloned().collect::>(), + ); + let receiver_type = runtime_receiver_type + .as_deref() + .map(|value| TypeExpr::parse(value, &method.language)) + .or(static_receiver_type); + let iteration = behavior.collection_callback_parameter(&call.message) + || behavior.block_call_semantics_with_receiver( + Some(&call.receiver), + receiver_type.as_ref(), + &call.message, + ) == crate::syntax::normalized_behavior::BlockCallSemantics::Iteration; + if !iteration { + continue; + } + let iteration_span = normalized_iteration_span(output, method, call); + let nested_iteration_spans = iteration_span + .map(|outer| { + normalized_iteration_spans(output, method) + .into_iter() + .filter(|inner| *inner != outer && span_contains(outer, *inner)) + .collect::>() + }) + .unwrap_or_default(); + let mut callback_points = points + .iter() + .filter(|point| { + point.source == call.source + && point.name != call.receiver + && point.callback_binding_position.is_some() + && iteration_span + .map(|span| { + span_contains(span, point.span) + && !nested_iteration_spans + .iter() + .any(|nested| span_contains(*nested, point.span)) + }) + .unwrap_or(point.span[0] == call.line) + }) + .collect::>(); + callback_points.sort_by_key(|point| point.callback_binding_position); + if callback_points.is_empty() { + continue; + } + let projections = behavior.runtime_collection_callback_projections( + runtime_receiver_type.as_deref(), + &call.message, + callback_points.len(), + ); + for (point, projection) in callback_points.into_iter().zip(projections) { + let callback_domain = projected_collection_domain(receiver_domain, projection); + if callback_domain.is_empty() { + continue; + } + merge_domain( + node_domains + .entry(( + point.source.as_str(), + point.node_id.as_str(), + point.place_id.as_str(), + )) + .or_default(), + &callback_domain, + ); + // A callback parameter is a fresh definition for its normalized + // callback region. Compound CFG nodes can contain multiple + // chained callbacks, so seed same-named uses in this exact region + // instead of conflating sibling bindings through one place. + let slot = flow.in_slot(&point.source, &point.name); + for use_point in slot.iter().filter(|use_point| { + span_contains(point.span, use_point.span) + && !slot.iter().any(|shadow| { + shadow.callback_binding_position.is_some() + && shadow.span != point.span + && span_contains(point.span, shadow.span) + && span_contains(shadow.span, use_point.span) + }) + }) { + merge_domain( + node_domains + .entry(( + use_point.source.as_str(), + use_point.node_id.as_str(), + use_point.place_id.as_str(), + )) + .or_default(), + &callback_domain, + ); + } + } + } +} + +fn normalized_iteration_span( + output: &ProfileOutput, + method: &MethodRecord, + call: &CallRecord, +) -> Option<[usize; 4]> { + output + .complexity_facts + .iter() + .filter(|fact| { + path_matches(&fact.path, &method.path) + && fact.owner == method.owner + && fact.function == method.name + && fact.line == method.line + }) + .flat_map(|fact| &fact.iterations) + .filter(|iteration| { + iteration.message.as_deref() == Some(call.message.as_str()) + && iteration.line == call.line + }) + .map(|iteration| iteration.span) + .filter(|span| span_contains(*span, call.span)) + .min_by_key(|span| { + ( + span[2].saturating_sub(span[0]), + span[3].saturating_sub(span[1]), + ) + }) +} + +fn normalized_iteration_spans(output: &ProfileOutput, method: &MethodRecord) -> Vec<[usize; 4]> { + output + .complexity_facts + .iter() + .filter(|fact| { + path_matches(&fact.path, &method.path) + && fact.owner == method.owner + && fact.function == method.name + && fact.line == method.line + }) + .flat_map(|fact| &fact.iterations) + .map(|iteration| iteration.span) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn seed_call_result_definitions<'p>( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + points: &'p [FlowPoint], + return_domains: &BTreeMap, + exact_result_domains: &BTreeMap, + selected: &BTreeMap>, + receiver_domains: &BTreeMap, + node_domains: &mut FlowNodeDomains<'p>, +) { + let calls = output + .calls + .iter() + .map(|call| ((call.source.as_str(), call.span), call)) + .collect::>(); + for point in points { + for (definition, spans) in &point.definition_call_sources { + let domains = spans + .iter() + .filter_map(|span| { + let call = calls.get(&(point.source.as_str(), *span)).copied()?; + call_result_domain( + call, + methods, + return_domains, + exact_result_domains, + selected, + receiver_domains, + ) + }) + .collect::>(); + // A producer set is sound only if every value-producing branch + // is resolved. Joining a known branch with an unknown one would + // silently erase a dynamic alternative. + if spans.is_empty() || domains.len() != spans.len() { + continue; + } + let domain_refs = domains.iter().collect::>(); + let Some(domain) = joined_domain(&domain_refs) else { + continue; + }; + let domain = point + .definition_sequence_projections + .get(definition) + .map(|position| sequence_position_domain(&domain, *position)) + .unwrap_or(domain); + if domain.is_empty() { + continue; + } + merge_domain( + node_domains + .entry(( + point.source.as_str(), + definition.as_str(), + point.place_id.as_str(), + )) + .or_default(), + &domain, + ); + } + } +} + +fn sequence_position_domain(sequence: &ValueDomain, position: usize) -> ValueDomain { + let mut projected = collection_element_domain(sequence); + for shape in &sequence.shapes { + if shape.kind != "tuple" { + continue; + } + let Some(element) = shape.elements.get(position) else { + continue; + }; + merge_domain(&mut projected, &value_shape_domain(element)); + } + projected +} + +fn value_shape_domain(shape: &ValueShape) -> ValueDomain { + let mut domain = ValueDomain::default(); + if !shape.name.is_empty() { + domain.types.insert(shape.name.clone()); + } + domain.shapes.push(shape.clone()); + domain +} + +fn call_result_domain( + call: &CallRecord, + methods: &MethodIndex<'_>, + return_domains: &BTreeMap, + exact_result_domains: &BTreeMap, + selected: &BTreeMap>, + receiver_domains: &BTreeMap, +) -> Option { + if let Some(domain) = exact_result_domains.get(&call.id) { + return Some(domain.clone()); + } + let mut observed = Vec::new(); + if let Some(domain) = call + .target + .as_deref() + .and_then(|target| return_domains.get(target)) + { + observed.push(domain); + } + if let Some(targets) = selected.get(&call.id) { + for target in targets { + let Some(definition) = &target.definition else { + continue; + }; + for method in methods.locate(definition) { + if let Some(domain) = return_domains.get(&method.id) { + observed.push(domain); + } + } + } + } + if let Some(domain) = joined_domain(&observed) { + return Some(domain); + } + + let method = methods.by_id.get(call.source.as_str())?; + let language = crate::syntax::Language::parse(&method.language).ok()?; + let behavior = crate::syntax::normalized_behavior::behavior(language); + let Some(receiver_domain) = receiver_domains.get(&call.id) else { + let result_type = behavior + .static_return_type(&call.message, None) + .or_else(|| behavior.known_return_type(&call.message))?; + let domain = domain_from_type_source(&result_type, &method.language); + return (!domain.is_empty()).then_some(domain); + }; + if selected.get(&call.id).is_some_and(|targets| { + !targets.is_empty() + && targets.iter().all(|target| { + is_runtime_record_accessor_symbol(&target.symbol, &call.message) + || runtime_target_is_generated_accessor(target, &call.message, methods) + }) + }) { + if let Some(domain) = runtime_record_accessor_result_domain(receiver_domain, &call.message) + { + return Some(domain); + } + } + let receiver_type = behavior.runtime_value_domain_type( + &receiver_domain.types.iter().cloned().collect::>(), + &receiver_domain.elements.iter().cloned().collect::>(), + &receiver_domain.keys.iter().cloned().collect::>(), + &receiver_domain.values.iter().cloned().collect::>(), + )?; + if let Some(projection) = behavior.runtime_call_result_projection( + Some(&receiver_type), + &call.message, + &call.arguments, + ) { + let domain = projected_call_result_domain(receiver_domain, projection); + if !domain.is_empty() { + return Some(domain); + } + } + let result_type = behavior + .static_return_type(&call.message, Some(&receiver_type)) + .or_else(|| { + behavior.propagated_collection_return_type(&call.message, Some(&receiver_type)) + })?; + let domain = domain_from_type_source(&result_type, &method.language); + (!domain.is_empty()).then_some(domain) +} + +fn runtime_target_is_generated_accessor( + target: &SemanticTarget, + message: &str, + methods: &MethodIndex<'_>, +) -> bool { + let Some(definition) = &target.definition else { + return false; + }; + let candidates = methods.locate(definition); + !candidates.is_empty() + && candidates.iter().all(|method| { + method.generated_declaration + && (method.name == message || method.dispatch_name == message) + }) +} + +// Generated readers are not universally observable as runtime call events +// (Ruby Struct readers are one example). A record shape already proves both +// the member's existence and the value observed in that slot, so use that +// evidence to continue the generic CFG/DFG value flow after the synthetic +// constant-time accessor target has been selected. The join is deliberately +// closed: every runtime alternative must be a record exposing this member. +fn runtime_record_accessor_result_domain( + receiver: &ValueDomain, + member: &str, +) -> Option { + if !runtime_record_domain_exposes(receiver, member) { + return None; + } + let domains = receiver + .types + .iter() + .flat_map(|runtime_type| { + receiver + .shapes + .iter() + .filter(move |shape| shape.kind == "record" && shape.name == *runtime_type) + .filter_map(|shape| shape.members.get(member)) + .map(value_domain_from_shape) + }) + .collect::>(); + (!domains.is_empty()).then(|| joined_domain(&domains.iter().collect::>()))? +} + +fn value_domain_from_shape(shape: &ValueShape) -> ValueDomain { + let mut domain = ValueDomain::default(); + match shape.kind.as_str() { + "class" => { + if !shape.name.is_empty() { + domain.types.insert(shape.name.clone()); + } + } + "record" => { + if !shape.name.is_empty() { + domain.types.insert(shape.name.clone()); + domain.shapes.push(shape.clone()); + } + } + "array" | "set" => { + domain.types.insert(if shape.kind == "array" { + "Array".to_string() + } else { + "Set".to_string() + }); + for element in &shape.elements { + let child = value_domain_from_shape(element); + domain.elements.extend(child.types); + } + domain.shapes.push(shape.clone()); + } + "hash" => { + domain.types.insert("Hash".to_string()); + for key in &shape.keys { + domain.keys.extend(value_domain_from_shape(key).types); + } + for value in &shape.values { + domain.values.extend(value_domain_from_shape(value).types); + } + domain.shapes.push(shape.clone()); + } + "tuple" | "unknown" | _ => {} + } + domain +} + +fn projected_call_result_domain( + receiver: &ValueDomain, + projection: crate::syntax::normalized_behavior::RuntimeCallResultProjection, +) -> ValueDomain { + use crate::syntax::normalized_behavior::RuntimeCallResultProjection; + match projection { + RuntimeCallResultProjection::Receiver => receiver.clone(), + RuntimeCallResultProjection::Element => collection_element_domain(receiver), + RuntimeCallResultProjection::Value => ValueDomain { + types: receiver.values.clone(), + shapes: receiver + .shapes + .iter() + .flat_map(|shape| shape.values.iter().cloned()) + .collect(), + ..ValueDomain::default() + }, + RuntimeCallResultProjection::Keys { collection_type } => ValueDomain { + types: BTreeSet::from([collection_type.to_string()]), + elements: receiver.keys.clone(), + ..ValueDomain::default() + }, + RuntimeCallResultProjection::Values { collection_type } => ValueDomain { + types: BTreeSet::from([collection_type.to_string()]), + elements: receiver.values.clone(), + ..ValueDomain::default() + }, + } +} + +fn collection_element_domain(collection: &ValueDomain) -> ValueDomain { + ValueDomain { + types: collection.elements.clone(), + shapes: collection + .shapes + .iter() + .flat_map(|shape| shape.elements.iter().cloned()) + .collect(), + ..ValueDomain::default() + } +} + +fn projected_collection_domain( + collection: &ValueDomain, + projection: crate::syntax::normalized_behavior::RuntimeValueProjection, +) -> ValueDomain { + use crate::syntax::normalized_behavior::RuntimeValueProjection; + match projection { + RuntimeValueProjection::Element => collection_element_domain(collection), + RuntimeValueProjection::Key => ValueDomain { + types: collection.keys.clone(), + ..ValueDomain::default() + }, + RuntimeValueProjection::Value => ValueDomain { + types: collection.values.clone(), + ..ValueDomain::default() + }, + RuntimeValueProjection::Entry { collection_type } => ValueDomain { + types: BTreeSet::from([collection_type.to_string()]), + elements: collection.keys.union(&collection.values).cloned().collect(), + ..ValueDomain::default() + }, + RuntimeValueProjection::Index { type_name } => ValueDomain { + types: BTreeSet::from([type_name.to_string()]), + ..ValueDomain::default() + }, + } +} + +fn span_contains_line(span: [usize; 4], line: usize) -> bool { + span[0] <= line && line <= span[2] +} + +fn span_contains(outer: [usize; 4], inner: [usize; 4]) -> bool { + (outer[0], outer[1]) <= (inner[0], inner[1]) && (outer[2], outer[3]) >= (inner[2], inner[3]) +} + +fn observed_return_domains( + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, +) -> BTreeMap { + let mut domains = BTreeMap::new(); + for observation in evidence + .observations + .iter() + .filter(|observation| observation.kind == "return") + { + for method in methods.locate_scope(&observation.scope) { + merge_domain( + domains.entry(method.id.clone()).or_default(), + &observation.domain, + ); + } + } + domains +} + +fn observed_receiver_domains( + output: &ProfileOutput, + calls: &CallIndex<'_>, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, +) -> BTreeMap { + let mut domains = BTreeMap::new(); + for observation in &evidence.observations { + match observation.kind.as_str() { + "parameter" => { + for method in methods.locate_scope(&observation.scope) { + seed_method_slot( + calls, + method, + &observation.slot, + &observation.domain, + &mut domains, + ); + } + } + "collection" if observation.slot_kind == "method_param" => { + let candidates = methods.locate_scope(&observation.scope); + for method in candidates { + seed_method_slot( + calls, + method, + &observation.slot, + &observation.domain, + &mut domains, + ); + } + // Legacy tracers may identify a parameter collection by path, + // declaration line, and slot without repeating the method name. + if observation.scope.function.is_empty() { + for method in methods + .methods + .iter() + .filter(|method| method.language == observation.scope.language) + .filter(|method| { + path_matches(&method.path, &observation.scope.path) + && method.line == observation.scope.line + && method.params.contains(&observation.slot) + }) + { + seed_method_slot( + calls, + method, + &observation.slot, + &observation.domain, + &mut domains, + ); + } + } + } + "state" => { + let slot = observation.slot.trim_start_matches('@'); + for call in output.calls.iter().filter(|call| { + call.state_receiver + && call.receiver.trim_start_matches('@') == slot + && (observation.scope.owner.is_empty() + || call.owner == observation.scope.owner + || nested_under(&call.owner, &observation.scope.owner)) + }) { + merge_domain( + domains.entry(call.id.clone()).or_default(), + &observation.domain, + ); + } + } + _ => {} + } + } + domains +} + +fn seed_method_slot( + calls: &CallIndex<'_>, + method: &MethodRecord, + slot: &str, + domain: &ValueDomain, + domains: &mut BTreeMap, +) { + for call in calls + .from_source(&method.id) + .iter() + .filter(|call| call.receiver == slot) + { + merge_domain(domains.entry(call.id.clone()).or_default(), domain); + } +} + +fn match_observed_calls( + calls: &CallIndex<'_>, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, +) -> BTreeMap> { + let mut selected = BTreeMap::>::new(); + for observed in &evidence.calls { + if observed.targets.is_empty() { + continue; + } + for call in matched_profile_calls(calls, methods, observed) { + let entry = selected.entry(call.id.clone()).or_default(); + entry.extend(observed.targets.clone()); + sort_dedup_targets(entry); + } + } + selected +} + +fn matched_observed_value_domains( + calls: &CallIndex<'_>, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, +) -> (BTreeMap, BTreeMap) { + let mut receivers = BTreeMap::::new(); + let mut results = BTreeMap::::new(); + for observed in &evidence.calls { + for call in matched_profile_calls(calls, methods, observed) { + if let Some(domain) = &observed.receiver_domain { + merge_domain(receivers.entry(call.id.clone()).or_default(), domain); + } + if let Some(domain) = &observed.result_domain { + merge_domain(results.entry(call.id.clone()).or_default(), domain); + } + } + } + (receivers, results) +} + +/// Narrow a runtime receiver domain only where a normalized capability guard +/// and a tracer-observed Boolean result jointly prove the branch alternative. +/// The evidence is partitioned by observed receiver type, so a dynamic type +/// that produced both results stays unclassified rather than being guessed. +fn runtime_capability_narrowed_domains( + output: &ProfileOutput, + calls: &CallIndex<'_>, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, + receiver_domains: &BTreeMap, +) -> BTreeMap { + let mut domains = receiver_domains.clone(); + for guard in &output.runtime_capability_guards { + let mut present = BTreeSet::new(); + let mut absent = BTreeSet::new(); + for observed in &evidence.calls { + if observed.result_truths.len() != 1 { + continue; + } + if !matched_profile_calls(calls, methods, observed) + .iter() + .any(|call| call.id == guard.condition_call_id) + { + continue; + } + let Some(receiver_domain) = observed.receiver_domain.as_ref() else { + continue; + }; + let truth = *observed.result_truths.iter().next().expect("single truth"); + let destination = if truth { &mut present } else { &mut absent }; + destination.extend(receiver_domain.types.iter().cloned()); + } + if present.is_empty() && absent.is_empty() { + continue; + } + for call in output + .calls + .iter() + .filter(|call| call.source == guard.source && call.receiver == guard.subject) + { + let allowed = if guard + .member_available_span + .is_some_and(|span| span_contains(span, call.span)) + { + &present + } else if guard + .member_unavailable_span + .is_some_and(|span| span_contains(span, call.span)) + { + &absent + } else { + continue; + }; + let Some(domain) = domains.get_mut(&call.id) else { + continue; + }; + // Every observed alternative must have one unambiguous predicate + // result. Otherwise a type may be state-dependent and filtering + // it would turn a runtime sample into an unsound closed world. + if domain.types.is_empty() + || !domain + .types + .iter() + .all(|ty| present.contains(ty) ^ absent.contains(ty)) + { + continue; + } + domain.types.retain(|ty| allowed.contains(ty)); + domain.shapes.retain(|shape| { + shape.kind != "record" || shape.name.is_empty() || allowed.contains(&shape.name) + }); + } + } + domains +} + +/// Apply a language-adapter truthiness fact only when the same CFG reaching +/// definitions flow from the condition into the guarded call. That prevents a +/// later assignment in the branch from inheriting the old value's runtime +/// type, while allowing a tracer-observed `NilClass | Record` result to close +/// a record reader on the branch where Ruby has proved the value truthy. +fn runtime_truthiness_narrowed_domains( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + receiver_domains: &BTreeMap, +) -> BTreeMap { + let mut domains = receiver_domains.clone(); + let points = flow_points(output, methods); + for guard in &output.runtime_truthiness_guards { + let guard_definitions = points + .iter() + .filter(|point| { + point.source == guard.source + && point.name == guard.subject + && span_contains(point.span, guard.condition_span) + }) + .map(|point| { + point + .reaching_definitions + .iter() + .cloned() + .collect::>() + }) + .filter(|definitions| !definitions.is_empty()) + .collect::>(); + if guard_definitions.is_empty() { + continue; + } + let Some(truthy_span) = guard.truthy_span else { + continue; + }; + for call in output.calls.iter().filter(|call| { + call.source == guard.source + && call.receiver == guard.subject + && span_contains(truthy_span, call.span) + }) { + let same_definition = points.iter().any(|point| { + point.source == call.source + && point.name == call.receiver + && span_contains(point.span, call.span) + && guard_definitions.contains( + &point + .reaching_definitions + .iter() + .cloned() + .collect::>(), + ) + }); + if !same_definition { + continue; + } + let Some(domain) = domains.get_mut(&call.id) else { + continue; + }; + let truthy_types = domain + .types + .iter() + .filter(|ty| ty.as_str() != "NilClass" && ty.as_str() != "FalseClass") + .cloned() + .collect::>(); + if truthy_types.is_empty() { + continue; + } + domain.types = truthy_types.clone(); + domain.shapes.retain(|shape| { + shape.kind != "record" + || shape.name.is_empty() + || truthy_types.contains(&shape.name) + }); + } + } + domains +} + +fn matched_profile_calls<'a>( + calls: &CallIndex<'a>, + methods: &MethodIndex<'_>, + observed: &ObservedCall, +) -> Vec<&'a CallRecord> { + if let Some(call_id) = observed.call_id.as_deref() { + return calls.with_id(call_id).to_vec(); + } + let mut candidates = methods + .locate(&observed.caller) + .into_iter() + .flat_map(|caller| calls.from_source(&caller.id).iter().copied()) + .filter(|call| call.message == observed.callsite.selector) + .filter(|call| path_matches(&call.path, &observed.callsite.path)) + .collect::>(); + if let Some(range) = observed.callsite.range { + candidates.retain(|call| zero_based(call.span) == range); + } else { + // A caller match alone is not enough: native/runtime implementation + // frames can report a selector at the active source line even when + // that selector is not spelled there. Joining it to another call in + // the same method (which merely shares the selector) corrupts the + // CFG/DFG value domain. Keep the runtime source anchor exact; the + // source-anchor fallback below handles genuinely synthetic callers. + candidates.retain(|call| call.line == observed.callsite.line); + } + if !candidates.is_empty() { + return candidates; + } + + // Runtime tracers necessarily see implementation frames. A callback can + // therefore execute under a synthetic/native frame (`Kernel#tap` in Ruby + // is one example) even though its source anchor still points at the + // lexical application call. The source anchor is an exact observation; + // recover it only after the method-locator match failed, and retain a + // statically-known receiver when it conflicts with the observed runtime + // domain. This is generic CFG/DFG joining, not a language-specific stack + // heuristic. + let mut fallback = calls + .with_message(&observed.callsite.selector) + .iter() + .copied() + .filter(|call| path_matches(&call.path, &observed.callsite.path)) + .collect::>(); + if let Some(range) = observed.callsite.range { + fallback.retain(|call| zero_based(call.span) == range); + } else { + fallback.retain(|call| call.line == observed.callsite.line); + } + fallback.retain(|call| runtime_receiver_domain_compatible(call, methods, observed)); + fallback +} + +fn runtime_receiver_domain_compatible( + call: &CallRecord, + methods: &MethodIndex<'_>, + observed: &ObservedCall, +) -> bool { + let observed_types = observed + .receiver_domain + .as_ref() + .map(|domain| domain.types.clone()) + .filter(|types| !types.is_empty()) + .unwrap_or_else(|| { + observed + .targets + .iter() + .filter_map(|target| { + (!target.receiver_type.is_empty()).then(|| target.receiver_type.clone()) + }) + .collect() + }); + if observed_types.is_empty() { + return true; + } + let Some(method) = methods.by_id.get(call.source.as_str()) else { + return false; + }; + let Some(static_type) = call + .receiver_type + .as_deref() + .or(call.receiver_symbol.as_deref()) + else { + return true; + }; + let static_domain = domain_from_type_source(static_type, &method.language); + static_domain.types.is_empty() || !static_domain.types.is_disjoint(&observed_types) +} + +#[derive(Clone)] +struct CatalogTarget { + target: SemanticTarget, + receiver_domain: Option, +} + +fn target_catalog(evidence: &RuntimeValueEvidence) -> BTreeMap> { + let mut catalog = BTreeMap::>::new(); + for call in &evidence.calls { + let entry = catalog.entry(call.callsite.selector.clone()).or_default(); + entry.extend(call.targets.iter().cloned().map(|target| CatalogTarget { + target, + receiver_domain: call.receiver_domain.clone(), + })); + entry.sort_by(|left, right| { + left.target + .symbol + .cmp(&right.target.symbol) + .then_with(|| left.receiver_domain.cmp(&right.receiver_domain)) + }); + entry.dedup_by(|left, right| { + left.target == right.target && left.receiver_domain == right.receiver_domain + }); + } + for observation in &evidence.target_catalog { + let entry = catalog.entry(observation.selector.clone()).or_default(); + entry.extend( + observation + .targets + .iter() + .cloned() + .map(|target| CatalogTarget { + target, + receiver_domain: Some(observation.receiver_domain.clone()), + }), + ); + entry.sort_by(|left, right| { + left.target + .symbol + .cmp(&right.target.symbol) + .then_with(|| left.receiver_domain.cmp(&right.receiver_domain)) + }); + entry.dedup_by(|left, right| { + left.target == right.target && left.receiver_domain == right.receiver_domain + }); + } + catalog +} + +fn propagate_call_results( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + return_domains: &BTreeMap, + exact_result_domains: &BTreeMap, + selected: &BTreeMap>, + receiver_domains: &mut BTreeMap, +) { + let mut producer_domains = BTreeMap::<(String, String, [usize; 4]), ValueDomain>::new(); + for producer in &output.calls { + let Some(domain) = call_result_domain( + producer, + methods, + return_domains, + exact_result_domains, + selected, + receiver_domains, + ) else { + continue; + }; + producer_domains.insert( + ( + producer.source.clone(), + producer.path.clone(), + producer.span, + ), + domain, + ); + } + for call in &output.calls { + let spans = call + .receiver_call_span + .into_iter() + .chain(call.receiver_definition_call_spans.iter().copied()); + let domains = spans + .filter_map(|span| { + producer_domains.get(&(call.source.clone(), call.path.clone(), span)) + }) + .collect::>(); + if let Some(mut domain) = joined_domain(&domains) { + if let Some(position) = call.receiver_definition_sequence_projection { + domain = sequence_position_domain(&domain, position); + } + if domain.is_empty() { + continue; + } + merge_domain( + receiver_domains.entry(call.id.clone()).or_default(), + &domain, + ); + } + } +} + +fn infer_targets( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + catalog: &BTreeMap>, + receiver_domains: &BTreeMap, + selected: &mut BTreeMap>, +) { + for call in &output.calls { + if selected.contains_key(&call.id) || call.target.is_some() { + continue; + } + let catalog_candidates = catalog + .get(&call.message) + .into_iter() + .flatten() + .filter(|target| { + call.receiver_kind != "type" + || target.target.kind == "class" + || target.target.kind == "static" + }) + .collect::>(); + let Some(domain) = receiver_domains.get(&call.id) else { + let owners = catalog_candidates + .iter() + .filter_map(|target| { + (!target.target.owner.is_empty()) + .then_some(target.target.owner.as_str()) + .or_else(|| { + (!target.target.receiver_type.is_empty()) + .then_some(target.target.receiver_type.as_str()) + }) + }) + .collect::>(); + if owners.len() == 1 && !catalog_candidates.is_empty() { + selected.insert( + call.id.clone(), + catalog_candidates + .into_iter() + .map(|candidate| candidate.target.clone()) + .collect(), + ); + } + continue; + }; + let behavior = methods + .by_id + .get(call.source.as_str()) + .and_then(|method| crate::syntax::Language::parse(&method.language).ok()) + .map(crate::syntax::normalized_behavior::behavior); + let identities = if domain.singletons.is_empty() { + domain + .types + .iter() + .map(|identity| (identity.as_str(), false)) + .collect::>() + } else { + domain + .singletons + .iter() + .map(|identity| (identity.as_str(), true)) + .collect::>() + }; + let mut candidates = Vec::new(); + let mut closed = !identities.is_empty(); + for (identity, singleton) in identities { + let mut matching = catalog_candidates + .iter() + .filter(|candidate| { + if singleton { + return candidate + .receiver_domain + .as_ref() + .is_some_and(|observed| observed.singletons.contains(identity)); + } + runtime_owner_matches(&candidate.target.receiver_type, identity) + || runtime_owner_matches(&candidate.target.owner, identity) + || behavior.is_some_and(|behavior| { + behavior + .runtime_dispatch_owner_matches(&candidate.target.owner, identity) + }) + || candidate + .receiver_domain + .as_ref() + .is_some_and(|observed| observed.types.contains(identity)) + }) + .map(|candidate| candidate.target.clone()) + .collect::>(); + if matching.is_empty() + && !singleton + && runtime_record_type_exposes(domain, identity, &call.message) + { + matching.push(runtime_record_accessor_target(&call.message)); + } + if matching.is_empty() { + closed = false; + break; + } + candidates.extend(matching); + } + if closed { + sort_dedup_targets(&mut candidates); + selected.insert(call.id.clone(), candidates); + } + } +} + +// A runtime type observation is enough to connect a normalized receiver call +// to a source declaration already extracted by FactMine. This is deliberately +// language-neutral: adapters own emitted method facts and tracers own runtime +// type identities; the join only requires exact normalized owner/name/kind +// agreement. It notably covers generated source declarations (Ruby +// `attr_reader`, Kotlin properties, and similar compiler-visible accessors) +// that a VM callback tracer may not report as ordinary calls. +fn infer_runtime_receiver_targets( + output: &ProfileOutput, + methods: &MethodIndex<'_>, + receiver_domains: &BTreeMap, + selected: &mut BTreeMap>, +) { + for call in &output.calls { + if selected.contains_key(&call.id) || call.target.is_some() { + continue; + } + let Some(domain) = receiver_domains.get(&call.id) else { + continue; + }; + if domain.types.is_empty() && domain.singletons.is_empty() { + continue; + } + let Some(caller) = methods.by_id.get(call.source.as_str()) else { + continue; + }; + let mut targets = Vec::new(); + let mut closed = true; + let runtime_identities = if domain.singletons.is_empty() { + domain + .types + .iter() + .map(|identity| (identity, false)) + .collect::>() + } else { + domain + .singletons + .iter() + .map(|identity| (identity, true)) + .collect::>() + }; + for (runtime_type, singleton) in runtime_identities { + let mut type_targets = methods + .methods + .iter() + .filter(|method| method.language == caller.language) + .filter(|method| { + method.name == call.message || method.dispatch_name == call.message + }) + .filter(|method| runtime_owner_matches(&method.owner, runtime_type)) + .filter(|method| { + if singleton { + matches!(method.kind.as_str(), "class" | "static") + } else { + project_method_kind_matches_call(method, call) + } + }) + .map(runtime_project_semantic_target) + .collect::>(); + // A language-neutral `record` shape is a second closed target + // form. A heterogeneous runtime union is complete when every + // alternative proves this selector, even if some alternatives use + // generated project readers and others are runtime records. + if type_targets.is_empty() + && runtime_record_type_exposes(domain, runtime_type, &call.message) + { + type_targets.push(runtime_record_accessor_target(&call.message)); + } + if type_targets.is_empty() { + closed = false; + break; + } + targets.extend(type_targets); + } + if closed && !targets.is_empty() { + sort_dedup_targets(&mut targets); + selected.insert(call.id.clone(), targets); + } + } +} + +fn infer_runtime_stdlib_targets( + output: &ProfileOutput, + evidence: &RuntimeValueEvidence, + methods: &MethodIndex<'_>, + receiver_domains: &BTreeMap, + selected: &mut BTreeMap>, +) { + for call in &output.calls { + if selected.contains_key(&call.id) || call.target.is_some() { + continue; + } + let Some(domain) = receiver_domains.get(&call.id) else { + continue; + }; + let Some(method) = methods.by_id.get(call.source.as_str()) else { + continue; + }; + let Ok(language) = crate::syntax::Language::parse(&method.language) else { + continue; + }; + let behavior = crate::syntax::normalized_behavior::behavior(language); + let identities = if domain.singletons.is_empty() { + domain + .types + .iter() + .map(|receiver_type| (receiver_type.as_str(), None)) + .collect::>() + } else { + domain + .singletons + .iter() + .map(|singleton| { + let receiver_type = domain + .types + .iter() + .next() + .map(String::as_str) + .unwrap_or("T.untyped"); + (receiver_type, Some(singleton.as_str())) + }) + .collect::>() + }; + if identities.is_empty() { + continue; + } + let targets = identities + .into_iter() + .map(|(receiver_type, singleton)| { + behavior.runtime_value_semantic_target( + receiver_type, + singleton, + &call.message, + &evidence.environment, + ) + }) + .collect::>>(); + let Some(targets) = targets else { + continue; + }; + let mut targets = targets + .into_iter() + .map(|target| SemanticTarget { + symbol: target.symbol, + owner: target.owner, + name: call.message.clone(), + kind: target.kind, + receiver_type: target.receiver_type, + definition: None, + }) + .collect::>(); + sort_dedup_targets(&mut targets); + selected.insert(call.id.clone(), targets); + } +} + +fn runtime_record_accessor_target(member: &str) -> SemanticTarget { + SemanticTarget { + symbol: runtime_record_accessor_symbol(member), + owner: "Record".to_string(), + name: member.to_string(), + kind: "instance".to_string(), + receiver_type: "Record".to_string(), + definition: None, + } +} + +fn project_method_kind_matches_call(method: &MethodRecord, call: &CallRecord) -> bool { + if call.receiver_kind == "type" { + matches!(method.kind.as_str(), "class" | "static") + } else { + !matches!(method.kind.as_str(), "class" | "static") + } +} + +fn runtime_project_semantic_target(method: &MethodRecord) -> SemanticTarget { + SemanticTarget { + // Use a FactMine-owned, method-id keyed symbol when the source did + // not provide compiler SCIP. Both the definition and reference are + // emitted in the same runtime overlay, so SCIP's normal exact-symbol + // join—not a path or short-name heuristic—remains the authority. + symbol: method + .semantic_symbol + .clone() + .unwrap_or_else(|| runtime_project_method_symbol(&method.id)), + owner: method.owner.clone(), + name: method.name.clone(), + kind: method.kind.clone(), + receiver_type: method.owner.clone(), + definition: Some(MethodLocator { + language: method.language.clone(), + path: method.path.clone(), + owner: method.owner.clone(), + name: method.name.clone(), + kind: method.kind.clone(), + line: method.line, + }), + } +} + +fn runtime_project_method_symbol(method_id: &str) -> String { + format!( + "fact-mine-runtime runtime-project v1 Method#`{}`().", + method_id.replace('`', "``") + ) +} + +fn build_scip_index( + output: &ProfileOutput, + evidence: &RuntimeValueEvidence, + observed: &BTreeMap>, + selected: &BTreeMap>, + methods: &MethodIndex<'_>, + stats: &mut OverlayStats, +) -> Result { + let project_root = std::env::current_dir() + .map(|root| crate::lsp_scip::path_to_file_uri(&root)) + .unwrap_or_default(); + let mut occurrences = BTreeMap::>::new(); + let mut symbols = BTreeMap::>::new(); + let mut languages = BTreeMap::::new(); + let calls = output + .calls + .iter() + .map(|call| (call.id.as_str(), call)) + .collect::>(); + // An observed runtime target can be weaker than an exact static project + // target, in which case the consumer correctly retains the static target. + // Preserve the independently useful coverage provenance nonetheless so + // downstream diagnostics do not misclassify that source callsite as + // unexecuted. These anchors are emitted only after the generic normalized + // caller/callsite join above, never directly from tracer frames. + let observed_call_sites = observed + .keys() + .filter_map(|call_id| calls.get(call_id.as_str()).copied()) + .map(|call| { + json!({ + "relativePath": normalized_document_path(&call.path), + "range": compact_range(zero_based(call.span)) + }) + }) + .collect::>(); + + for (call_id, targets) in selected { + let Some(call) = calls.get(call_id.as_str()).copied() else { + continue; + }; + let path = normalized_document_path(&call.path); + let range = selector_range(call).unwrap_or_else(|| zero_based(call.span)); + let scip_range = compact_range(range); + let language = methods + .by_id + .get(call.source.as_str()) + .map(|method| method.language.clone()) + .unwrap_or_default(); + languages.insert(path.clone(), language); + for target in targets { + occurrences.entry(path.clone()).or_default().push(json!({ + "range": scip_range, + "symbol": target.symbol, + "symbolRoles": 0 + })); + stats.emitted_occurrences += 1; + let Some(definition) = &target.definition else { + continue; + }; + for method in methods.locate(definition) { + let definition_path = normalized_document_path(&method.path); + let definition_range = definition_name_range(method).unwrap_or_else(|| { + zero_based(method.span.unwrap_or([ + method.line, + 0, + method.line, + method.name.len(), + ])) + }); + occurrences + .entry(definition_path.clone()) + .or_default() + .push(json!({ + "range": compact_range(definition_range), + "symbol": target.symbol, + "symbolRoles": 1 + })); + symbols + .entry(definition_path.clone()) + .or_default() + .entry(target.symbol.clone()) + .or_insert_with(|| json!({"symbol": target.symbol})); + languages.insert(definition_path, method.language.clone()); + } + } + } + + let documents = occurrences + .into_iter() + .map(|(path, mut rows)| { + rows.sort_by_key(|row| serde_json::to_string(row).unwrap_or_default()); + rows.dedup(); + json!({ + "language": languages.get(&path).cloned().unwrap_or_default(), + "relativePath": path, + "occurrences": rows, + "symbols": symbols.remove(&path).unwrap_or_default().into_values().collect::>() + }) + }) + .collect::>(); + Ok(json!({ + "metadata": { + "version": 0, + "toolInfo": { + "name": "nil-kill-runtime", + "version": "2", + "arguments": ["--fact-mine-index-authority=runtime-modeled-world"] + }, + "projectRoot": project_root, + "textDocumentEncoding": 1 + }, + "documents": documents, + "externalSymbols": [], + "_runtimeEvidence": { + "schema": evidence.schema, + "runs": evidence.runs, + "observedCallSites": stats.observed_call_sites, + "observedCallsiteAnchors": observed_call_sites, + "inferredCallSites": stats.inferred_call_sites, + "typedReceivers": stats.typed_receivers, + "emittedOccurrences": stats.emitted_occurrences + } + })) +} + +fn selector_range(call: &CallRecord) -> Option<[usize; 4]> { + if let Some(span) = call.selector_span { + return Some(zero_based(span)); + } + if call.span[0] != call.span[2] { + return None; + } + let line = fs::read_to_string(&call.path) + .ok()? + .lines() + .nth(call.span[0].saturating_sub(1))? + .to_string(); + let start = call.span[1].min(line.len()); + let end = call.span[3].min(line.len()); + let source = line.get(start..end)?; + let message = call + .message + .trim() + .trim_start_matches("self.") + .trim_start_matches("this."); + if matches!(message, "[]" | "[]=") { + let offset = source.find('[')?; + return Some([ + call.span[0].saturating_sub(1), + start + offset, + call.span[0].saturating_sub(1), + start + offset + 1, + ]); + } + let offset = source.rfind(message)?; + let selector_length = if source + .get(offset + message.len()..) + .is_some_and(|suffix| suffix.starts_with('=')) + && matches!( + message, + "+" | "-" | "*" | "/" | "%" | "**" | "<<" | ">>" | "&" | "|" | "^" + ) { + message.len() + 1 + } else { + message.len() + }; + Some([ + call.span[0].saturating_sub(1), + start + offset, + call.span[0].saturating_sub(1), + start + offset + selector_length, + ]) +} + +fn definition_name_range(method: &MethodRecord) -> Option<[usize; 4]> { + let span = method.span?; + let source = fs::read_to_string(&method.path).ok()?; + for (offset, line) in source + .lines() + .skip(span[0].saturating_sub(1)) + .take(span[2].saturating_sub(span[0]) + 1) + .enumerate() + { + if let Some(column) = line.find(&method.name) { + let zero_line = span[0].saturating_sub(1) + offset; + return Some([zero_line, column, zero_line, column + method.name.len()]); + } + } + None +} + +fn compact_range(range: [usize; 4]) -> Vec { + if range[0] == range[2] { + vec![range[0], range[1], range[3]] + } else { + range.to_vec() + } +} + +fn zero_based(span: [usize; 4]) -> [usize; 4] { + [ + span[0].saturating_sub(1), + span[1], + span[2].saturating_sub(1), + span[3], + ] +} + +fn normalized_document_path(path: &str) -> String { + let path = path.replace('\\', "/"); + std::env::current_dir() + .ok() + .and_then(|root| { + Path::new(&path) + .strip_prefix(root) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + }) + .unwrap_or(path) +} + +/// Path comparison treats the two separators as one. Every join filter runs +/// through here, so it reads the bytes in place rather than building four +/// normalized copies per comparison. +fn separator_normalized(byte: u8) -> u8 { + if byte == b'\\' { + b'/' + } else { + byte + } +} + +fn path_bytes_eq(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .all(|(left, right)| separator_normalized(*left) == separator_normalized(*right)) +} + +/// Whether `whole` ends with a separator followed by `tail`. +fn path_ends_with_segment(whole: &[u8], tail: &[u8]) -> bool { + whole.len() > tail.len() + && separator_normalized(whole[whole.len() - tail.len() - 1]) == b'/' + && path_bytes_eq(&whole[whole.len() - tail.len()..], tail) +} + +fn path_matches(left: &str, right: &str) -> bool { + let (left, right) = (left.as_bytes(), right.as_bytes()); + path_bytes_eq(left, right) + || path_ends_with_segment(left, right) + || path_ends_with_segment(right, left) +} + +fn runtime_owner_matches(observed: &str, expected: &str) -> bool { + !observed.is_empty() + && !expected.is_empty() + && (observed == expected + || nested_under(observed, expected) + || nested_under(expected, observed)) +} + +fn domain_from_type_source(source: &str, language: &str) -> ValueDomain { + let parsed = TypeExpr::from_flow_hint(source, language) + .unwrap_or_else(|| TypeExpr::parse(source, language)); + domain_from_type_expr_source_language(&parsed, language) +} + +fn domain_from_type_expr_source_language(value: &TypeExpr, language: &str) -> ValueDomain { + let Some(language) = crate::syntax::Language::parse(language).ok() else { + return ValueDomain::default(); + }; + domain_from_type_expr( + value, + crate::syntax::normalized_behavior::behavior(language), + ) +} + +fn domain_from_type_expr( + value: &TypeExpr, + behavior: &dyn crate::syntax::normalized_behavior::NormalizedLanguageBehavior, +) -> ValueDomain { + match value { + TypeExpr::Untyped => ValueDomain::default(), + TypeExpr::NilClass => behavior + .runtime_nil_type_name() + .map(|name| ValueDomain { + types: BTreeSet::from([name.to_string()]), + ..ValueDomain::default() + }) + .unwrap_or_default(), + TypeExpr::Primitive(name) => ValueDomain { + types: BTreeSet::from([name.clone()]), + ..ValueDomain::default() + }, + TypeExpr::Nilable(inner) => { + let mut domain = domain_from_type_expr(inner, behavior); + if let Some(name) = behavior.runtime_nil_type_name() { + domain.types.insert(name.to_string()); + } + domain + } + TypeExpr::Array(inner) => { + let mut domain = ValueDomain { + elements: domain_from_type_expr(inner, behavior).types, + ..ValueDomain::default() + }; + if let Some(name) = behavior.runtime_array_type_name() { + domain.types.insert(name.to_string()); + } + domain + } + TypeExpr::Hash { key, value } => ValueDomain { + types: behavior + .runtime_hash_type_name() + .map(|name| BTreeSet::from([name.to_string()])) + .unwrap_or_default(), + keys: domain_from_type_expr(key, behavior).types, + values: domain_from_type_expr(value, behavior).types, + ..ValueDomain::default() + }, + TypeExpr::Set(inner) => ValueDomain { + types: behavior + .runtime_set_type_name() + .map(|name| BTreeSet::from([name.to_string()])) + .unwrap_or_default(), + elements: domain_from_type_expr(inner, behavior).types, + ..ValueDomain::default() + }, + TypeExpr::Union(parts) => { + let mut domain = ValueDomain::default(); + for part in parts { + merge_domain(&mut domain, &domain_from_type_expr(part, behavior)); + } + domain + } + } +} + +/// Reports whether the target grew. A merge only ever adds alternatives, so +/// counting them is an exact change test -- which lets a fixpoint stop on "no +/// alternative was added" instead of cloning and comparing the whole map on +/// every round. +fn merge_domain(target: &mut ValueDomain, source: &ValueDomain) -> bool { + let before = target.types.len() + + target.singletons.len() + + target.elements.len() + + target.keys.len() + + target.values.len() + + target.shapes.len(); + target.types.extend(source.types.iter().cloned()); + target.singletons.extend(source.singletons.iter().cloned()); + target.elements.extend(source.elements.iter().cloned()); + target.keys.extend(source.keys.iter().cloned()); + target.values.extend(source.values.iter().cloned()); + for shape in &source.shapes { + if !target.shapes.contains(shape) { + target.shapes.push(shape.clone()); + } + } + before + != target.types.len() + + target.singletons.len() + + target.elements.len() + + target.keys.len() + + target.values.len() + + target.shapes.len() +} + +fn joined_domain(domains: &[&ValueDomain]) -> Option { + let mut joined = ValueDomain::default(); + for domain in domains { + merge_domain(&mut joined, domain); + } + (!joined.is_empty()).then_some(joined) +} + +fn sort_dedup_targets(targets: &mut Vec) { + targets.sort_by(|left, right| left.symbol.cmp(&right.symbol)); + targets.dedup(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{self, Profile}; + use crate::syntax::{self, Language}; + use protobuf::{EnumOrUnknown, MessageField}; + use serde_json::json; + use std::io::Write; + + #[test] + fn canonical_protocol_preserves_nested_collection_shapes() { + let runtime_value = |name: &str| runtime_protocol::RuntimeValue { + type_symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {name}#"), + ..runtime_protocol::RuntimeValue::default() + }; + let integer = runtime_value("Integer"); + let mut nested_array = runtime_value("Array"); + nested_array.shape = Some(runtime_value::Shape::Sequence( + runtime_protocol::SequenceShape { + elements: MessageField::some(runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(integer), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }), + ..runtime_protocol::SequenceShape::default() + }, + )); + let mut hash = runtime_value("Hash"); + hash.shape = Some(runtime_value::Shape::Mapping( + runtime_protocol::MappingShape { + entries: vec![runtime_protocol::MappingEntry { + key: MessageField::some(runtime_value("String")), + value: MessageField::some(nested_array), + count: 1, + ..runtime_protocol::MappingEntry::default() + }], + ..runtime_protocol::MappingShape::default() + }, + )); + let mut outer = runtime_value("Array"); + outer.shape = Some(runtime_value::Shape::Sequence( + runtime_protocol::SequenceShape { + elements: MessageField::some(runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(hash), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }), + ..runtime_protocol::SequenceShape::default() + }, + )); + + let domain = protocol_value_domain(&outer, "ruby").expect("canonical value domain"); + let outer_shape = domain.shapes.first().expect("outer sequence"); + let hash_shape = outer_shape.elements.first().expect("hash element"); + let value_shape = hash_shape.values.first().expect("hash value"); + let integer_shape = value_shape.elements.first().expect("nested array element"); + + assert_eq!(outer_shape.kind, "array"); + assert_eq!(hash_shape.kind, "hash"); + assert_eq!(hash_shape.keys[0].name, "String"); + assert_eq!(value_shape.kind, "array"); + assert_eq!(integer_shape.name, "Integer"); + } + + #[test] + fn canonical_protocol_excludes_nonproduction_runtime_values_from_inference() { + let runtime_value = |name: &str, source_role| runtime_protocol::RuntimeValue { + type_symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {name}#"), + source_role: EnumOrUnknown::new(source_role), + ..runtime_protocol::RuntimeValue::default() + }; + let values = runtime_protocol::ValueSet { + alternatives: vec![ + runtime_protocol::WeightedValue { + value: MessageField::some(runtime_value( + "ProductionRow", + runtime_protocol::SourceRole::PRODUCTION, + )), + count: 1, + ..runtime_protocol::WeightedValue::default() + }, + runtime_protocol::WeightedValue { + value: MessageField::some(runtime_value( + "TestDouble", + runtime_protocol::SourceRole::NON_PRODUCTION, + )), + count: 1, + ..runtime_protocol::WeightedValue::default() + }, + ], + ..runtime_protocol::ValueSet::default() + }; + + let domain = protocol_value_set_domain(&values, "ruby").expect("runtime value domain"); + assert_eq!(domain.types, BTreeSet::from(["ProductionRow".to_string()])); + assert_eq!( + protocol_value_set_shapes(Some(&values), "ruby") + .expect("runtime value shapes") + .into_iter() + .map(|shape| shape.name) + .collect::>(), + vec!["ProductionRow"] + ); + } + + #[test] + fn canonical_workspace_definition_locator_binds_an_out_of_plan_method() { + let directory = tempfile::tempdir().expect("directory"); + let helper = directory.path().join("helper.rb"); + std::fs::write( + &helper, + "module Helper\n def self.size\n 1\n end\nend\n", + ) + .expect("helper source"); + let document = syntax::parse_file(helper.clone(), Language::Ruby).expect("parse helper"); + let profile = profile::extract(&document, Profile::Espalier); + let methods = profile + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let expected = profile + .methods + .iter() + .find(|method| method.dispatch_name == "size") + .expect("helper method"); + let definition = runtime_protocol::RuntimeDefinition { + symbol: "nil-kill-runtime workspace fixture workspace Helper.size().".to_string(), + relative_path: "helper.rb".to_string(), + range: MessageField::some(runtime_protocol::SourceRange { + start_line: (expected.line - 1) as u32, + end_line: (expected.line - 1) as u32, + ..runtime_protocol::SourceRange::default() + }), + ..runtime_protocol::RuntimeDefinition::default() + }; + + assert_eq!( + protocol_definition_method(&definition, &methods).map(|method| method.id.as_str()), + Some(expected.id.as_str()) + ); + + let missing = runtime_protocol::RuntimeDefinition { + relative_path: "missing.rb".to_string(), + range: definition.range.clone(), + ..runtime_protocol::RuntimeDefinition::default() + }; + assert!(protocol_definition_method(&missing, &methods).is_none()); + } + + fn valid_evidence() -> serde_json::Value { + json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "environment": {"runtime.version": "3.2.3"}, + "runs": ["run-1"], + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", + "path": "lib/worker.rb", + "owner": "Worker", + "function": "run", + "line": 2 + }, + "slot": "rows", + "domain": { + "types": ["Array"], + "elements": ["Row"] + }, + "count": 3 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", + "path": "lib/worker.rb", + "owner": "Worker", + "name": "run", + "kind": "instance", + "line": 2 + }, + "callsite": { + "path": "lib/worker.rb", + "line": 3, + "selector": "kind" + }, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", + "name": "kind", + "kind": "instance", + "receiver_type": "Row" + }], + "count": 3 + }] + }) + } + + #[test] + fn accepts_language_neutral_value_and_call_evidence() { + let evidence = + RuntimeValueEvidence::from_json(&valid_evidence().to_string()).expect("evidence"); + assert_eq!(evidence.observations[0].domain.elements.len(), 1); + assert_eq!(evidence.calls[0].targets[0].owner, "Row"); + } + + #[test] + fn reads_gzip_runtime_evidence_emitted_by_default_collects() { + let file = tempfile::Builder::new() + .suffix(".json.gz") + .tempfile() + .expect("compressed evidence"); + let mut encoder = flate2::write::GzEncoder::new( + file.reopen().expect("writer"), + flate2::Compression::fast(), + ); + encoder + .write_all(valid_evidence().to_string().as_bytes()) + .expect("write evidence"); + encoder.finish().expect("finish gzip"); + + let evidence = RuntimeValueEvidence::from_path(file.path()).expect("read gzip evidence"); + assert_eq!(evidence.authority, "runtime-modeled-world"); + assert_eq!(evidence.calls[0].targets[0].owner, "Row"); + } + + #[test] + fn cfg_dfg_overlay_exports_shared_runtime_record_accessors_as_portable_scip() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def label(value) + value.kind + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "label", "line": 2 + }, + "slot": "value", + "domain": { + "types": ["NamedRecord", "T.untyped"], + "shapes": [ + {"kind": "record", "name": "NamedRecord", "members": {"kind": {"kind": "unknown"}}}, + {"kind": "record", "name": "T.untyped", "members": {"kind": {"kind": "unknown"}}} + ] + }, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + let overlay = apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "label" && call.message == "kind") + .expect("record accessor"); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#kind().") + ); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("runtime_scip_modeled:conservative_external_candidate_max") + ); + assert_eq!(overlay.stats.inferred_call_sites, 1); + assert!(overlay.index.to_string().contains("Record#kind")); + } + + #[test] + fn cfg_dfg_overlay_projects_container_record_shapes_into_ruby_callback_values() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def labels(rows) + rows.map { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "labels", "line": 2 + }, + "slot": "rows", + "domain": { + "types": ["Array"], + "elements": ["ObservedRow"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "ObservedRow", + "members": {"kind": {"kind": "unknown"}} + }] + }] + }, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "labels" && call.message == "kind") + .expect("record accessor"); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_projects_observed_iterator_receiver_shapes_into_ruby_callbacks() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def labels(rows) + rows.each do |row| + row.kind.upcase + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "labels", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "each"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Array#each().", + "owner": "Array", "name": "each", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": { + "types": ["Array"], "elements": ["ObservedRow"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "ObservedRow", + "members": {"kind": {"kind": "class", "name": "String"}} + }] + }] + }, + "count": 1 + }, { + // Ruby's C-backed `sort_by` may internally enumerate the + // receiver. TracePoint attributes that native `each` to + // the active source line even though no `each` call is + // spelled there. It must not be joined to the earlier + // source `rows.each` merely because the selector matches. + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "labels", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 4, "selector": "each"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Array#each().", + "owner": "Array", "name": "each", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": { + "types": ["Array"], "elements": ["UnrelatedRow"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "UnrelatedRow", + "members": {"other": {"kind": "class", "name": "String"}} + }] + }] + }, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "labels" && call.message == "kind") + .expect("record accessor"); + assert_eq!(accessor.receiver_type.as_deref(), Some("ObservedRow")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#kind().") + ); + let upcase = output + .calls + .iter() + .find(|call| call.function == "labels" && call.message == "upcase") + .expect("record member operation"); + assert_eq!(upcase.receiver_type.as_deref(), Some("String")); + } + + #[test] + fn cfg_dfg_overlay_joins_runtime_receiver_types_to_generated_project_methods() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class BranchArm + attr_reader :kind +end + +class Worker + def labels(rows) + rows.map { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "labels", "line": 6 + }, + "slot": "rows", + "domain": {"types": ["Array"], "elements": ["BranchArm"]}, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "labels" && call.message == "kind") + .expect("generated accessor"); + assert!(accessor.target.is_some()); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("generated_callable_declaration") + ); + } + + #[test] + fn cfg_dfg_overlay_closes_mixed_generated_and_record_receiver_domains() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class BranchArm + attr_reader :kind +end + +class Worker + def labels(rows) + rows.map { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "labels", "line": 6 + }, + "slot": "rows", + "domain": { + "types": ["Array"], "elements": ["BranchArm", "ObservedArm"], + "shapes": [{"kind": "array", "elements": [{ + "kind": "record", "name": "ObservedArm", + "members": {"kind": {"kind": "unknown"}} + }]}] + }, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "labels" && call.message == "kind") + .expect("mixed accessor"); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("runtime_scip_modeled:mixed_project_external_candidate_max+generated_accessor") + ); + assert!(accessor.consumer_closed_candidate_set); + assert!(accessor.target.is_none()); + assert!(!accessor.candidate_targets.is_empty()); + } + + #[test] + fn cfg_dfg_overlay_narrows_runtime_record_domains_through_capability_guards() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("source"); + file.write_all( + br#"class Worker + def label(arm) + arm.respond_to?(:detail) ? arm.detail : arm.fallback + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + assert_eq!(output.runtime_capability_guards.len(), 1); + let trace_plan = profile::extract(&document, Profile::TracePlan); + assert!(trace_plan + .runtime_result_call_sites + .iter() + .any(|site| site.span[0] == 3)); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "label", "line": 2 + }, + "slot": "arm", + "domain": { + "types": ["DetailArm", "FallbackArm"], + "shapes": [ + {"kind": "record", "name": "DetailArm", "members": {"detail": {"kind": "unknown"}}}, + {"kind": "record", "name": "FallbackArm", "members": {"fallback": {"kind": "unknown"}}} + ] + }, + "count": 2 + }], + "calls": [ + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "label", "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Object#respond_to?().", + "owner": "Object", "name": "respond_to?", "kind": "instance", + "receiver_type": "DetailArm" + }], + "receiver_domain": {"types": ["DetailArm"]}, + "result_truths": [true], + "count": 1 + }, + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "label", "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Object#respond_to?().", + "owner": "Object", "name": "respond_to?", "kind": "instance", + "receiver_type": "FallbackArm" + }], + "receiver_domain": {"types": ["FallbackArm"]}, + "result_truths": [false], + "count": 1 + } + ] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + for member in ["detail", "fallback"] { + let accessor = output + .calls + .iter() + .find(|call| call.function == "label" && call.message == member) + .expect("guarded accessor"); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + let expected_symbol = + format!("fact-mine-runtime runtime-contract v1 Record#{member}()."); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some(expected_symbol.as_str()) + ); + } + } + + #[test] + fn cfg_dfg_overlay_closes_mixed_generated_records_inside_capability_ternaries() { + let mut file = tempfile::Builder::new() + .suffix(".rb") + .tempfile() + .expect("source"); + file.write_all( + br#"NativeArm = Struct.new(:kind, :member, :decision_span, :arm_span) + +module Worker + module_function + + def signature(arm) + [ + arm.kind, + (arm.respond_to?(:member) ? arm.member : nil), + Array(arm.decision_span).map(&:to_i), + Array(arm.respond_to?(:arm_span) ? arm.arm_span : arm.span).map(&:to_i) + ] + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + assert_eq!(output.runtime_capability_guards.len(), 2); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "environment": {"runtime.version": "3.2.3"}, + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "signature", "line": 6 + }, + "slot": "arm", + "domain": { + "types": ["NativeArm", "AnonymousArm"], + "shapes": [ + { + "kind": "record", "name": "NativeArm", + "members": { + "kind": {"kind": "class", "name": "String"}, + "member": {"kind": "class", "name": "String"}, + "decision_span": { + "kind": "array", + "elements": [{"kind": "class", "name": "Integer"}] + }, + "arm_span": { + "kind": "array", + "elements": [{"kind": "class", "name": "Integer"}] + } + } + }, + { + "kind": "record", "name": "AnonymousArm", + "members": { + "kind": {"kind": "class", "name": "Symbol"}, + "member": {"kind": "class", "name": "String"}, + "decision_span": { + "kind": "array", + "elements": [{"kind": "class", "name": "Integer"}] + }, + "span": { + "kind": "array", + "elements": [{"kind": "class", "name": "Integer"}] + } + } + } + ] + }, + "count": 2 + }], + "calls": [ + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "signature", + "kind": "class", "line": 6 + }, + "callsite": {"path": path, "line": 9, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#respond_to?().", + "owner": "Kernel", "name": "respond_to?", "kind": "instance", + "receiver_type": "NativeArm" + }], + "receiver_domain": {"types": ["NativeArm"]}, + "result_truths": [true], + "count": 1 + }, + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "signature", + "kind": "class", "line": 6 + }, + "callsite": {"path": path, "line": 9, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#respond_to?().", + "owner": "Kernel", "name": "respond_to?", "kind": "instance", + "receiver_type": "AnonymousArm" + }], + "receiver_domain": {"types": ["AnonymousArm"]}, + "result_truths": [true], + "count": 1 + }, + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "signature", + "kind": "class", "line": 6 + }, + "callsite": {"path": path, "line": 11, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#respond_to?().", + "owner": "Kernel", "name": "respond_to?", "kind": "instance", + "receiver_type": "NativeArm" + }], + "receiver_domain": {"types": ["NativeArm"]}, + "result_truths": [true], + "count": 1 + }, + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "signature", + "kind": "class", "line": 6 + }, + "callsite": {"path": path, "line": 11, "selector": "respond_to?"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#respond_to?().", + "owner": "Kernel", "name": "respond_to?", "kind": "instance", + "receiver_type": "AnonymousArm" + }], + "receiver_domain": {"types": ["AnonymousArm"]}, + "result_truths": [false], + "count": 1 + } + ] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + for member in ["kind", "member", "decision_span", "arm_span", "span"] { + let accessor = output + .calls + .iter() + .find(|call| call.message == member) + .unwrap_or_else(|| panic!("{member} accessor")); + assert_eq!( + accessor.known_time_complexity.as_deref(), + Some("O(1)"), + "{member} remained unresolved: {accessor:#?}" + ); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + } + } + + #[test] + fn cfg_dfg_overlay_rejects_record_accessors_when_any_observed_type_lacks_the_member() { + let domain = ValueDomain { + types: BTreeSet::from(["Left".to_string(), "Right".to_string()]), + shapes: vec![ + ValueShape { + kind: "record".to_string(), + name: "Left".to_string(), + members: BTreeMap::from([( + "kind".to_string(), + ValueShape { + kind: "unknown".to_string(), + ..ValueShape::default() + }, + )]), + ..ValueShape::default() + }, + ValueShape { + kind: "record".to_string(), + name: "Right".to_string(), + members: BTreeMap::new(), + ..ValueShape::default() + }, + ], + ..ValueDomain::default() + }; + assert!(!runtime_record_domain_exposes(&domain, "kind")); + } + + #[test] + fn rejects_analysis_rules_and_empty_domains_at_the_contract_boundary() { + let mut evidence = valid_evidence(); + evidence["observations"][0]["kind"] = json!("assignment"); + assert!(RuntimeValueEvidence::from_json(&evidence.to_string()) + .unwrap_err() + .to_string() + .contains("unsupported kind")); + + let mut evidence = valid_evidence(); + evidence["observations"][0]["domain"] = json!({}); + assert!(RuntimeValueEvidence::from_json(&evidence.to_string()) + .unwrap_err() + .to_string() + .contains("empty value domain")); + + let mut evidence = valid_evidence(); + evidence["calls"][0]["targets"] = json!([]); + assert!(RuntimeValueEvidence::from_json(&evidence.to_string()) + .unwrap_err() + .to_string() + .contains("without targets")); + evidence["calls"][0]["target_observation_complete"] = json!(false); + evidence["calls"][0]["receiver_domain"] = json!({"types": ["Worker"]}); + assert!(RuntimeValueEvidence::from_json(&evidence.to_string()).is_ok()); + + let mut evidence = valid_evidence(); + evidence["calls"][0]["targets"] = json!([]); + evidence["calls"][0]["target_observation_complete"] = json!(false); + evidence["calls"][0] + .as_object_mut() + .unwrap() + .remove("receiver_domain"); + evidence["calls"][0]["result_domain"] = json!({"types": ["String"]}); + assert!( + RuntimeValueEvidence::from_json(&evidence.to_string()).is_ok(), + "an exact result observation remains useful without inventing a receiver or target" + ); + } + + #[test] + fn cfg_dfg_overlay_propagates_parameter_elements_to_block_receivers() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"Row = Struct.new(:kind) +class Worker + def observed(row) + row.kind + end + + def run(rows) + rows.each { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 7 + }, + "slot": "rows", + "domain": {"types": ["Array"], "elements": ["Row"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 3 + }, + "callsite": {"path": path, "line": 4, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + let overlay = apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred block call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert!( + inferred.semantic_symbol.as_deref().is_some_and( + |symbol| symbol.starts_with("fact-mine-runtime runtime-project v1 Method#") + ), + "the normalized Struct reader should resolve to its generated project declaration" + ); + assert_eq!(inferred.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(inferred.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(overlay.stats.observed_call_sites, 1); + assert!( + overlay.stats.inferred_call_sites >= 1, + "the generated block-reader target must be inferred" + ); + } + + #[test] + fn cfg_dfg_overlay_follows_block_binding_reaching_definitions_across_statements() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(row) + row.kind + end + + def run(rows) + selected = rows.select do |row| + row.active? + end + selected.map do |row| + row.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 6 + }, + "slot": "rows", + "domain": {"types": ["Array"], "elements": ["Row"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred block call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Row#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_uses_language_normalized_hash_callback_bindings() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(row) + row.kind + end + + def run(rows) + rows.each do |key, row| + row.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 6 + }, + "slot": "rows", + "domain": { + "types": ["Hash"], "keys": ["String"], "values": ["Row"] + }, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred hash value call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Row#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_projects_normalized_collection_call_results() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(row) + row.kind + end + + def run(rows) + rows[:first].kind + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 6 + }, + "slot": "rows", + "domain": { + "types": ["Hash"], "keys": ["Symbol"], "values": ["Row"] + }, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred hash result call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Row#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_propagates_block_call_results_to_chained_receivers() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(rows) + rows.map { |row| row } + end + + def run(rows) + rows.select do + true + end.map do |row| + row.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 6 + }, + "slot": "rows", + "domain": { + "types": ["Array"], "elements": ["Row"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "Row", + "members": {"kind": {"kind": "unknown"}} + }] + }] + }, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "map"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Array#map().", + "owner": "Array", "name": "map", "kind": "instance", + "receiver_type": "Array" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let map = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "map") + .expect("chained map"); + assert_eq!(map.receiver_type.as_deref(), Some("Array")); + assert_eq!( + map.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 Array#map().") + ); + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("chained callback accessor"); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_binds_each_chained_iterator_to_its_own_callback_scope() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(rows) + rows.map do |row| + build(row) + end.sort_by do |row| + row.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "map"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Enumerable#map().", + "owner": "Enumerable", "name": "map", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": { + "types": ["Array"], "elements": ["Hash"], + "shapes": [{ + "kind": "array", + "elements": [{"kind": "hash"}] + }] + }, + "result_domain": { + "types": ["Array"], "elements": ["AnonymousResult"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "AnonymousResult", + "members": { + "kind": {"kind": "class", "name": "String"} + } + }] + }] + }, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("outer iterator callback accessor"); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#kind()."), + "the outer sort callback must receive the mapped anonymous record domain" + ); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + } + + #[test] + fn cfg_dfg_overlay_reaches_a_fixed_point_through_nested_generated_accessors() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(rows) + rows.map { |row| row.arm.function } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "rows", + "domain": { + "types": ["Array"], "elements": ["ArmCoverage"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "ArmCoverage", + "members": { + "arm": { + "kind": "record", "name": "T.untyped", + "members": { + "function": {"kind": "class", "name": "String"} + } + } + } + }] + }] + }, + "count": 1 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + for message in ["arm", "function"] { + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == message) + .unwrap_or_else(|| panic!("{message} accessor")); + let expected = format!("fact-mine-runtime runtime-contract v1 Record#{message}()."); + assert_eq!(accessor.semantic_symbol.as_deref(), Some(expected.as_str())); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + } + } + + #[test] + fn cfg_dfg_overlay_propagates_a_generated_accessor_result_through_assignment() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(rows) + rows.map do |wrapper| + item = wrapper.item + item.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "rows", + "domain": { + "types": ["Array"], "elements": ["Wrapper"], + "shapes": [{ + "kind": "array", + "elements": [{ + "kind": "record", "name": "Wrapper", + "members": { + "item": { + "kind": "record", "name": "Item", + "members": { + "kind": {"kind": "class", "name": "String"} + } + } + } + }] + }] + }, + "count": 1 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + for message in ["item", "kind"] { + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == message) + .unwrap_or_else(|| panic!("{message} accessor")); + let expected = format!("fact-mine-runtime runtime-contract v1 Record#{message}()."); + assert_eq!(accessor.semantic_symbol.as_deref(), Some(expected.as_str())); + } + } + + #[test] + fn cfg_dfg_overlay_propagates_nested_record_accessors_after_a_callback_guard() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"RowCoverage = Struct.new(:row, :covered) + +class Worker + def run(rows) + rows.filter_map do |row_cov| + next if row_cov.covered + row = row_cov.row + row.kind + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 4 + }, + "callsite": {"path": path, "line": 5, "selector": "filter_map"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Enumerable#filter_map().", + "owner": "Enumerable", "name": "filter_map", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": { + "types": ["Array"], "elements": ["RowCoverage", "AnonymousCoverage"], + "shapes": [{ + "kind": "array", + "elements": [ + { + "kind": "record", "name": "RowCoverage", + "members": { + "covered": {"kind": "class", "name": "FalseClass"}, + "row": { + "kind": "record", "name": "Row", + "members": { + "kind": {"kind": "class", "name": "String"} + } + } + } + }, + { + "kind": "record", "name": "AnonymousCoverage", + "members": { + "covered": {"kind": "class", "name": "FalseClass"}, + "row": { + "kind": "record", "name": "Row", + "members": { + "kind": {"kind": "class", "name": "String"} + } + } + } + } + ] + }] + }, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + for message in ["covered", "row", "kind"] { + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == message) + .unwrap_or_else(|| panic!("{message} accessor")); + let expected = format!("fact-mine-runtime runtime-contract v1 Record#{message}()."); + assert_eq!(accessor.semantic_symbol.as_deref(), Some(expected.as_str())); + } + } + + #[test] + fn cfg_dfg_overlay_joins_value_preserving_alternative_call_results() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(index) + existing = index[:left] || index[:right] + existing.hits.to_s + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let row_domain = json!({ + "types": ["Row"], + "shapes": [{ + "kind": "record", "name": "Row", + "members": {"hits": {"kind": "class", "name": "Integer"}} + }] + }); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "index", + "domain": {"types": ["Hash"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "[]"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().", + "owner": "Hash", "name": "[]", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": {"types": ["Hash"]}, + "result_domain": row_domain, + "count": 2 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "hits") + .expect("alternative result accessor"); + assert_eq!(accessor.receiver_type.as_deref(), Some("Row")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#hits().") + ); + let conversion = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "to_s") + .expect("record member conversion"); + assert_eq!(conversion.receiver_type.as_deref(), Some("Integer")); + } + + #[test] + fn cfg_dfg_overlay_preserves_record_shapes_projected_from_hash_values() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(table) + entry = table[:entry] + entry.name + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "table", + "domain": {"types": ["Hash"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "[]"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().", + "owner": "Hash", "name": "[]", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": { + "types": ["Hash"], "values": ["Row"], + "shapes": [{ + "kind": "hash", + "values": [{ + "kind": "record", "name": "Row", + "members": {"name": {"kind": "class", "name": "String"}} + }] + }] + }, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "name") + .expect("projected record accessor"); + assert_eq!(accessor.receiver_type.as_deref(), Some("Row")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#name().") + ); + } + + #[test] + fn runtime_evidence_uses_a_unique_source_callsite_when_the_runtime_block_frame_is_synthetic() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(items) + items.each { |item| item.upcase } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "items", + "domain": { + "types": ["Array"], "elements": ["String"], + "shapes": [{"kind": "array", "elements": [{"kind": "class", "name": "String"}]}] + }, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": "", + "owner": "Kernel", "name": "tap", + "kind": "instance", "line": 0 + }, + "callsite": {"path": path, "line": 3, "selector": "each"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Array#each().", + "owner": "Array", "name": "each", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": { + "types": ["Array"], "elements": ["String"], + "shapes": [{"kind": "array", "elements": [{"kind": "class", "name": "String"}]}] + }, + "count": 1 + }, { + "language": "ruby", + "caller": { + "language": "ruby", "path": "", + "owner": "Kernel", "name": "tap", + "kind": "instance", "line": 0 + }, + "callsite": {"path": path, "line": 3, "selector": "upcase"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 String#upcase().", + "owner": "String", "name": "upcase", "kind": "instance", + "receiver_type": "String" + }], + "receiver_domain": {"types": ["String"]}, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let upcase = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "upcase") + .expect("callback operation"); + assert_eq!(upcase.receiver_type.as_deref(), Some("String")); + assert_eq!( + upcase.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 String#upcase().") + ); + assert!(upcase.runtime_evidence_observed); + } + + #[test] + fn cfg_dfg_overlay_refines_a_truthy_runtime_record_result_through_cfg_definitions() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(index) + existing = index[:left] || index[:right] + if existing + existing.hits + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + assert_eq!(output.runtime_truthiness_guards.len(), 1); + let path = file.path().to_string_lossy(); + let row_domain = json!({ + "types": ["NilClass", "Row"], + "shapes": [{ + "kind": "record", "name": "Row", + "members": {"hits": {"kind": "class", "name": "Integer"}} + }] + }); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "index", + "domain": {"types": ["Hash"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "[]"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().", + "owner": "Hash", "name": "[]", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": {"types": ["Hash"]}, + "result_domain": row_domain, + "count": 2 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let accessor = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "hits") + .expect("truthy record accessor"); + assert_eq!(accessor.receiver_type.as_deref(), Some("Row")); + assert_eq!( + accessor.semantic_symbol.as_deref(), + Some("fact-mine-runtime runtime-contract v1 Record#hits().") + ); + } + + #[test] + fn runtime_scip_uses_the_exact_ruby_fcall_selector_inside_nested_arguments() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(arm) + Array(arm.respond_to?(:arm_span) ? arm.arm_span : arm.span) + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "Array"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#Array().", + "owner": "Kernel", "name": "Array", "kind": "instance", + "receiver_type": "Module" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + let overlay = apply_to_profile(&mut output, &evidence).expect("overlay"); + let array = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "Array") + .expect("Array conversion"); + assert_eq!(array.selector_span, Some([3, 4, 3, 9])); + assert_eq!( + array.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 Kernel#Array().") + ); + assert_eq!( + overlay.index["_runtimeEvidence"]["observedCallsiteAnchors"] + .as_array() + .map(Vec::len), + Some(1) + ); + } + + #[test] + fn runtime_scip_matches_a_class_method_by_its_normalized_dispatch_name() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def self.render(value) + value.to_s + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "render", "kind": "class", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "to_s"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 String#to_s().", + "owner": "String", "name": "to_s", "kind": "instance", + "receiver_type": "String" + }], + "receiver_domain": {"types": ["String"]}, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let call = output + .calls + .iter() + .find(|call| call.function == "self.render" && call.message == "to_s") + .expect("class method call"); + assert_eq!(call.receiver_type.as_deref(), Some("String")); + assert_eq!( + call.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 String#to_s().") + ); + } + + #[test] + fn cfg_dfg_overlay_propagates_exact_observed_call_results() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(row) + row.kind + end + + def run(payload) + rows = payload.fetch("rows", []) + rows.each { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 6 + }, + "callsite": {"path": path, "line": 7, "selector": "fetch"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3 Hash#fetch().", + "owner": "Hash", "name": "fetch", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": { + "types": ["Hash"], "keys": ["String"], "values": ["Array"] + }, + "result_domain": { + "types": ["Array"], "elements": ["Row"] + }, + "count": 1 + }, { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred exact-result callback call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Row#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_prices_runtime_typed_stdlib_calls_without_a_second_trace() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(payload) + payload.fetch("kind").to_sym + end + + def launch(command) + system(command) + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "environment": {"runtime.version": "3.2.3"}, + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "fetch"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#fetch().", + "owner": "Hash", "name": "fetch", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": {"types": ["Hash"]}, + "result_domain": {"types": ["String"]}, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let to_sym = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "to_sym") + .expect("String#to_sym"); + assert_eq!(to_sym.receiver_type.as_deref(), Some("String")); + assert_eq!( + to_sym.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 String#to_sym().") + ); + assert_eq!(to_sym.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(to_sym.known_space_complexity.as_deref(), Some("O(N)")); + + let system = output + .calls + .iter() + .find(|call| call.function == "launch" && call.message == "system") + .expect("Kernel#system"); + assert_eq!( + system.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 Kernel#system().") + ); + assert!(system.known_time_complexity.is_some()); + assert!(system.known_space_complexity.is_some()); + } + + #[test] + fn cfg_dfg_overlay_uses_language_mixin_ownership_for_runtime_receivers() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(rows) + rows.sort_by { |row| row } + end + + def run(weights) + weights.sort_by { |_, weight| -weight } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "environment": {"runtime.version": "3.2.3"}, + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 6 + }, + "slot": "weights", + "domain": { + "types": ["Hash"], "keys": ["String"], "values": ["Float"] + }, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "sort_by"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Enumerable#sort_by().", + "owner": "Enumerable", "name": "sort_by", "kind": "instance", + "receiver_type": "Array" + }], + "receiver_domain": {"types": ["Array"]}, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let sort = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "sort_by") + .expect("Hash Enumerable#sort_by"); + assert!(sort.known_time_complexity.is_some()); + assert!(sort.known_space_complexity.is_some()); + } + + #[test] + fn cfg_dfg_overlay_refines_the_bare_subject_of_a_short_circuit_guard() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def run(existing) + if existing && existing.active? + existing.hits + end + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + assert!( + output + .runtime_truthiness_guards + .iter() + .any(|guard| guard.subject == "existing"), + "the true branch of `a && b` proves the bare left subject truthy" + ); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "existing", + "domain": { + "types": ["NilClass", "Row"], + "shapes": [{ + "kind": "record", "name": "Row", + "members": { + "active?": {"kind": "class", "name": "TrueClass"}, + "hits": {"kind": "class", "name": "Integer"} + } + }] + }, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let hits = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "hits") + .expect("truthy branch accessor"); + assert_eq!(hits.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(hits.known_space_complexity.as_deref(), Some("O(1)")); + } + + #[test] + fn cfg_dfg_overlay_uses_fact_mine_proven_receiver_types() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(value) + value.render + end + + def run + value = "preview" + value.render + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "render"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc String#render().", + "owner": "String", "name": "render", "kind": "instance", + "receiver_type": "String" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "render") + .expect("inferred statically typed call"); + + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc String#render().") + ); + } + + #[test] + fn modeled_world_infers_an_untyped_call_only_when_observed_owners_converge() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(value) + value.render + end + + def run(value) + value.render + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "render"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Renderer#render().", + "owner": "Renderer", "name": "render", "kind": "instance", + "receiver_type": "Renderer" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "render") + .expect("inferred modeled-world call"); + + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Renderer#render().") + ); + } + + #[test] + fn cfg_dfg_overlay_closes_provider_dispatch_from_exact_runtime_module_identities() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"module FirstProvider + def self.rule_id_for + "first" + end +end + +module SecondProvider + def self.rule_id_for + "second" + end +end + +class Worker + def run(provider) + provider.rule_id_for + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let run_line = output + .methods + .iter() + .find(|method| method.owner == "Worker" && method.name == "run") + .expect("run method") + .line; + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": run_line + }, + "slot": "provider", + "domain": { + "types": ["Module"], + "singletons": ["FirstProvider", "SecondProvider"] + }, + "count": 2 + }], + "calls": [] + }) + .to_string(), + ) + .expect("evidence"); + + let methods = MethodIndex::new(&output.methods); + let call_index = CallIndex::new(&output.calls); + let seeded = observed_receiver_domains(&output, &call_index, &evidence, &methods); + let provider_call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "rule_id_for") + .expect("provider call before overlay"); + assert_eq!( + seeded + .get(&provider_call.id) + .map(|domain| domain.singletons.clone()), + Some(BTreeSet::from([ + "FirstProvider".to_string(), + "SecondProvider".to_string() + ])) + ); + apply_to_profile(&mut output, &evidence).expect("overlay"); + let dispatch = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "rule_id_for") + .expect("provider dispatch"); + + assert!( + dispatch.consumer_closed_candidate_set, + "provider dispatch remained open: {dispatch:#?}" + ); + assert_eq!(dispatch.candidate_targets.len(), 2); + assert_eq!( + dispatch + .candidate_targets + .iter() + .filter_map(|target| output + .methods + .iter() + .find(|method| method.id == *target) + .map(|method| method.owner.as_str())) + .collect::>(), + BTreeSet::from(["FirstProvider", "SecondProvider"]) + ); + } + + #[test] + fn cfg_dfg_overlay_reuses_a_production_target_for_a_test_replaced_call() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed + File.read("first") + end + + def replaced + File.read("second") + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [ + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "read"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 IO.read().", + "owner": "IO", "name": "read", "kind": "class", + "receiver_type": "Class" + }], + "receiver_domain": { + "types": ["Class"], "singletons": ["File"] + }, + "count": 1 + }, + { + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "replaced", + "kind": "instance", "line": 6 + }, + "callsite": {"path": path, "line": 7, "selector": "read"}, + "targets": [], + "target_observation_complete": false, + "receiver_domain": { + "types": ["Class"], "singletons": ["File"] + }, + "count": 1 + } + ] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let replaced = output + .calls + .iter() + .find(|call| call.function == "replaced" && call.message == "read") + .expect("replaced File.read"); + + assert_eq!( + replaced.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 IO.read().") + ); + assert_eq!(replaced.known_time_complexity.as_deref(), Some("O(N+C)")); + assert_eq!(replaced.known_space_complexity.as_deref(), Some("O(N+S)")); + } + + #[test] + fn cfg_dfg_overlay_propagates_observed_project_returns_through_direct_calls() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + def observed(row) + row.kind + end + + def rows + [] + end + + def run + rows.each { |row| row.kind } + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "return", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "rows", "line": 6 + }, + "domain": {"types": ["Array"], "elements": ["Row"]}, + "count": 1 + }], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "observed", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "kind"}, + "targets": [{ + "symbol": "nil-kill-runtime workspace demo abc Row#kind().", + "owner": "Row", "name": "kind", "kind": "instance", + "receiver_type": "Row" + }], + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let inferred = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "kind") + .expect("inferred block call"); + + assert_eq!(inferred.receiver_type.as_deref(), Some("Row")); + assert_eq!( + inferred.semantic_symbol.as_deref(), + Some("nil-kill-runtime workspace demo abc Row#kind().") + ); + } + + #[test] + fn cfg_dfg_overlay_propagates_a_project_return_through_value_preserving_assignment() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Worker + Fact = Struct.new(:score) do + def strong? + score.to_i > 0 + end + end + + def self.missing + Fact.new(0) + end + + def self.run(fact) + fact ||= missing + fact.strong? + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "return", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "missing", "line": 8 + }, + "domain": { + "types": ["Worker::Fact"], + "shapes": [{ + "kind": "record", "name": "Worker::Fact", + "members": {"score": {"kind": "class", "name": "Integer"}} + }] + }, + "count": 1 + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let predicate = output + .calls + .iter() + .find(|call| call.function == "self.run" && call.message == "strong?") + .expect("predicate"); + assert_eq!(predicate.receiver_type.as_deref(), Some("Worker::Fact")); + assert!( + predicate.target.is_some() || predicate.semantic_symbol.is_some(), + "{predicate:#?}" + ); + } + + #[test] + fn method_index_matches_runtime_scope_to_a_class_method_dispatch_name() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + br#"class Producer + def self.build_rows + [] + end +end +"#, + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let producer_line = output + .methods + .iter() + .find(|method| method.dispatch_name == "build_rows") + .expect("producer") + .line; + let index = MethodIndex::new(&output.methods); + let located = index.locate_scope(&ValueScope { + language: "ruby".to_string(), + path: path.to_string(), + owner: "Producer".to_string(), + function: "build_rows".to_string(), + line: producer_line, + method_id: None, + }); + + assert_eq!(located.len(), 1); + assert_eq!(located[0].dispatch_name, "build_rows"); + } + + #[test] + fn canonical_partial_evidence_joins_complete_fields_only_to_the_exact_anchor() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("worker.rb"); + std::fs::write( + &file, + "class Worker\n def run(left, right)\n left.size + right.size\n end\nend\n", + ) + .expect("source"); + let document = syntax::parse_file(file.clone(), Language::Ruby).expect("parse"); + let mut overlay_profile = profile::extract(&document, Profile::Espalier); + let plan_profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + std::slice::from_ref(&file), + directory.path(), + ) + .expect("plan"); + let selected = built + .bindings + .iter() + .find_map(|(anchor, binding)| match binding { + AnchorBinding::Call { call_id } + if overlay_profile + .calls + .iter() + .find(|call| call.id == *call_id) + .is_some_and(|call| call.message == "size" && call.receiver == "left") => + { + Some((anchor.clone(), call_id.clone())) + } + _ => None, + }) + .expect("left.size anchor"); + let run_id = "run-1".to_string(); + let runtime_value = |name: &str| runtime_protocol::RuntimeValue { + type_symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {name}#"), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::PRODUCTION), + ..runtime_protocol::RuntimeValue::default() + }; + let runtime_values = |name: &str| runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_value(name)), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }; + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let selected_anchor = anchor.symbol == selected.0; + runtime_protocol::AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(if selected_anchor { + CaptureStatus::PARTIAL + } else { + CaptureStatus::NOT_EXECUTED + }), + run_ids: vec![run_id.clone()], + observed_executions: u64::from(selected_anchor), + reason: if selected_anchor { + "result value was not captured".to_string() + } else { + "anchor did not execute in the modeled run".to_string() + }, + complete_kinds: if selected_anchor { + vec![ + EnumOrUnknown::new(EvidenceKind::RECEIVER_VALUE), + EnumOrUnknown::new(EvidenceKind::CALL_TARGET), + ] + } else { + request.required.clone() + }, + ..runtime_protocol::CaptureSummary::default() + }), + executions: selected_anchor + .then(|| runtime_protocol::ExecutionBucket { + count: 1, + receiver: MessageField::some(runtime_values("String")), + target: MessageField::some(runtime_protocol::RuntimeTarget { + symbol: "nil-kill-runtime ruby ruby 3.2.3 String#size()." + .to_string(), + source_role: EnumOrUnknown::new( + runtime_protocol::SourceRole::STANDARD_LIBRARY, + ), + package_manager: "ruby".to_string(), + package_name: "ruby".to_string(), + package_version: "3.2.3".to_string(), + ..runtime_protocol::RuntimeTarget::default() + }), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id: run_id.clone(), + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }) + .into_iter() + .collect(), + ..runtime_protocol::AnchorEvidence::default() + } + }) + .collect(); + let evidence = runtime_protocol::RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(runtime_protocol::ToolInfo { + name: "nil-kill".to_string(), + version: "1".to_string(), + ..runtime_protocol::ToolInfo::default() + }), + authority: EnumOrUnknown::new(runtime_protocol::Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![runtime_protocol::Run { + id: run_id, + status: EnumOrUnknown::new(runtime_protocol::RunStatus::SUCCEEDED), + ..runtime_protocol::Run::default() + }], + anchors, + ..runtime_protocol::RuntimeEvidence::default() + }; + + apply_protocol_to_profile(&mut overlay_profile, &built, &evidence).expect("overlay"); + let selected_call = overlay_profile + .calls + .iter() + .find(|call| call.id == selected.1) + .expect("selected call"); + assert!(selected_call.runtime_evidence_observed); + assert_eq!( + selected_call.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 String#size().") + ); + let other = overlay_profile + .calls + .iter() + .find(|call| call.message == "size" && call.receiver == "right") + .expect("other same-selector call"); + assert!(!other.runtime_evidence_observed); + } + + #[test] + fn canonical_call_normalization_keeps_exact_results_and_drops_empty_filtered_buckets() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all(b"class Worker\n def run(value)\n value.to_s\n end\nend\n") + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let output = profile::extract(&document, Profile::Espalier); + let call = output + .calls + .iter() + .find(|call| call.message == "to_s") + .expect("call"); + let method = output + .methods + .iter() + .find(|method| method.id == call.source) + .expect("method"); + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let bindings = BTreeMap::new(); + let anchor_methods = BTreeMap::new(); + let filtered_target = runtime_protocol::ExecutionBucket { + count: 1, + target: MessageField::some(runtime_protocol::RuntimeTarget { + symbol: "nil-kill-runtime ruby minitest 5 Mock#to_s().".to_string(), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::NON_PRODUCTION), + ..runtime_protocol::RuntimeTarget::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }; + assert!( + protocol_call_observation( + call, + method, + &call.id, + &filtered_target, + &BTreeSet::from([EvidenceKind::CALL_TARGET.value()]), + &methods, + &bindings, + &anchor_methods, + ) + .expect("filtered target") + .is_none(), + "a filtered test-double target with no value fact must not become an empty call row" + ); + + let result_only = runtime_protocol::ExecutionBucket { + count: 1, + result: MessageField::some(runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_protocol::RuntimeValue { + type_symbol: "nil-kill-runtime ruby ruby 3.2.3 String#".to_string(), + source_role: EnumOrUnknown::new( + runtime_protocol::SourceRole::STANDARD_LIBRARY, + ), + ..runtime_protocol::RuntimeValue::default() + }), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }; + let observation = protocol_call_observation( + call, + method, + &call.id, + &result_only, + &BTreeSet::from([EvidenceKind::RESULT_VALUE.value()]), + &methods, + &bindings, + &anchor_methods, + ) + .expect("result-only observation") + .expect("useful exact result"); + assert!(observation.targets.is_empty()); + assert!(observation.receiver_domain.is_none()); + assert_eq!( + observation.result_domain.expect("result domain").types, + BTreeSet::from(["String".to_string()]) + ); + } + + #[test] + fn canonical_candidate_group_uses_fact_mine_dfg_to_join_shared_receivers() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("worker.rb"); + std::fs::write( + &file, + "class Worker\n def run(row)\n row.size + row.size\n end\nend\n", + ) + .expect("source"); + let document = syntax::parse_file(file.clone(), Language::Ruby).expect("parse"); + let mut overlay_profile = profile::extract(&document, Profile::Espalier); + let plan_profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + std::slice::from_ref(&file), + directory.path(), + ) + .expect("plan"); + let candidates = built + .bindings + .iter() + .filter_map(|(anchor, binding)| match binding { + AnchorBinding::Call { call_id } + if overlay_profile + .calls + .iter() + .find(|call| call.id == *call_id) + .is_some_and(|call| call.message == "size") => + { + Some(anchor.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!(candidates.len(), 2); + let run_id = "run-1".to_string(); + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let candidate = candidates.contains(&anchor.symbol); + runtime_protocol::AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(if candidate { + CaptureStatus::PARTIAL + } else { + CaptureStatus::NOT_EXECUTED + }), + run_ids: vec![run_id.clone()], + reason: if candidate { + "execution is represented by a candidate group".to_string() + } else { + "anchor did not execute in the modeled run".to_string() + }, + complete_kinds: if candidate { + Vec::new() + } else { + request.required.clone() + }, + ..runtime_protocol::CaptureSummary::default() + }), + ..runtime_protocol::AnchorEvidence::default() + } + }) + .collect(); + let runtime_values = runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_protocol::RuntimeValue { + type_symbol: "nil-kill-runtime ruby ruby 3.2.3 String#".to_string(), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::PRODUCTION), + ..runtime_protocol::RuntimeValue::default() + }), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }; + let evidence = runtime_protocol::RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(runtime_protocol::ToolInfo { + name: "nil-kill".to_string(), + version: "1".to_string(), + ..runtime_protocol::ToolInfo::default() + }), + authority: EnumOrUnknown::new(runtime_protocol::Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![runtime_protocol::Run { + id: run_id.clone(), + status: EnumOrUnknown::new(runtime_protocol::RunStatus::SUCCEEDED), + ..runtime_protocol::Run::default() + }], + anchors, + correlations: vec![runtime_protocol::CorrelationEvidence { + group_id: "same-value-size-calls".to_string(), + candidate_anchor_symbols: candidates, + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::COMPLETE_FOR_RUNS), + run_ids: vec![run_id.clone()], + observed_executions: 2, + complete_kinds: vec![ + EnumOrUnknown::new(EvidenceKind::RECEIVER_VALUE), + EnumOrUnknown::new(EvidenceKind::CALL_TARGET), + ], + ..runtime_protocol::CaptureSummary::default() + }), + executions: vec![runtime_protocol::ExecutionBucket { + count: 2, + receiver: MessageField::some(runtime_values), + target: MessageField::some(runtime_protocol::RuntimeTarget { + symbol: "nil-kill-runtime ruby ruby 3.2.3 String#size().".to_string(), + source_role: EnumOrUnknown::new( + runtime_protocol::SourceRole::STANDARD_LIBRARY, + ), + package_manager: "ruby".to_string(), + package_name: "ruby".to_string(), + package_version: "3.2.3".to_string(), + ..runtime_protocol::RuntimeTarget::default() + }), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id, + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }], + ..runtime_protocol::CorrelationEvidence::default() + }], + ..runtime_protocol::RuntimeEvidence::default() + }; + + apply_protocol_to_profile(&mut overlay_profile, &built, &evidence).expect("overlay"); + let size_calls = overlay_profile + .calls + .iter() + .filter(|call| call.message == "size") + .collect::>(); + assert_eq!(size_calls.len(), 2); + assert!(size_calls.iter().all(|call| call.runtime_evidence_observed)); + assert!(size_calls.iter().all(|call| { + call.semantic_symbol.as_deref() + == Some("nil-kill-runtime ruby ruby 3.2.3 String#size().") + })); + } + + #[test] + fn candidate_group_dfg_does_not_equate_distinct_same_line_receivers() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + b"class Worker\n def run(left, right)\n left.size + right.size\n end\nend\n", + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let output = profile::extract(&document, Profile::Espalier); + let methods = MethodIndex::new(&output.methods); + let points = flow_points(&output, &methods); + let calls = output + .calls + .iter() + .filter(|call| call.message == "size") + .collect::>(); + + assert_eq!(calls.len(), 2); + assert!(!correlation_calls_share_reaching_value(&calls, &points)); + } + + #[test] + fn canonical_candidate_group_seeds_dispatch_catalog_for_cfg_dfg_inference() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("worker.rb"); + std::fs::write( + &file, + "class Worker\n def run(left, right)\n left.to_s + right.to_s\n end\nend\n", + ) + .expect("source"); + let document = syntax::parse_file(file.clone(), Language::Ruby).expect("parse"); + let mut overlay_profile = profile::extract(&document, Profile::Espalier); + let plan_profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + std::slice::from_ref(&file), + directory.path(), + ) + .expect("plan"); + let candidates = built + .bindings + .iter() + .filter_map(|(anchor, binding)| match binding { + AnchorBinding::Call { call_id } + if overlay_profile + .calls + .iter() + .find(|call| call.id == *call_id) + .is_some_and(|call| call.message == "to_s") => + { + Some(anchor.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!(candidates.len(), 2); + let run_id = "run-1".to_string(); + let runtime_values = runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_protocol::RuntimeValue { + type_symbol: "nil-kill-runtime ruby ruby 3.2.3 String#".to_string(), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::PRODUCTION), + ..runtime_protocol::RuntimeValue::default() + }), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }; + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let candidate = candidates.contains(&anchor.symbol); + let parameter = matches!( + built.bindings.get(&anchor.symbol), + Some(AnchorBinding::Parameter { .. }) + ); + runtime_protocol::AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(if candidate { + CaptureStatus::PARTIAL + } else if parameter { + CaptureStatus::COMPLETE_FOR_RUNS + } else { + CaptureStatus::NOT_EXECUTED + }), + run_ids: vec![run_id.clone()], + observed_executions: u64::from(parameter), + reason: if candidate { + "execution is represented by a candidate group".to_string() + } else if parameter { + String::new() + } else { + "anchor did not execute in the modeled run".to_string() + }, + complete_kinds: if candidate { + Vec::new() + } else { + request.required.clone() + }, + ..runtime_protocol::CaptureSummary::default() + }), + executions: parameter + .then(|| runtime_protocol::ExecutionBucket { + count: 1, + value: MessageField::some(runtime_values.clone()), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id: run_id.clone(), + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }) + .into_iter() + .collect(), + ..runtime_protocol::AnchorEvidence::default() + } + }) + .collect(); + let evidence = runtime_protocol::RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(runtime_protocol::ToolInfo { + name: "nil-kill".to_string(), + version: "1".to_string(), + ..runtime_protocol::ToolInfo::default() + }), + authority: EnumOrUnknown::new(runtime_protocol::Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![runtime_protocol::Run { + id: run_id.clone(), + status: EnumOrUnknown::new(runtime_protocol::RunStatus::SUCCEEDED), + ..runtime_protocol::Run::default() + }], + anchors, + correlations: vec![runtime_protocol::CorrelationEvidence { + group_id: "distinct-parameter-to-s".to_string(), + candidate_anchor_symbols: candidates, + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::COMPLETE_FOR_RUNS), + run_ids: vec![run_id.clone()], + observed_executions: 2, + complete_kinds: vec![ + EnumOrUnknown::new(EvidenceKind::RECEIVER_VALUE), + EnumOrUnknown::new(EvidenceKind::CALL_TARGET), + ], + ..runtime_protocol::CaptureSummary::default() + }), + executions: vec![runtime_protocol::ExecutionBucket { + count: 2, + receiver: MessageField::some(runtime_values), + target: MessageField::some(runtime_protocol::RuntimeTarget { + symbol: "nil-kill-runtime ruby ruby 3.2.3 String#to_s().".to_string(), + source_role: EnumOrUnknown::new( + runtime_protocol::SourceRole::STANDARD_LIBRARY, + ), + package_manager: "ruby".to_string(), + package_name: "ruby".to_string(), + package_version: "3.2.3".to_string(), + ..runtime_protocol::RuntimeTarget::default() + }), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id, + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }], + ..runtime_protocol::CorrelationEvidence::default() + }], + ..runtime_protocol::RuntimeEvidence::default() + }; + + apply_protocol_to_profile(&mut overlay_profile, &built, &evidence).expect("overlay"); + let calls = overlay_profile + .calls + .iter() + .filter(|call| call.message == "to_s") + .collect::>(); + assert_eq!(calls.len(), 2); + assert!(calls.iter().all(|call| { + call.receiver_type.as_deref() == Some("String") + && call.semantic_symbol.as_deref() + == Some("nil-kill-runtime ruby ruby 3.2.3 String#to_s().") + && !call.runtime_evidence_observed + })); + } + + #[test] + fn cfg_dfg_projects_an_exact_parser_result_through_nested_hash_dispatch() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + b"class Worker\n def run(payload)\n data = JSON.parse(payload)\n data[\"coverage\"].is_a?(Hash)\n end\nend\n", + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [], + "calls": [{ + "language": "ruby", + "caller": { + "language": "ruby", "path": path, + "owner": "Worker", "name": "run", + "kind": "instance", "line": 2 + }, + "callsite": {"path": path, "line": 3, "selector": "parse"}, + "targets": [{ + "symbol": "nil-kill-runtime ruby json 2 JSON.parse().", + "owner": "JSON", "name": "parse", "kind": "class", + "receiver_type": "JSON" + }], + "result_domain": { + "types": ["Hash"], + "keys": ["String"], + "values": ["Hash"], + "shapes": [{ + "kind": "hash", + "keys": [{"kind": "class", "name": "String"}], + "values": [{"kind": "class", "name": "Hash"}] + }] + }, + "count": 1 + }], + "target_catalog": [{ + "language": "ruby", + "selector": "[]", + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().", + "owner": "Hash", "name": "[]", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": {"types": ["Hash"]} + }, { + "language": "ruby", + "selector": "is_a?", + "targets": [{ + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Kernel#`is_a?`().", + "owner": "Kernel", "name": "is_a?", "kind": "instance", + "receiver_type": "Hash" + }], + "receiver_domain": {"types": ["Hash"]} + }] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let index = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "[]") + .expect("hash index"); + assert_eq!( + index.receiver_type.as_deref(), + Some("Hash"), + "calls={:#?}\nflow={:#?}", + output.calls, + output.flow_local_types + ); + assert_eq!( + index.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().") + ); + let type_check = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "is_a?") + .expect("nested type check"); + assert_eq!(type_check.receiver_type.as_deref(), Some("Hash")); + assert_eq!( + type_check.semantic_symbol.as_deref(), + Some("nil-kill-runtime ruby ruby 3.2.3 Kernel#`is_a?`().") + ); + } + + #[test] + fn cfg_dfg_projects_call_results_through_destructured_assignments() { + let mut file = tempfile::NamedTempFile::new().expect("source"); + file.write_all( + b"class Worker\n def run(spec)\n left, right = spec.to_s.split(\":\", 2)\n left.to_s + right.to_s\n end\nend\n", + ) + .expect("write"); + let document = + syntax::parse_file(file.path().to_path_buf(), Language::Ruby).expect("parse"); + let mut output = profile::extract(&document, Profile::Espalier); + let path = file.path().to_string_lossy(); + let target = |name: &str, receiver_type: &str| { + json!({ + "symbol": format!( + "nil-kill-runtime ruby ruby 3.2.3 {receiver_type}#{name}()." + ), + "owner": receiver_type, + "name": name, + "kind": "instance", + "receiver_type": receiver_type + }) + }; + let evidence = RuntimeValueEvidence::from_json( + &json!({ + "schema": SCHEMA, + "authority": "runtime-modeled-world", + "observations": [{ + "kind": "parameter", + "scope": { + "language": "ruby", "path": path, + "owner": "Worker", "function": "run", "line": 2 + }, + "slot": "spec", + "domain": {"types": ["String"]}, + "count": 1 + }], + "target_catalog": [ + { + "language": "ruby", + "selector": "to_s", + "targets": [target("to_s", "String")], + "receiver_domain": {"types": ["String"]} + }, + { + "language": "ruby", + "selector": "split", + "targets": [target("split", "String")], + "receiver_domain": {"types": ["String"]} + } + ] + }) + .to_string(), + ) + .expect("evidence"); + + apply_to_profile(&mut output, &evidence).expect("overlay"); + let destructured_calls = output + .calls + .iter() + .filter(|call| { + call.line == 4 + && call.message == "to_s" + && matches!(call.receiver.as_str(), "left" | "right") + }) + .collect::>(); + assert_eq!(destructured_calls.len(), 2); + assert!( + destructured_calls.iter().all(|call| { + call.receiver_type.as_deref() == Some("String") + && call.semantic_symbol.as_deref() + == Some("nil-kill-runtime ruby ruby 3.2.3 String#to_s().") + }), + "{destructured_calls:#?}" + ); + } + + #[test] + fn canonical_parameter_shape_costs_a_callback_hash_writer() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("worker.rb"); + std::fs::write( + &file, + "class Worker\n def run(rows)\n rows.each { |row| row[\"seen\"] = true }\n end\nend\n", + ) + .expect("source"); + let document = syntax::parse_file(file.clone(), Language::Ruby).expect("parse"); + let mut overlay_profile = profile::extract(&document, Profile::Espalier); + let plan_profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + std::slice::from_ref(&file), + directory.path(), + ) + .expect("plan"); + let parameter_anchor = built + .bindings + .iter() + .find_map(|(anchor, binding)| match binding { + AnchorBinding::Parameter { name, .. } if name == "rows" => Some(anchor.clone()), + _ => None, + }) + .expect("rows parameter anchor"); + let runtime_value = |name: &str| runtime_protocol::RuntimeValue { + type_symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {name}#"), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::PRODUCTION), + ..runtime_protocol::RuntimeValue::default() + }; + let mut array = runtime_value("Array"); + array.shape = Some(runtime_value::Shape::Sequence( + runtime_protocol::SequenceShape { + elements: MessageField::some(runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_value("Hash")), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }), + ..runtime_protocol::SequenceShape::default() + }, + )); + let run_id = "run-1".to_string(); + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let selected = anchor.symbol == parameter_anchor; + runtime_protocol::AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(if selected { + CaptureStatus::COMPLETE_FOR_RUNS + } else { + CaptureStatus::NOT_EXECUTED + }), + run_ids: vec![run_id.clone()], + observed_executions: u64::from(selected), + reason: (!selected) + .then(|| "anchor did not execute in the modeled run".to_string()) + .unwrap_or_default(), + complete_kinds: request.required.clone(), + ..runtime_protocol::CaptureSummary::default() + }), + executions: selected + .then(|| runtime_protocol::ExecutionBucket { + count: 1, + value: MessageField::some(runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(array.clone()), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id: run_id.clone(), + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }) + .into_iter() + .collect(), + ..runtime_protocol::AnchorEvidence::default() + } + }) + .collect(); + let evidence = runtime_protocol::RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(runtime_protocol::ToolInfo { + name: "nil-kill".to_string(), + version: "1".to_string(), + ..runtime_protocol::ToolInfo::default() + }), + authority: EnumOrUnknown::new(runtime_protocol::Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![runtime_protocol::Run { + id: run_id, + status: EnumOrUnknown::new(runtime_protocol::RunStatus::SUCCEEDED), + ..runtime_protocol::Run::default() + }], + anchors, + ..runtime_protocol::RuntimeEvidence::default() + }; + + apply_protocol_to_profile(&mut overlay_profile, &built, &evidence).expect("overlay"); + let writer = overlay_profile + .calls + .iter() + .find(|call| call.receiver == "row" && call.message == "[]=") + .expect("callback hash writer"); + assert_eq!(writer.receiver_type.as_deref(), Some("Hash")); + assert_eq!(writer.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(writer.known_space_complexity.as_deref(), Some("O(1)")); + } + + #[test] + fn canonical_execution_buckets_preserve_receiver_target_correlation() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("worker.rb"); + std::fs::write( + &file, + "class Worker\n def run(value)\n value.size\n end\nend\n", + ) + .expect("source"); + let document = syntax::parse_file(file.clone(), Language::Ruby).expect("parse"); + let profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &profile, + std::slice::from_ref(&file), + directory.path(), + ) + .expect("plan"); + let call_anchor = built + .bindings + .iter() + .find_map(|(anchor, binding)| { + matches!(binding, AnchorBinding::Call { .. }).then(|| anchor.clone()) + }) + .expect("call anchor"); + let value_set = |name: &str| runtime_protocol::ValueSet { + alternatives: vec![runtime_protocol::WeightedValue { + value: MessageField::some(runtime_protocol::RuntimeValue { + type_symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {name}#"), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::PRODUCTION), + ..runtime_protocol::RuntimeValue::default() + }), + count: 1, + ..runtime_protocol::WeightedValue::default() + }], + ..runtime_protocol::ValueSet::default() + }; + let bucket = |receiver: &str| runtime_protocol::ExecutionBucket { + count: 1, + receiver: MessageField::some(value_set(receiver)), + target: MessageField::some(runtime_protocol::RuntimeTarget { + symbol: format!("nil-kill-runtime ruby ruby 3.2.3 {receiver}#size()."), + source_role: EnumOrUnknown::new(runtime_protocol::SourceRole::STANDARD_LIBRARY), + package_manager: "ruby".to_string(), + package_name: "ruby".to_string(), + package_version: "3.2.3".to_string(), + ..runtime_protocol::RuntimeTarget::default() + }), + provenance: MessageField::some(runtime_protocol::Provenance { + run_id: "run-1".to_string(), + provider: "ruby-tracepoint".to_string(), + provider_version: "1".to_string(), + ..runtime_protocol::Provenance::default() + }), + ..runtime_protocol::ExecutionBucket::default() + }; + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let selected = anchor.symbol == call_anchor; + runtime_protocol::AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(runtime_protocol::CaptureSummary { + status: EnumOrUnknown::new(if selected { + CaptureStatus::COMPLETE_FOR_RUNS + } else { + CaptureStatus::NOT_EXECUTED + }), + run_ids: vec!["run-1".to_string()], + observed_executions: if selected { 2 } else { 0 }, + reason: (!selected) + .then(|| "anchor did not execute in the modeled run".to_string()) + .unwrap_or_default(), + complete_kinds: request.required.clone(), + ..runtime_protocol::CaptureSummary::default() + }), + executions: if selected { + vec![bucket("String"), bucket("Array")] + } else { + Vec::new() + }, + ..runtime_protocol::AnchorEvidence::default() + } + }) + .collect(); + let evidence = runtime_protocol::RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(runtime_protocol::ToolInfo { + name: "nil-kill".to_string(), + version: "1".to_string(), + ..runtime_protocol::ToolInfo::default() + }), + authority: EnumOrUnknown::new(runtime_protocol::Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![runtime_protocol::Run { + id: "run-1".to_string(), + status: EnumOrUnknown::new(runtime_protocol::RunStatus::SUCCEEDED), + ..runtime_protocol::Run::default() + }], + anchors, + ..runtime_protocol::RuntimeEvidence::default() + }; + runtime_protocol::validate_runtime_evidence(&built.plan, &evidence).expect("valid"); + let facts = protocol_evidence(&profile, &built, &evidence).expect("facts"); + let correlated = facts + .calls + .iter() + .map(|call| { + ( + call.receiver_domain + .as_ref() + .expect("receiver") + .types + .iter() + .next() + .expect("type") + .clone(), + call.targets[0].symbol.clone(), + ) + }) + .collect::>(); + assert_eq!( + correlated, + BTreeSet::from([ + ( + "Array".to_string(), + "nil-kill-runtime ruby ruby 3.2.3 Array#size().".to_string(), + ), + ( + "String".to_string(), + "nil-kill-runtime ruby ruby 3.2.3 String#size().".to_string(), + ), + ]) + ); + } +} diff --git a/gems/fact-mine/src/runtime_protocol.rs b/gems/fact-mine/src/runtime_protocol.rs new file mode 100644 index 000000000..69ef2ee46 --- /dev/null +++ b/gems/fact-mine/src/runtime_protocol.rs @@ -0,0 +1,1717 @@ +//! Canonical Runtime Semantic Evidence protocol. +//! +//! The generated messages are the only accepted wire contract shared with +//! runtime collectors. Semantic validation lives here; source/CFG inference +//! belongs to the runtime evidence overlay. + +#[allow( + dead_code, + missing_docs, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + trivial_casts, + unused_attributes, + unused_mut, + unused_results +)] +mod generated { + include!(concat!( + env!("OUT_DIR"), + "/runtime_evidence_protocol_embedded.rs" + )); +} + +pub use generated::*; + +use anyhow::{bail, Context, Result}; +use flate2::read::GzDecoder; +use protobuf::{Enum, Message, MessageField}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path}; + +pub const PROTOCOL_VERSION: u32 = 1; + +/// The exact normalized entity named by a trace-plan anchor. +/// +/// This table is deliberately not serialized. FactMine regenerates it from +/// the same source snapshot before consuming evidence and refuses evidence +/// whose plan digest differs. Runtime collectors therefore never need to +/// reproduce FactMine IDs or source matching rules. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AnchorBinding { + Parameter { + method_id: String, + ordinal: usize, + name: String, + }, + Return { + method_id: String, + }, + Call { + call_id: String, + }, + State { + access_id: String, + }, +} + +#[derive(Clone, Debug)] +pub struct BuiltTracePlan { + pub plan: TracePlan, + pub bindings: BTreeMap, +} + +pub fn build_trace_plan( + profile: &crate::profile::ProfileOutput, + files: &[std::path::PathBuf], + root: &Path, +) -> Result { + Ok(build_trace_plan_with_bindings(profile, files, root)?.plan) +} + +pub fn build_trace_plan_with_bindings( + profile: &crate::profile::ProfileOutput, + files: &[std::path::PathBuf], + root: &Path, +) -> Result { + let root = root + .canonicalize() + .with_context(|| format!("failed to canonicalize trace-plan root {}", root.display()))?; + let mut documents = Vec::new(); + let mut path_lookup = BTreeMap::::new(); + for file in files { + let absolute = if file.is_absolute() { + file.clone() + } else { + root.join(file) + } + .canonicalize() + .with_context(|| format!("failed to canonicalize trace-plan input {}", file.display()))?; + let relative = absolute + .strip_prefix(&root) + .with_context(|| { + format!( + "trace-plan input {} is outside project root {}", + absolute.display(), + root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + validate_relative_path(&relative, "trace-plan input")?; + let bytes = fs::read(&absolute) + .with_context(|| format!("failed to read trace-plan input {}", absolute.display()))?; + let language = crate::syntax::Language::for_path(&absolute) + .with_context(|| format!("cannot detect language for {}", absolute.display()))?; + documents.push(PlannedDocument { + relative_path: relative.clone(), + language: language.as_str().to_string(), + position_encoding: PositionEncoding::UTF8_CODE_UNIT_OFFSET_FROM_LINE_START.into(), + content_sha256: Sha256::digest(bytes).to_vec(), + ..PlannedDocument::default() + }); + path_lookup.insert(normalize_profile_path(file), relative.clone()); + path_lookup.insert(normalize_profile_path(&absolute), relative); + } + documents.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + documents.dedup_by(|left, right| left.relative_path == right.relative_path); + + let methods = profile + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let result_sites = profile + .runtime_result_call_sites + .iter() + .map(|site| (normalized_plan_path(&site.path, &path_lookup), site.span)) + .collect::>(); + let collection_sites = profile + .runtime_collection_receiver_sites + .iter() + .map(|site| (normalized_plan_path(&site.path, &path_lookup), site.span)) + .collect::>(); + let predicate_calls = profile + .runtime_capability_guards + .iter() + .map(|guard| guard.condition_call_id.as_str()) + .collect::>(); + let call_ordinals = stable_ordinals( + profile + .calls + .iter() + .map(|call| (call.source.as_str(), call.id.as_str(), call.span)), + ); + let state_ordinals = stable_ordinals( + profile + .state_accesses + .iter() + .map(|access| (access.function_id.as_str(), access.id.as_str(), access.span)), + ); + + let mut requests = Vec::new(); + let mut bindings = BTreeMap::new(); + for method in profile + .methods + .iter() + .filter(|method| method.source_export_eligible && !method.generated_declaration) + { + let relative_path = normalized_plan_path(&method.path, &path_lookup); + let span = method.span.unwrap_or([method.line, 0, method.line, 0]); + let enclosing_symbol = plan_method_symbol(method, &relative_path); + let method_anchor_id = stable_id(&format!( + "{}\0{}\0{}\0{}", + relative_path, method.owner, method.name, method.kind + )); + for (ordinal, parameter) in method.params.iter().enumerate() { + let anchor = plan_anchor( + &format!("param-{method_anchor_id}-{ordinal}"), + &relative_path, + span, + AnchorKind::FUNCTION_ENTRY, + &enclosing_symbol, + &format!( + "{}\0parameter\0{ordinal}\0{parameter}", + method.normalized_source + ), + parameter, + ); + bindings.insert( + anchor.symbol.clone(), + AnchorBinding::Parameter { + method_id: method.id.clone(), + ordinal, + name: parameter.clone(), + }, + ); + requests.push(EvidenceRequest { + anchor: MessageField::some(anchor), + required: vec![EvidenceKind::PARAMETER_VALUE.into()], + parameter_ordinal: Some(ordinal as u32), + ..EvidenceRequest::default() + }); + } + let anchor = plan_anchor( + &format!("return-{method_anchor_id}"), + &relative_path, + span, + AnchorKind::FUNCTION_RETURN, + &enclosing_symbol, + &format!("{}\0return", method.normalized_source), + "return", + ); + bindings.insert( + anchor.symbol.clone(), + AnchorBinding::Return { + method_id: method.id.clone(), + }, + ); + requests.push(EvidenceRequest { + anchor: MessageField::some(anchor), + required: vec![EvidenceKind::RETURN_VALUE.into()], + ..EvidenceRequest::default() + }); + } + + for call in &profile.calls { + let Some(method) = methods.get(call.source.as_str()) else { + continue; + }; + let relative_path = normalized_plan_path(&call.path, &path_lookup); + let needs_result = result_sites.contains(&(relative_path.clone(), call.span)); + let needs_collection = collection_sites.contains(&(relative_path.clone(), call.span)); + let needs_predicate = predicate_calls.contains(call.id.as_str()); + let unresolved = call.target.is_none() && call.semantic_symbol.is_none(); + if !unresolved && !needs_result && !needs_collection && !needs_predicate { + continue; + } + let mut required = vec![EvidenceKind::RECEIVER_VALUE, EvidenceKind::CALL_TARGET]; + if needs_result { + required.push(EvidenceKind::RESULT_VALUE); + } + if needs_collection { + required.push(EvidenceKind::COLLECTION_VALUE); + } + if needs_predicate { + required.push(EvidenceKind::BOOLEAN_RESULT); + } + required.sort_by_key(|kind| kind.value()); + required.dedup(); + let selector_span = call.selector_span.unwrap_or(call.span); + let enclosing_symbol = plan_method_symbol(method, &relative_path); + let ordinal = call_ordinals.get(call.id.as_str()).copied().unwrap_or(0); + let method_anchor_id = stable_id(&format!( + "{}\0{}\0{}\0{}", + relative_path, method.owner, method.name, method.kind + )); + let anchor = plan_anchor( + &format!("call-{method_anchor_id}-{ordinal}"), + &relative_path, + selector_span, + if needs_predicate { + AnchorKind::BRANCH_PREDICATE + } else if needs_collection { + AnchorKind::COLLECTION_OPERATION + } else { + AnchorKind::CALL_SELECTOR + }, + &enclosing_symbol, + &format!( + "{}\0call\0{ordinal}\0{}\0{}", + method.normalized_source, call.receiver, call.message + ), + &call.message, + ); + bindings.insert( + anchor.symbol.clone(), + AnchorBinding::Call { + call_id: call.id.clone(), + }, + ); + requests.push(EvidenceRequest { + anchor: MessageField::some(anchor), + required: required.into_iter().map(Into::into).collect(), + execution_range: MessageField::some(plan_source_range( + call.execution_span.unwrap_or(call.span), + )), + ..EvidenceRequest::default() + }); + } + + for access in profile + .state_accesses + .iter() + .filter(|access| access.kind.contains("write")) + { + let Some(method) = methods.get(access.function_id.as_str()) else { + continue; + }; + let relative_path = normalized_plan_path(&access.path, &path_lookup); + let ordinal = state_ordinals.get(access.id.as_str()).copied().unwrap_or(0); + let method_anchor_id = stable_id(&format!( + "{}\0{}\0{}\0{}", + relative_path, method.owner, method.name, method.kind + )); + let anchor = plan_anchor( + &format!("state-{method_anchor_id}-{ordinal}"), + &relative_path, + access.span, + AnchorKind::STATE_WRITE, + &plan_method_symbol(method, &relative_path), + &format!( + "{}\0state\0{ordinal}\0{}", + method.normalized_source, access.field + ), + &access.field, + ); + bindings.insert( + anchor.symbol.clone(), + AnchorBinding::State { + access_id: access.id.clone(), + }, + ); + requests.push(EvidenceRequest { + anchor: MessageField::some(anchor), + required: vec![EvidenceKind::STATE_VALUE.into()], + ..EvidenceRequest::default() + }); + } + + requests.sort_by(|left, right| { + let left = left.anchor.as_ref().expect("constructed anchor"); + let right = right.anchor.as_ref().expect("constructed anchor"); + (&left.relative_path, &left.symbol).cmp(&(&right.relative_path, &right.symbol)) + }); + let mut plan = TracePlan { + protocol_version: PROTOCOL_VERSION, + producer: MessageField::some(ToolInfo { + name: "fact-mine-rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + arguments: vec!["runtime-plan".to_string()], + ..ToolInfo::default() + }), + project_root: format!("file://{}", root.to_string_lossy()), + documents, + requests, + ..TracePlan::default() + }; + plan.plan_digest = trace_plan_digest(&plan)?; + validate_trace_plan(&plan)?; + if plan.requests.len() != bindings.len() { + bail!( + "trace plan contains {} requests but {} exact bindings", + plan.requests.len(), + bindings.len() + ); + } + Ok(BuiltTracePlan { plan, bindings }) +} + +fn plan_anchor( + id: &str, + relative_path: &str, + span: [usize; 4], + kind: AnchorKind, + enclosing_symbol: &str, + semantic_source: &str, + display_name: &str, +) -> SourceAnchor { + SourceAnchor { + symbol: format!("local {id}"), + relative_path: relative_path.to_string(), + range: MessageField::some(plan_source_range(span)), + kind: kind.into(), + enclosing_symbol: enclosing_symbol.to_string(), + semantic_digest: Sha256::digest(semantic_source.as_bytes()).to_vec(), + display_name: display_name.to_string(), + ..SourceAnchor::default() + } +} + +fn plan_source_range(span: [usize; 4]) -> SourceRange { + SourceRange { + start_line: span[0].saturating_sub(1) as u32, + start_character: span[1] as u32, + end_line: span[2].saturating_sub(1) as u32, + end_character: span[3] as u32, + ..SourceRange::default() + } +} + +fn plan_method_symbol(method: &crate::profile::MethodRecord, relative_path: &str) -> String { + method.semantic_symbol.clone().unwrap_or_else(|| { + format!( + "fact-mine workspace project . Method#{}().", + stable_id(&format!( + "{}\0{}\0{}\0{}", + relative_path, method.owner, method.name, method.kind + )) + ) + }) +} + +fn stable_id(value: &str) -> String { + Sha256::digest(value.as_bytes())[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn stable_ordinals<'a>( + rows: impl Iterator, +) -> BTreeMap<&'a str, usize> { + let mut grouped = BTreeMap::<&str, Vec<(&str, [usize; 4])>>::new(); + for (source, id, span) in rows { + grouped.entry(source).or_default().push((id, span)); + } + let mut ordinals = BTreeMap::new(); + for rows in grouped.values_mut() { + rows.sort_by_key(|(_, span)| *span); + for (ordinal, (id, _)) in rows.iter().enumerate() { + ordinals.insert(*id, ordinal); + } + } + ordinals +} + +fn normalize_profile_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn normalized_plan_path(path: &str, lookup: &BTreeMap) -> String { + lookup + .get(&path.replace('\\', "/")) + .cloned() + .unwrap_or_else(|| path.replace('\\', "/").trim_start_matches("./").to_string()) +} + +pub fn parse_trace_plan_json(json: &str) -> Result { + let plan = protobuf_json_mapping::parse_from_str(json) + .context("trace plan is not valid canonical ProtoJSON")?; + validate_trace_plan(&plan)?; + Ok(plan) +} + +pub fn parse_runtime_evidence_json(json: &str) -> Result { + let evidence = protobuf_json_mapping::parse_from_str(json) + .context("runtime evidence is not valid canonical ProtoJSON")?; + validate_runtime_evidence_shape(&evidence)?; + Ok(evidence) +} + +pub fn read_trace_plan(path: &Path) -> Result { + parse_trace_plan_json(&read_json(path)?) + .with_context(|| format!("invalid trace plan {}", path.display())) +} + +pub fn read_runtime_evidence(path: &Path) -> Result { + parse_runtime_evidence_json(&read_json(path)?) + .with_context(|| format!("invalid runtime evidence {}", path.display())) +} + +/// The producer's encoding: every field present, including defaults, so a +/// consumer never has to distinguish "absent" from "zero". +pub fn to_json_with_defaults(message: &dyn protobuf::MessageDyn) -> Result { + protobuf_json_mapping::print_to_string_with_options( + message, + &protobuf_json_mapping::PrintOptions { + enum_values_int: false, + proto_field_name: true, + always_output_default_values: true, + _future_options: (), + }, + ) + .context("failed to encode canonical ProtoJSON") +} + +pub fn to_json(message: &dyn protobuf::MessageDyn) -> Result { + protobuf_json_mapping::print_to_string_with_options( + message, + &protobuf_json_mapping::PrintOptions { + enum_values_int: false, + proto_field_name: true, + always_output_default_values: false, + _future_options: (), + }, + ) + .context("failed to encode canonical ProtoJSON") +} + +pub fn read_json(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + if bytes.starts_with(&[0x1f, 0x8b]) { + let mut decoded = String::new(); + GzDecoder::new(bytes.as_slice()) + .read_to_string(&mut decoded) + .with_context(|| format!("failed to decompress {}", path.display()))?; + Ok(decoded) + } else { + String::from_utf8(bytes) + .with_context(|| format!("{} is not valid UTF-8 ProtoJSON", path.display())) + } +} + +pub fn trace_plan_digest(plan: &TracePlan) -> Result> { + let mut canonical = plan.clone(); + canonical.plan_digest.clear(); + Ok(Sha256::digest(canonical.write_to_bytes()?).to_vec()) +} + +pub fn validate_trace_plan(plan: &TracePlan) -> Result<()> { + if plan.protocol_version != PROTOCOL_VERSION { + bail!( + "trace plan protocol_version must be {PROTOCOL_VERSION}, got {}", + plan.protocol_version + ); + } + validate_tool(plan.producer.as_ref(), "trace plan producer")?; + if plan.project_root.is_empty() { + bail!("trace plan project_root must not be empty"); + } + if plan.documents.is_empty() { + bail!("trace plan must contain at least one document"); + } + + let mut documents = BTreeMap::new(); + for (index, document) in plan.documents.iter().enumerate() { + validate_relative_path( + &document.relative_path, + &format!("documents[{index}].relative_path"), + )?; + if document.language.is_empty() { + bail!("documents[{index}].language must not be empty"); + } + if document.position_encoding.enum_value_or_default() + == PositionEncoding::POSITION_ENCODING_UNSPECIFIED + { + bail!("documents[{index}].position_encoding must be explicit"); + } + if document.content_sha256.len() != 32 { + bail!("documents[{index}].content_sha256 must contain 32 bytes"); + } + if documents + .insert(document.relative_path.as_str(), document) + .is_some() + { + bail!("duplicate trace-plan document {:?}", document.relative_path); + } + } + + let mut anchors = BTreeSet::new(); + for (index, request) in plan.requests.iter().enumerate() { + let anchor = request + .anchor + .as_ref() + .with_context(|| format!("requests[{index}] requires an anchor"))?; + validate_anchor(anchor, &documents, &format!("requests[{index}].anchor"))?; + let call_anchor = matches!( + anchor.kind.enum_value_or_default(), + AnchorKind::CALL_SELECTOR + | AnchorKind::COLLECTION_OPERATION + | AnchorKind::BRANCH_PREDICATE + ); + if call_anchor && request.execution_range.is_none() { + bail!("requests[{index}].execution_range is required for a call anchor"); + } + if let Some(execution_range) = request.execution_range.as_ref() { + validate_range( + Some(execution_range), + &format!("requests[{index}].execution_range"), + )?; + let selector_range = anchor.range.as_ref().expect("validated anchor range"); + if (execution_range.start_line, execution_range.start_character) + > (selector_range.start_line, selector_range.start_character) + || (execution_range.end_line, execution_range.end_character) + < (selector_range.end_line, selector_range.end_character) + { + bail!("requests[{index}].execution_range must contain the complete anchor range"); + } + } + if !anchors.insert(anchor.symbol.as_str()) { + bail!("duplicate trace-plan anchor {:?}", anchor.symbol); + } + if request.required.is_empty() { + bail!("requests[{index}] must request at least one evidence kind"); + } + let mut kinds = BTreeSet::new(); + for kind in &request.required { + let kind = kind.enum_value().map_err(|value| { + anyhow::anyhow!("requests[{index}] has unknown evidence kind {value}") + })?; + if kind == EvidenceKind::EVIDENCE_KIND_UNSPECIFIED { + bail!("requests[{index}] contains an unspecified evidence kind"); + } + if !kinds.insert(kind.value()) { + bail!("requests[{index}] contains duplicate evidence kind {kind:?}"); + } + } + let allowed_kinds = allowed_evidence_kinds(anchor.kind.enum_value_or_default()); + if !kinds.iter().all(|kind| allowed_kinds.contains(kind)) { + bail!( + "requests[{index}] requests evidence incompatible with {:?}", + anchor.kind.enum_value_or_default() + ); + } + if request.parameter_ordinal.is_some() + && !kinds.contains(&EvidenceKind::PARAMETER_VALUE.value()) + { + bail!("requests[{index}] has parameter_ordinal without PARAMETER_VALUE"); + } + if let Some(activation) = request.activation_anchor.as_ref() { + validate_anchor( + activation, + &documents, + &format!("requests[{index}].activation_anchor"), + )?; + } + } + + if plan.plan_digest.len() != 32 { + bail!("trace plan plan_digest must contain 32 bytes"); + } + if trace_plan_digest(plan)? != plan.plan_digest { + bail!("trace plan plan_digest does not match its canonical contents"); + } + Ok(()) +} + +fn allowed_evidence_kinds(kind: AnchorKind) -> BTreeSet { + let kinds: &[EvidenceKind] = match kind { + AnchorKind::FUNCTION_ENTRY | AnchorKind::CALLBACK_ENTRY => &[EvidenceKind::PARAMETER_VALUE], + AnchorKind::FUNCTION_RETURN => &[EvidenceKind::RETURN_VALUE], + AnchorKind::CALL_SELECTOR => &[ + EvidenceKind::RECEIVER_VALUE, + EvidenceKind::CALL_TARGET, + EvidenceKind::RESULT_VALUE, + ], + AnchorKind::STATE_READ | AnchorKind::STATE_WRITE => &[EvidenceKind::STATE_VALUE], + AnchorKind::COLLECTION_OPERATION => &[ + EvidenceKind::RECEIVER_VALUE, + EvidenceKind::CALL_TARGET, + EvidenceKind::RESULT_VALUE, + EvidenceKind::COLLECTION_VALUE, + ], + AnchorKind::BRANCH_PREDICATE => &[ + EvidenceKind::RECEIVER_VALUE, + EvidenceKind::CALL_TARGET, + EvidenceKind::RESULT_VALUE, + EvidenceKind::BOOLEAN_RESULT, + ], + AnchorKind::ANCHOR_KIND_UNSPECIFIED => &[], + }; + kinds.iter().map(|kind| kind.value()).collect() +} + +pub fn validate_runtime_evidence(plan: &TracePlan, evidence: &RuntimeEvidence) -> Result<()> { + validate_trace_plan(plan)?; + validate_runtime_evidence_shape(evidence)?; + if evidence.trace_plan_digest != plan.plan_digest { + bail!("runtime evidence trace_plan_digest does not match the supplied trace plan"); + } + + let requests = plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("validated anchor"); + (anchor.symbol.as_str(), (request, anchor)) + }) + .collect::>(); + let mut observed = BTreeSet::new(); + for (index, anchor_evidence) in evidence.anchors.iter().enumerate() { + let Some((request, anchor)) = requests.get(anchor_evidence.anchor_symbol.as_str()) else { + bail!( + "anchors[{index}] references unknown plan anchor {:?}", + anchor_evidence.anchor_symbol + ); + }; + if !observed.insert(anchor_evidence.anchor_symbol.as_str()) { + bail!( + "duplicate evidence for plan anchor {:?}", + anchor_evidence.anchor_symbol + ); + } + if anchor_evidence.anchor_semantic_digest != anchor.semantic_digest { + bail!( + "anchors[{index}] semantic digest does not match {:?}", + anchor_evidence.anchor_symbol + ); + } + validate_anchor_evidence(index, request, anchor_evidence, evidence)?; + } + let mut correlation_ids = BTreeSet::new(); + for (index, correlation) in evidence.correlations.iter().enumerate() { + if !correlation_ids.insert(correlation.group_id.as_str()) { + bail!( + "duplicate runtime correlation group {:?}", + correlation.group_id + ); + } + validate_correlation_evidence(index, correlation, &requests, evidence)?; + } + // An anchor with no entry was not executed in these runs. Requiring an + // explicit entry for every planned anchor made a shard's evidence + // proportional to the plan rather than to what it observed: a 682-byte + // trace produced a 4.6MB document, almost all of it saying "nothing + // happened here", and every stage downstream paid to write, read and merge + // it. Absence carries the same claim at no cost. + Ok(()) +} + +fn validate_runtime_evidence_shape(evidence: &RuntimeEvidence) -> Result<()> { + if evidence.protocol_version != PROTOCOL_VERSION { + bail!( + "runtime evidence protocol_version must be {PROTOCOL_VERSION}, got {}", + evidence.protocol_version + ); + } + validate_tool(evidence.producer.as_ref(), "runtime evidence producer")?; + if evidence.authority.enum_value_or_default() != Authority::MODELED_RUNS { + bail!("runtime evidence authority must be MODELED_RUNS"); + } + if evidence.trace_plan_digest.len() != 32 { + bail!("runtime evidence trace_plan_digest must contain 32 bytes"); + } + let mut environment = BTreeSet::new(); + for (index, claim) in evidence.environment.iter().enumerate() { + if claim.key.is_empty() || claim.value.is_empty() { + bail!("environment[{index}] requires a non-empty key and value"); + } + if !environment.insert(claim.key.as_str()) { + bail!("duplicate runtime environment claim {:?}", claim.key); + } + } + let mut runs = BTreeSet::new(); + for (index, run) in evidence.runs.iter().enumerate() { + if run.id.is_empty() { + bail!("runs[{index}].id must not be empty"); + } + if !runs.insert(run.id.as_str()) { + bail!("duplicate runtime run {:?}", run.id); + } + if run.status.enum_value_or_default() == RunStatus::RUN_STATUS_UNSPECIFIED { + bail!("runs[{index}].status must be explicit"); + } + } + if evidence.runs.is_empty() + && (!evidence.anchors.is_empty() || !evidence.correlations.is_empty()) + { + bail!("runtime evidence with observations must declare at least one run"); + } + Ok(()) +} + +fn validate_anchor_evidence( + index: usize, + request: &EvidenceRequest, + evidence: &AnchorEvidence, + bundle: &RuntimeEvidence, +) -> Result<()> { + let context = format!("anchors[{index}]"); + let capture = evidence + .capture + .as_ref() + .with_context(|| format!("{context} requires capture metadata"))?; + let required = request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + .collect::>(); + validate_capture_and_buckets(&context, capture, &evidence.executions, &required, bundle) +} + +fn validate_correlation_evidence( + index: usize, + evidence: &CorrelationEvidence, + requests: &BTreeMap<&str, (&EvidenceRequest, &SourceAnchor)>, + bundle: &RuntimeEvidence, +) -> Result<()> { + let context = format!("correlations[{index}]"); + if evidence.group_id.is_empty() { + bail!("{context} group_id must not be empty"); + } + if evidence.candidate_anchor_symbols.len() < 2 { + bail!("{context} requires at least two candidate anchors"); + } + if evidence + .candidate_anchor_symbols + .windows(2) + .any(|pair| pair[0] >= pair[1]) + { + bail!("{context} candidate anchors must be unique and sorted"); + } + let mut candidates = Vec::new(); + let mut required = BTreeSet::new(); + for symbol in &evidence.candidate_anchor_symbols { + let Some((request, anchor)) = requests.get(symbol.as_str()) else { + bail!("{context} references unknown plan anchor {symbol:?}"); + }; + if !matches!( + anchor.kind.enum_value_or_default(), + AnchorKind::CALL_SELECTOR + | AnchorKind::COLLECTION_OPERATION + | AnchorKind::BRANCH_PREDICATE + ) { + bail!("{context} candidate {symbol:?} is not a call anchor"); + } + required.extend( + request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())), + ); + candidates.push(*anchor); + } + let first = candidates[0]; + if candidates.iter().any(|anchor| { + anchor.relative_path != first.relative_path || anchor.display_name != first.display_name + }) { + bail!("{context} candidates must share a document and observed selector"); + } + let latest_start = candidates + .iter() + .filter_map(|anchor| anchor.range.as_ref().map(|range| range.start_line)) + .max() + .context("validated correlation candidate is missing a range")?; + let earliest_end = candidates + .iter() + .filter_map(|anchor| anchor.range.as_ref().map(|range| range.end_line)) + .min() + .context("validated correlation candidate is missing a range")?; + if latest_start > earliest_end { + bail!("{context} candidate source ranges do not overlap"); + } + let capture = evidence + .capture + .as_ref() + .with_context(|| format!("{context} requires capture metadata"))?; + let status = capture + .status + .enum_value() + .map_err(|value| anyhow::anyhow!("{context} has unknown capture status {value}"))?; + if !matches!( + status, + CaptureStatus::COMPLETE_FOR_RUNS | CaptureStatus::PARTIAL + ) || evidence.executions.is_empty() + { + bail!("{context} must contain a complete or partial observed execution"); + } + validate_capture_and_buckets(&context, capture, &evidence.executions, &required, bundle) +} + +fn validate_capture_and_buckets( + context: &str, + capture: &CaptureSummary, + executions: &[ExecutionBucket], + allowed_kinds: &BTreeSet, + bundle: &RuntimeEvidence, +) -> Result<()> { + let status = capture + .status + .enum_value() + .map_err(|value| anyhow::anyhow!("{context} has unknown capture status {value}"))?; + if status == CaptureStatus::CAPTURE_STATUS_UNSPECIFIED { + bail!("{context} capture status must be explicit"); + } + if status != CaptureStatus::COMPLETE_FOR_RUNS && capture.reason.trim().is_empty() { + bail!("{context} non-complete capture requires a precise reason"); + } + let known_runs = bundle + .runs + .iter() + .map(|run| run.id.as_str()) + .collect::>(); + let mut capture_runs = BTreeSet::new(); + for run in &capture.run_ids { + if !known_runs.contains(run.as_str()) { + bail!("{context} references unknown run {run:?}"); + } + if !capture_runs.insert(run.as_str()) { + bail!("{context} contains duplicate run {run:?}"); + } + } + if capture.run_ids.is_empty() { + bail!("{context} must identify its contributing runs"); + } + let bucket_count = executions + .iter() + .try_fold(0u64, |total, bucket| total.checked_add(bucket.count)) + .with_context(|| format!("{context} execution count overflow"))?; + if bucket_count != capture.observed_executions { + bail!( + "{context} observed_executions {} does not equal bucket count {bucket_count}", + capture.observed_executions + ); + } + match status { + CaptureStatus::COMPLETE_FOR_RUNS => { + if capture.dropped_executions != 0 { + bail!("{context} complete capture cannot contain dropped executions"); + } + if capture.observed_executions == 0 { + bail!("{context} COMPLETE_FOR_RUNS requires an execution"); + } + } + CaptureStatus::NOT_EXECUTED => { + if capture.observed_executions != 0 + || capture.dropped_executions != 0 + || !executions.is_empty() + { + bail!("{context} NOT_EXECUTED must have no executions"); + } + } + _ => {} + } + let complete = capture + .complete_kinds + .iter() + .map(|kind| { + kind.enum_value() + .map(|kind| kind.value()) + .map_err(|value| anyhow::anyhow!("{context} has unknown complete kind {value}")) + }) + .collect::>>()?; + if complete.contains(&EvidenceKind::EVIDENCE_KIND_UNSPECIFIED.value()) { + bail!("{context} complete_kinds must be explicit"); + } + if complete.len() != capture.complete_kinds.len() { + bail!("{context} contains duplicate complete_kinds"); + } + if !complete.iter().all(|kind| allowed_kinds.contains(kind)) { + bail!("{context} completes evidence that the trace plan did not request"); + } + if status == CaptureStatus::COMPLETE_FOR_RUNS && complete != *allowed_kinds { + bail!("{context} COMPLETE_FOR_RUNS must complete every requested evidence kind"); + } + for (bucket_index, bucket) in executions.iter().enumerate() { + if bucket.count == 0 { + bail!("{context}.executions[{bucket_index}].count must be positive"); + } + if (complete.contains(&EvidenceKind::RECEIVER_VALUE.value()) + || complete.contains(&EvidenceKind::COLLECTION_VALUE.value())) + && bucket.receiver.is_none() + { + bail!("{context}.executions[{bucket_index}] lacks required receiver"); + } + if complete.contains(&EvidenceKind::CALL_TARGET.value()) && bucket.target.is_none() { + bail!("{context}.executions[{bucket_index}] lacks required target"); + } + if complete.contains(&EvidenceKind::RESULT_VALUE.value()) && bucket.result.is_none() { + bail!("{context}.executions[{bucket_index}] lacks required result"); + } + if complete.contains(&EvidenceKind::BOOLEAN_RESULT.value()) + && bucket.boolean_result.is_none() + { + bail!("{context}.executions[{bucket_index}] lacks required Boolean result"); + } + if complete.iter().any(|kind| { + *kind == EvidenceKind::PARAMETER_VALUE.value() + || *kind == EvidenceKind::RETURN_VALUE.value() + || *kind == EvidenceKind::STATE_VALUE.value() + }) && bucket.value.is_none() + { + bail!("{context}.executions[{bucket_index}] lacks required boundary value"); + } + if let Some(receiver) = bucket.receiver.as_ref() { + validate_value_set( + Some(receiver), + &format!("{context}.executions[{bucket_index}].receiver"), + )?; + } + if let Some(result) = bucket.result.as_ref() { + validate_value_set( + Some(result), + &format!("{context}.executions[{bucket_index}].result"), + )?; + } + if let Some(value) = bucket.value.as_ref() { + validate_value_set( + Some(value), + &format!("{context}.executions[{bucket_index}].value"), + )?; + } + if let Some(target) = bucket.target.as_ref() { + validate_runtime_target( + target, + &format!("{context}.executions[{bucket_index}].target"), + )?; + } + let provenance = bucket + .provenance + .as_ref() + .with_context(|| format!("{context}.executions[{bucket_index}] requires provenance"))?; + if provenance.run_id.is_empty() + || provenance.provider.is_empty() + || provenance.provider_version.is_empty() + { + bail!("{context}.executions[{bucket_index}] provenance is incomplete"); + } + if !capture_runs.contains(provenance.run_id.as_str()) { + bail!("{context}.executions[{bucket_index}] provenance run is outside capture runs"); + } + if ((complete.contains(&EvidenceKind::RECEIVER_VALUE.value()) + || complete.contains(&EvidenceKind::COLLECTION_VALUE.value())) + && bucket.receiver.as_ref().is_some_and(value_set_is_truncated)) + || (complete.contains(&EvidenceKind::RESULT_VALUE.value()) + && bucket.result.as_ref().is_some_and(value_set_is_truncated)) + || (complete.iter().any(|kind| { + *kind == EvidenceKind::PARAMETER_VALUE.value() + || *kind == EvidenceKind::RETURN_VALUE.value() + || *kind == EvidenceKind::STATE_VALUE.value() + }) && bucket.value.as_ref().is_some_and(value_set_is_truncated)) + { + bail!("{context} complete evidence kind cannot contain truncated values"); + } + } + Ok(()) +} + +fn validate_runtime_target(target: &RuntimeTarget, context: &str) -> Result<()> { + validate_global_symbol(&target.symbol, &format!("{context}.symbol"))?; + if target.source_role.enum_value_or_default() == SourceRole::SOURCE_ROLE_UNSPECIFIED { + bail!("{context}.source_role must be explicit"); + } + if target.package_manager.is_empty() + || target.package_name.is_empty() + || target.package_version.is_empty() + { + bail!("{context} requires package manager, name, and version"); + } + if let Some(definition) = target.definition.as_ref() { + if !definition.symbol.is_empty() { + validate_global_symbol(&definition.symbol, &format!("{context}.definition.symbol"))?; + } + if !definition.anchor_symbol.is_empty() { + validate_local_symbol( + &definition.anchor_symbol, + &format!("{context}.definition.anchor_symbol"), + )?; + } + if !definition.relative_path.is_empty() { + validate_relative_path( + &definition.relative_path, + &format!("{context}.definition.relative_path"), + )?; + } + } + Ok(()) +} + +fn validate_runtime_value(value: &RuntimeValue, context: &str) -> Result<()> { + validate_global_symbol(&value.type_symbol, &format!("{context}.type_symbol"))?; + if !value.singleton_symbol.is_empty() { + validate_global_symbol( + &value.singleton_symbol, + &format!("{context}.singleton_symbol"), + )?; + } + if value.source_role.enum_value_or_default() == SourceRole::SOURCE_ROLE_UNSPECIFIED { + bail!("{context}.source_role must be explicit"); + } + match value.shape.as_ref() { + Some(runtime_value::Shape::Sequence(shape)) => validate_value_set( + shape.elements.as_ref(), + &format!("{context}.sequence.elements"), + )?, + Some(runtime_value::Shape::Mapping(shape)) => { + for (index, entry) in shape.entries.iter().enumerate() { + let key = entry + .key + .as_ref() + .with_context(|| format!("{context}.mapping.entries[{index}] lacks key"))?; + let value = entry + .value + .as_ref() + .with_context(|| format!("{context}.mapping.entries[{index}] lacks value"))?; + validate_runtime_value(key, &format!("{context}.mapping.entries[{index}].key"))?; + validate_runtime_value( + value, + &format!("{context}.mapping.entries[{index}].value"), + )?; + if entry.count == 0 { + bail!("{context}.mapping.entries[{index}].count must be positive"); + } + } + } + Some(runtime_value::Shape::Record(shape)) => { + let mut members = BTreeSet::new(); + for (index, member) in shape.members.iter().enumerate() { + if member.name.is_empty() || !members.insert(member.name.as_str()) { + bail!("{context}.record.members[{index}] has an empty or duplicate name"); + } + validate_value_set( + member.values.as_ref(), + &format!("{context}.record.members[{index}].values"), + )?; + } + } + Some(runtime_value::Shape::Tuple(shape)) => { + for (index, element) in shape.elements.iter().enumerate() { + validate_value_set(Some(element), &format!("{context}.tuple.elements[{index}]"))?; + } + } + None => {} + } + Ok(()) +} + +fn validate_value_set(values: Option<&ValueSet>, context: &str) -> Result<()> { + let values = values.with_context(|| format!("{context} is missing"))?; + if values.alternatives.is_empty() && !values.truncated { + bail!("{context} must contain an alternative or be marked truncated"); + } + for (index, alternative) in values.alternatives.iter().enumerate() { + if alternative.count == 0 { + bail!("{context}.alternatives[{index}].count must be positive"); + } + let value = alternative + .value + .as_ref() + .with_context(|| format!("{context}.alternatives[{index}] lacks a value"))?; + validate_runtime_value(value, &format!("{context}.alternatives[{index}].value"))?; + } + Ok(()) +} + +fn runtime_value_is_truncated(value: &RuntimeValue) -> bool { + value.truncated + || match value.shape.as_ref() { + Some(runtime_value::Shape::Sequence(shape)) => shape + .elements + .as_ref() + .is_some_and(|values| values.truncated), + Some(runtime_value::Shape::Mapping(shape)) => shape.truncated, + Some(runtime_value::Shape::Record(shape)) => shape.truncated, + Some(runtime_value::Shape::Tuple(shape)) => shape.truncated, + None => false, + } +} + +fn value_set_is_truncated(values: &ValueSet) -> bool { + values.truncated + || values.alternatives.iter().any(|alternative| { + alternative + .value + .as_ref() + .is_some_and(runtime_value_is_truncated) + }) +} + +fn validate_tool(tool: Option<&ToolInfo>, context: &str) -> Result<()> { + let tool = tool.with_context(|| format!("{context} is required"))?; + if tool.name.is_empty() || tool.version.is_empty() { + bail!("{context} requires a name and version"); + } + Ok(()) +} + +fn validate_anchor( + anchor: &SourceAnchor, + documents: &BTreeMap<&str, &PlannedDocument>, + context: &str, +) -> Result<()> { + validate_local_symbol(&anchor.symbol, &format!("{context}.symbol"))?; + let document = documents + .get(anchor.relative_path.as_str()) + .with_context(|| format!("{context} references an unknown document"))?; + validate_range(anchor.range.as_ref(), &format!("{context}.range"))?; + if anchor.kind.enum_value_or_default() == AnchorKind::ANCHOR_KIND_UNSPECIFIED { + bail!("{context}.kind must be explicit"); + } + validate_global_symbol( + &anchor.enclosing_symbol, + &format!("{context}.enclosing_symbol"), + )?; + if anchor.semantic_digest.len() != 32 { + bail!("{context}.semantic_digest must contain 32 bytes"); + } + if document.position_encoding.enum_value_or_default() + == PositionEncoding::POSITION_ENCODING_UNSPECIFIED + { + bail!("{context} document position encoding is unspecified"); + } + Ok(()) +} + +fn validate_range(range: Option<&SourceRange>, context: &str) -> Result<()> { + let range = range.with_context(|| format!("{context} is required"))?; + if (range.end_line, range.end_character) < (range.start_line, range.start_character) { + bail!("{context} must be a non-negative half-open range"); + } + Ok(()) +} + +fn validate_relative_path(path: &str, context: &str) -> Result<()> { + if path.is_empty() || path.contains('\\') { + bail!("{context} must be a non-empty canonical '/'-separated path"); + } + let parsed = Path::new(path); + if parsed.is_absolute() + || parsed.components().any(|component| { + matches!( + component, + Component::RootDir + | Component::ParentDir + | Component::CurDir + | Component::Prefix(_) + ) + }) + { + bail!("{context} must be canonical and project-relative"); + } + Ok(()) +} + +fn validate_local_symbol(symbol: &str, context: &str) -> Result<()> { + match scip::symbol::try_parse_local_symbol(symbol) { + Ok(Some(_)) => Ok(()), + _ => bail!("{context} must be a valid document-local SCIP symbol"), + } +} + +thread_local! { + /// Symbols already proven canonical. Whether a symbol is in canonical form + /// is a property of the symbol alone -- `context` only names the field for + /// the error -- and a corpus repeats the same few thousand symbols across + /// every anchor that mentions them. + static CANONICAL_SYMBOLS: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashSet::new()); +} + +fn validate_global_symbol(symbol: &str, context: &str) -> Result<()> { + if symbol.is_empty() || scip::symbol::is_local_symbol(symbol) { + bail!("{context} must be a canonical global SCIP symbol"); + } + if CANONICAL_SYMBOLS.with(|known| known.borrow().contains(symbol)) { + return Ok(()); + } + let parsed = scip::symbol::parse_symbol(symbol) + .map_err(|error| anyhow::anyhow!("{context}: {error:?}"))?; + if scip::symbol::format_symbol(parsed) != symbol { + bail!("{context} is not in canonical SCIP symbol form"); + } + CANONICAL_SYMBOLS.with(|known| known.borrow_mut().insert(symbol.to_string())); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use protobuf::{EnumOrUnknown, MessageField}; + use std::io::Write; + + fn conformance_fixture(name: &str) -> String { + fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../protocol/runtime-evidence/v1/conformance") + .join(name), + ) + .expect("shared runtime protocol conformance fixture") + } + + fn tool(name: &str) -> ToolInfo { + ToolInfo { + name: name.to_string(), + version: "1".to_string(), + ..ToolInfo::default() + } + } + + fn plan() -> TracePlan { + let digest = Sha256::digest(b"source").to_vec(); + let anchor = SourceAnchor { + symbol: "local call-1".to_string(), + relative_path: "lib/worker.rb".to_string(), + range: MessageField::some(SourceRange { + start_line: 2, + start_character: 8, + end_line: 2, + end_character: 12, + ..SourceRange::default() + }), + kind: EnumOrUnknown::new(AnchorKind::CALL_SELECTOR), + enclosing_symbol: "fact-mine workspace fixture . Worker#run().".to_string(), + semantic_digest: Sha256::digest(b"call").to_vec(), + display_name: "work".to_string(), + ..SourceAnchor::default() + }; + let mut plan = TracePlan { + protocol_version: PROTOCOL_VERSION, + producer: MessageField::some(tool("fact-mine")), + project_root: "file:///workspace".to_string(), + documents: vec![PlannedDocument { + relative_path: "lib/worker.rb".to_string(), + language: "ruby".to_string(), + position_encoding: EnumOrUnknown::new( + PositionEncoding::UTF8_CODE_UNIT_OFFSET_FROM_LINE_START, + ), + content_sha256: digest, + ..PlannedDocument::default() + }], + requests: vec![EvidenceRequest { + anchor: MessageField::some(anchor), + required: vec![ + EnumOrUnknown::new(EvidenceKind::RECEIVER_VALUE), + EnumOrUnknown::new(EvidenceKind::CALL_TARGET), + EnumOrUnknown::new(EvidenceKind::RESULT_VALUE), + ], + execution_range: MessageField::some(SourceRange { + start_line: 2, + start_character: 0, + end_line: 2, + end_character: 13, + ..SourceRange::default() + }), + ..EvidenceRequest::default() + }], + ..TracePlan::default() + }; + plan.plan_digest = trace_plan_digest(&plan).expect("digest"); + plan + } + + fn runtime_value(symbol: &str) -> RuntimeValue { + RuntimeValue { + type_symbol: symbol.to_string(), + source_role: EnumOrUnknown::new(SourceRole::PRODUCTION), + ..RuntimeValue::default() + } + } + + fn value_set(symbol: &str) -> ValueSet { + ValueSet { + alternatives: vec![WeightedValue { + value: MessageField::some(runtime_value(symbol)), + count: 1, + ..WeightedValue::default() + }], + ..ValueSet::default() + } + } + + fn evidence(plan: &TracePlan) -> RuntimeEvidence { + let request = &plan.requests[0]; + let anchor = request.anchor.as_ref().expect("anchor"); + RuntimeEvidence { + protocol_version: PROTOCOL_VERSION, + producer: MessageField::some(tool("nil-kill")), + authority: EnumOrUnknown::new(Authority::MODELED_RUNS), + trace_plan_digest: plan.plan_digest.clone(), + runs: vec![Run { + id: "run-1".to_string(), + status: EnumOrUnknown::new(RunStatus::SUCCEEDED), + ..Run::default() + }], + anchors: vec![AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::COMPLETE_FOR_RUNS), + run_ids: vec!["run-1".to_string()], + observed_executions: 1, + complete_kinds: request.required.clone(), + ..CaptureSummary::default() + }), + executions: vec![ExecutionBucket { + count: 1, + receiver: MessageField::some(value_set( + "nil-kill-runtime ruby ruby 3.2.3 String#", + )), + target: MessageField::some(RuntimeTarget { + symbol: "nil-kill-runtime ruby ruby 3.2.3 String#size().".to_string(), + source_role: EnumOrUnknown::new(SourceRole::STANDARD_LIBRARY), + package_manager: "ruby".to_string(), + package_name: "ruby".to_string(), + package_version: "3.2.3".to_string(), + ..RuntimeTarget::default() + }), + result: MessageField::some(value_set( + "nil-kill-runtime ruby ruby 3.2.3 Integer#", + )), + provenance: MessageField::some(Provenance { + run_id: "run-1".to_string(), + provider: "ruby".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }], + ..AnchorEvidence::default() + }], + ..RuntimeEvidence::default() + } + } + + fn correlation_evidence() -> (TracePlan, RuntimeEvidence) { + let mut plan = plan(); + let mut second = plan.requests[0].clone(); + let second_anchor = second.anchor.as_mut().expect("anchor"); + second_anchor.symbol = "local call-2".to_string(); + second_anchor.semantic_digest = Sha256::digest(b"second call").to_vec(); + second_anchor.range.as_mut().expect("range").start_character = 20; + second_anchor.range.as_mut().expect("range").end_character = 24; + second.execution_range = MessageField::some(SourceRange { + start_line: 2, + start_character: 14, + end_line: 2, + end_character: 25, + ..SourceRange::default() + }); + plan.requests.push(second); + plan.plan_digest = trace_plan_digest(&plan).expect("digest"); + + let execution = evidence(&plan).anchors[0].executions[0].clone(); + let anchors = plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::PARTIAL), + run_ids: vec!["run-1".to_string()], + reason: "execution is represented by a candidate group".to_string(), + ..CaptureSummary::default() + }), + ..AnchorEvidence::default() + } + }) + .collect(); + let bundle = RuntimeEvidence { + protocol_version: PROTOCOL_VERSION, + producer: MessageField::some(tool("nil-kill")), + authority: EnumOrUnknown::new(Authority::MODELED_RUNS), + trace_plan_digest: plan.plan_digest.clone(), + runs: vec![Run { + id: "run-1".to_string(), + status: EnumOrUnknown::new(RunStatus::SUCCEEDED), + ..Run::default() + }], + anchors, + correlations: vec![CorrelationEvidence { + group_id: "candidate-group-1".to_string(), + candidate_anchor_symbols: vec![ + "local call-1".to_string(), + "local call-2".to_string(), + ], + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::COMPLETE_FOR_RUNS), + run_ids: vec!["run-1".to_string()], + observed_executions: 1, + complete_kinds: plan.requests[0].required.clone(), + ..CaptureSummary::default() + }), + executions: vec![execution], + ..CorrelationEvidence::default() + }], + ..RuntimeEvidence::default() + }; + (plan, bundle) + } + + #[test] + fn canonical_plan_and_correlated_evidence_validate() { + let plan = plan(); + let evidence = evidence(&plan); + validate_trace_plan(&plan).expect("plan"); + validate_runtime_evidence(&plan, &evidence).expect("evidence"); + let json = to_json(&evidence).expect("json"); + let decoded = parse_runtime_evidence_json(&json).expect("decode"); + assert_eq!(decoded, evidence); + } + + #[test] + fn candidate_correlations_are_strict_raw_evidence_not_guessed_anchors() { + let (plan, bundle) = correlation_evidence(); + validate_runtime_evidence(&plan, &bundle).expect("candidate correlation"); + + let mut one_candidate = bundle.clone(); + one_candidate.correlations[0].candidate_anchor_symbols.pop(); + assert!(validate_runtime_evidence(&plan, &one_candidate) + .unwrap_err() + .to_string() + .contains("at least two")); + + let mut missing_target = bundle; + missing_target.correlations[0].executions[0].target = MessageField::none(); + assert!(validate_runtime_evidence(&plan, &missing_target) + .unwrap_err() + .to_string() + .contains("lacks required target")); + } + + #[test] + fn shared_conformance_corpus_is_accepted_and_rejected_consistently() { + let plan = + parse_trace_plan_json(&conformance_fixture("trace-plan.valid.json")).expect("plan"); + let evidence = + parse_runtime_evidence_json(&conformance_fixture("runtime-evidence.valid.json")) + .expect("evidence shape"); + validate_runtime_evidence(&plan, &evidence).expect("valid shared evidence"); + let correlation_plan = + parse_trace_plan_json(&conformance_fixture("trace-plan.valid-correlation.json")) + .expect("correlation plan"); + let correlation_evidence = parse_runtime_evidence_json(&conformance_fixture( + "runtime-evidence.valid-correlation.json", + )) + .expect("correlation evidence shape"); + validate_runtime_evidence(&correlation_plan, &correlation_evidence) + .expect("valid shared candidate correlation"); + + assert!(parse_runtime_evidence_json(&conformance_fixture( + "runtime-evidence.invalid-unknown-field.json" + )) + .is_err()); + // Absence is a claim the plan can read -- that anchor did not execute -- + // but evidence for an anchor the plan never requested is about nothing, + // and stays a hard error. + let unknown = parse_runtime_evidence_json(&conformance_fixture( + "runtime-evidence.invalid-unknown-anchor.json", + )) + .expect("schema-valid semantic failure"); + assert!(validate_runtime_evidence(&plan, &unknown) + .unwrap_err() + .to_string() + .contains("unknown plan anchor")); + } + + #[test] + fn protojson_rejects_unknown_fields() { + let json = to_json(&plan()).expect("json"); + let mutated = json.replacen('{', "{\"unknownContractField\":true,", 1); + assert!(parse_trace_plan_json(&mutated) + .unwrap_err() + .to_string() + .contains("ProtoJSON")); + } + + #[test] + fn canonical_runtime_symbols_cover_operator_descriptors() { + for symbol in [ + "nil-kill-runtime ruby ruby 3.2.3 Integer#+().", + "nil-kill-runtime ruby ruby 3.2.3 Integer#-().", + "nil-kill-runtime ruby ruby 3.2.3 Array#`[]`().", + ] { + validate_global_symbol(symbol, "operator target").expect(symbol); + } + } + + // Evidence is sparse: an anchor with no entry was not executed in these + // runs, which is the same claim an explicit NOT_EXECUTED entry made at the + // cost of making every shard's document scale with the plan. What evidence + // must never do is name an anchor twice, or one the plan never requested. + #[test] + fn evidence_may_omit_an_anchor_that_did_not_execute() { + let plan = plan(); + let mut bundle = evidence(&plan); + bundle.anchors.clear(); + validate_runtime_evidence(&plan, &bundle) + .expect("absence means the anchor did not execute"); + } + + #[test] + fn evidence_must_not_report_an_anchor_twice() { + let plan = plan(); + let mut bundle = evidence(&plan); + let row = evidence(&plan).anchors.remove(0); + bundle.anchors = vec![row.clone(), row]; + assert!(validate_runtime_evidence(&plan, &bundle) + .unwrap_err() + .to_string() + .contains("duplicate evidence")); + } + + #[test] + fn stale_plan_and_anchor_digests_fail_closed() { + let plan = plan(); + let mut bundle = evidence(&plan); + bundle.trace_plan_digest[0] ^= 0xff; + assert!(validate_runtime_evidence(&plan, &bundle) + .unwrap_err() + .to_string() + .contains("trace_plan_digest")); + + let mut bundle = evidence(&plan); + bundle.anchors[0].anchor_semantic_digest[0] ^= 0xff; + assert!(validate_runtime_evidence(&plan, &bundle) + .unwrap_err() + .to_string() + .contains("semantic digest")); + } + + #[test] + fn complete_capture_rejects_drops_and_truncation() { + let plan = plan(); + let mut bundle = evidence(&plan); + bundle.anchors[0] + .capture + .as_mut() + .expect("capture") + .dropped_executions = 1; + assert!(validate_runtime_evidence(&plan, &bundle) + .unwrap_err() + .to_string() + .contains("dropped")); + + let mut bundle = evidence(&plan); + bundle.anchors[0].executions[0] + .receiver + .as_mut() + .expect("receiver") + .truncated = true; + assert!(validate_runtime_evidence(&plan, &bundle) + .unwrap_err() + .to_string() + .contains("truncated")); + } + + #[test] + fn not_executed_is_explicit_valid_evidence() { + let plan = plan(); + let anchor = plan.requests[0].anchor.as_ref().expect("anchor"); + let mut bundle = evidence(&plan); + bundle.anchors = vec![AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::NOT_EXECUTED), + run_ids: vec!["run-1".to_string()], + reason: "anchor did not execute in the modeled run".to_string(), + complete_kinds: plan.requests[0].required.clone(), + ..CaptureSummary::default() + }), + ..AnchorEvidence::default() + }]; + validate_runtime_evidence(&plan, &bundle).expect("not executed"); + } + + #[test] + fn fact_mine_emits_exact_relocatable_trace_plan_anchors() { + let directory = tempfile::tempdir().expect("directory"); + let source = directory.path().join("worker.rb"); + let ruby = "class Worker\n def run(value)\n value.size\n end\nend\n"; + fs::write(&source, ruby).expect("source"); + let document = crate::syntax::parse_file(source.clone(), crate::syntax::Language::Ruby) + .expect("parse"); + let profile = crate::profile::extract(&document, crate::profile::Profile::TracePlan); + let first = build_trace_plan(&profile, std::slice::from_ref(&source), directory.path()) + .expect("plan"); + + let call = first + .requests + .iter() + .find_map(|request| { + let anchor = request.anchor.as_ref()?; + (anchor.display_name == "size").then_some(anchor) + }) + .expect("call anchor"); + assert_eq!(call.relative_path, "worker.rb"); + assert_eq!(call.range.as_ref().expect("range").start_line, 2); + assert!(first.requests.iter().any(|request| { + request.parameter_ordinal == Some(0) + && request + .required + .iter() + .any(|kind| kind.enum_value_or_default() == EvidenceKind::PARAMETER_VALUE) + })); + + let mut shifted = fs::File::create(&source).expect("rewrite"); + shifted + .write_all(format!("\n{ruby}").as_bytes()) + .expect("shift"); + let document = crate::syntax::parse_file(source.clone(), crate::syntax::Language::Ruby) + .expect("parse shifted"); + let profile = crate::profile::extract(&document, crate::profile::Profile::TracePlan); + let second = build_trace_plan(&profile, &[source], directory.path()).expect("shifted plan"); + let shifted_call = second + .requests + .iter() + .find_map(|request| { + let anchor = request.anchor.as_ref()?; + (anchor.display_name == "size").then_some(anchor) + }) + .expect("shifted call anchor"); + + assert_eq!(shifted_call.symbol, call.symbol); + assert_eq!(shifted_call.semantic_digest, call.semantic_digest); + assert_eq!( + shifted_call + .range + .as_ref() + .expect("shifted range") + .start_line, + call.range.as_ref().expect("range").start_line + 1 + ); + assert_ne!(second.plan_digest, first.plan_digest); + } + + #[test] + fn trace_plan_enclosing_symbols_ignore_absolute_profile_path_form() { + let directory = tempfile::tempdir().expect("directory"); + let source = directory.path().join("worker.rb"); + fs::write(&source, "class Worker\n def run\n 1\n end\nend\n").expect("source"); + let document = + crate::syntax::parse_file(source, crate::syntax::Language::Ruby).expect("parse"); + let profile = crate::profile::extract(&document, crate::profile::Profile::TracePlan); + let mut relative = profile + .methods + .iter() + .find(|method| method.name == "run") + .expect("method") + .clone(); + relative.path = "gems/demo/lib/worker.rb".to_string(); + let mut absolute = relative.clone(); + absolute.path = "/checkout/gems/demo/lib/worker.rb".to_string(); + + assert_eq!( + plan_method_symbol(&relative, "gems/demo/lib/worker.rb"), + plan_method_symbol(&absolute, "gems/demo/lib/worker.rb") + ); + } +} diff --git a/gems/fact-mine/src/runtime_trace.rs b/gems/fact-mine/src/runtime_trace.rs new file mode 100644 index 000000000..54c208ac4 --- /dev/null +++ b/gems/fact-mine/src/runtime_trace.rs @@ -0,0 +1,1910 @@ +//! Join a runtime trace against a trace plan. +//! +//! A collector observes a run; it does not decide which planned anchor an +//! observation satisfies. That decision is this module's, so there is one +//! implementation of it rather than one per collector language. The input is +//! the language-neutral trace artifact a collector writes: normalized +//! observations and call rows, plus the execution tallies that separate "never +//! executed" from "executed but its value was not captured". + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::Path; + +use crate::runtime_protocol::{self, AnchorKind, TracePlan}; + +pub const TRACE_VERSION: u32 = 1; + +#[derive(Debug, Deserialize)] +pub struct Trace { + pub trace_version: u32, + #[serde(default)] + pub trace_plan_digest: String, + #[serde(default)] + pub run_ids: Vec, + #[serde(default)] + pub observations: Vec, + #[serde(default)] + pub calls: Vec, + #[serde(default)] + pub executed_callsites: Vec, + #[serde(default)] + pub exact_anchor_executions: Vec, + #[serde(default)] + pub function_entries: Vec, + #[serde(default)] + pub coverage: Vec, + #[serde(default)] + pub environment: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Observation { + pub kind: String, + /// The protocol value this observation contributes, already encoded by the + /// collector because minting it needs that language's type-symbol rules. + #[serde(default)] + pub bucket: Option, + #[serde(default)] + pub scope: Scope, + #[serde(default)] + pub slot: String, + #[serde(default)] + pub domain: serde_json::Value, + #[serde(default = "one")] + pub count: i64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct Scope { + #[serde(default)] + pub language: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub line: i64, +} + +#[derive(Debug, Deserialize)] +pub struct CallEntry { + pub row: CallRow, + #[serde(default)] + pub bucket: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CallRow { + pub callsite: Callsite, + #[serde(default)] + pub receiver_domain: serde_json::Value, + #[serde(default)] + pub result_domain: serde_json::Value, + #[serde(default)] + pub result_truths: Vec, + #[serde(default)] + pub target: serde_json::Value, + #[serde(default)] + pub receiver_source_role: Option, + #[serde(default = "one")] + pub count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct Callsite { + #[serde(default)] + pub path: String, + #[serde(default)] + pub line: i64, + #[serde(default)] + pub selector: String, + #[serde(default)] + pub anchor_symbol: String, +} + +#[derive(Debug, Deserialize)] +pub struct ExecutedCallsite { + #[serde(default)] + pub path: String, + #[serde(default)] + pub line: i64, + #[serde(default)] + pub selector: String, +} + +#[derive(Debug, Deserialize)] +pub struct ExactAnchorExecution { + pub symbol: String, + #[serde(default = "one")] + pub count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct FunctionEntry { + #[serde(default)] + pub path: String, + #[serde(default)] + pub line: i64, +} + +#[derive(Debug, Deserialize)] +pub struct CoverageRow { + #[serde(default)] + pub path: String, + #[serde(default)] + pub lines: Vec, +} + +fn one() -> i64 { + 1 +} + +/// A collector writes the plan inside an envelope alongside its own metadata, +/// so accept either the envelope or the bare plan. +pub fn read_plan(path: &Path) -> Result { + let raw = runtime_protocol::read_json(path) + .with_context(|| format!("unreadable trace plan {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("invalid trace plan {}", path.display()))?; + let inner = value + .get("runtime_evidence") + .cloned() + .unwrap_or(value); + runtime_protocol::parse_trace_plan_json(&serde_json::to_string(&inner)?) + .with_context(|| format!("invalid trace plan {}", path.display())) +} + +pub fn read_trace(path: &Path) -> Result { + let raw = runtime_protocol::read_json(path) + .with_context(|| format!("unreadable runtime trace {}", path.display()))?; + let trace: Trace = serde_json::from_str(&raw) + .with_context(|| format!("invalid runtime trace {}", path.display()))?; + if trace.trace_version != TRACE_VERSION { + bail!( + "unsupported runtime trace version {} (expected {})", + trace.trace_version, + TRACE_VERSION + ); + } + Ok(trace) +} + +/// A path as the plan names it: relative to the analyzed root, forward slashes. +fn canonical_path(root: &Path, path: &str) -> String { + if path.is_empty() { + return String::new(); + } + let candidate = Path::new(path); + let absolute = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.join(candidate) + }; + match absolute.strip_prefix(root) { + Ok(relative) => relative.to_string_lossy().replace('\\', "/"), + Err(_) => path.replace('\\', "/"), + } +} + +fn line_in_range(range: &runtime_protocol::SourceRange, one_based: i64) -> bool { + let line = one_based - 1; + i64::from(range.start_line) <= line && line <= i64::from(range.end_line) +} + +fn anchor_kind(anchor: &runtime_protocol::SourceAnchor) -> AnchorKind { + anchor.kind.enum_value_or_default() +} + +fn is_call_anchor(kind: AnchorKind) -> bool { + !matches!( + kind, + AnchorKind::FUNCTION_ENTRY + | AnchorKind::FUNCTION_RETURN + | AnchorKind::STATE_READ + | AnchorKind::STATE_WRITE + ) +} + +/// The observation kind a non-call anchor is satisfied by. +fn observation_kind(kind: AnchorKind) -> Option<&'static str> { + match kind { + AnchorKind::FUNCTION_ENTRY => Some("parameter"), + AnchorKind::FUNCTION_RETURN => Some("return"), + AnchorKind::STATE_READ | AnchorKind::STATE_WRITE => Some("state"), + _ => None, + } +} + +pub struct Join<'a> { + root: &'a Path, + trace: &'a Trace, + observations_by_kind_path: HashMap<(String, String), Vec>, + calls_by_path_selector: HashMap<(String, String), Vec>, + executed_by_path_selector: HashMap<(String, String), Vec>, + exact_symbols: HashSet, + exact_counts: HashMap, + entries_by_path: HashMap>, + covered_by_path: HashMap>, + // Function boundaries indexed by file. Resolving a target used to scan + // every request, which is quadratic in the plan and was most of the join. + function_anchors_by_path: HashMap>, +} + +impl<'a> Join<'a> { + pub fn new(root: &'a Path, plan: &'a TracePlan, trace: &'a Trace) -> Self { + let mut observations_by_kind_path: HashMap<(String, String), Vec> = HashMap::new(); + for (index, row) in trace.observations.iter().enumerate() { + observations_by_kind_path + .entry((row.kind.clone(), canonical_path(root, &row.scope.path))) + .or_default() + .push(index); + } + let mut calls_by_path_selector: HashMap<(String, String), Vec> = HashMap::new(); + for (index, entry) in trace.calls.iter().enumerate() { + calls_by_path_selector + .entry(( + canonical_path(root, &entry.row.callsite.path), + entry.row.callsite.selector.clone(), + )) + .or_default() + .push(index); + } + let mut executed_by_path_selector: HashMap<(String, String), Vec> = HashMap::new(); + for row in &trace.executed_callsites { + executed_by_path_selector + .entry((canonical_path(root, &row.path), row.selector.clone())) + .or_default() + .push(row.line); + } + let mut exact_symbols = HashSet::new(); + let mut exact_counts: HashMap = HashMap::new(); + for row in &trace.exact_anchor_executions { + exact_symbols.insert(row.symbol.clone()); + *exact_counts.entry(row.symbol.clone()).or_insert(0) += row.count.max(0); + } + let mut entries_by_path: HashMap> = HashMap::new(); + for row in &trace.function_entries { + entries_by_path + .entry(canonical_path(root, &row.path)) + .or_default() + .push(row.line); + } + let mut covered_by_path: HashMap> = HashMap::new(); + for row in &trace.coverage { + covered_by_path + .entry(canonical_path(root, &row.path)) + .or_default() + .extend(row.lines.iter().copied()); + } + let mut function_anchors_by_path: HashMap> = + HashMap::new(); + for request in &plan.requests { + let Some(anchor) = request.anchor.as_ref() else { + continue; + }; + if matches!( + anchor.kind.enum_value_or_default(), + AnchorKind::FUNCTION_ENTRY | AnchorKind::FUNCTION_RETURN + ) { + function_anchors_by_path + .entry(anchor.relative_path.clone()) + .or_default() + .push(anchor); + } + } + Self { + root, + trace, + function_anchors_by_path, + observations_by_kind_path, + calls_by_path_selector, + executed_by_path_selector, + exact_symbols, + exact_counts, + entries_by_path, + covered_by_path, + } + } + + /// Observations at one normalized storage boundary. More than one row there + /// is additive runs, not ambiguous source identity, so this never reports + /// ambiguity. + fn matching_observations( + &self, + anchor: &runtime_protocol::SourceAnchor, + kind: &str, + ) -> Vec { + let range = anchor.range.as_ref(); + self.observations_by_kind_path + .get(&(kind.to_string(), anchor.relative_path.clone())) + .map(|rows| { + rows.iter() + .copied() + .filter(|index| { + let row = &self.trace.observations[*index]; + range.is_some_and(|r| line_in_range(r, row.scope.line)) + && (!matches!(kind, "parameter" | "state") + || row.slot == anchor.display_name) + }) + .collect() + }) + .unwrap_or_default() + } + + /// Exact anchor identity dominates the collector's informational source + /// line: a multiline call may be reported at its receiver line while the + /// plan anchors the selector line, and the exact binding already proved + /// which planned anchor ran. Without one, never guess between two identical + /// selectors on the same line. + fn matching_calls(&self, anchor: &runtime_protocol::SourceAnchor) -> (Vec, bool) { + let key = (anchor.relative_path.clone(), anchor.display_name.clone()); + let candidates = match self.calls_by_path_selector.get(&key) { + Some(rows) => rows, + None => return (Vec::new(), false), + }; + let exact: Vec = candidates + .iter() + .copied() + .filter(|index| self.trace.calls[*index].row.callsite.anchor_symbol == anchor.symbol) + .collect(); + if !exact.is_empty() { + return (exact, false); + } + let range = anchor.range.as_ref(); + let loose: Vec = candidates + .iter() + .copied() + .filter(|index| { + let callsite = &self.trace.calls[*index].row.callsite; + callsite.anchor_symbol.is_empty() + && range.is_some_and(|r| line_in_range(r, callsite.line)) + }) + .collect(); + (loose, false) + } + + /// Whether the anchor ran at all, which is what separates "no execution in + /// the modeled runs" from "executed but the collector captured no value". + fn anchor_executed( + &self, + anchor: &runtime_protocol::SourceAnchor, + has_execution_range: bool, + ) -> bool { + let range = anchor.range.as_ref(); + if is_call_anchor(anchor_kind(anchor)) { + if has_execution_range { + return self.exact_symbols.contains(&anchor.symbol); + } + let observed = self + .executed_by_path_selector + .get(&(anchor.relative_path.clone(), anchor.display_name.clone())) + .is_some_and(|lines| { + lines + .iter() + .any(|line| range.is_some_and(|r| line_in_range(r, *line))) + }); + if observed { + return true; + } + // Line coverage is a raw execution fact. It cannot prove which + // same-line call ran, so it is used only to fail closed. + return self + .covered_by_path + .get(&anchor.relative_path) + .is_some_and(|lines| { + lines + .iter() + .any(|line| range.is_some_and(|r| line_in_range(r, *line))) + }); + } + if anchor_kind(anchor) == AnchorKind::FUNCTION_RETURN { + // A return anchor is reached only on a normal return, and a + // conforming collector reports every returned value including null + // and false. No matching observation therefore means this boundary + // did not execute -- every invocation raised, say -- rather than + // that it executed uncaptured. + return false; + } + self.entries_by_path + .get(&anchor.relative_path) + .is_some_and(|lines| { + lines + .iter() + .any(|line| range.is_some_and(|r| line_in_range(r, *line))) + }) + } + + pub fn exact_count(&self, symbol: &str) -> i64 { + self.exact_counts.get(symbol).copied().unwrap_or(0) + } + + pub fn root(&self) -> &Path { + self.root + } +} + +/// A domain carries a value only when it names at least one type; a collector +/// that saw nothing contributes no alternative and so no bucket field. +fn has_types(domain: &serde_json::Value) -> bool { + domain + .get("types") + .and_then(|types| types.as_array()) + .is_some_and(|types| { + types + .iter() + .any(|entry| entry.as_str().is_some_and(|name| !name.is_empty())) + }) +} + +/// The bucket a match contributes, or nothing when the collector observed no +/// value for it -- which is not an execution, it is a call whose value was not +/// captured. +fn bucket_of(trace: &Trace, index: usize, is_call: bool) -> Option<&serde_json::Value> { + if is_call { + trace.calls[index].bucket.as_ref() + } else { + trace.observations[index].bucket.as_ref() + } +} + +fn bucket_has(bucket: &serde_json::Value, field: &str) -> bool { + bucket.get(field).is_some_and(|value| !value.is_null()) +} + +/// Which requested evidence kind each execution-bucket field satisfies. +pub fn evidence_field(kind: &str) -> Option<&'static str> { + Some(match kind { + "PARAMETER_VALUE" | "RETURN_VALUE" | "STATE_VALUE" => "value", + "RECEIVER_VALUE" | "COLLECTION_VALUE" => "receiver", + "CALL_TARGET" => "target", + "RESULT_VALUE" => "result", + "BOOLEAN_RESULT" => "boolean_result", + _ => return None, + }) +} + +impl Join<'_> { + /// One anchor's evidence: which observations satisfy it, whether they + /// covered everything the request asked for, and how many executions they + /// account for. + pub fn evaluate( + &self, + request: &runtime_protocol::EvidenceRequest, + anchor: &runtime_protocol::SourceAnchor, + run_ids: &[String], + ) -> Result { + let trace = self.trace; + let (matches, ambiguous) = match observation_kind(anchor_kind(anchor)) { + Some(kind) => (self.matching_observations(anchor, kind), false), + None => self.matching_calls(anchor), + }; + let is_call = observation_kind(anchor_kind(anchor)).is_none(); + let requested: Vec = request + .required + .iter() + .map(|kind| format!("{:?}", kind.enum_value_or_default())) + .collect(); + + // A match that yields no bucket is not an execution: the collector saw + // the call but captured nothing about it. + let kept: Vec = if ambiguous { + Vec::new() + } else { + matches + .iter() + .filter_map(|index| bucket_of(trace, *index, is_call).cloned()) + .map(|mut bucket| { + self.resolve_target(&mut bucket); + bucket + }) + .collect() + }; + let kept: Vec<&serde_json::Value> = kept.iter().collect(); + let mut buckets = merge_identical_buckets(&kept, run_ids); + + let executed_without_capture = buckets.is_empty() + && self.anchor_executed(anchor, request.execution_range.is_some()); + let complete_kinds: Vec = if ambiguous || executed_without_capture { + Vec::new() + } else if buckets.is_empty() { + // No execution in a modeled run is a complete (empty) observation + // for every requested field. + requested.clone() + } else { + requested + .iter() + .filter(|kind| { + evidence_field(kind) + .is_some_and(|field| buckets.iter().all(|b| bucket_has(b, field))) + }) + .cloned() + .collect() + }; + let (status, reason) = if ambiguous { + ( + "PARTIAL", + Some("observed execution is preserved in a candidate correlation group"), + ) + } else if buckets.is_empty() { + if executed_without_capture { + ( + "NOT_INSTRUMENTED", + Some("anchor executed but the provider did not capture its requested value"), + ) + } else { + ("NOT_EXECUTED", Some("no matching execution in the modeled runs")) + } + } else if complete_kinds.len() != requested.len() { + ( + "PARTIAL", + Some("provider did not capture every value requested at this anchor"), + ) + } else { + ("COMPLETE_FOR_RUNS", None) + }; + + let mut observed: i64 = buckets + .iter() + .map(|b| b.get("count").and_then(|c| c.as_i64()).unwrap_or(1).max(1)) + .sum(); + // Identity-only collection may retain one representative bucket while + // the exact marker counted every execution. With one bucket there is no + // distribution ambiguity, so its exact multiplicity is restored. + let exact = self.exact_count(&anchor.symbol); + if buckets.len() == 1 && exact > observed { + observed = exact; + if let Some(object) = buckets[0].as_object_mut() { + object.insert("count".into(), serde_json::json!(exact)); + } + } + + let mut capture = serde_json::Map::new(); + capture.insert("status".into(), serde_json::json!(status)); + capture.insert("run_ids".into(), serde_json::json!(run_ids)); + capture.insert("observed_executions".into(), serde_json::json!(observed)); + capture.insert("dropped_executions".into(), serde_json::json!(0)); + if let Some(reason) = reason { + capture.insert("reason".into(), serde_json::json!(reason)); + } + capture.insert("complete_kinds".into(), serde_json::json!(complete_kinds)); + + Ok(serde_json::json!({ + "anchor_symbol": anchor.symbol, + "anchor_semantic_digest": base64_standard(&anchor.semantic_digest), + "capture": capture, + "executions": buckets, + })) + } +} + +/// ProtoJSON encodes `bytes` as standard base64. Written out rather than taken +/// as a dependency: it is fifteen lines and the alphabet is fixed by the spec. +impl Join<'_> { + /// A collector reports where a callee was declared; the plan says which + /// function that is. Exactly one planned boundary at that declaration names + /// it -- more than one is not a resolution -- and otherwise the raw locator + /// is preserved so the consumer can bind it from source itself. + fn resolve_target(&self, bucket: &mut serde_json::Value) { + let Some(definition) = bucket.get("target_definition").cloned() else { + return; + }; + if let Some(object) = bucket.as_object_mut() { + object.remove("target_definition"); + } + if definition.is_null() { + return; + } + let path = definition + .get("path") + .and_then(|p| p.as_str()) + .map(|p| canonical_path(self.root, p)) + .unwrap_or_default(); + let line = definition.get("line").and_then(|l| l.as_i64()).unwrap_or(0); + + let mut seen: Vec<&str> = Vec::new(); + let mut candidates: Vec<&runtime_protocol::SourceAnchor> = Vec::new(); + for anchor in self.function_anchors_by_path.get(&path).into_iter().flatten() { + if !anchor.range.as_ref().is_some_and(|r| line_in_range(r, line)) { + continue; + } + if seen.contains(&anchor.enclosing_symbol.as_str()) { + continue; + } + seen.push(&anchor.enclosing_symbol); + candidates.push(anchor); + } + + let Some(target) = bucket.get_mut("target").and_then(|t| t.as_object_mut()) else { + return; + }; + if candidates.len() != 1 { + if line <= 0 || path.is_empty() { + return; + } + let symbol = target + .get("symbol") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_string(); + target.insert( + "definition".into(), + serde_json::json!({ + "symbol": symbol, + "anchor_symbol": "", + "relative_path": path, + "range": { + "start_line": line - 1, "start_character": 0, + "end_line": line - 1, "end_character": 0, + }, + }), + ); + return; + } + let anchor = candidates[0]; + let range = anchor.range.as_ref(); + target.insert( + "symbol".into(), + serde_json::json!(anchor.enclosing_symbol), + ); + target.insert( + "definition".into(), + serde_json::json!({ + "symbol": anchor.enclosing_symbol, + "anchor_symbol": anchor.symbol, + "relative_path": anchor.relative_path, + "range": { + "start_line": range.map_or(0, |r| r.start_line), + "start_character": range.map_or(0, |r| r.start_character), + "end_line": range.map_or(0, |r| r.end_line), + "end_character": range.map_or(0, |r| r.end_character), + }, + }), + ); + } +} + +fn base64_standard(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(triple >> 18) as usize & 63] as char); + out.push(ALPHABET[(triple >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + ALPHABET[(triple >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + ALPHABET[triple as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// Buckets that differ only in how many executions they account for are one +/// observation seen repeatedly, not several. +fn merge_identical_buckets( + buckets: &[&serde_json::Value], + run_ids: &[String], +) -> Vec { + let mut order: Vec = Vec::new(); + let mut merged: HashMap = HashMap::new(); + for bucket in buckets { + let mut owned = (*bucket).clone(); + if let Some(object) = owned.as_object_mut() { + // A bucket with no run of its own belongs to the run being reported. + let empty = object + .get("provenance") + .and_then(|p| p.get("run_id")) + .and_then(|id| id.as_str()) + .is_none_or(|id| id.is_empty()); + if empty { + if let Some(provenance) = object.get_mut("provenance").and_then(|p| p.as_object_mut()) + { + provenance.insert( + "run_id".into(), + serde_json::json!(run_ids.first().cloned().unwrap_or_default()), + ); + } + } + } + let mut key_value = owned.clone(); + if let Some(object) = key_value.as_object_mut() { + object.remove("count"); + } + let key = serde_json::to_string(&key_value).unwrap_or_default(); + match merged.get_mut(&key) { + Some(existing) => { + let add = owned.get("count").and_then(|c| c.as_i64()).unwrap_or(1); + let total = existing.get("count").and_then(|c| c.as_i64()).unwrap_or(1) + add; + if let Some(object) = existing.as_object_mut() { + object.insert("count".into(), serde_json::json!(total)); + } + } + None => { + order.push(key.clone()); + merged.insert(key, owned); + } + } + } + order + .into_iter() + .filter_map(|key| merged.remove(&key)) + .collect() +} + +/// The evidence a trace supports, in the plan's request order. +/// +/// Built as ProtoJSON and then round-tripped through the protocol message, so +/// the result is canonical by construction rather than by matching a producer's +/// formatting. +pub fn build_evidence(root: &Path, plan: &TracePlan, trace: &Trace) -> Result { + let join = Join::new(root, plan, trace); + let mut run_ids: Vec = trace + .run_ids + .iter() + .filter(|id| !id.is_empty()) + .cloned() + .collect(); + run_ids.sort(); + run_ids.dedup(); + if run_ids.is_empty() { + run_ids.push("unidentified-run".to_string()); + } + + let mut anchors = Vec::with_capacity(plan.requests.len()); + for request in &plan.requests { + let anchor = request + .anchor + .as_ref() + .context("trace plan request has no anchor")?; + let outcome = join.evaluate(request, anchor, &run_ids)?; + // An anchor nothing was observed for is left out: absence already says + // "not executed in these runs", and saying it explicitly for every + // planned anchor is what made this document scale with the plan instead + // of with the run. + if !is_vacuous(&outcome) { + anchors.push(outcome); + } + } + + let evidence = serde_json::json!({ + "protocol_version": 1, + "producer": { "name": "nil-kill", "version": "1", "arguments": ["collect", "runtime-evidence"] }, + "authority": "MODELED_RUNS", + "trace_plan_digest": trace.trace_plan_digest, + "environment": trace.environment, + "runs": run_ids.iter().map(|id| serde_json::json!({ "id": id, "status": "SUCCEEDED" })) + .collect::>(), + "anchors": anchors, + "correlations": Vec::::new(), + }); + let canonical = runtime_protocol::parse_runtime_evidence_json(&serde_json::to_string(&evidence)?) + .context("joined evidence is not canonical ProtoJSON")?; + runtime_protocol::to_json_with_defaults(&canonical) +} + +/// True when an entry carries nothing a consumer could not infer from its +/// absence: no executions, and the status that absence itself means. +fn is_vacuous(anchor: &serde_json::Value) -> bool { + let empty = anchor + .get("executions") + .and_then(|e| e.as_array()) + .is_none_or(|e| e.is_empty()); + let status = anchor + .get("capture") + .and_then(|c| c.get("status")) + .and_then(|s| s.as_str()) + .unwrap_or_default(); + empty && status == "NOT_EXECUTED" +} + + +/// Combine the evidence of several shards into one document. +/// +/// Works on protocol messages rather than JSON. The shards arrive as messages +/// and the result leaves as one, so nothing is encoded, parsed and re-encoded +/// in between -- that round trip was three passes over tens of megabytes and +/// most of what the join cost. +/// +/// A shard contributes what it observed, so shards legitimately cover different +/// anchors and a symbol is merged from the shards that saw it. The rules are the +/// collector's, ported rather than reinvented: the worst status wins, run ids +/// union, execution counts add, and a kind is complete only if every +/// contributing shard found it complete. +/// Bring a shard collected under an older plan onto the current one. +/// +/// An anchor the shard has no entry for was not executed in it, which is what +/// absence already means. STALE exists for the other case: an entry that IS +/// present and describes source that has since changed. +/// +/// Rehydrating an entry for every planned anchor instead made each shard's +/// contribution scale with the plan rather than with the run -- thirteen shards +/// of a 0.65s suite merged to 46MB, nearly all of it saying nothing happened. +pub fn rebase_evidence( + bundle: &runtime_protocol::RuntimeEvidence, + plan: &runtime_protocol::TracePlan, +) -> runtime_protocol::RuntimeEvidence { + use runtime_protocol::{AnchorEvidence, CaptureStatus, CaptureSummary}; + + let existing: BTreeMap<&str, &AnchorEvidence> = bundle + .anchors + .iter() + .map(|anchor| (anchor.anchor_symbol.as_str(), anchor)) + .collect(); + let run_ids = bundle.runs.iter().map(|run| run.id.clone()).collect::>(); + + let mut rebased = bundle.clone(); + rebased.anchors = plan + .requests + .iter() + .filter_map(|request| { + let anchor = request.anchor.as_ref()?; + let row = existing.get(anchor.symbol.as_str())?; + if row.anchor_semantic_digest == anchor.semantic_digest { + return Some((*row).clone()); + } + Some(AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: protobuf::MessageField::some(CaptureSummary { + status: protobuf::EnumOrUnknown::new(CaptureStatus::STALE), + run_ids: run_ids.clone(), + observed_executions: 0, + dropped_executions: 0, + reason: "source semantics changed after this shard was collected".to_string(), + ..Default::default() + }), + executions: Vec::new(), + ..Default::default() + }) + }) + .collect(); + rebased.trace_plan_digest = plan.plan_digest.clone(); + rebased +} + +pub fn merge_evidence( + documents: &[runtime_protocol::RuntimeEvidence], +) -> Result { + use runtime_protocol::{AnchorEvidence, CaptureSummary, CaptureStatus}; + + let first = documents.first().context("no evidence to merge")?; + let mut order: Vec = Vec::new(); + let mut by_symbol: BTreeMap> = BTreeMap::new(); + for document in documents { + let mut seen: HashSet<&str> = HashSet::new(); + for anchor in &document.anchors { + if !seen.insert(anchor.anchor_symbol.as_str()) { + bail!("runtime evidence shard contains duplicate anchors"); + } + let entry = by_symbol.entry(anchor.anchor_symbol.clone()).or_default(); + if entry.is_empty() { + order.push(anchor.anchor_symbol.clone()); + } + entry.push(anchor); + } + } + + let mut anchors = Vec::with_capacity(by_symbol.len()); + for (symbol, rows) in &by_symbol { + let digests: BTreeSet<&[u8]> = rows + .iter() + .map(|row| row.anchor_semantic_digest.as_slice()) + .collect(); + if digests.len() > 1 { + bail!("conflicting semantic digest for {symbol}"); + } + let executions: Vec<&runtime_protocol::ExecutionBucket> = + rows.iter().flat_map(|row| row.executions.iter()).collect(); + let captures: Vec<&CaptureSummary> = + rows.iter().filter_map(|row| row.capture.as_ref()).collect(); + + let mut run_ids: Vec = captures + .iter() + .flat_map(|capture| capture.run_ids.iter().cloned()) + .collect(); + run_ids.sort(); + run_ids.dedup(); + + // A kind is complete only where every contributing shard found it so. + let mut complete: Option> = None; + for capture in &captures { + let kinds: BTreeSet = capture + .complete_kinds + .iter() + .map(|kind| kind.value()) + .collect(); + complete = Some(match complete { + None => kinds, + Some(existing) => existing.intersection(&kinds).copied().collect(), + }); + } + + let status = merged_status(&captures, &executions); + let merged_executions = merge_buckets(&executions); + let mut capture = CaptureSummary::new(); + capture.status = protobuf::EnumOrUnknown::new(status); + capture.run_ids = run_ids; + capture.observed_executions = merged_executions.iter().map(|b| b.count).sum(); + capture.dropped_executions = captures.iter().map(|c| c.dropped_executions).sum(); + capture.reason = merged_reason(&captures, status); + // Sorted by name, not by enum value: the order is what a reader compares + // against, and the collector has always sorted these alphabetically. + let mut kinds: Vec> = complete + .unwrap_or_default() + .into_iter() + .map(protobuf::EnumOrUnknown::from_i32) + .collect(); + kinds.sort_by_key(|kind| format!("{:?}", kind.enum_value_or_default())); + capture.complete_kinds = kinds; + + let mut anchor = AnchorEvidence::new(); + anchor.anchor_symbol = symbol.clone(); + anchor.anchor_semantic_digest = rows[0].anchor_semantic_digest.clone(); + anchor.capture = protobuf::MessageField::some(capture); + anchor.executions = merged_executions; + anchors.push(anchor); + } + + let mut runs: Vec = documents + .iter() + .flat_map(|d| d.runs.iter().cloned()) + .collect(); + runs.sort_by(|a, b| a.id.cmp(&b.id)); + runs.dedup_by(|a, b| a.id == b.id); + + let mut environment: BTreeMap = BTreeMap::new(); + for document in documents { + for claim in &document.environment { + if let Some(existing) = environment.get(&claim.key) { + if *existing != claim.value { + bail!("conflicting runtime environment claim {}", claim.key); + } + } + environment.insert(claim.key.clone(), claim.value.clone()); + } + } + + let mut merged = runtime_protocol::RuntimeEvidence::new(); + merged.protocol_version = runtime_protocol::PROTOCOL_VERSION; + merged.producer = first.producer.clone(); + merged.authority = first.authority; + merged.trace_plan_digest = first.trace_plan_digest.clone(); + merged.environment = environment + .into_iter() + .map(|(key, value)| { + let mut claim = runtime_protocol::EnvironmentClaim::new(); + claim.key = key; + claim.value = value; + claim + }) + .collect(); + merged.runs = runs; + merged.anchors = anchors; + Ok(merged) +} + +/// The worst outcome any shard saw is the outcome for the whole. +fn merged_status( + captures: &[&runtime_protocol::CaptureSummary], + executions: &[&runtime_protocol::ExecutionBucket], +) -> runtime_protocol::CaptureStatus { + use runtime_protocol::CaptureStatus as S; + let statuses: Vec = captures + .iter() + .map(|capture| capture.status.enum_value_or_default()) + .collect(); + if statuses.contains(&S::FAILED_CAPTURE) { + return S::FAILED_CAPTURE; + } + if statuses.contains(&S::STALE) { + return S::STALE; + } + if statuses + .iter() + .any(|s| matches!(s, S::PARTIAL | S::NOT_INSTRUMENTED | S::UNSUPPORTED)) + { + return S::PARTIAL; + } + if executions.is_empty() { + return S::NOT_EXECUTED; + } + S::COMPLETE_FOR_RUNS +} + +/// Only the shards that fell short explain why the whole did. +fn merged_reason( + captures: &[&runtime_protocol::CaptureSummary], + status: runtime_protocol::CaptureStatus, +) -> String { + use runtime_protocol::CaptureStatus as S; + if status == S::COMPLETE_FOR_RUNS { + return String::new(); + } + let short: Vec<&&runtime_protocol::CaptureSummary> = captures + .iter() + .filter(|c| { + !matches!( + c.status.enum_value_or_default(), + S::COMPLETE_FOR_RUNS | S::NOT_EXECUTED + ) + }) + .collect(); + let source: Vec<&&runtime_protocol::CaptureSummary> = if short.is_empty() { + captures.iter().collect() + } else { + short + }; + let reasons: BTreeSet<&str> = source + .iter() + .map(|c| c.reason.as_str()) + .filter(|r| !r.is_empty()) + .collect(); + reasons.into_iter().collect::>().join("; ") +} + +/// Buckets differing only in count are one observation seen repeatedly. +/// Messages compare structurally, so nothing has to be serialised to group them. +fn merge_buckets( + rows: &[&runtime_protocol::ExecutionBucket], +) -> Vec { + let mut merged: Vec = Vec::new(); + for row in rows { + let matched = merged.iter_mut().find(|kept| { + let mut a = (*kept).clone(); + let mut b = (*row).clone(); + a.count = 0; + b.count = 0; + a == b + }); + match matched { + Some(existing) => existing.count += row.count.max(1), + None => { + let mut bucket = (*row).clone(); + bucket.count = bucket.count.max(1); + merged.push(bucket); + } + } + } + // Deterministic order, and the same one the collector produced: a reader + // that takes the first bucket as representative must get the same one every + // run and from either implementation. + merged.sort_by_cached_key(|bucket| { + // The same rendering the collector sorted by, so both orderings agree. + runtime_protocol::to_json_with_defaults(bucket).unwrap_or_default() + }); + merged +} + +/// Write a document where the collector expects it, gzipped when named so. +pub fn write_json(path: &Path, contents: &str) -> Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + if path.extension().is_some_and(|ext| ext == "gz") { + let file = std::fs::File::create(path) + .with_context(|| format!("failed to write {}", path.display()))?; + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + encoder.write_all(contents.as_bytes())?; + encoder.finish()?; + } else { + std::fs::write(path, contents) + .with_context(|| format!("failed to write {}", path.display()))?; + } + Ok(()) +} + +/// A stable summary of what the trace covers, keyed by capture status. +pub fn summarize(root: &Path, plan: &TracePlan, trace: &Trace) -> Result> { + let join = Join::new(root, plan, trace); + let run_ids = vec!["summary".to_string()]; + let mut counts: BTreeMap = BTreeMap::new(); + for request in &plan.requests { + let anchor = request + .anchor + .as_ref() + .context("trace plan request has no anchor")?; + let row = join.evaluate(request, anchor, &run_ids)?; + let status = row + .get("capture") + .and_then(|capture| capture.get("status")) + .and_then(|status| status.as_str()) + .unwrap_or("UNKNOWN") + .to_string(); + *counts.entry(status).or_insert(0) += 1; + } + Ok(counts) +} + + +#[cfg(test)] +mod tests { + use super::*; + use protobuf::MessageField; + + fn range(start: u32, end: u32) -> runtime_protocol::SourceRange { + let mut r = runtime_protocol::SourceRange::new(); + r.start_line = start; + r.end_line = end; + r + } + + fn anchor( + symbol: &str, + path: &str, + name: &str, + kind: AnchorKind, + lines: (u32, u32), + ) -> runtime_protocol::SourceAnchor { + let mut a = runtime_protocol::SourceAnchor::new(); + a.symbol = symbol.to_string(); + a.relative_path = path.to_string(); + a.display_name = name.to_string(); + a.kind = protobuf::EnumOrUnknown::new(kind); + a.semantic_digest = vec![1, 2, 3]; + a.enclosing_symbol = format!("enclosing/{symbol}"); + a.range = MessageField::some(range(lines.0, lines.1)); + a + } + + fn request( + anchor: runtime_protocol::SourceAnchor, + required: &[runtime_protocol::EvidenceKind], + ) -> runtime_protocol::EvidenceRequest { + let mut r = runtime_protocol::EvidenceRequest::new(); + r.anchor = MessageField::some(anchor); + r.required = required + .iter() + .map(|kind| protobuf::EnumOrUnknown::new(*kind)) + .collect(); + r + } + + fn plan_of(requests: Vec) -> TracePlan { + let mut plan = TracePlan::new(); + plan.requests = requests; + plan + } + + fn trace_of(json: serde_json::Value) -> Trace { + serde_json::from_value(json).expect("trace fixture") + } + + fn value_bucket(kind: &str) -> serde_json::Value { + serde_json::json!({ + "count": 1, + "value": { "alternatives": [{ "value": { "type_symbol": kind }, "count": 1 }] }, + "provenance": { "run_id": "", "provider": "p", "provider_version": "1" } + }) + } + + fn call_bucket(with_result: bool) -> serde_json::Value { + let mut bucket = serde_json::json!({ + "count": 2, + "receiver": { "alternatives": [{ "value": { "type_symbol": "String" }, "count": 1 }] }, + "target": { "symbol": "T" }, + "provenance": { "run_id": "r1", "provider": "p", "provider_version": "1" } + }); + if with_result { + bucket["result"] = serde_json::json!({ "alternatives": [] }); + } + bucket + } + + // --- path and range ----------------------------------------------------- + + #[test] + fn canonical_path_is_relative_to_the_analyzed_root() { + let root = Path::new("/repo"); + assert_eq!(canonical_path(root, "/repo/lib/a.rb"), "lib/a.rb"); + assert_eq!(canonical_path(root, "lib/a.rb"), "lib/a.rb"); + assert_eq!(canonical_path(root, ""), ""); + } + + #[test] + fn a_path_outside_the_root_keeps_its_own_identity() { + assert_eq!(canonical_path(Path::new("/repo"), "/other/x.rb"), "/other/x.rb"); + } + + #[test] + fn a_range_is_matched_against_one_based_lines() { + // The plan is zero-based; collectors report the line a human would. + let r = range(4, 6); + assert!(!line_in_range(&r, 4)); + assert!(line_in_range(&r, 5)); + assert!(line_in_range(&r, 7)); + assert!(!line_in_range(&r, 8)); + } + + #[test] + fn only_boundary_anchors_are_satisfied_by_observations() { + assert_eq!(observation_kind(AnchorKind::FUNCTION_ENTRY), Some("parameter")); + assert_eq!(observation_kind(AnchorKind::FUNCTION_RETURN), Some("return")); + assert_eq!(observation_kind(AnchorKind::STATE_WRITE), Some("state")); + assert_eq!(observation_kind(AnchorKind::CALL_SELECTOR), None); + assert!(is_call_anchor(AnchorKind::CALL_SELECTOR)); + assert!(!is_call_anchor(AnchorKind::FUNCTION_ENTRY)); + } + + // --- what a match contributes ------------------------------------------- + + #[test] + fn a_domain_with_no_named_type_contributes_nothing() { + assert!(!has_types(&serde_json::json!({ "types": [] }))); + assert!(!has_types(&serde_json::json!({ "types": [""] }))); + assert!(!has_types(&serde_json::json!({}))); + assert!(has_types(&serde_json::json!({ "types": ["String"] }))); + } + + #[test] + fn bucket_fields_are_reported_by_presence_not_by_nulls() { + let bucket = serde_json::json!({ "receiver": {}, "result": serde_json::Value::Null }); + assert!(bucket_has(&bucket, "receiver")); + assert!(!bucket_has(&bucket, "result")); + assert!(!bucket_has(&bucket, "target")); + } + + #[test] + fn every_requested_kind_maps_to_the_field_that_satisfies_it() { + assert_eq!(evidence_field("PARAMETER_VALUE"), Some("value")); + assert_eq!(evidence_field("RETURN_VALUE"), Some("value")); + assert_eq!(evidence_field("STATE_VALUE"), Some("value")); + assert_eq!(evidence_field("RECEIVER_VALUE"), Some("receiver")); + assert_eq!(evidence_field("COLLECTION_VALUE"), Some("receiver")); + assert_eq!(evidence_field("CALL_TARGET"), Some("target")); + assert_eq!(evidence_field("RESULT_VALUE"), Some("result")); + assert_eq!(evidence_field("BOOLEAN_RESULT"), Some("boolean_result")); + assert_eq!(evidence_field("NOT_A_KIND"), None); + } + + // --- matching ----------------------------------------------------------- + + #[test] + fn a_parameter_anchor_matches_only_its_own_slot() { + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "observations": [ + { "kind": "parameter", "slot": "value", "count": 1, + "scope": { "path": "lib/a.rb", "line": 5 }, + "domain": { "types": ["String"] }, "bucket": value_bucket("String") }, + { "kind": "parameter", "slot": "other", "count": 1, + "scope": { "path": "lib/a.rb", "line": 5 }, + "domain": { "types": ["Integer"] }, "bucket": value_bucket("Integer") } + ] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (4, 4)); + assert_eq!(join.matching_observations(&a, "parameter"), vec![0]); + } + + #[test] + fn exact_anchor_identity_beats_the_collectors_reported_line() { + // A multiline call may be reported at its receiver line while the plan + // anchors the selector line. The exact binding already proved which + // planned anchor ran, so it wins. + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "calls": [ + { "row": { "callsite": { "path": "lib/a.rb", "line": 99, "selector": "map", + "anchor_symbol": "s" }, "count": 1 }, + "bucket": call_bucket(false) }, + { "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 1 }, + "bucket": call_bucket(false) } + ] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)); + let (matched, ambiguous) = join.matching_calls(&a); + assert_eq!(matched, vec![0], "the exactly-bound call, not the line match"); + assert!(!ambiguous); + } + + #[test] + fn without_an_exact_binding_only_unbound_calls_on_the_line_match() { + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "calls": [ + { "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "someone-elses" }, "count": 1 }, + "bucket": call_bucket(false) }, + { "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 1 }, + "bucket": call_bucket(false) } + ] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)); + assert_eq!(join.matching_calls(&a).0, vec![1]); + } + + // --- executed vs captured ---------------------------------------------- + + #[test] + fn a_return_anchor_with_no_observation_did_not_execute() { + // A conforming collector reports every returned value, including null + // and false, so absence means the boundary was never reached -- not + // that it ran uncaptured. + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "function_entries": [{ "path": "lib/a.rb", "line": 5 }] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "return", AnchorKind::FUNCTION_RETURN, (4, 4)); + assert!(!join.anchor_executed(&a, false)); + } + + #[test] + fn an_entry_anchor_whose_function_ran_is_executed() { + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "function_entries": [{ "path": "lib/a.rb", "line": 5 }] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (4, 4)); + assert!(join.anchor_executed(&a, false)); + } + + #[test] + fn an_exact_execution_range_is_proven_by_the_marker_alone() { + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "exact_anchor_executions": [{ "symbol": "s", "count": 3 }], + "executed_callsites": [{ "path": "lib/a.rb", "line": 5, "selector": "map" }] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)); + assert!(join.anchor_executed(&a, true)); + assert_eq!(join.exact_count("s"), 3); + let other = anchor("t", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)); + assert!(!join.anchor_executed(&other, true), "a different marker is not this one"); + assert!(join.anchor_executed(&other, false), "but the callsite did run"); + } + + #[test] + fn coverage_alone_only_fails_closed() { + // Line coverage cannot prove which same-line call ran, so it may say + // "executed but uncaptured" and never "this anchor ran". + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "coverage": [{ "path": "lib/a.rb", "lines": [5] }] + })); + let plan = plan_of(vec![]); + let join = Join::new(Path::new("/repo"), &plan, &trace); + let a = anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)); + assert!(join.anchor_executed(&a, false)); + } + + // --- whole-anchor outcomes --------------------------------------------- + + fn statuses(plan: &TracePlan, trace: &Trace) -> Vec<(String, String, i64)> { + let join = Join::new(Path::new("/repo"), plan, trace); + let runs = vec!["run-1".to_string()]; + plan.requests + .iter() + .map(|request| { + let a = request.anchor.as_ref().unwrap(); + let row = join.evaluate(request, a, &runs).unwrap(); + ( + a.symbol.clone(), + row["capture"]["status"].as_str().unwrap().to_string(), + row["capture"]["observed_executions"].as_i64().unwrap(), + ) + }) + .collect() + } + + #[test] + fn an_anchor_nothing_ran_is_not_executed_and_complete_for_every_kind() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (4, 4)), + &[runtime_protocol::EvidenceKind::PARAMETER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + assert_eq!(statuses(&plan, &trace), vec![("s".into(), "NOT_EXECUTED".into(), 0)]); + } + + #[test] + fn an_anchor_that_ran_without_a_captured_value_is_not_instrumented() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (4, 4)), + &[runtime_protocol::EvidenceKind::PARAMETER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "function_entries": [{ "path": "lib/a.rb", "line": 5 }] + })); + assert_eq!(statuses(&plan, &trace)[0].1, "NOT_INSTRUMENTED"); + } + + #[test] + fn a_kind_no_bucket_carries_makes_the_capture_partial() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)), + &[ + runtime_protocol::EvidenceKind::RECEIVER_VALUE, + runtime_protocol::EvidenceKind::RESULT_VALUE, + ], + )]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "calls": [{ "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 2 }, + "bucket": call_bucket(false) }] + })); + let rows = statuses(&plan, &trace); + assert_eq!(rows[0].1, "PARTIAL", "no result was captured"); + assert_eq!(rows[0].2, 2); + } + + #[test] + fn a_capture_carrying_every_requested_kind_is_complete() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)), + &[runtime_protocol::EvidenceKind::RECEIVER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "calls": [{ "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 2 }, + "bucket": call_bucket(true) }] + })); + assert_eq!(statuses(&plan, &trace)[0], ("s".into(), "COMPLETE_FOR_RUNS".into(), 2)); + } + + #[test] + fn a_match_that_captured_nothing_is_not_an_execution() { + // The collector saw the call but recorded no value for it, so there is + // no bucket and the anchor must not read as executed-and-captured. + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)), + &[runtime_protocol::EvidenceKind::RECEIVER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "calls": [{ "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 2 } }] + })); + assert_eq!(statuses(&plan, &trace)[0].1, "NOT_EXECUTED"); + } + + #[test] + fn one_representative_bucket_regains_the_markers_exact_count() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)), + &[runtime_protocol::EvidenceKind::RECEIVER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "exact_anchor_executions": [{ "symbol": "s", "count": 9 }], + "calls": [{ "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 2 }, + "bucket": call_bucket(true) }] + })); + assert_eq!(statuses(&plan, &trace)[0].2, 9); + } + + // --- emission ----------------------------------------------------------- + + #[test] + fn an_entry_absence_would_already_imply_is_left_out() { + let vacuous = serde_json::json!({ + "capture": { "status": "NOT_EXECUTED" }, "executions": [] + }); + let ran = serde_json::json!({ + "capture": { "status": "COMPLETE_FOR_RUNS" }, "executions": [{ "count": 1 }] + }); + let uncaptured = serde_json::json!({ + "capture": { "status": "NOT_INSTRUMENTED" }, "executions": [] + }); + assert!(is_vacuous(&vacuous)); + assert!(!is_vacuous(&ran)); + assert!(!is_vacuous(&uncaptured), "NOT_INSTRUMENTED is not implied by absence"); + } + + #[test] + fn bytes_are_encoded_as_standard_base64() { + assert_eq!(base64_standard(b""), ""); + assert_eq!(base64_standard(b"f"), "Zg=="); + assert_eq!(base64_standard(b"fo"), "Zm8="); + assert_eq!(base64_standard(b"foo"), "Zm9v"); + assert_eq!(base64_standard(b"foob"), "Zm9vYg=="); + assert_eq!(base64_standard(&[251, 255, 190]), "+/++"); + } + + // --- merging ------------------------------------------------------------ + + /// Build a real protocol message so the merge is tested through the same + /// parse the pipeline uses, not a hand-rolled struct. + fn evidence_doc(anchors: serde_json::Value, extra: serde_json::Value) -> runtime_protocol::RuntimeEvidence { + let mut doc = serde_json::json!({ + "protocol_version": 1, + "producer": { "name": "nil-kill", "version": "1" }, + "authority": "MODELED_RUNS", + "trace_plan_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "environment": [], + "runs": [{ "id": "r1", "status": "SUCCEEDED" }], + "anchors": anchors, + "correlations": [] + }); + for (key, value) in extra.as_object().cloned().unwrap_or_default() { + doc[key] = value; + } + runtime_protocol::parse_runtime_evidence_json(&doc.to_string()).expect("evidence fixture") + } + + fn anchor_row(symbol: &str, status: &str, kinds: &[&str], counts: &[(u64, &str)]) -> serde_json::Value { + anchor_row_for("r1", symbol, status, kinds, counts) + } + + fn anchor_row_for(run: &str, symbol: &str, status: &str, kinds: &[&str], counts: &[(u64, &str)]) -> serde_json::Value { + serde_json::json!({ + "anchor_symbol": symbol, + "anchor_semantic_digest": "AQID", + "capture": { + "status": status, + "run_ids": [run], + "observed_executions": "0", + "dropped_executions": "0", + "complete_kinds": kinds + }, + "executions": counts.iter().map(|(count, receiver)| serde_json::json!({ + "count": count.to_string(), + "receiver": { "alternatives": [{ "value": { "type_symbol": receiver }, "count": "1" }] } + })).collect::>() + }) + } + + #[test] + fn buckets_differing_only_in_count_are_one_observation() { + let doc = evidence_doc( + serde_json::json!([anchor_row("a", "COMPLETE_FOR_RUNS", &[], &[(2, "R"), (3, "R"), (1, "S")])]), + serde_json::json!({}), + ); + let merged = merge_evidence(&[doc]).expect("merge"); + let executions = &merged.anchors[0].executions; + assert_eq!(executions.len(), 2, "identical buckets fuse, distinct ones do not"); + assert_eq!(executions.iter().map(|b| b.count).sum::(), 6); + } + + #[test] + fn the_worst_status_any_shard_saw_wins() { + let cases = [ + (["COMPLETE_FOR_RUNS", "COMPLETE_FOR_RUNS"], "COMPLETE_FOR_RUNS"), + (["COMPLETE_FOR_RUNS", "PARTIAL"], "PARTIAL"), + (["COMPLETE_FOR_RUNS", "NOT_INSTRUMENTED"], "PARTIAL"), + (["PARTIAL", "STALE"], "STALE"), + (["STALE", "FAILED_CAPTURE"], "FAILED_CAPTURE"), + ]; + for (statuses, expected) in cases { + let docs: Vec<_> = statuses + .iter() + .map(|status| { + evidence_doc( + serde_json::json!([anchor_row("a", status, &["RECEIVER_VALUE"], &[(1, "R")])]), + serde_json::json!({}), + ) + }) + .collect(); + let merged = merge_evidence(&docs).expect("merge"); + assert_eq!( + format!("{:?}", merged.anchors[0].capture.status.enum_value_or_default()), + expected, + "{statuses:?}" + ); + } + } + + #[test] + fn an_anchor_no_shard_executed_is_not_executed() { + let doc = evidence_doc( + serde_json::json!([anchor_row("a", "COMPLETE_FOR_RUNS", &[], &[])]), + serde_json::json!({}), + ); + let merged = merge_evidence(&[doc]).expect("merge"); + assert_eq!( + format!("{:?}", merged.anchors[0].capture.status.enum_value_or_default()), + "NOT_EXECUTED" + ); + } + + #[test] + fn merging_unions_anchors_sums_counts_and_intersects_complete_kinds() { + let left = evidence_doc( + serde_json::json!([anchor_row( + "a", "COMPLETE_FOR_RUNS", &["RECEIVER_VALUE", "CALL_TARGET"], &[(2, "R")] + )]), + serde_json::json!({ + "runs": [{ "id": "r1", "status": "SUCCEEDED" }], + "environment": [{ "key": "ruby", "value": "3.2.3" }] + }), + ); + let right = evidence_doc( + serde_json::json!([ + anchor_row_for("r2", "a", "PARTIAL", &["RECEIVER_VALUE"], &[(3, "R")]), + anchor_row_for("r2", "b", "COMPLETE_FOR_RUNS", &[], &[(1, "S")]) + ]), + serde_json::json!({ + "runs": [{ "id": "r2", "status": "SUCCEEDED" }], + "environment": [{ "key": "ruby", "value": "3.2.3" }] + }), + ); + + let merged = merge_evidence(&[left, right]).expect("merge"); + assert_eq!(merged.anchors.len(), 2, "a shard contributes what it observed"); + + let a = merged + .anchors + .iter() + .find(|x| x.anchor_symbol == "a") + .expect("anchor a"); + assert_eq!( + format!("{:?}", a.capture.status.enum_value_or_default()), + "PARTIAL" + ); + assert_eq!(a.capture.observed_executions, 5, "counts add"); + assert_eq!(a.capture.run_ids, vec!["r1".to_string(), "r2".to_string()]); + assert_eq!( + a.capture + .complete_kinds + .iter() + .map(|k| format!("{:?}", k.enum_value_or_default())) + .collect::>(), + vec!["RECEIVER_VALUE"], + "a kind is complete only where every shard found it so" + ); + assert_eq!(a.executions.len(), 1, "identical buckets fuse across shards"); + assert_eq!(merged.environment.len(), 1); + assert_eq!(merged.runs.len(), 2); + } + + #[test] + fn merging_rejects_a_shard_that_repeats_an_anchor() { + let doc = evidence_doc( + serde_json::json!([ + anchor_row("a", "COMPLETE_FOR_RUNS", &[], &[(1, "R")]), + anchor_row("a", "COMPLETE_FOR_RUNS", &[], &[(1, "R")]) + ]), + serde_json::json!({}), + ); + assert!(merge_evidence(&[doc]).is_err()); + } + + #[test] + fn merging_rejects_conflicting_environment_claims() { + let doc = |version: &str| { + evidence_doc( + serde_json::json!([]), + serde_json::json!({ "environment": [{ "key": "ruby", "value": version }] }), + ) + }; + assert!(merge_evidence(&[doc("3.2.3"), doc("3.3.0")]).is_err()); + } + + // --- target resolution -------------------------------------------------- + + fn bucket_with_definition(path: &str, line: i64) -> serde_json::Value { + serde_json::json!({ + "count": 1, + "target": { "symbol": "observed-symbol", "source_role": "PRODUCTION" }, + "target_definition": { "path": path, "line": line } + }) + } + + fn join_with_plan<'a>( + plan: &'a TracePlan, + trace: &'a Trace, + ) -> Join<'a> { + Join::new(Path::new("/repo"), plan, trace) + } + + #[test] + fn a_declaration_matching_one_planned_function_takes_that_functions_identity() { + let plan = plan_of(vec![request( + anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (9, 11)), + &[runtime_protocol::EvidenceKind::PARAMETER_VALUE], + )]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = bucket_with_definition("/repo/lib/a.rb", 10); + join.resolve_target(&mut bucket); + + assert_eq!(bucket["target"]["symbol"], "enclosing/s"); + assert_eq!(bucket["target"]["definition"]["anchor_symbol"], "s"); + assert_eq!(bucket["target"]["definition"]["relative_path"], "lib/a.rb"); + assert!(bucket.get("target_definition").is_none(), "the locator is consumed"); + } + + #[test] + fn two_planned_functions_at_one_declaration_is_not_a_resolution() { + // Two distinct enclosing symbols covering the same line means the + // declaration does not name one of them, so the raw locator is kept + // for the consumer to bind from source itself. + let plan = plan_of(vec![ + request( + anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (9, 11)), + &[runtime_protocol::EvidenceKind::PARAMETER_VALUE], + ), + request( + anchor("t", "lib/a.rb", "other", AnchorKind::FUNCTION_ENTRY, (9, 11)), + &[runtime_protocol::EvidenceKind::PARAMETER_VALUE], + ), + ]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = bucket_with_definition("/repo/lib/a.rb", 10); + join.resolve_target(&mut bucket); + + assert_eq!(bucket["target"]["symbol"], "observed-symbol", "kept as observed"); + assert_eq!(bucket["target"]["definition"]["anchor_symbol"], ""); + assert_eq!(bucket["target"]["definition"]["range"]["start_line"], 9); + } + + #[test] + fn the_same_function_entered_and_returned_is_still_one_candidate() { + // FUNCTION_ENTRY and FUNCTION_RETURN of one method share an enclosing + // symbol, so they must not read as an ambiguous pair. + let mut entry = anchor("s", "lib/a.rb", "value", AnchorKind::FUNCTION_ENTRY, (9, 11)); + let mut ret = anchor("r", "lib/a.rb", "return", AnchorKind::FUNCTION_RETURN, (9, 11)); + entry.enclosing_symbol = "same/method".to_string(); + ret.enclosing_symbol = "same/method".to_string(); + let plan = plan_of(vec![ + request(entry, &[runtime_protocol::EvidenceKind::PARAMETER_VALUE]), + request(ret, &[runtime_protocol::EvidenceKind::RETURN_VALUE]), + ]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = bucket_with_definition("/repo/lib/a.rb", 10); + join.resolve_target(&mut bucket); + assert_eq!(bucket["target"]["symbol"], "same/method"); + } + + #[test] + fn a_declaration_outside_the_plan_keeps_its_observed_locator() { + let plan = plan_of(vec![]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = bucket_with_definition("/repo/vendor/dep.rb", 7); + join.resolve_target(&mut bucket); + assert_eq!(bucket["target"]["definition"]["relative_path"], "vendor/dep.rb"); + assert_eq!(bucket["target"]["definition"]["range"]["start_line"], 6); + } + + #[test] + fn a_bucket_with_no_declaration_is_left_alone() { + let plan = plan_of(vec![]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = serde_json::json!({ "count": 1, "target": { "symbol": "x" } }); + join.resolve_target(&mut bucket); + assert_eq!(bucket["target"]["symbol"], "x"); + assert!(bucket["target"].get("definition").is_none()); + } + + #[test] + fn a_declaration_without_a_usable_line_is_not_invented() { + let plan = plan_of(vec![]); + let trace = trace_of(serde_json::json!({ "trace_version": 1 })); + let join = join_with_plan(&plan, &trace); + + let mut bucket = bucket_with_definition("/repo/lib/a.rb", 0); + join.resolve_target(&mut bucket); + assert!(bucket["target"].get("definition").is_none()); + } + + // --- documents in and out ----------------------------------------------- + + #[test] + fn a_trace_of_another_version_is_refused() { + let dir = std::env::temp_dir().join(format!("nk-trace-version-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tmp"); + let path = dir.join("trace.json"); + std::fs::write(&path, serde_json::json!({ "trace_version": 99 }).to_string()).expect("write"); + let error = read_trace(&path).expect_err("refused"); + assert!(error.to_string().contains("unsupported runtime trace version")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_document_is_gzipped_when_its_name_says_so_and_round_trips() { + let dir = std::env::temp_dir().join(format!("nk-write-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tmp"); + + let gz = dir.join("doc.json.gz"); + write_json(&gz, "{\"a\":1}").expect("gz write"); + let bytes = std::fs::read(&gz).expect("read"); + assert_eq!(&bytes[..2], &[0x1f, 0x8b], "gzip magic"); + assert_eq!(runtime_protocol::read_json(&gz).expect("read back"), "{\"a\":1}"); + + let plain = dir.join("doc.json"); + write_json(&plain, "{\"a\":1}").expect("plain write"); + assert_eq!(std::fs::read_to_string(&plain).expect("read"), "{\"a\":1}"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_plan_is_accepted_inside_its_envelope_or_bare() { + let dir = std::env::temp_dir().join(format!("nk-plan-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tmp"); + let bare = runtime_protocol::to_json(&plan_of(vec![])).expect("plan json"); + + let wrapped = dir.join("wrapped.json"); + std::fs::write( + &wrapped, + serde_json::json!({ + "version": 1, + "runtime_evidence": serde_json::from_str::(&bare).unwrap() + }) + .to_string(), + ) + .expect("write"); + let flat = dir.join("flat.json"); + std::fs::write(&flat, &bare).expect("write"); + + // Both forms reach the same validation, which is what unwrapping means. + // A plan still inside its envelope used to fail on the envelope's own + // fields ("Unknown field name: version") before ever being read. + let from_envelope = format!("{:?}", read_plan(&wrapped)); + let from_bare = format!("{:?}", read_plan(&flat)); + let cause = "trace plan protocol_version must be 1"; + assert!(from_bare.contains(cause), "{from_bare}"); + assert!(from_envelope.contains(cause), "{from_envelope}"); + assert!(!from_envelope.contains("Unknown field name"), "{from_envelope}"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_emitted_document_omits_only_what_absence_already_says() { + let plan = plan_of(vec![ + request( + anchor("ran", "lib/a.rb", "map", AnchorKind::CALL_SELECTOR, (4, 4)), + &[runtime_protocol::EvidenceKind::RECEIVER_VALUE], + ), + request( + anchor("idle", "lib/a.rb", "each", AnchorKind::CALL_SELECTOR, (4, 4)), + &[runtime_protocol::EvidenceKind::RECEIVER_VALUE], + ), + ]); + let trace = trace_of(serde_json::json!({ + "trace_version": 1, + "run_ids": ["r1"], + "environment": [], + "trace_plan_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "calls": [{ "row": { "callsite": { "path": "lib/a.rb", "line": 5, "selector": "map", + "anchor_symbol": "" }, "count": 1 }, + "bucket": call_bucket(true) }] + })); + let document = build_evidence(Path::new("/repo"), &plan, &trace).expect("evidence"); + let parsed: serde_json::Value = serde_json::from_str(&document).expect("json"); + let symbols: Vec<&str> = parsed["anchors"] + .as_array() + .unwrap() + .iter() + .map(|a| a["anchor_symbol"].as_str().unwrap()) + .collect(); + assert_eq!(symbols, vec!["ran"], "the idle anchor is implied by its absence"); + assert_eq!(parsed["runs"][0]["id"], "r1"); + } +} diff --git a/gems/fact-mine/src/scip.rs b/gems/fact-mine/src/scip.rs index 28c079908..6a85d63a8 100644 --- a/gems/fact-mine/src/scip.rs +++ b/gems/fact-mine/src/scip.rs @@ -5,14 +5,17 @@ //! occurrence to the innermost emitted project method. Language-owned external //! symbol parsing is delegated back to the source adapter. -use crate::profile::{summarize_call_resolution, CallRecord, MethodRecord, ProfileOutput}; +use crate::profile::{ + summarize_call_resolution, CallRecord, MethodRecord, ProfileOutput, SemanticIndex, +}; use crate::syntax; +use crate::type_inference::TypeExpr; use anyhow::{Context, Result}; +use protobuf::Message; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::path::Path; -use std::process::Command; +use std::path::{Path, PathBuf}; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ImportStats { @@ -30,14 +33,60 @@ struct Index { metadata: Option, #[serde(default)] documents: Vec, + /// FactMine's runtime-SCIP envelope carries normalized, exact source + /// anchors separately from SCIP semantic occurrences. An observed runtime + /// target may lose to a stronger static project target, but that must not + /// erase the fact that the source callsite executed. + #[serde(rename = "_runtimeEvidence", default)] + runtime_evidence: RuntimeEvidence, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] +struct RuntimeEvidence { + #[serde(default, alias = "observedCallsiteAnchors")] + observed_callsite_anchors: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct ObservedCallsiteAnchor { + #[serde(default, alias = "relativePath")] + relative_path: String, + #[serde(default)] + range: Vec, +} + +#[derive(Clone, Debug, Deserialize)] struct Metadata { + #[serde(default, alias = "toolInfo")] + tool_info: Option, #[serde(default, alias = "textDocumentEncoding")] text_document_encoding: TextDocumentEncoding, } +#[derive(Clone, Debug, Deserialize)] +struct ToolInfo { + #[serde(default)] + name: String, + #[serde(default)] + version: String, + #[serde(default)] + arguments: Vec, +} + +const OBSERVED_OPEN_AUTHORITY_ARGUMENT: &str = "--fact-mine-index-authority=observed-open"; +const RUNTIME_MODELED_AUTHORITY_ARGUMENT: &str = + "--fact-mine-index-authority=runtime-modeled-world"; +const RUNTIME_MODELED_QUALITY: &str = "upper_bound_modeled_world"; +const RUNTIME_MODELED_ASSUMPTION: &str = + "observed call targets exhaust the attested workload and runtime environment"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum IndexAuthority { + Compiler, + ObservedOpen, + RuntimeModeled, +} + #[derive(Clone, Debug, Default, Deserialize)] #[serde(untagged)] enum TextDocumentEncoding { @@ -73,8 +122,25 @@ struct Document { #[derive(Debug, Deserialize)] struct SymbolInformation { symbol: String, + /// Historical SCIP indexers, including current scip-dotnet releases, + /// render declaration signatures as fenced code in `documentation` + /// instead of populating `signature_documentation`. + #[serde(default)] + documentation: Vec, #[serde(default)] relationships: Vec, + /// The indexer's rendering of the declaration, e.g. + /// `func newRootCmd(ctx context.Context, version string) (*gremlinsCmd, error)`. + /// Its grammar is language-specific, so it is only ever read through the + /// adapter seam `NormalizedLanguageBehavior::parse_signature`. + #[serde(default, alias = "signatureDocumentation")] + signature_documentation: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct SignatureDocumentation { + #[serde(default)] + text: String, } #[derive(Debug, Deserialize)] @@ -180,35 +246,60 @@ struct Definition { } pub fn apply_json_file(output: &mut ProfileOutput, index_path: &Path) -> Result { - let json = if index_path + if index_path .extension() .and_then(|extension| extension.to_str()) == Some("scip") { - let binary = std::env::var("SCIP_BINARY").unwrap_or_else(|_| "scip".to_string()); - let result = Command::new(&binary) - .args(["print", "--json"]) - .arg(index_path) - .output() - .with_context(|| format!("failed to execute {binary} print --json"))?; - if !result.status.success() { - anyhow::bail!( - "{binary} print --json failed for {}: {}", - index_path.display(), - String::from_utf8_lossy(&result.stderr) - ); - } - String::from_utf8(result.stdout).context("SCIP JSON output was not UTF-8")? + let bytes = fs::read(index_path).with_context(|| { + format!("failed to read binary SCIP index {}", index_path.display()) + })?; + let protobuf = scip::types::Index::parse_from_bytes(&bytes).with_context(|| { + format!( + "failed to decode binary SCIP index {}", + index_path.display() + ) + })?; + let index = index_from_protobuf(protobuf) + .with_context(|| format!("invalid binary SCIP index {}", index_path.display()))?; + apply_index(output, index) + .with_context(|| format!("failed to import SCIP index {}", index_path.display())) } else { - fs::read_to_string(index_path) - .with_context(|| format!("failed to read SCIP JSON index {}", index_path.display()))? - }; - apply_json(output, &json) - .with_context(|| format!("failed to import SCIP JSON index {}", index_path.display())) + let json = fs::read_to_string(index_path) + .with_context(|| format!("failed to read SCIP JSON index {}", index_path.display()))?; + apply_json(output, &json) + .with_context(|| format!("failed to import SCIP JSON index {}", index_path.display())) + } } pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result { let index: Index = serde_json::from_str(json)?; + apply_index(output, index) +} + +/// Import an index the caller already holds. The runtime overlay builds its +/// index as a `Value` and then imports it; printing that to a string only to +/// lex it straight back is work neither side needs. +pub fn apply_value(output: &mut ProfileOutput, value: &serde_json::Value) -> Result { + let index: Index = serde_json::from_value(value.clone())?; + apply_index(output, index) +} + +fn apply_index(output: &mut ProfileOutput, mut index: Index) -> Result { + let authority = index_authority(&index)?; + let runtime_observed_callsite_anchors = (authority == IndexAuthority::RuntimeModeled) + .then(|| runtime_observed_callsite_anchors(&index)) + .unwrap_or_default(); + for information in index + .documents + .iter_mut() + .flat_map(|document| &mut document.symbols) + { + if information.signature_documentation.is_none() { + information.signature_documentation = + legacy_signature_documentation(&information.documentation); + } + } if index .metadata .as_ref() @@ -218,9 +309,45 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result "SCIP index uses non-UTF-8 text_document_encoding; column conversion is required" ); } - assign_method_symbols(&mut output.methods, &index.documents); + if authority == IndexAuthority::Compiler { + assign_method_symbols(&mut output.methods, &index.documents); + apply_signature_types(output, &index); + apply_local_variable_types(output, &index); + apply_scalar_operator_types(output, &index); + } let methods_by_path = methods_by_document(&output.methods, &index.documents); + // An index that joins to no analyzed method contributes no identity, yet + // every downstream metric would still be stamped with the SCIP resolution + // tier. Indexers produce this silently: `scip-go .` on a multi-package + // module writes a valid, empty index and exits 0. Refuse it rather than + // degrade to source-only under a tier label that is no longer true. + if !output.methods.is_empty() && methods_by_path.values().all(|methods| methods.is_empty()) { + anyhow::bail!( + "SCIP index covers none of the {} analyzed methods ({} indexed documents); \ + re-index the whole project (for example `scip-go ./...`, not `scip-go .`)", + output.methods.len(), + index.documents.len() + ); + } + if let Some(tool_info) = index + .metadata + .as_ref() + .and_then(|metadata| metadata.tool_info.as_ref()) + .filter(|tool_info| !tool_info.name.is_empty() && !tool_info.version.is_empty()) + { + output.semantic_indexes.push(SemanticIndex { + tool: tool_info.name.clone(), + version: tool_info.version.clone(), + }); + output.semantic_indexes.sort(); + output.semantic_indexes.dedup(); + } let definitions = definitions_by_symbol(&index.documents, &methods_by_path); + let indexed_roots = indexed_source_roots(&methods_by_path); + let indexed_sources = + indexed_document_sources(&index.documents, &methods_by_path, &indexed_roots); + let preprocessor_definitions = + indexed_preprocessor_definitions(&index.documents, &indexed_sources); let implementation_targets = implementation_targets(&index.documents, &definitions); let method_languages = output .methods @@ -238,6 +365,9 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result stats.unmatched_calls += 1; continue; }; + call.runtime_evidence_observed |= runtime_observed_callsite_anchors + .get(&document.relative_path) + .is_some_and(|anchors| anchors.contains(&zero_based_span(call.span))); let source = fs::read_to_string(&call.path).unwrap_or_default(); let Some(selected) = select_call_occurrences(call, document, &source, language) else { stats.unmatched_calls += 1; @@ -260,6 +390,32 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result }) .filter_map(|definition| definition.method_id.as_deref()) .collect::>(); + let project_symbols = selected + .alternatives + .iter() + .filter(|candidate| { + let key = definition_key(&document.relative_path, &candidate.symbol); + definitions.get(&key).is_some_and(|rows| { + rows.iter().any(|definition| definition.method_id.is_some()) + }) + }) + .map(|candidate| candidate.symbol.as_str()) + .collect::>(); + if authority == IndexAuthority::ObservedOpen { + stats.matched_occurrences += 1; + if call.target.is_some() { + continue; + } + apply_observed_open_candidates( + call, + language, + occurrence, + &selected_symbols, + target_ids, + &mut stats, + ); + continue; + } let candidate_costs = selected .alternatives .iter() @@ -267,21 +423,122 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result syntax::external_symbol_call_complexity(language, &candidate.symbol, &call.message) }) .collect::>(); + if authority == IndexAuthority::RuntimeModeled { + stats.matched_occurrences += 1; + if call.target.is_some() { + continue; + } + apply_runtime_modeled_candidates( + call, + language, + occurrence, + &selected_symbols, + &project_symbols, + target_ids, + &mut stats, + )?; + continue; + } + let compiler_macro = selected + .alternatives + .iter() + .all(|candidate| candidate.symbol.ends_with('!')); + let macro_costs = if call.preprocessor_callable || compiler_macro { + selected + .alternatives + .iter() + .filter_map(|candidate| { + let definition = preprocessor_definitions + .get(&candidate.symbol) + .cloned() + .or_else(|| { + syntax::preprocessor_definition_location(language, &candidate.symbol) + .and_then(|(path, line)| { + indexed_definition_at( + &indexed_sources, + &indexed_roots, + &path, + line, + ) + }) + }); + definition.as_deref().and_then(|definition| { + syntax::preprocessor_definition_call_complexity(language, definition) + }) + }) + .collect::>() + } else { + Vec::new() + }; let converged_cost = (selected_symbols.len() == 1 || (candidate_costs.len() == selected_symbols.len() && candidate_costs .windows(2) .all(|pair| equivalent_external_cost(&pair[0], &pair[1])))) .then(|| candidate_costs.into_iter().next()) - .flatten(); + .flatten() + .or_else(|| { + (selected_symbols.len() == 1 + || (macro_costs.len() == selected_symbols.len() + && macro_costs + .windows(2) + .all(|pair| equivalent_external_cost(&pair[0], &pair[1])))) + .then(|| macro_costs.into_iter().next()) + .flatten() + }); + + // A function-local import establishes a distinct lexical dispatch + // domain. A compiler occurrence that points the call back to its own + // enclosing definition contradicts that source fact (typically an + // unresolved dependent overload), so retain the adapter-proven import + // and its cost instead of manufacturing recursion. + if call.lexical_symbol_origin.as_deref() == Some("function_local_import") + && target_ids.len() == 1 + && target_ids + .iter() + .next() + .is_some_and(|target| *target == call.source) + { + continue; + } stats.matched_occurrences += 1; call.semantic_symbol = Some(occurrence.symbol.clone()); call.target_provenance = Some("scip".to_string()); + call.preprocessor_callable |= compiler_macro; call.candidate_targets.clear(); call.candidate_reason = None; + call.consumer_closed_candidate_set = true; - if target_ids.len() == 1 { + if target_ids.len() == 1 + && compiler_proven_abstract_project_target( + &output.owners, + &output.methods, + target_ids.iter().next().copied().unwrap(), + ) + { + // The symbol selects an abstract declaration, not an executable + // body. Keeping it as an exact project target makes the + // aggregator wait for a summary that can never exist. The + // compiler-proven dispatch boundary is instead one invocation of + // an implementation supplied by the caller. + let (time, space) = syntax::parametric_call_complexity("callback_once").unwrap(); + call.target = None; + call.kind = "interface_call".to_string(); + call.callback_receiver = true; + call.external_symbol_scope = Some("project_interface".to_string()); + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = + Some("compiler_proven_abstract_project_contract".to_string()); + call.complexity_bound_quality = + Some("upper_bound_parametric_callback_once".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + stats.modeled_external_symbols += 1; + } else if target_ids.len() == 1 { let target = target_ids.into_iter().next().unwrap().to_string(); call.kind = "resolved_call".to_string(); call.target = Some(target); @@ -417,13 +674,20 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result } } - reconcile_non_recursive_overload_calls(output); + if authority == IndexAuthority::Compiler { + reconcile_constructor_delegations(output); + reconcile_inactive_preprocessor_project_calls(output); + reconcile_non_recursive_overload_calls(output); + } let raw_parser_call_sites = output.call_resolution_coverage.raw_parser_call_sites; let raw_calls_not_normalized = output.call_resolution_coverage.raw_calls_not_normalized; let raw_calls_not_normalized_inside_function = output .call_resolution_coverage .raw_calls_not_normalized_inside_function; + let source_export_eligible_methods_overlapping_raw_call_loss = output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss; let raw_calls_not_normalized_outside_function = output .call_resolution_coverage .raw_calls_not_normalized_outside_function; @@ -445,6 +709,10 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result output .call_resolution_coverage .raw_calls_not_normalized_inside_function = raw_calls_not_normalized_inside_function; + output + .call_resolution_coverage + .source_export_eligible_methods_overlapping_raw_call_loss = + source_export_eligible_methods_overlapping_raw_call_loss; output .call_resolution_coverage .raw_calls_not_normalized_outside_function = raw_calls_not_normalized_outside_function; @@ -462,154 +730,345 @@ pub fn apply_json(output: &mut ProfileOutput, json: &str) -> Result // contract after that replacement so cross-file callable declarations are // not lost merely because the compiler correctly rejected the heuristic. crate::profile::reapply_declared_callback_costs(output); + // Runtime/native indexes may name generated source declarations (such as + // attribute readers) under a runtime symbol rather than the ordinary + // source-method identity. Reconcile those only through language-owned + // symbol parsing and declaration syntax, then apply the generic unique + // project join. + crate::profile::reapply_generated_callable_costs(output); + // A runtime type domain can close a generated project declaration even + // when no compiler symbol exists. The shared candidate join is safe only + // for one exact, language-owned generated contract. + crate::profile::reapply_runtime_generated_candidate_costs(output); + // The closed candidate join above can promote one generated declaration + // to an exact project target. Re-run the exact-target contract so its + // canonical declaration cost replaces the intermediate candidate-max + // annotation. + crate::profile::reapply_generated_callable_costs(output); + // Runtime SCIP can likewise be the first source of a generated record + // reader's receiver identity. Re-run the same normalized declaration + // contract after semantic identities are imported. + crate::profile::reapply_generated_record_costs(output); + // SCIP may be the first proof of the producer call's exact project target. + // Re-run direct-result propagation after importing those identities so an + // `auto value = factory(); value.method()` chain can consume the declared + // return contract without guessing the local's type. + crate::profile::reapply_direct_call_result_costs(output); + apply_semantic_block_call_semantics(output); + apply_resolved_call_costs_to_contexts(output); Ok(stats) } -fn compiler_proven_project_interface_call( - owners: &[crate::profile::OwnerRecord], - language: &str, - symbol: &str, -) -> bool { - let Some(symbol_owner) = syntax::external_symbol_owner(language, symbol) else { - return false; - }; - let suffix = format!(".{symbol_owner}"); - let exact = owners.iter().filter(|owner| { - owner.language == language - && owner.kind == "interface" - && (owner.symbol.as_deref() == Some(symbol_owner.as_str()) - || owner - .symbol - .as_deref() - .is_some_and(|candidate| candidate.ends_with(&suffix))) - }); - if exact.count() == 1 { - return true; +fn index_authority(index: &Index) -> Result { + let arguments = index + .metadata + .as_ref() + .and_then(|metadata| metadata.tool_info.as_ref()) + .map(|tool_info| tool_info.arguments.as_slice()) + .unwrap_or_default(); + let declared = arguments + .iter() + .filter_map(|argument| argument.strip_prefix("--fact-mine-index-authority=")) + .collect::>(); + if declared.is_empty() { + Ok(IndexAuthority::Compiler) + } else if declared.len() == 1 + && declared.contains( + OBSERVED_OPEN_AUTHORITY_ARGUMENT + .strip_prefix("--fact-mine-index-authority=") + .expect("authority argument prefix"), + ) + { + Ok(IndexAuthority::ObservedOpen) + } else if declared.len() == 1 + && declared.contains( + RUNTIME_MODELED_AUTHORITY_ARGUMENT + .strip_prefix("--fact-mine-index-authority=") + .expect("authority argument prefix"), + ) + { + Ok(IndexAuthority::RuntimeModeled) + } else { + anyhow::bail!( + "unsupported or conflicting SCIP index authority declarations: {}", + declared.into_iter().collect::>().join(", ") + ) } +} - // Some producers encode an import path rather than the source package - // name (`github.com/acme/tool/v2` versus `package tool`). The final type - // descriptor remains compiler-proven. Accept it only when it selects one - // normalized project interface; duplicate short names remain ambiguous. - let unqualified = symbol_owner.rsplit('.').next().unwrap_or(&symbol_owner); - owners - .iter() - .filter(|owner| { - owner.language == language && owner.kind == "interface" && owner.name == unqualified - }) - .count() - == 1 +fn runtime_observed_callsite_anchors(index: &Index) -> BTreeMap> { + let mut anchors = BTreeMap::>::new(); + for anchor in &index.runtime_evidence.observed_callsite_anchors { + if anchor.relative_path.is_empty() { + continue; + } + let occurrence = Occurrence { + range: anchor.range.clone(), + typed_range: None, + symbol: String::new(), + symbol_roles: 0, + }; + if let Some(span) = occurrence.span() { + anchors + .entry(anchor.relative_path.clone()) + .or_default() + .insert(span); + } + } + anchors } -fn equivalent_external_cost( - left: &crate::syntax::ExternalCallComplexity, - right: &crate::syntax::ExternalCallComplexity, -) -> bool { - left.time == right.time - && left.space == right.space - && left.provenance == right.provenance - && left.bound_quality == right.bound_quality - && left.candidates == right.candidates - && left.assumption == right.assumption +fn zero_based_span(span: [usize; 4]) -> [usize; 4] { + [ + span[0].saturating_sub(1), + span[1], + span[2].saturating_sub(1), + span[3], + ] } -fn implementation_targets( - documents: &[Document], - definitions: &BTreeMap>, -) -> BTreeMap> { - let mut implementation_symbols = BTreeMap::>::new(); - for information in documents.iter().flat_map(|document| &document.symbols) { - for relationship in &information.relationships { - if relationship.is_implementation { - implementation_symbols - .entry(relationship.symbol.clone()) - .or_default() - .insert(information.symbol.clone()); - } +fn apply_runtime_modeled_candidates( + call: &mut CallRecord, + language: &str, + occurrence: &Occurrence, + selected_symbols: &BTreeSet<&str>, + project_symbols: &BTreeSet<&str>, + target_ids: BTreeSet<&str>, + stats: &mut ImportStats, +) -> Result<()> { + call.target = None; + call.semantic_symbol = Some(occurrence.symbol.clone()); + call.target_provenance = Some("runtime_scip_modeled".to_string()); + call.candidate_targets.clear(); + call.complexity_candidates = selected_symbols + .iter() + .map(|symbol| (*symbol).to_string()) + .collect(); + call.complexity_bound_quality = Some(RUNTIME_MODELED_QUALITY.to_string()); + call.complexity_assumptions = vec![RUNTIME_MODELED_ASSUMPTION.to_string()]; + call.empty_domain_cause = None; + + let all_project = !selected_symbols.is_empty() && project_symbols == selected_symbols; + let all_external = project_symbols.is_empty(); + let external_symbols = selected_symbols + .difference(project_symbols) + .copied() + .collect::>(); + let external_upper_bound = + runtime_external_candidate_upper_bound(language, &external_symbols, &call.message)?; + if all_project { + call.kind = "unresolved_call".to_string(); + call.external_symbol_scope = Some("project".to_string()); + call.complexity_missing_kind = None; + call.known_time_complexity = None; + call.known_space_complexity = None; + call.complexity_provenance = Some("runtime_scip_modeled_project_set".to_string()); + call.candidate_targets = target_ids.into_iter().map(str::to_string).collect(); + call.candidate_reason = Some("runtime_modeled_observed_candidate_set".to_string()); + call.consumer_closed_candidate_set = true; + call.unresolved_reason = + Some("runtime_modeled_project_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + return Ok(()); + } + + if all_external { + let metadata = syntax::external_symbol_metadata(language, &occurrence.symbol); + call.kind = "external_call".to_string(); + call.external_symbol_scope = Some(metadata.scope.to_string()); + call.complexity_missing_kind = Some(metadata.missing_cost_kind); + call.candidate_reason = Some("runtime_modeled_observed_external_set".to_string()); + call.consumer_closed_candidate_set = true; + stats.external_symbols += 1; + + if let Some((time, space, assumptions)) = external_upper_bound { + call.known_time_complexity = Some(time); + call.known_space_complexity = Some(space); + call.complexity_provenance = + Some("runtime_scip_modeled:conservative_external_candidate_max".to_string()); + call.complexity_assumptions.extend(assumptions); + call.complexity_assumptions.sort(); + call.complexity_assumptions.dedup(); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + stats.modeled_external_symbols += 1; + return Ok(()); + } + + // Preserve a language adapter's independently justified source model. + if call.known_time_complexity.is_some() && call.known_space_complexity.is_some() { + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.complexity_missing_kind = None; + stats.modeled_external_symbols += 1; + } else { + call.unresolved_reason = Some("runtime_modeled_external_symbol_unmodeled".to_string()); + call.resolution_missing_proof = + Some("dependency_or_stdlib_symbol_known_cost_unavailable".to_string()); } + return Ok(()); } - implementation_symbols - .into_iter() - .filter_map(|(declaration, implementations)| { - let targets = implementations - .iter() - .flat_map(|symbol| definitions.get(symbol).into_iter().flatten()) - .filter_map(|definition| definition.method_id.clone()) - .collect::>(); - (!targets.is_empty()).then_some((declaration, targets)) - }) - .collect() + + if let Some((time, space, assumptions)) = external_upper_bound { + call.kind = "unresolved_call".to_string(); + call.external_symbol_scope = Some("mixed".to_string()); + call.complexity_missing_kind = None; + call.candidate_targets = target_ids.into_iter().map(str::to_string).collect(); + call.candidate_reason = Some("runtime_modeled_mixed_candidate_set".to_string()); + call.consumer_closed_candidate_set = true; + call.known_time_complexity = Some(time); + call.known_space_complexity = Some(space); + call.complexity_provenance = + Some("runtime_scip_modeled:mixed_project_external_candidate_max".to_string()); + call.complexity_assumptions.extend(assumptions); + call.complexity_assumptions.sort(); + call.complexity_assumptions.dedup(); + call.unresolved_reason = + Some("runtime_modeled_mixed_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + stats.external_symbols += external_symbols.len(); + stats.modeled_external_symbols += external_symbols.len(); + return Ok(()); + } + + // Retain every identity but do not claim closure when at least one + // external candidate has no reviewed or parametric cost. + call.kind = "unresolved_call".to_string(); + call.external_symbol_scope = Some("mixed".to_string()); + call.complexity_missing_kind = Some("mixed_project_external_cost_join_missing".to_string()); + call.candidate_targets = target_ids.into_iter().map(str::to_string).collect(); + call.candidate_reason = Some("runtime_observed_mixed_candidate_set".to_string()); + call.consumer_closed_candidate_set = false; + call.known_time_complexity = None; + call.known_space_complexity = None; + call.unresolved_reason = Some("runtime_observed_mixed_candidate_set_open".to_string()); + call.resolution_missing_proof = + Some("mixed_project_external_candidate_join_required".to_string()); + Ok(()) } -fn assign_method_symbols(methods: &mut [MethodRecord], documents: &[Document]) { - let methods_by_path = methods_by_document(methods, documents); - let covered_method_ids = methods_by_path - .values() - .flatten() - .map(|method| method.id.clone()) - .collect::>(); - let mut symbols = BTreeMap::>::new(); - for document in documents { - let source = methods_by_path - .get(&document.relative_path) - .and_then(|rows| rows.first()) - .and_then(|method| fs::read_to_string(&method.path).ok()) - .unwrap_or_default(); - for occurrence in document - .occurrences - .iter() - .filter(|occurrence| occurrence.symbol_roles & 1 == 1) - { - let Some(span) = occurrence.span() else { - continue; - }; - let one_based = [span[0] + 1, span[1], span[2] + 1, span[3]]; - if let Some(method) = methods_by_path - .get(&document.relative_path) - .into_iter() - .flatten() - .filter(|method| method.span.is_some_and(|outer| contains(outer, one_based))) - .min_by_key(|method| method_span_size(method)) - { - let declaration = occurrence_text(&source, span); - if !callable_symbol(&occurrence.symbol) - && declaration != method.name - && declaration != method.dispatch_name - { - continue; - } - symbols - .entry(method.id.clone()) - .or_default() - .insert(occurrence.symbol.clone()); - } - } +fn runtime_external_candidate_upper_bound( + language: &str, + symbols: &BTreeSet<&str>, + message: &str, +) -> Result)>> { + if symbols.is_empty() { + return Ok(None); } - drop(methods_by_path); - for method in methods { - // Multiple --scip-index inputs are applied sequentially. An index may - // update only declarations in documents it actually covers; clearing - // every other symbol here would erase the preceding repository. - if !covered_method_ids.contains(&method.id) { + if symbols + .iter() + .all(|symbol| crate::runtime_evidence::is_runtime_record_accessor_symbol(symbol, message)) + { + return Ok(Some(("O(1)".to_string(), "O(1)".to_string(), Vec::new()))); + } + let mut costs = Vec::new(); + for symbol in symbols { + if let Some(complexity) = syntax::external_symbol_call_complexity(language, symbol, message) + { + costs.push(( + complexity.time.to_string(), + complexity.space.to_string(), + complexity.assumption.into_iter().collect::>(), + )); continue; } - method.semantic_symbol = symbols - .remove(&method.id) - .filter(|candidates| candidates.len() == 1) - .and_then(|candidates| candidates.into_iter().next()); + let metadata = syntax::external_symbol_metadata(language, symbol); + let Some(parametric) = metadata.parametric_cost else { + return Ok(None); + }; + let (time, space) = syntax::parametric_call_complexity(¶metric).ok_or_else(|| { + anyhow::anyhow!("unsupported parametric runtime cost {parametric} for {symbol}") + })?; + costs.push((time.to_string(), space.to_string(), Vec::new())); } + let time = costs + .iter() + .max_by_key(|(time, _, _)| conservative_complexity_rank(time)) + .map(|(time, _, _)| time.clone()) + .expect("nonempty runtime candidate costs"); + let space = costs + .iter() + .max_by_key(|(_, space, _)| conservative_complexity_rank(space)) + .map(|(_, space, _)| space.clone()) + .expect("nonempty runtime candidate costs"); + let mut assumptions = costs + .into_iter() + .flat_map(|(_, _, assumptions)| assumptions) + .collect::>(); + assumptions.sort(); + assumptions.dedup(); + Ok(Some((time, space, assumptions))) } -/// Syntax-only recursion extraction deliberately runs before corpus call -/// resolution. At that point a bare same-spelled call can only be treated as -/// potentially recursive. Once SCIP has supplied exact method IDs, remove the -/// false positive when every such call is resolved and every target is a -/// different overload. Genuine self-recursion and partially resolved groups -/// remain untouched. -fn reconcile_non_recursive_overload_calls(output: &mut ProfileOutput) { - let mut method_ids = BTreeMap::<(String, String, String, usize), Vec>::new(); +fn conservative_complexity_rank(complexity: &str) -> usize { + let normalized = complexity.replace([' ', '_'], "").to_ascii_uppercase(); + if normalized == "O(1)" { + 0 + } else if normalized.contains('!') { + 4_000_000 + } else if normalized.contains("2^") { + 3_000_000 + } else if normalized.contains('N') || normalized.contains('C') || normalized.contains('R') { + let explicit_exponent = normalized.split('^').nth(1).and_then(|value| { + let digits = value + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + (!digits.is_empty()) + .then(|| digits.parse::().ok()) + .flatten() + }); + let degree = explicit_exponent.unwrap_or_else(|| normalized.matches('*').count() + 1); + 2_000_000 + degree * 100 + usize::from(normalized.contains("LOG")) + } else if normalized.contains("LOG") { + 1_000_000 + } else { + 1 + } +} + +fn apply_observed_open_candidates( + call: &mut CallRecord, + language: &str, + occurrence: &Occurrence, + selected_symbols: &BTreeSet<&str>, + target_ids: BTreeSet<&str>, + stats: &mut ImportStats, +) { + call.target = None; + call.semantic_symbol = Some(occurrence.symbol.clone()); + call.target_provenance = Some("runtime_scip_observed".to_string()); + call.consumer_closed_candidate_set = false; + call.candidate_targets + .extend(target_ids.iter().map(|target| (*target).to_string())); + call.candidate_targets.sort(); + call.candidate_targets.dedup(); + call.complexity_candidates + .extend(selected_symbols.iter().map(|symbol| (*symbol).to_string())); + call.complexity_candidates.sort(); + call.complexity_candidates.dedup(); + call.candidate_reason = Some("runtime_observed_candidate_set".to_string()); + call.unresolved_reason = Some("runtime_observed_candidate_set_open".to_string()); + call.resolution_missing_proof = Some("consumer_closed_candidate_set_required".to_string()); + call.empty_domain_cause = None; + if target_ids.is_empty() { + let metadata = syntax::external_symbol_metadata(language, &occurrence.symbol); + call.kind = "external_call".to_string(); + call.external_symbol_scope = Some(metadata.scope.to_string()); + call.complexity_missing_kind = Some(metadata.missing_cost_kind); + stats.external_symbols += 1; + } else { + call.kind = "unresolved_call".to_string(); + call.external_symbol_scope = Some("project".to_string()); + call.complexity_missing_kind = None; + } +} + +pub(crate) fn apply_resolved_call_costs_to_contexts(output: &mut ProfileOutput) -> usize { + let mut methods = BTreeMap::<(String, String, String, usize), BTreeSet>::new(); for method in &output.methods { - method_ids + methods .entry(( method.path.clone(), method.owner.clone(), @@ -617,1916 +1076,4772 @@ fn reconcile_non_recursive_overload_calls(output: &mut ProfileOutput) { method.line, )) .or_default() - .push(method.id.clone()); + .insert(method.id.clone()); + } + let mut costs = BTreeMap::<(String, [usize; 4], String), BTreeSet<(String, String)>>::new(); + for call in &output.calls { + let (Some(time), Some(space)) = ( + call.known_time_complexity.as_deref(), + call.known_space_complexity.as_deref(), + ) else { + continue; + }; + costs + .entry(( + call.source.clone(), + call.span, + bare_message(&call.message).to_string(), + )) + .or_default() + .insert((time.to_string(), space.to_string())); } - let calls_by_source = output - .calls - .iter() - .filter(|call| { - call.implicit_receiver - || matches!(call.receiver.as_str(), "self" | "this") - || call.receiver.is_empty() - }) - .fold( - BTreeMap::>::new(), - |mut rows, call| { - rows.entry(call.source.clone()).or_default().push(call); - rows - }, - ); - + let mut applied = 0; for fact in &mut output.complexity_facts { - if fact.recursion.calls == 0 { - continue; - } - let key = ( + let Some(method_ids) = methods.get(&( fact.path.clone(), fact.owner.clone(), fact.function.clone(), fact.line, - ); - let Some(ids) = method_ids.get(&key) else { + )) else { continue; }; - if ids.len() != 1 { + if method_ids.len() != 1 { continue; } - let source = &ids[0]; - let candidates = calls_by_source - .get(source) - .into_iter() - .flatten() - .filter(|call| call.message == fact.function) - .collect::>(); - if candidates.len() != fact.recursion.calls - || candidates.iter().any(|call| call.target.is_none()) - || candidates - .iter() - .any(|call| call.target.as_deref() == Some(source.as_str())) - { - continue; + let method_id = method_ids.iter().next().unwrap(); + for context in &mut fact.call_contexts { + let Some(candidates) = costs.get(&( + method_id.clone(), + context.span, + bare_message(&context.message).to_string(), + )) else { + continue; + }; + if candidates.len() != 1 { + continue; + } + let (time, space) = candidates.iter().next().unwrap(); + if context.known_time_complexity.is_none() { + context.known_time_complexity = Some(time.clone()); + } + if context.known_space_complexity.is_none() { + context.known_space_complexity = Some(space.clone()); + } + if context.known_time_complexity.is_some() && context.known_space_complexity.is_some() { + context.evidence_gap = None; + applied += 1; + } } - - fact.recursion = Default::default(); } + applied } -fn methods_by_document<'a>( - methods: &'a [MethodRecord], - documents: &[Document], -) -> BTreeMap> { - let mut by_document = documents - .iter() - .map(|document| (document.relative_path.clone(), Vec::new())) - .collect::>>(); - for method in methods { - if let Some(document) = select_document_for_path(&method.path, documents) { - by_document - .entry(document.relative_path.clone()) - .or_default() - .push(method); - } +/// Reconcile a conservative syntax-only block boundary after SCIP proves the +/// exact callable. Language adapters own symbol interpretation; this shared +/// join only applies their normalized execution contract to matching facts. +fn apply_semantic_block_call_semantics(output: &mut ProfileOutput) -> usize { + use crate::syntax::normalized_behavior::BlockCallSemantics; + + #[derive(Clone)] + struct Proof { + path: String, + line: usize, + span: [usize; 4], + message: String, + semantics: BlockCallSemantics, } - by_document -} -/// A repository may contain both `lru.go` and `simplelru/lru.go`. Both are -/// suffixes of an absolute source path, but only the longest matching SCIP -/// document is its identity. Equal-specificity matches remain ambiguous. -fn select_document_for_path<'a>(path: &str, documents: &'a [Document]) -> Option<&'a Document> { - let matches = documents + let languages = output + .methods .iter() - .filter(|document| path_ends_with(path, &document.relative_path)) - .collect::>(); - let specificity = matches + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + let proofs = output + .calls .iter() - .map(|document| document.relative_path.replace('\\', "/").len()) - .max()?; - let best = matches - .into_iter() - .filter(|document| document.relative_path.replace('\\', "/").len() == specificity) + .filter_map(|call| { + let symbol = call.semantic_symbol.as_deref()?; + let language = languages.get(call.source.as_str()).copied()?; + let behavior = syntax::normalized_behavior::behavior_for_name(language)?; + let semantics = + behavior.semantic_symbol_block_call_semantics(symbol, bare_message(&call.message)); + (semantics != BlockCallSemantics::Unknown).then(|| Proof { + path: call.path.clone(), + line: call.line, + span: call.span, + message: bare_message(&call.message).to_string(), + semantics, + }) + }) .collect::>(); - (best.len() == 1).then(|| best[0]) -} -fn definitions_by_symbol( - documents: &[Document], - methods_by_path: &BTreeMap>, -) -> BTreeMap> { - let mut definitions = BTreeMap::>::new(); - for document in documents { - for occurrence in document - .occurrences - .iter() - .filter(|occurrence| occurrence.symbol_roles & 1 == 1) - { - let Some(span) = occurrence.span() else { + let mut applied = 0; + for fact in &mut output.complexity_facts { + let mut remove = BTreeSet::new(); + for iteration_index in 0..fact.iterations.len() { + let iteration = fact.iterations[iteration_index].clone(); + if iteration.cardinality_relation != "unknown" { + continue; + } + let Some(message) = iteration.message.as_deref() else { continue; }; - let one_based = [span[0] + 1, span[1], span[2] + 1, span[3]]; - let method_id = methods_by_path - .get(&document.relative_path) - .into_iter() - .flatten() - .filter(|method| method.span.is_some_and(|outer| contains(outer, one_based))) - .min_by_key(|method| method_span_size(method)) - .map(|method| method.id.clone()); - definitions - .entry(definition_key(&document.relative_path, &occurrence.symbol)) - .or_default() - .push(Definition { method_id }); + let matching = proofs + .iter() + .filter(|proof| { + proof.path == fact.path + && proof.line == iteration.line + && proof.message == bare_message(message) + && contains(iteration.span, proof.span) + }) + .collect::>(); + let Some(first) = matching.first() else { + continue; + }; + if matching + .iter() + .any(|proof| proof.semantics != first.semantics) + { + continue; + } + let Some(anchor) = fact.call_contexts.iter().find(|context| { + context.span == first.span && bare_message(&context.message) == first.message + }) else { + continue; + }; + let anchor_symbolic = anchor.symbolic_execution.clone(); + let anchor_power = anchor.power; + let anchor_multiplicity = anchor.execution_multiplicity.clone(); + let local_factors = iteration + .symbolic_time + .iter() + .flat_map(|symbolic| &symbolic.factors) + .filter(|factor| { + !anchor_symbolic.as_ref().is_some_and(|symbolic| { + symbolic + .factors + .iter() + .any(|anchor| anchor.domain_id == factor.domain_id) + }) + }) + .cloned() + .collect::>(); + if first.semantics == BlockCallSemantics::LogarithmicIteration + && (local_factors.len() != 1 || local_factors[0].exponent != 1) + { + continue; + } + if !matches!( + first.semantics, + BlockCallSemantics::Once | BlockCallSemantics::LogarithmicIteration + ) { + continue; + } + let local_domains = local_factors + .iter() + .map(|factor| factor.domain_id.clone()) + .collect::>(); + + for context in &mut fact.call_contexts { + if context.span == first.span || !contains(iteration.span, context.span) { + continue; + } + if let Some(symbolic) = &mut context.symbolic_execution { + symbolic + .factors + .retain(|factor| !local_domains.contains(&factor.domain_id)); + if first.semantics == BlockCallSemantics::LogarithmicIteration { + symbolic.logarithmic = true; + symbolic.logarithmic_domain_id = + Some(local_factors[0].domain_id.clone()); + } + symbolic.complete = true; + context.power = symbolic.factors.iter().map(|factor| factor.exponent).sum(); + context.execution_multiplicity = + symbolic_multiplicity(context.power, symbolic.logarithmic); + } else { + context.symbolic_execution = anchor_symbolic.clone(); + context.power = anchor_power; + context.execution_multiplicity = anchor_multiplicity.clone(); + } + if context.evidence_gap.as_deref() == Some("unresolved_iteration_cardinality") { + context.evidence_gap = None; + } + } + if first.semantics == BlockCallSemantics::Once { + remove.insert(iteration_index); + } else { + let iteration = &mut fact.iterations[iteration_index]; + let symbolic = iteration + .symbolic_time + .get_or_insert_with(Default::default); + symbolic + .factors + .retain(|factor| !local_domains.contains(&factor.domain_id)); + symbolic.logarithmic = true; + symbolic.logarithmic_domain_id = Some(local_factors[0].domain_id.clone()); + symbolic.complete = true; + iteration.power = symbolic.factors.iter().map(|factor| factor.exponent).sum(); + iteration.execution_multiplicity = + symbolic_multiplicity(iteration.power, true); + iteration.cardinality_relation = "logarithmic_of".to_string(); + iteration.bound_classification = "input".to_string(); + iteration.evidence_gap = None; + } + applied += 1; + } + if !remove.is_empty() { + fact.iterations = fact + .iterations + .drain(..) + .enumerate() + .filter_map(|(index, iteration)| (!remove.contains(&index)).then_some(iteration)) + .collect(); } } - definitions + applied } -fn select_call_occurrences<'a>( - call: &CallRecord, - document: &'a Document, - source: &str, - language: &str, -) -> Option> { - let call_span = [ - call.span[0].saturating_sub(1), - call.span[1], - call.span[2].saturating_sub(1), - call.span[3], - ]; - let message = bare_message(&call.message); - let argument_start = first_argument_start(source, call_span); - let contained = document - .occurrences - .iter() - .filter(|occurrence| occurrence.symbol_roles & 1 == 0) - .filter(|occurrence| { - occurrence - .span() - .is_some_and(|span| contains(call_span, span)) - }) - .collect::>(); - let mut exact = contained - .iter() - .copied() - .filter(|occurrence| { - occurrence - .span() - .is_some_and(|span| occurrence_text(source, span) == message) - }) - .collect::>(); - exact.sort_by_key(|occurrence| occurrence.span()); - if let Some(receiver_span) = call.receiver_call_span.map(|span| { - [ - span[0].saturating_sub(1), - span[1], - span[2].saturating_sub(1), - span[3], - ] - }) { - let outside_receiver = exact - .iter() - .copied() - .filter(|occurrence| { - occurrence - .span() - .is_some_and(|span| !contains(receiver_span, span)) - }) - .collect::>(); - if language == "java" { - if let Some(selected) = first_semantic_occurrence(&outside_receiver) { - return selected_occurrences(&[selected]); - } - } else if !outside_receiver.is_empty() { - return selected_occurrences(&outside_receiver); - } +fn symbolic_multiplicity(power: usize, logarithmic: bool) -> String { + if !logarithmic { + return polynomial(power); } - // A normalized call span covers its arguments, so a nested call may - // contribute another same-spelled SCIP occurrence. The outer callee is - // the unique occurrence before the call's first argument delimiter. This - // is syntax-position evidence, independent of the producer language, and - // avoids rejecting `pkg.New(value.New())` merely because both declarations - // are semantically distinct. - if let Some(argument_start) = argument_start { - let callee_occurrences = exact - .iter() - .copied() - .filter(|occurrence| { - occurrence - .span() - .is_some_and(|span| (span[2], span[3]) <= argument_start) - }) - .collect::>(); - if callee_occurrences.len() == 1 { - return selected_occurrences(&callee_occurrences); - } + match power { + 0 => "O(log N)".to_string(), + 1 => "O(N log N)".to_string(), + _ => format!("O(N^{power} log N)"), } - let outer_selector = exact - .iter() - .copied() - .filter(|occurrence| { - occurrence - .span() - .is_some_and(|span| occurrence_is_outer_selector(source, call_span, span)) - }) - .collect::>(); - if !outer_selector.is_empty() { - let preferred = if call.preprocessor_callable { - let macros = outer_selector - .iter() - .copied() - .filter(|occurrence| occurrence.symbol.ends_with('!')) - .collect::>(); - (!macros.is_empty()).then_some(macros) - } else { - None - }; - return selected_occurrences(preferred.as_deref().unwrap_or(&outer_selector)); +} + +fn polynomial(power: usize) -> String { + match power { + 0 => "O(1)".to_string(), + 1 => "O(N)".to_string(), + _ => format!("O(N^{power})"), } - let callable = exact - .iter() - .copied() - .filter(|occurrence| { - callable_symbol(&occurrence.symbol) || occurrence.symbol.starts_with("local ") +} + +fn index_from_protobuf(index: scip::types::Index) -> Result { + let metadata = index.metadata.as_ref().map(|metadata| Metadata { + tool_info: metadata.tool_info.as_ref().map(|tool_info| ToolInfo { + name: tool_info.name.clone(), + version: tool_info.version.clone(), + arguments: tool_info.arguments.clone(), + }), + text_document_encoding: TextDocumentEncoding::Number( + u32::try_from(metadata.text_document_encoding.value()).unwrap_or(u32::MAX), + ), + }); + let documents = index + .documents + .into_iter() + .map(|document| { + let occurrences = document + .occurrences + .into_iter() + .map(occurrence_from_protobuf) + .collect::>>()?; + let symbols = document + .symbols + .into_iter() + .map(|information| { + let documentation = information.documentation; + let signature_documentation = information + .signature_documentation + .into_option() + .map(|signature| SignatureDocumentation { + text: signature.text, + }) + .or_else(|| legacy_signature_documentation(&documentation)); + SymbolInformation { + symbol: information.symbol, + documentation, + relationships: information + .relationships + .into_iter() + .map(|relationship| Relationship { + symbol: relationship.symbol, + is_implementation: relationship.is_implementation, + }) + .collect(), + signature_documentation, + } + }) + .collect(); + Ok(Document { + relative_path: document.relative_path, + occurrences, + symbols, + }) }) - .collect::>(); - if language == "java" { - if let Some(selected) = first_semantic_occurrence(&callable) { - return selected_occurrences(&[selected]); + .collect::>>()?; + Ok(Index { + metadata, + documents, + runtime_evidence: RuntimeEvidence::default(), + }) +} + +/// SCIP historically allowed signatures in markdown documentation. Recover +/// only a fenced code block, never prose, so an indexer's narrative docs cannot +/// be mistaken for a native declaration. +fn legacy_signature_documentation(documentation: &[String]) -> Option { + documentation.iter().find_map(|markdown| { + let mut lines = markdown.lines(); + while let Some(line) = lines.next() { + if !line.trim_start().starts_with("```") { + continue; + } + let signature = lines + .by_ref() + .take_while(|line| !line.trim_start().starts_with("```")) + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + if !signature.is_empty() { + return Some(SignatureDocumentation { text: signature }); + } } - } - let property_accesses = exact - .iter() - .copied() - .filter(|occurrence| { - semantic_symbol(&occurrence.symbol) - && syntax::scip_noncall_access_is_callable(language, &occurrence.symbol) - }) - .collect::>(); - if !property_accesses.is_empty() { - return selected_occurrences(&property_accesses); - } - // Never borrow the identity of a nested call when SCIP has no occurrence - // spelling the normalized outer message. This is common for conversions - // and other syntax-only constructs (`int(inner())`, casts, wrappers). - let selected = unambiguous_identity_occurrence(&callable)?; - selected_occurrences(&[selected]) + None + }) } -fn selected_occurrences<'a>(rows: &[&'a Occurrence]) -> Option> { - let semantic = rows - .iter() - .copied() - .filter(|occurrence| semantic_symbol(&occurrence.symbol)) - .collect::>(); - let alternatives = if semantic.is_empty() { - rows.to_vec() - } else { - semantic - }; - let primary = *alternatives.first()?; - Some(SelectedOccurrences { - primary, - alternatives, +fn occurrence_from_protobuf(occurrence: scip::types::Occurrence) -> Result { + let range = occurrence + .range + .into_iter() + .map(|value| { + usize::try_from(value) + .with_context(|| format!("SCIP occurrence range contains negative value {value}")) + }) + .collect::>>()?; + let typed_range = occurrence + .typed_range + .map(|range| -> Result { + match range { + scip::types::occurrence::Typed_range::SingleLineRange(value) => { + Ok(TypedRange::Single { + value: SingleLineRange { + line: nonnegative_position(value.line)?, + start_character: nonnegative_position(value.start_character)?, + end_character: nonnegative_position(value.end_character)?, + }, + }) + } + scip::types::occurrence::Typed_range::MultiLineRange(value) => { + Ok(TypedRange::Multi { + value: MultiLineRange { + start_line: nonnegative_position(value.start_line)?, + start_character: nonnegative_position(value.start_character)?, + end_line: nonnegative_position(value.end_line)?, + end_character: nonnegative_position(value.end_character)?, + }, + }) + } + _ => anyhow::bail!("SCIP occurrence uses an unsupported typed range"), + } + }) + .transpose()?; + Ok(Occurrence { + range, + typed_range, + symbol: occurrence.symbol, + symbol_roles: u32::try_from(occurrence.symbol_roles).with_context(|| { + format!( + "SCIP occurrence symbol_roles contains negative value {}", + occurrence.symbol_roles + ) + })?, }) } -/// A normalized call span may contain several nested calls and repeated -/// selector spellings. Select the occurrence whose following argument list -/// closes at the end of this call span. This is grammar-independent source -/// position evidence and avoids reconstructing a language-specific receiver. -fn occurrence_is_outer_selector( - source: &str, - call_span: [usize; 4], - occurrence_span: [usize; 4], +fn nonnegative_position(value: i32) -> Result { + usize::try_from(value).with_context(|| format!("SCIP range contains negative value {value}")) +} + +fn compiler_proven_project_interface_call( + owners: &[crate::profile::OwnerRecord], + language: &str, + symbol: &str, ) -> bool { - if occurrence_span[0] != occurrence_span[2] { + let Some(symbol_owner) = syntax::external_symbol_owner(language, symbol) else { return false; + }; + let suffix = format!(".{symbol_owner}"); + let exact = owners.iter().filter(|owner| { + owner.language == language + && owner.kind == "interface" + && (owner.symbol.as_deref() == Some(symbol_owner.as_str()) + || owner + .symbol + .as_deref() + .is_some_and(|candidate| candidate.ends_with(&suffix))) + }); + if exact.count() == 1 { + return true; } - let lines = source.lines().collect::>(); - let Some(occurrence_end) = source_offset(&lines, occurrence_span[2], occurrence_span[3]) else { + + // Some producers encode an import path rather than the source package + // name (`github.com/acme/tool/v2` versus `package tool`). The final type + // descriptor remains compiler-proven. Accept it only when it selects one + // normalized project interface; duplicate short names remain ambiguous. + let unqualified = symbol_owner.rsplit('.').next().unwrap_or(&symbol_owner); + owners + .iter() + .filter(|owner| { + owner.language == language && owner.kind == "interface" && owner.name == unqualified + }) + .count() + == 1 +} + +fn compiler_proven_abstract_project_target( + owners: &[crate::profile::OwnerRecord], + methods: &[crate::profile::MethodRecord], + target_id: &str, +) -> bool { + let Some(method) = methods.iter().find(|method| method.id == target_id) else { return false; }; - let Some(call_end) = source_offset(&lines, call_span[2], call_span[3]) else { + let Ok(language) = syntax::Language::parse(&method.language) else { return false; }; - let bytes = source.as_bytes(); - let mut open = occurrence_end; - while open < call_end && bytes.get(open).is_some_and(u8::is_ascii_whitespace) { - open += 1; + // An abstract dispatch contract must denote a declaration without an + // executable implementation. The language adapter owns that syntax fact; + // a same-named interface elsewhere in the project must never turn a + // concrete method into a callback contract. + if method.source_export_eligible { + return false; } - if bytes.get(open) == Some(&b'<') { - let mut template_depth = 0usize; - let mut close = None; - for (offset, byte) in bytes[open..call_end].iter().copied().enumerate() { - if byte == b'<' { - template_depth += 1; - } else if byte == b'>' { - template_depth = template_depth.saturating_sub(1); - if template_depth == 0 { - close = Some(open + offset + 1); - break; - } - } - } - let Some(template_end) = close else { - return false; - }; - open = template_end; - while open < call_end && bytes.get(open).is_some_and(u8::is_ascii_whitespace) { - open += 1; - } + let behavior = syntax::normalized_behavior::behavior(language); + // Recovery around conditional default-interface bodies can preserve the + // exact compiler symbol while losing the normalized method's instance + // shape and owner ID. The SCIP descriptor still proves the declared owner; + // match it to one unique normalized abstract owner instead of discarding + // that stronger identity. + let semantic_abstract_owner = method + .semantic_symbol + .as_deref() + .and_then(|symbol| behavior.external_symbol_owner(symbol)) + .map(|owner| { + owner + .rsplit(['.', ':', '/']) + .find(|part| !part.is_empty()) + .unwrap_or(&owner) + .to_string() + }) + .is_some_and(|semantic_owner| { + owners + .iter() + .filter(|owner| { + owner.language == method.language + && owner.name.rsplit("::").next() == Some(semantic_owner.as_str()) + && behavior.type_kind_is_abstract_dispatch(&owner.kind) + }) + .count() + == 1 + }); + if semantic_abstract_owner { + return true; } - if bytes.get(open) != Some(&b'(') { + if method.kind != "instance" { return false; } - let mut depth = 0usize; - let mut quote = None; - let mut escaped = false; - for (offset, byte) in bytes[open..call_end].iter().copied().enumerate() { - if let Some(active) = quote { - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == active { - quote = None; - } - continue; - } - if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - continue; - } - if byte == b'(' { - depth += 1; - } else if byte == b')' { - depth = depth.saturating_sub(1); - if depth == 0 { - let close = open + offset + 1; - return bytes[close..call_end].iter().all(u8::is_ascii_whitespace); - } - } - } - false + let abstract_owner = owners.iter().any(|owner| { + owner.id == method.owner_id + && owner.language == method.language + && behavior.type_kind_is_abstract_dispatch(&owner.kind) + }); + abstract_owner + && !method.raw_source.contains('{') + && method.raw_source.trim_end().ends_with(';') } -fn source_offset(lines: &[&str], line: usize, column: usize) -> Option { - let current = *lines.get(line)?; - (column <= current.len()) - .then(|| lines[..line].iter().map(|row| row.len() + 1).sum::() + column) +fn equivalent_external_cost( + left: &crate::syntax::ExternalCallComplexity, + right: &crate::syntax::ExternalCallComplexity, +) -> bool { + left.time == right.time + && left.space == right.space + && left.provenance == right.provenance + && left.bound_quality == right.bound_quality + && left.candidates == right.candidates + && left.assumption == right.assumption } -fn first_argument_start(source: &str, call_span: [usize; 4]) -> Option<(usize, usize)> { - if let Some(line_index) = (call_span[0]..=call_span[2]).next() { - let line = source.lines().nth(line_index)?; - let start = if line_index == call_span[0] { - call_span[1] - } else { - 0 +/// Adopt the indexer's declared signatures. +/// +/// SCIP carries a `signature_documentation` for nearly every symbol - the +/// compiler frontend's own rendering of the declaration, including parameter and +/// return types. That is authoritative type information we would otherwise try +/// to re-derive from source. The signature *text* is language-specific, so it is +/// normalized exclusively through the adapter seam +/// (`NormalizedLanguageBehavior::parse_signature`); nothing language-specific +/// belongs here. +/// +/// A method's own source-derived signature wins when it has one; this only fills +/// the gaps, so a language whose extractor already recovers signatures is +/// unaffected. +fn apply_signature_types(output: &mut ProfileOutput, index: &Index) -> usize { + let signatures = index + .documents + .iter() + .flat_map(|document| &document.symbols) + .filter_map(|information| { + let text = information.signature_documentation.as_ref()?.text.trim(); + (!text.is_empty()).then(|| (information.symbol.as_str(), text)) + }) + .collect::>(); + if signatures.is_empty() { + return 0; + } + let mut adopted = 0; + for method in output.methods.iter_mut() { + if !method.signature.trim().is_empty() { + continue; + } + let Some(symbol) = method.semantic_symbol.as_deref() else { + continue; }; - let end = if line_index == call_span[2] { - call_span[3].min(line.len()) - } else { - line.len() + let Some(text) = signatures.get(symbol) else { + continue; }; - let offset = line.get(start..end)?.find('(')?; - return Some((line_index, start + offset)); + // Only adopt a signature the adapter can actually normalize, so an + // unparsable rendering never becomes a bogus declared type. + let Ok(language) = crate::syntax::Language::parse(&method.language) else { + continue; + }; + if crate::syntax::normalized_behavior::behavior(language) + .parse_signature(text) + .is_empty() + { + continue; + } + method.signature = (*text).to_string(); + adopted += 1; } - None -} - -fn first_semantic_occurrence<'a>(rows: &[&'a Occurrence]) -> Option<&'a Occurrence> { - rows.iter() - .copied() - .filter(|occurrence| semantic_symbol(&occurrence.symbol)) - .min_by_key(|occurrence| occurrence.span()) + adopted } -fn unambiguous_identity_occurrence<'a>(rows: &[&'a Occurrence]) -> Option<&'a Occurrence> { - let semantic = rows - .iter() - .copied() - .filter(|occurrence| semantic_symbol(&occurrence.symbol)) - .collect::>(); - let preferred = if semantic.is_empty() { rows } else { &semantic }; - let symbols = preferred +/// Adopt the indexer's local-variable types. +/// +/// SCIP emits a `local N` symbol per local binding, carrying the frontend's own +/// rendering of its declaration (`let out: Output`, `var uc *unleashCmd`). That +/// is authoritative typing for exactly the receivers a source-only analysis +/// cannot type. For every call whose receiver is a plain local, find that +/// receiver's occurrence inside the call span and attach the local's declared +/// type. +/// +/// The declaration grammar is language-specific and is read only through +/// `NormalizedLanguageBehavior::parse_variable_declaration`; a receiver that +/// already carries a type is never overwritten. +fn apply_local_variable_types(output: &mut ProfileOutput, index: &Index) -> usize { + let has_local_types = index + .documents .iter() - .map(|occurrence| occurrence.symbol.as_str()) - .collect::>(); - (symbols.len() == 1).then(|| preferred[0]) -} - -fn callable_symbol(symbol: &str) -> bool { - symbol.ends_with(").") || symbol.contains("``") -} - -fn semantic_symbol(symbol: &str) -> bool { - !symbol.is_empty() && !symbol.starts_with("local ") -} - -fn bare_message(message: &str) -> &str { - crate::syntax::normalized_behavior::balanced_selector_name(message) -} - -fn occurrence_text(source: &str, span: [usize; 4]) -> &str { - if span[0] != span[2] { - return ""; + .flat_map(|document| &document.symbols) + .any(|information| { + information.symbol.starts_with("local ") + && information + .signature_documentation + .as_ref() + .is_some_and(|documentation| !documentation.text.trim().is_empty()) + }); + if !has_local_types { + return 0; } - source - .lines() - .nth(span[0]) - .and_then(|line| line.get(span[1]..span[3])) - .unwrap_or("") -} - -fn definition_key(document: &str, symbol: &str) -> String { - if symbol.starts_with("local ") { - format!("{document}\0{symbol}") - } else { - symbol.to_string() + let methods = output + .methods + .iter() + .map(|method| (method.id.clone(), (method.language.clone(), method.span))) + .collect::>(); + let mut typed = 0; + for call in output.calls.iter_mut() { + let local_callable = + call.implicit_receiver && call.target.is_none() && call.semantic_symbol.is_none(); + if (!local_callable && call.receiver_type.is_some()) || call.receiver.is_empty() { + continue; + } + let local_name = if local_callable { + call.message.as_str() + } else { + call.receiver.as_str() + }; + let Some((language, method_span)) = methods.get(&call.source) else { + continue; + }; + let Ok(language) = crate::syntax::Language::parse(language) else { + continue; + }; + let Some(document) = select_document_for_path(&call.path, &index.documents) else { + continue; + }; + let source = fs::read_to_string(&call.path).unwrap_or_default(); + // Normalized syntax spans are one-based while SCIP ranges are + // zero-based. Keep this conversion at the importer boundary; comparing + // the two coordinate systems directly silently prevented every local + // receiver occurrence after the first source line from matching. + let call_span = [ + call.span[0].saturating_sub(1), + call.span[1], + call.span[2].saturating_sub(1), + call.span[3], + ]; + // The receiver's own occurrence: inside the call, spelled like the + // receiver, and bound to a local symbol. + let direct_symbol = document + .occurrences + .iter() + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| contains(call_span, span)) + }) + .find(|occurrence| { + occurrence.symbol.starts_with("local ") + && occurrence + .span() + .is_some_and(|span| occurrence_text(&source, span) == local_name) + }) + .map(|occurrence| occurrence.symbol.as_str()); + // An inactive C# preprocessor branch has no occurrence at the call + // itself. The binding still has indexed occurrences in the enclosing + // method (normally its declaration and active-branch uses). Adopt that + // compiler identity only when the method contains exactly one local + // symbol with this source name, preserving shadowing safety. + let enclosing_symbols = method_span + .map(|span| { + [ + span[0].saturating_sub(1), + span[1], + span[2].saturating_sub(1), + span[3], + ] + }) + .into_iter() + .flat_map(|span| { + document.occurrences.iter().filter(move |occurrence| { + occurrence.symbol.starts_with("local ") + && occurrence.span().is_some_and(|inner| contains(span, inner)) + }) + }) + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| occurrence_text(&source, span) == local_name) + }) + .map(|occurrence| occurrence.symbol.as_str()) + .collect::>(); + let fallback_symbol = (!local_callable && enclosing_symbols.len() == 1) + .then(|| enclosing_symbols.into_iter().next()) + .flatten(); + let declaration = direct_symbol + .or(fallback_symbol) + .and_then(|symbol| { + document + .symbols + .iter() + .find(|information| information.symbol == symbol) + }) + .and_then(|information| information.signature_documentation.as_ref()) + .map(|documentation| documentation.text.trim()) + .filter(|text| !text.is_empty()); + let Some(declaration) = declaration else { + continue; + }; + let behavior = crate::syntax::normalized_behavior::behavior(language); + let Some(declared) = behavior.parse_variable_declaration(declaration) else { + continue; + }; + if local_callable { + let Some(kind) = behavior.declared_callable_cost(&declared) else { + continue; + }; + let Some((time, space)) = crate::syntax::parametric_call_complexity(&kind) else { + continue; + }; + call.callback_receiver = true; + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = Some("scip_local_declared_callable_contract".to_string()); + call.complexity_bound_quality = Some(format!("upper_bound_parametric_{kind}")); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + typed += 1; + continue; + } + let receiver_type = TypeExpr::parse(&declared, language.as_str()); + if call.known_time_complexity.is_none() && call.known_space_complexity.is_none() { + if let Some(complexity) = behavior.call_complexity(&receiver_type, &call.message) { + call.known_time_complexity = Some(complexity.time.to_string()); + call.known_space_complexity = Some(complexity.space.to_string()); + call.complexity_provenance = + Some("scip_local_declared_receiver_registry".to_string()); + call.complexity_bound_quality = + Some("upper_bound_compiler_declared_receiver".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } else if let Some(kind) = behavior.parametric_call_cost(&receiver_type, &call.message) + { + if let Some((time, space)) = crate::syntax::parametric_call_complexity(&kind) { + call.callback_receiver = true; + call.known_time_complexity = Some(time.to_string()); + call.known_space_complexity = Some(space.to_string()); + call.complexity_provenance = + Some("scip_local_declared_receiver_contract".to_string()); + call.complexity_bound_quality = Some(format!("upper_bound_parametric_{kind}")); + call.complexity_missing_kind = None; + call.unresolved_reason = None; + call.resolution_missing_proof = None; + call.empty_domain_cause = None; + } + } + } + call.receiver_type = Some(declared); + call.receiver_type_origin = Some("scip_local_declaration".to_string()); + typed += 1; } + typed } -fn path_ends_with(path: &str, relative: &str) -> bool { - let path = path.replace('\\', "/"); - let relative = relative.replace('\\', "/"); - path == relative || path.ends_with(&format!("/{relative}")) +/// Reconcile operator facts that are not emitted as ordinary call records. +/// The first local occurrence in an operator span is its left operand; its SCIP +/// declaration is authoritative. The language adapter still decides whether +/// that exact type/operator pair is scalar and constant-time. +fn apply_scalar_operator_types(output: &mut ProfileOutput, index: &Index) -> usize { + let has_local_types = index + .documents + .iter() + .flat_map(|document| &document.symbols) + .any(|information| { + information.symbol.starts_with("local ") + && information + .signature_documentation + .as_ref() + .is_some_and(|documentation| !documentation.text.trim().is_empty()) + }); + if !has_local_types { + return 0; + } + let method_languages = output + .methods + .iter() + .map(|method| { + ( + ( + method.path.as_str(), + method.owner.as_str(), + method.name.as_str(), + method.line, + ), + method.language.as_str(), + ) + }) + .collect::>(); + let mut applied = 0; + for fact in &mut output.complexity_facts { + let Some(language) = method_languages.get(&( + fact.path.as_str(), + fact.owner.as_str(), + fact.function.as_str(), + fact.line, + )) else { + continue; + }; + let Ok(language_kind) = crate::syntax::Language::parse(language) else { + continue; + }; + let behavior = crate::syntax::normalized_behavior::behavior(language_kind); + let Some(document) = select_document_for_path(&fact.path, &index.documents) else { + continue; + }; + for context in &mut fact.call_contexts { + if context.known_time_complexity.is_some() || context.known_space_complexity.is_some() { + continue; + } + let span = [ + context.span[0].saturating_sub(1), + context.span[1], + context.span[2].saturating_sub(1), + context.span[3], + ]; + let declaration = document + .occurrences + .iter() + .filter(|occurrence| occurrence.symbol.starts_with("local ")) + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|occurrence_span| contains(span, occurrence_span)) + }) + .min_by_key(|occurrence| occurrence.span()) + .and_then(|occurrence| { + document + .symbols + .iter() + .find(|information| information.symbol == occurrence.symbol) + }) + .and_then(|information| information.signature_documentation.as_ref()) + .map(|documentation| documentation.text.trim()) + .filter(|text| !text.is_empty()); + let Some(declared) = + declaration.and_then(|text| behavior.parse_variable_declaration(text)) + else { + continue; + }; + let operand_type = TypeExpr::parse(&declared, language); + let Some(complexity) = + behavior.scalar_operator_complexity(&context.message, Some(&operand_type)) + else { + continue; + }; + context.known_time_complexity = Some(complexity.time.to_string()); + context.known_space_complexity = Some(complexity.space.to_string()); + context.evidence_gap = None; + applied += 1; + } + } + applied } -fn contains(outer: [usize; 4], inner: [usize; 4]) -> bool { - (outer[0], outer[1]) <= (inner[0], inner[1]) && (inner[2], inner[3]) <= (outer[2], outer[3]) +/// Whether this language's indexer emits several overlapping occurrences per +/// call site, so the first semantic one is the callee. Owned by the adapter - +/// this file must not branch on language. +fn prefers_first_semantic_occurrence(language: &str) -> bool { + crate::syntax::Language::parse(language).is_ok_and(|language| { + crate::syntax::normalized_behavior::behavior(language) + .scip_prefers_first_semantic_occurrence() + }) } -fn method_span_size(method: &&MethodRecord) -> (usize, usize) { - let span = method.span.unwrap_or([0, 0, usize::MAX, usize::MAX]); - (span[2].saturating_sub(span[0]), span[3].abs_diff(span[1])) +fn implementation_targets( + documents: &[Document], + definitions: &BTreeMap>, +) -> BTreeMap> { + let mut implementation_symbols = BTreeMap::>::new(); + for information in documents.iter().flat_map(|document| &document.symbols) { + for relationship in &information.relationships { + if relationship.is_implementation { + implementation_symbols + .entry(relationship.symbol.clone()) + .or_default() + .insert(information.symbol.clone()); + } + } + } + implementation_symbols + .into_iter() + .filter_map(|(declaration, implementations)| { + let targets = implementations + .iter() + .flat_map(|symbol| definitions.get(symbol).into_iter().flatten()) + .filter_map(|definition| definition.method_id.clone()) + .collect::>(); + (!targets.is_empty()).then_some((declaration, targets)) + }) + .collect() } -#[cfg(test)] -#[allow(clippy::field_reassign_with_default)] // Fixtures build semantic records incrementally for readability. -mod tests { - use super::*; - use crate::profile::{CallRecord, MethodRecord, OwnerRecord}; - use serde_json::json; - use tempfile::tempdir; +fn assign_method_symbols(methods: &mut [MethodRecord], documents: &[Document]) { + let methods_by_path = methods_by_document(methods, documents); + let covered_method_ids = methods_by_path + .values() + .flatten() + .map(|method| method.id.clone()) + .collect::>(); + let mut symbols = BTreeMap::>::new(); + for document in documents { + let source = methods_by_path + .get(&document.relative_path) + .and_then(|rows| rows.first()) + .and_then(|method| fs::read_to_string(&method.path).ok()) + .unwrap_or_default(); + for occurrence in document + .occurrences + .iter() + .filter(|occurrence| occurrence.symbol_roles & 1 == 1) + // SCIP local symbols identify bindings, not callable project + // definitions. Joining a local declaration to its enclosing + // method makes a later variable read look like a self-call. + .filter(|occurrence| semantic_symbol(&occurrence.symbol)) + { + let Some(span) = occurrence.span() else { + continue; + }; + let one_based = [span[0] + 1, span[1], span[2] + 1, span[3]]; + if let Some(method) = methods_by_path + .get(&document.relative_path) + .into_iter() + .flatten() + .filter(|method| method.span.is_some_and(|outer| contains(outer, one_based))) + .min_by_key(|method| method_span_size(method)) + { + let declaration = occurrence_text(&source, span); + if !callable_symbol(&occurrence.symbol) + && declaration != method.name + && declaration != method.dispatch_name + { + continue; + } + symbols + .entry(method.id.clone()) + .or_default() + .insert(occurrence.symbol.clone()); + } + } + } + drop(methods_by_path); + for method in methods { + // Multiple --scip-index inputs are applied sequentially. An index may + // update only declarations in documents it actually covers; clearing + // every other symbol here would erase the preceding repository. + if !covered_method_ids.contains(&method.id) { + continue; + } + method.semantic_symbol = symbols + .remove(&method.id) + .filter(|candidates| candidates.len() == 1) + .and_then(|candidates| candidates.into_iter().next()); + } +} - fn method(id: &str, path: &str, name: &str, span: [usize; 4]) -> MethodRecord { - MethodRecord { - id: id.into(), - semantic_symbol: None, - owner_id: "owner:Demo".into(), - key: vec!["Demo".into(), name.into()], - owner: "Demo".into(), - symbol_owner: None, - lexical_symbol: None, - name: name.into(), - dispatch_name: name.into(), - kind: "method".into(), - path: path.into(), - line: span[0], - span: Some(span), - language: "java".into(), - signature: String::new(), - visibility: "public".into(), - local_complexity: 0.0, - complexity_signals: BTreeMap::new(), - params: Vec::new(), - raw_source: String::new(), - normalized_source: String::new(), - untraceable_params: Vec::new(), - source: json!({}), +/// Constructor-initializer keywords (`this(...)`) have no callable occurrence +/// in scip-dotnet. The normalized call nevertheless carries an exact owner and +/// constructor dispatch name. C# rejects cyclic initializer chains, so close +/// the call over every other constructor on that exact owner and join their +/// summaries rather than leaving the compiler-valid delegation unidentified. +fn reconcile_constructor_delegations(output: &mut ProfileOutput) -> usize { + let methods = output + .methods + .iter() + .map(|method| { + ( + ( + method.language.as_str(), + method.symbol_owner.as_deref(), + method.dispatch_name.as_str(), + method.kind.as_str(), + ), + (method.id.as_str(), method.id.clone()), + ) + }) + .fold( + BTreeMap::<(&str, Option<&str>, &str, &str), Vec<(&str, String)>>::new(), + |mut rows, (key, method)| { + rows.entry(key).or_default().push(method); + rows + }, + ); + let source_languages = output + .methods + .iter() + .map(|method| (method.id.as_str(), method.language.as_str())) + .collect::>(); + let mut reconciled = 0; + for call in output.calls.iter_mut().filter(|call| { + call.target.is_none() + && call.candidate_targets.is_empty() + && call.constructor_target.is_some() + }) { + let Some(language) = source_languages.get(call.source.as_str()).copied() else { + continue; + }; + let Ok(language_kind) = crate::syntax::Language::parse(language) else { + continue; + }; + if !crate::syntax::normalized_behavior::behavior(language_kind) + .constructor_delegation_excludes_self() + { + continue; + } + let Some(owner) = call.receiver_symbol.as_deref() else { + continue; + }; + let constructor = call.constructor_target.as_deref().expect("filtered"); + let candidates = methods + .get(&(language, Some(owner), constructor, "instance")) + .into_iter() + .flatten() + .filter(|(id, _)| *id != call.source) + .map(|(_, id)| id.clone()) + .collect::>(); + if candidates.is_empty() { + continue; } + if candidates.len() == 1 { + call.target = candidates.into_iter().next(); + call.kind = "resolved_call".to_string(); + call.confidence = "high".to_string(); + call.unresolved_reason = None; + call.resolution_missing_proof = None; + } else { + call.candidate_targets = candidates.into_iter().collect(); + call.candidate_reason = Some("compiler_valid_constructor_delegation_set".to_string()); + call.external_symbol_scope = Some("project".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = + Some("closed_constructor_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + call.empty_domain_cause = None; + } + reconciled += 1; } + reconciled +} - fn call(source: &str, path: &str, message: &str, span: [usize; 4]) -> CallRecord { - CallRecord { - id: format!("call:{source}:{message}"), - source: source.into(), - target: None, - semantic_symbol: None, - external_symbol_scope: None, - complexity_missing_kind: None, - target_provenance: None, - candidate_targets: Vec::new(), - candidate_reason: None, - kind: "unresolved_call".into(), - owner: "Demo".into(), - function: "caller".into(), - receiver: "value".into(), - receiver_kind: "value".into(), - receiver_binding_kind: "local".into(), - symbol_namespace: None, - lexical_symbol: None, - lexical_symbol_origin: None, - receiver_call_span: None, - receiver_definition_call_spans: Vec::new(), - receiver_symbol: None, - receiver_type: None, - receiver_type_origin: None, - receiver_symbol_origin: None, - implicit_receiver: false, - state_receiver: false, - callback_receiver: false, - preprocessor_callable: false, - dispatch_boundary: None, - constructor_target: None, - known_time_complexity: None, - known_space_complexity: None, - complexity_provenance: None, - complexity_bound_quality: None, - complexity_candidates: Vec::new(), - complexity_assumptions: Vec::new(), - message: message.into(), - argument_count: 0, - path: path.into(), - line: span[0], - span, - conditional: false, - confidence: "unknown".into(), - unresolved_reason: Some("receiver_requires_corpus_resolution".into()), - resolution_missing_proof: None, - empty_domain_cause: None, +/// A compiler indexes only the active arm of a preprocessor conditional. The +/// source analyzer still (correctly) retains calls from every arm, so an +/// inactive overload call has no SCIP occurrence of its own. When an active +/// sibling proves the exact project owner, close the inactive call over every +/// same-owner/same-arity project overload and let Espalier join their costs by +/// maximum. This is deliberately a candidate set, never an overload guess. +fn reconcile_inactive_preprocessor_project_calls(output: &mut ProfileOutput) -> usize { + let methods_by_id = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let mut sources = BTreeMap::::new(); + let indexed_siblings = output + .calls + .iter() + .filter_map(|call| { + Some(( + ( + call.source.clone(), + call.receiver.clone(), + call.message.clone(), + call.argument_count, + ), + (call.line, call.target.clone()?), + )) + }) + .fold( + BTreeMap::<(String, String, String, usize), Vec<(usize, String)>>::new(), + |mut rows, (key, sibling)| { + rows.entry(key).or_default().push(sibling); + rows + }, + ); + let mut reconciled = 0; + for call in output.calls.iter_mut().filter(|call| { + call.target.is_none() && call.semantic_symbol.is_none() && call.candidate_targets.is_empty() + }) { + let key = ( + call.source.clone(), + call.receiver.clone(), + call.message.clone(), + call.argument_count, + ); + let source = sources + .entry(call.path.clone()) + .or_insert_with(|| fs::read_to_string(&call.path).unwrap_or_default()); + let sibling_methods = indexed_siblings + .get(&key) + .into_iter() + .flatten() + .filter(|(line, _)| preprocessor_alternate_lines(source, *line, call.line)) + .filter_map(|(_, target)| methods_by_id.get(target.as_str()).copied()) + .collect::>(); + if sibling_methods.is_empty() { + continue; + } + let owners = sibling_methods + .iter() + .map(|method| { + ( + method.language.as_str(), + method.owner.as_str(), + method.kind.as_str(), + ) + }) + .collect::>(); + if owners.len() != 1 { + continue; + } + let (language, owner, kind) = owners.into_iter().next().expect("one owner"); + let candidates = output + .methods + .iter() + .filter(|method| { + method.language == language + && method.owner == owner + && method.kind == kind + && method.dispatch_name == call.message + && method.params.len() == call.argument_count + }) + .map(|method| method.id.clone()) + .collect::>(); + if candidates.is_empty() { + continue; + } + if candidates.len() == 1 { + call.target = candidates.into_iter().next(); + call.kind = "resolved_call".to_string(); + call.confidence = "high".to_string(); + call.unresolved_reason = None; + call.resolution_missing_proof = None; + } else { + call.candidate_targets = candidates.into_iter().collect(); + call.candidate_reason = Some("compiler_indexed_preprocessor_overload_set".to_string()); + call.external_symbol_scope = Some("project".to_string()); + call.complexity_missing_kind = None; + call.unresolved_reason = + Some("closed_preprocessor_project_candidate_set_requires_summary".to_string()); + call.resolution_missing_proof = Some("closed_candidate_cost_join_required".to_string()); + call.empty_domain_cause = None; } + reconciled += 1; } + reconciled +} - #[test] - fn go_project_interface_symbols_are_parametric_dispatch_contracts() { - let owner = OwnerRecord { - id: "logger".into(), - name: "Logger".into(), - kind: "interface".into(), - language: "go".into(), - path: "/project/ants.go".into(), - line: 1, - span: [1, 0, 3, 1], - confidence: "high".into(), - symbol: Some("/project.ants.Logger".into()), - supertypes: Vec::new(), - }; - let symbol = - "scip-go gomod example.test/ants/v2 v1.0.0 `example.test/ants/v2`/Logger#Printf."; +fn preprocessor_alternate_lines(source: &str, left_line: usize, right_line: usize) -> bool { + let start = left_line.min(right_line).saturating_sub(1); + let end = left_line.max(right_line); + source + .lines() + .skip(start) + .take(end.saturating_sub(start)) + .map(str::trim_start) + .any(|line| { + line.starts_with("#if") + || line.starts_with("#elif") + || line.starts_with("#else") + || line.starts_with("#endif") + }) +} - assert!(compiler_proven_project_interface_call( - &[owner], - "go", - symbol - )); +/// Syntax-only recursion extraction deliberately runs before corpus call +/// resolution. At that point a bare same-spelled call can only be treated as +/// potentially recursive. Once SCIP has supplied exact method IDs, remove the +/// false positive when every such call is resolved and every target is a +/// different overload. Genuine self-recursion and partially resolved groups +/// remain untouched. +fn reconcile_non_recursive_overload_calls(output: &mut ProfileOutput) { + let mut method_ids = BTreeMap::<(String, String, String, usize), Vec>::new(); + for method in &output.methods { + method_ids + .entry(( + method.path.clone(), + method.owner.clone(), + method.name.clone(), + method.line, + )) + .or_default() + .push(method.id.clone()); } - fn occurrence(range: [usize; 3], symbol: &str, roles: u32) -> serde_json::Value { - json!({ - "TypedRange": {"SingleLineRange": { - "line": range[0], "start_character": range[1], "end_character": range[2] - }}, - "symbol": symbol, - "symbol_roles": roles + let calls_by_source = output + .calls + .iter() + .filter(|call| { + call.implicit_receiver + || matches!(call.receiver.as_str(), "self" | "this") + || call.receiver.is_empty() }) + .fold( + BTreeMap::>::new(), + |mut rows, call| { + rows.entry(call.source.clone()).or_default().push(call); + rows + }, + ); + + for fact in &mut output.complexity_facts { + if fact.recursion.calls == 0 { + continue; + } + let key = ( + fact.path.clone(), + fact.owner.clone(), + fact.function.clone(), + fact.line, + ); + let Some(ids) = method_ids.get(&key) else { + continue; + }; + if ids.len() != 1 { + continue; + } + let source = &ids[0]; + let candidates = calls_by_source + .get(source) + .into_iter() + .flatten() + .filter(|call| call.message == fact.function) + .collect::>(); + if candidates.len() != fact.recursion.calls + || candidates.iter().any(|call| call.target.is_none()) + || candidates + .iter() + .any(|call| call.target.as_deref() == Some(source.as_str())) + { + continue; + } + + fact.recursion = Default::default(); + } +} + +fn methods_by_document<'a>( + methods: &'a [MethodRecord], + documents: &[Document], +) -> BTreeMap> { + let mut by_document = documents + .iter() + .map(|document| (document.relative_path.clone(), Vec::new())) + .collect::>>(); + for method in methods { + if let Some(document) = select_document_for_path(&method.path, documents) { + by_document + .entry(document.relative_path.clone()) + .or_default() + .push(method); + } + } + by_document +} + +/// A repository may contain both `lru.go` and `simplelru/lru.go`. Both are +/// suffixes of an absolute source path, but only the longest matching SCIP +/// document is its identity. Equal-specificity matches remain ambiguous. +fn select_document_for_path<'a>(path: &str, documents: &'a [Document]) -> Option<&'a Document> { + let matches = documents + .iter() + .filter(|document| path_ends_with(path, &document.relative_path)) + .collect::>(); + let specificity = matches + .iter() + .map(|document| document.relative_path.replace('\\', "/").len()) + .max()?; + let best = matches + .into_iter() + .filter(|document| document.relative_path.replace('\\', "/").len() == specificity) + .collect::>(); + (best.len() == 1).then(|| best[0]) +} + +fn definitions_by_symbol( + documents: &[Document], + methods_by_path: &BTreeMap>, +) -> BTreeMap> { + let mut definitions = BTreeMap::>::new(); + for document in documents { + for occurrence in document + .occurrences + .iter() + .filter(|occurrence| occurrence.symbol_roles & 1 == 1) + { + let Some(span) = occurrence.span() else { + continue; + }; + let one_based = [span[0] + 1, span[1], span[2] + 1, span[3]]; + let method_id = methods_by_path + .get(&document.relative_path) + .into_iter() + .flatten() + .filter(|method| method.span.is_some_and(|outer| contains(outer, one_based))) + .min_by_key(|method| method_span_size(method)) + .map(|method| method.id.clone()); + definitions + .entry(definition_key(&document.relative_path, &occurrence.symbol)) + .or_default() + .push(Definition { method_id }); + } } + definitions +} + +fn indexed_preprocessor_definitions( + documents: &[Document], + sources: &BTreeMap, +) -> BTreeMap { + let mut definitions = BTreeMap::>::new(); + for document in documents { + let Some(source) = sources.get(&document.relative_path) else { + continue; + }; + for occurrence in document + .occurrences + .iter() + .filter(|occurrence| occurrence.symbol_roles & 1 == 1) + .filter(|occurrence| occurrence.symbol.ends_with('!')) + { + let Some(span) = occurrence.span() else { + continue; + }; + let Some(definition) = preprocessor_definition_source(source, span[0]) else { + continue; + }; + definitions + .entry(occurrence.symbol.clone()) + .or_default() + .insert(definition); + } + } + definitions + .into_iter() + .filter_map(|(symbol, candidates)| { + (candidates.len() == 1).then(|| (symbol, candidates.into_iter().next().unwrap())) + }) + .collect() +} + +fn indexed_document_sources( + documents: &[Document], + methods_by_path: &BTreeMap>, + roots: &BTreeSet, +) -> BTreeMap { + documents + .iter() + .filter_map(|document| { + let path = indexed_document_path(document, methods_by_path, roots)?; + let source = fs::read_to_string(path).ok()?; + Some((document.relative_path.clone(), source)) + }) + .collect() +} + +fn indexed_definition_at( + sources: &BTreeMap, + roots: &BTreeSet, + path: &str, + one_based_line: usize, +) -> Option { + let matching = sources + .iter() + .filter(|(relative, _source)| { + path_ends_with(relative, path) || path_ends_with(path, relative) + }) + .collect::>(); + let source = if matching.len() == 1 { + matching[0].1.clone() + } else if matching.is_empty() { + let relative = Path::new(path); + let candidates = roots + .iter() + .map(|root| root.join(relative)) + .filter(|candidate| candidate.is_file()) + .collect::>(); + if candidates.len() != 1 { + return None; + } + fs::read_to_string(candidates.iter().next().unwrap()).ok()? + } else { + return None; + }; + preprocessor_definition_source(&source, one_based_line.saturating_sub(1)) +} + +fn indexed_source_roots( + methods_by_path: &BTreeMap>, +) -> BTreeSet { + let mut roots = BTreeSet::new(); + for (relative, methods) in methods_by_path { + let relative = Path::new(relative); + let depth = relative.components().count(); + for method in methods { + let actual = Path::new(&method.path); + if !actual.ends_with(relative) { + continue; + } + if let Some(root) = actual.ancestors().nth(depth) { + roots.insert(root.to_path_buf()); + } + } + } + roots +} + +fn indexed_document_path( + document: &Document, + methods_by_path: &BTreeMap>, + roots: &BTreeSet, +) -> Option { + let mut candidates = methods_by_path + .get(&document.relative_path) + .into_iter() + .flatten() + .map(|method| PathBuf::from(&method.path)) + .filter(|path| path.is_file()) + .collect::>(); + let relative = Path::new(&document.relative_path); + if relative.is_absolute() && relative.is_file() { + candidates.insert(relative.to_path_buf()); + } else { + candidates.extend( + roots + .iter() + .map(|root| root.join(relative)) + .filter(|path| path.is_file()), + ); + } + (candidates.len() == 1).then(|| candidates.into_iter().next().unwrap()) +} + +fn preprocessor_definition_source(source: &str, line: usize) -> Option { + let lines = source.lines().collect::>(); + let mut index = line; + let first = lines.get(index)?.trim_start(); + let directive = first.strip_prefix('#')?.trim_start(); + let Some(after_define) = directive.strip_prefix("define") else { + return None; + }; + if after_define + .chars() + .next() + .is_some_and(|character| !character.is_whitespace()) + { + return None; + } + let mut definition = String::new(); + loop { + let row = *lines.get(index)?; + definition.push_str(row); + if !row.trim_end().ends_with('\\') { + break; + } + definition.push('\n'); + index += 1; + } + Some(definition) +} + +fn select_call_occurrences<'a>( + call: &CallRecord, + document: &'a Document, + source: &str, + language: &str, +) -> Option> { + let call_span = [ + call.span[0].saturating_sub(1), + call.span[1], + call.span[2].saturating_sub(1), + call.span[3], + ]; + let message = bare_message(&call.message); + let argument_start = first_argument_start(source, call_span); + let contained = document + .occurrences + .iter() + .filter(|occurrence| occurrence.symbol_roles & 1 == 0) + // Local occurrences remain available to the dedicated type-enrichment + // passes, but they cannot establish call identity. A closure/function + // value needs an explicit callable contract instead of borrowing the + // identity of the method that contains its binding. + .filter(|occurrence| semantic_symbol(&occurrence.symbol)) + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| contains(call_span, span)) + }) + .collect::>(); + // Runtime producers can use the exact normalized call range when the + // source language has no independently addressable selector token (Ruby + // attribute writers are one example). Equality with FactMine's call span + // is already an exact anchor; preserve all symbols at that range as the + // modeled dispatch alternatives instead of requiring the whole + // expression text to equal the selector spelling. + let exact_call_range = contained + .iter() + .copied() + .filter(|occurrence| { + occurrence.span() == Some(call_span) + && (callable_symbol(&occurrence.symbol) + || syntax::scip_noncall_access_is_callable(language, &occurrence.symbol)) + }) + .collect::>(); + if !exact_call_range.is_empty() { + return selected_occurrences(&exact_call_range); + } + // A normalized writer includes its right-hand side in the call span. + // When that value is itself a call, the trace-plan anchor deliberately + // ends before the nested expression so collectors can bind the writer + // independently. Accept that exact prefix only when the semantic symbol + // itself names the normalized writer; sharing the call start excludes the + // nested RHS and the language adapter prevents a receiver occurrence from + // masquerading as the setter. + if message.ends_with('=') { + let exact_writer_prefix = contained + .iter() + .copied() + .filter(|occurrence| { + occurrence.span().is_some_and(|span| { + (span[0], span[1]) == (call_span[0], call_span[1]) + && syntax::scip_occurrence_matches_call( + language, + &occurrence.symbol, + message, + message, + ) + }) + }) + .collect::>(); + if !exact_writer_prefix.is_empty() { + return selected_occurrences(&exact_writer_prefix); + } + } + let mut exact = contained + .iter() + .copied() + .filter(|occurrence| { + occurrence.span().is_some_and(|span| { + syntax::scip_occurrence_matches_call( + language, + &occurrence.symbol, + occurrence_text(source, span), + message, + ) + }) + }) + .collect::>(); + exact.sort_by_key(|occurrence| occurrence.span()); + if let Some(receiver_span) = call.receiver_call_span.map(|span| { + [ + span[0].saturating_sub(1), + span[1], + span[2].saturating_sub(1), + span[3], + ] + }) { + let outside_receiver = exact + .iter() + .copied() + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| !contains(receiver_span, span)) + }) + .collect::>(); + if prefers_first_semantic_occurrence(language) { + if let Some(selected) = first_semantic_occurrence(&outside_receiver) { + return selected_occurrences(&[selected]); + } + } else if !outside_receiver.is_empty() { + return selected_occurrences(&outside_receiver); + } + } + if !exact.is_empty() + && exact + .windows(2) + .all(|pair| pair[0].span() == pair[1].span()) + { + let callable_alternatives = exact + .iter() + .copied() + .filter(|occurrence| { + callable_symbol(&occurrence.symbol) + || syntax::scip_noncall_access_is_callable(language, &occurrence.symbol) + }) + .collect::>(); + if !callable_alternatives.is_empty() { + return selected_occurrences(&callable_alternatives); + } + } + // Index selectors have no parenthesized argument delimiter for the + // generic outer-selector matcher below. For a simple receiver the first + // exact `[` inside the normalized call span is the outer access; when the + // receiver is itself a call, `receiver_call_span` above has already + // removed its occurrences. Preserve every semantic alternative at that + // exact selector range so modeled-world dispatch remains conservative. + if matches!(message, "[]" | "[]=") { + if let Some(first_span) = exact + .iter() + .filter_map(|occurrence| occurrence.span()) + .min() + { + let first_selector = exact + .iter() + .copied() + .filter(|occurrence| occurrence.span() == Some(first_span)) + .collect::>(); + if !first_selector.is_empty() { + return selected_occurrences(&first_selector); + } + } + } + // A normalized call span covers its arguments, so a nested call may + // contribute another same-spelled SCIP occurrence. The outer callee is + // the unique occurrence before the call's first argument delimiter. This + // is syntax-position evidence, independent of the producer language, and + // avoids rejecting `pkg.New(value.New())` merely because both declarations + // are semantically distinct. + if let Some(argument_start) = argument_start { + let callee_occurrences = exact + .iter() + .copied() + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| (span[2], span[3]) <= argument_start) + }) + .collect::>(); + if callee_occurrences.len() == 1 { + return selected_occurrences(&callee_occurrences); + } + } + let outer_selector = exact + .iter() + .copied() + .filter(|occurrence| { + occurrence + .span() + .is_some_and(|span| occurrence_is_outer_selector(source, call_span, span)) + }) + .collect::>(); + if !outer_selector.is_empty() { + let macros = outer_selector + .iter() + .copied() + .filter(|occurrence| { + syntax::preprocessor_definition_location(language, &occurrence.symbol).is_some() + }) + .collect::>(); + let preferred = (!macros.is_empty()).then_some(macros); + return selected_occurrences(preferred.as_deref().unwrap_or(&outer_selector)); + } + let callable = exact + .iter() + .copied() + .filter(|occurrence| callable_symbol(&occurrence.symbol)) + .collect::>(); + if prefers_first_semantic_occurrence(language) { + if let Some(selected) = first_semantic_occurrence(&callable) { + return selected_occurrences(&[selected]); + } + } + let property_accesses = exact + .iter() + .copied() + .filter(|occurrence| { + semantic_symbol(&occurrence.symbol) + && syntax::scip_noncall_access_is_callable(language, &occurrence.symbol) + }) + .collect::>(); + if !property_accesses.is_empty() { + return selected_occurrences(&property_accesses); + } + // Never borrow the identity of a nested call when SCIP has no occurrence + // spelling the normalized outer message. This is common for conversions + // and other syntax-only constructs (`int(inner())`, casts, wrappers). + let selected = unambiguous_identity_occurrence(&callable)?; + selected_occurrences(&[selected]) +} + +fn selected_occurrences<'a>(rows: &[&'a Occurrence]) -> Option> { + let semantic = rows + .iter() + .copied() + .filter(|occurrence| semantic_symbol(&occurrence.symbol)) + .collect::>(); + let alternatives = if semantic.is_empty() { + rows.to_vec() + } else { + semantic + }; + let primary = *alternatives.first()?; + Some(SelectedOccurrences { + primary, + alternatives, + }) +} + +/// A normalized call span may contain several nested calls and repeated +/// selector spellings. Select the occurrence whose following argument list +/// closes at the end of this call span. This is grammar-independent source +/// position evidence and avoids reconstructing a language-specific receiver. +fn occurrence_is_outer_selector( + source: &str, + call_span: [usize; 4], + occurrence_span: [usize; 4], +) -> bool { + if occurrence_span[0] != occurrence_span[2] { + return false; + } + let lines = source.lines().collect::>(); + let Some(occurrence_end) = source_offset(&lines, occurrence_span[2], occurrence_span[3]) else { + return false; + }; + let Some(call_end) = source_offset(&lines, call_span[2], call_span[3]) else { + return false; + }; + let bytes = source.as_bytes(); + let mut open = occurrence_end; + while open < call_end && bytes.get(open).is_some_and(u8::is_ascii_whitespace) { + open += 1; + } + if bytes.get(open) == Some(&b'<') { + let mut template_depth = 0usize; + let mut close = None; + for (offset, byte) in bytes[open..call_end].iter().copied().enumerate() { + if byte == b'<' { + template_depth += 1; + } else if byte == b'>' { + template_depth = template_depth.saturating_sub(1); + if template_depth == 0 { + close = Some(open + offset + 1); + break; + } + } + } + let Some(template_end) = close else { + return false; + }; + open = template_end; + while open < call_end && bytes.get(open).is_some_and(u8::is_ascii_whitespace) { + open += 1; + } + } + if bytes.get(open) != Some(&b'(') { + return false; + } + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in bytes[open..call_end].iter().copied().enumerate() { + if let Some(active) = quote { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == active { + quote = None; + } + continue; + } + if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + continue; + } + if byte == b'(' { + depth += 1; + } else if byte == b')' { + depth = depth.saturating_sub(1); + if depth == 0 { + let close = open + offset + 1; + return bytes[close..call_end].iter().all(u8::is_ascii_whitespace); + } + } + } + false +} + +fn source_offset(lines: &[&str], line: usize, column: usize) -> Option { + let current = *lines.get(line)?; + (column <= current.len()) + .then(|| lines[..line].iter().map(|row| row.len() + 1).sum::() + column) +} + +fn first_argument_start(source: &str, call_span: [usize; 4]) -> Option<(usize, usize)> { + if let Some(line_index) = (call_span[0]..=call_span[2]).next() { + let line = source.lines().nth(line_index)?; + let start = if line_index == call_span[0] { + call_span[1] + } else { + 0 + }; + let end = if line_index == call_span[2] { + call_span[3].min(line.len()) + } else { + line.len() + }; + let offset = line.get(start..end)?.find('(')?; + return Some((line_index, start + offset)); + } + None +} + +fn first_semantic_occurrence<'a>(rows: &[&'a Occurrence]) -> Option<&'a Occurrence> { + rows.iter() + .copied() + .filter(|occurrence| semantic_symbol(&occurrence.symbol)) + .min_by_key(|occurrence| occurrence.span()) +} + +fn unambiguous_identity_occurrence<'a>(rows: &[&'a Occurrence]) -> Option<&'a Occurrence> { + let semantic = rows + .iter() + .copied() + .filter(|occurrence| semantic_symbol(&occurrence.symbol)) + .collect::>(); + let preferred = if semantic.is_empty() { rows } else { &semantic }; + let symbols = preferred + .iter() + .map(|occurrence| occurrence.symbol.as_str()) + .collect::>(); + (symbols.len() == 1).then(|| preferred[0]) +} + +fn callable_symbol(symbol: &str) -> bool { + symbol.ends_with(").") || symbol.contains("``") +} + +fn semantic_symbol(symbol: &str) -> bool { + !symbol.is_empty() && !symbol.starts_with("local ") +} + +fn bare_message(message: &str) -> &str { + crate::syntax::normalized_behavior::balanced_selector_name(message) +} + +fn occurrence_text(source: &str, span: [usize; 4]) -> &str { + if span[0] != span[2] { + return ""; + } + source + .lines() + .nth(span[0]) + .and_then(|line| line.get(span[1]..span[3])) + .unwrap_or("") +} + +fn definition_key(document: &str, symbol: &str) -> String { + if symbol.starts_with("local ") { + format!("{document}\0{symbol}") + } else { + symbol.to_string() + } +} + +fn path_ends_with(path: &str, relative: &str) -> bool { + let path = path.replace('\\', "/"); + let relative = relative.replace('\\', "/"); + path == relative || path.ends_with(&format!("/{relative}")) +} + +fn contains(outer: [usize; 4], inner: [usize; 4]) -> bool { + (outer[0], outer[1]) <= (inner[0], inner[1]) && (inner[2], inner[3]) <= (outer[2], outer[3]) +} + +fn method_span_size(method: &&MethodRecord) -> (usize, usize) { + let span = method.span.unwrap_or([0, 0, usize::MAX, usize::MAX]); + (span[2].saturating_sub(span[0]), span[3].abs_diff(span[1])) +} + +#[cfg(test)] +#[allow(clippy::field_reassign_with_default)] // Fixtures build semantic records incrementally for readability. +mod tests { + use super::*; + use crate::profile::{CallRecord, MethodRecord, OwnerRecord}; + use protobuf::Message; + use serde_json::json; + use tempfile::tempdir; + + fn method(id: &str, path: &str, name: &str, span: [usize; 4]) -> MethodRecord { + MethodRecord { + id: id.into(), + semantic_symbol: None, + owner_id: "owner:Demo".into(), + key: vec!["Demo".into(), name.into()], + owner: "Demo".into(), + symbol_owner: None, + lexical_symbol: None, + name: name.into(), + dispatch_name: name.into(), + kind: "method".into(), + path: path.into(), + line: span[0], + span: Some(span), + language: "java".into(), + signature: String::new(), + visibility: "public".into(), + local_complexity: 0.0, + complexity_signals: BTreeMap::new(), + params: Vec::new(), + callback_params: Vec::new(), + source_export_eligible: true, + generated_declaration: false, + raw_source: String::new(), + normalized_source: String::new(), + untraceable_params: Vec::new(), + source: json!({}), + } + } + + fn call(source: &str, path: &str, message: &str, span: [usize; 4]) -> CallRecord { + CallRecord { + id: format!("call:{source}:{message}"), + source: source.into(), + target: None, + semantic_symbol: None, + external_symbol_scope: None, + complexity_missing_kind: None, + target_provenance: None, + candidate_targets: Vec::new(), + candidate_reason: None, + consumer_closed_candidate_set: false, + kind: "unresolved_call".into(), + owner: "Demo".into(), + function: "caller".into(), + receiver: "value".into(), + receiver_kind: "value".into(), + receiver_binding_kind: "local".into(), + symbol_namespace: None, + lexical_symbol: None, + lexical_symbol_origin: None, + receiver_call_span: None, + selector_span: None, + execution_span: None, + receiver_definition_call_spans: Vec::new(), + receiver_definition_sequence_projection: None, + receiver_symbol: None, + receiver_type: None, + receiver_type_origin: None, + receiver_symbol_origin: None, + implicit_receiver: false, + state_receiver: false, + callback_receiver: false, + preprocessor_callable: false, + dispatch_boundary: None, + constructor_target: None, + known_time_complexity: None, + known_space_complexity: None, + complexity_provenance: None, + complexity_bound_quality: None, + complexity_candidates: Vec::new(), + complexity_assumptions: Vec::new(), + message: message.into(), + argument_count: 0, + arguments: Vec::new(), + path: path.into(), + line: span[0], + span, + conditional: false, + confidence: "unknown".into(), + unresolved_reason: Some("receiver_requires_corpus_resolution".into()), + resolution_missing_proof: None, + empty_domain_cause: None, + runtime_evidence_observed: false, + } + } + + #[test] + fn go_project_interface_symbols_are_parametric_dispatch_contracts() { + let owner = OwnerRecord { + id: "logger".into(), + name: "Logger".into(), + kind: "interface".into(), + language: "go".into(), + path: "/project/ants.go".into(), + line: 1, + span: [1, 0, 3, 1], + confidence: "high".into(), + symbol: Some("/project.ants.Logger".into()), + supertypes: Vec::new(), + requirements: Vec::new(), + }; + let symbol = + "scip-go gomod example.test/ants/v2 v1.0.0 `example.test/ants/v2`/Logger#Printf."; + + assert!(compiler_proven_project_interface_call( + &[owner], + "go", + symbol + )); + } + + #[test] + fn exact_java_interface_declarations_are_parametric_not_body_targets() { + let dir = tempdir().unwrap(); + let source_path = dir.path().join("Demo.java"); + let declaration = "interface Demo { String value(); }"; + let caller = "class Caller { String run(Demo demo) { return demo.value(); } }"; + fs::write(&source_path, format!("{declaration}\n{caller}\n")).unwrap(); + let path = source_path.to_string_lossy().to_string(); + let symbol = "semanticdb maven demo current Demo#value()."; + let declaration_column = declaration.find("value").unwrap(); + let call_column = caller.find("value").unwrap(); + let index = json!({"documents": [{ + "relative_path": "Demo.java", + "occurrences": [ + occurrence( + [0, declaration_column, declaration_column + "value".len()], + symbol, + 1, + ), + occurrence( + [1, call_column, call_column + "value".len()], + symbol, + 8, + ), + ] + }]}); + + let mut abstract_method = method("value", &path, "value", [1, 17, 1, 32]); + abstract_method.owner_id = "owner:Demo".into(); + abstract_method.owner = "Demo".into(); + abstract_method.kind = "instance".into(); + abstract_method.raw_source = "String value();".into(); + abstract_method.source_export_eligible = false; + let mut caller_method = method("caller", &path, "run", [2, 0, 2, caller.len()]); + caller_method.owner_id = "owner:Caller".into(); + caller_method.owner = "Caller".into(); + let mut interface_call = call( + "caller", + &path, + "value", + [2, call_column, 2, call_column + "value".len() + 2], + ); + interface_call.receiver = "demo".into(); + interface_call.receiver_type = Some("Demo".into()); + let mut output = ProfileOutput::default(); + output.owners = vec![OwnerRecord { + id: "owner:Demo".into(), + name: "Demo".into(), + kind: "interface".into(), + language: "java".into(), + path: path.clone(), + line: 1, + span: [1, 0, 1, declaration.len()], + confidence: "high".into(), + symbol: Some("Demo".into()), + supertypes: Vec::new(), + requirements: vec!["value".into()], + }]; + output.methods = vec![abstract_method, caller_method]; + output.calls = vec![interface_call]; + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.exact_project_targets, 0); + assert_eq!(stats.modeled_external_symbols, 1); + assert_eq!(output.calls[0].target, None); + assert_eq!(output.calls[0].kind, "interface_call"); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(C)") + ); + assert_eq!( + output.calls[0].known_space_complexity.as_deref(), + Some("O(S)") + ); + assert_eq!( + output.calls[0].complexity_provenance.as_deref(), + Some("compiler_proven_abstract_project_contract") + ); + } + + fn occurrence(range: [usize; 3], symbol: &str, roles: u32) -> serde_json::Value { + json!({ + "TypedRange": {"SingleLineRange": { + "line": range[0], "start_character": range[1], "end_character": range[2] + }}, + "symbol": symbol, + "symbol_roles": roles + }) + } + + fn canonical_occurrence(range: [usize; 3], symbol: &str, roles: u32) -> serde_json::Value { + json!({ + "range": range, + "TypedRange": null, + "symbol": symbol, + "symbol_roles": roles + }) + } + + /// `scip-go .` on a multi-package module emits a well-formed 87-byte index + /// with zero documents, and exits 0. Accepting it degrades every result to + /// source-only while the run still reports the SCIP resolution tier, so the + /// import must fail instead of succeeding with nothing. + #[test] + fn an_index_that_covers_none_of_the_profile_is_rejected() { + let build = || { + let mut output = ProfileOutput::default(); + output.methods = vec![method("callee", "/repo/demo.java", "callee", [1, 1, 1, 20])]; + output + }; + + let empty = json!({"documents": []}); + let error = apply_json(&mut build(), &empty.to_string()).unwrap_err(); + assert!( + error.to_string().contains("covers none"), + "unexpected error: {error}" + ); + + let foreign = json!({"documents": [{ + "relative_path": "other/Unrelated.java", + "occurrences": [] + }]}); + assert!(apply_json(&mut build(), &foreign.to_string()).is_err()); + + let covering = json!({"documents": [{ + "relative_path": "demo.java", + "occurrences": [] + }]}); + assert!(apply_json(&mut build(), &covering.to_string()).is_ok()); + + // A profile with no methods has nothing to cover and must not be + // reported as an indexing failure. + assert!(apply_json(&mut ProfileOutput::default(), &empty.to_string()).is_ok()); + } + + #[test] + fn imports_binary_scip_without_an_external_printer() { + let dir = tempdir().unwrap(); + let source_path = dir.path().join("Demo.java"); + let index_path = dir.path().join("index.scip"); + let declaration = "void callee() {}"; + let caller = "void caller() { callee(); }"; + fs::write(&source_path, format!("{declaration}\n{caller}\n")).unwrap(); + let symbol = "scip-java maven demo current Demo#callee()."; + let mut document = scip::types::Document::new(); + document.relative_path = "Demo.java".into(); + for (line, column, roles) in [ + (0, declaration.find("callee").unwrap(), 1), + (1, caller.find("callee").unwrap(), 8), + ] { + let mut occurrence = scip::types::Occurrence::new(); + occurrence.range = vec![ + line, + i32::try_from(column).unwrap(), + i32::try_from(column + "callee".len()).unwrap(), + ]; + occurrence.symbol = symbol.into(); + occurrence.symbol_roles = roles; + document.occurrences.push(occurrence); + } + let mut index = scip::types::Index::new(); + index.documents.push(document); + fs::write(&index_path, index.write_to_bytes().unwrap()).unwrap(); + + let path = source_path.to_string_lossy().to_string(); + let mut output = ProfileOutput::default(); + output.methods = vec![ + method("callee", &path, "callee", [1, 0, 1, declaration.len()]), + method("caller", &path, "caller", [2, 0, 2, caller.len()]), + ]; + output.calls = vec![call( + "caller", + &path, + "callee", + [2, caller.find("callee").unwrap(), 2, caller.len()], + )]; + + let stats = apply_json_file(&mut output, &index_path).unwrap(); + + assert_eq!(1, stats.exact_project_targets); + assert_eq!(output.calls[0].target.as_deref(), Some("callee")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + } + + #[test] + fn longest_relative_document_path_wins_suffix_collisions() { + let documents = vec![ + Document { + relative_path: "lru.go".into(), + occurrences: Vec::new(), + symbols: Vec::new(), + }, + Document { + relative_path: "simplelru/lru.go".into(), + occurrences: Vec::new(), + symbols: Vec::new(), + }, + ]; + assert_eq!( + select_document_for_path("/repo/simplelru/lru.go", &documents) + .map(|document| document.relative_path.as_str()), + Some("simplelru/lru.go") + ); + assert_eq!( + select_document_for_path("/repo/lru.go", &documents) + .map(|document| document.relative_path.as_str()), + Some("lru.go") + ); + } + + #[test] + fn imports_exact_project_targets_from_all_supported_compiler_indexes() { + let cases = [ + ("c", "demo.c", "cxx . demo v1$ callee(abc)."), + ("cpp", "demo.cpp", "cxx . demo v1$ Demo#callee(abc)."), + ("csharp", "Demo.cs", "scip-dotnet nuget . . Demo/Callee()."), + ( + "kotlin", + "Demo.kt", + "scip-java maven example/demo 1.0.0 demo/callee().", + ), + ( + "php", + "Demo.php", + "scip-php composer example/demo 1.0.0 callee().", + ), + ( + "lua", + "demo.lua", + "scip-lua luarocks example-demo workspace demo/L0C0/callee().", + ), + ("swift", "Demo.swift", "swift Demo callee()."), + ( + "typescript", + "demo.ts", + "scip-typescript npm demo 1.0.0 src/demo/callee().", + ), + ]; + + for (language, filename, symbol) in cases { + let dir = tempdir().unwrap(); + let path = dir.path().join(filename); + let declaration = "function callee() {}"; + let caller = "function caller() { callee(); }"; + fs::write(&path, format!("{declaration}\n{caller}\n")).unwrap(); + let path = path.to_string_lossy().to_string(); + let declaration_column = declaration.find("callee").unwrap(); + let call_column = caller.find("callee").unwrap(); + let index = json!({"documents": [{ + "relative_path": filename, + "occurrences": [ + canonical_occurrence( + [0, declaration_column, declaration_column + "callee".len()], + symbol, + 1, + ), + canonical_occurrence( + [1, call_column, call_column + "callee".len()], + symbol, + 8, + ), + ] + }]}); + let mut callee = method("callee", &path, "callee", [1, 1, 1, declaration.len()]); + let mut caller_method = method("caller", &path, "caller", [2, 1, 2, caller.len()]); + callee.language = language.into(); + caller_method.language = language.into(); + let mut output = ProfileOutput::default(); + output.methods = vec![callee, caller_method]; + output.calls.push(call( + "caller", + &path, + "callee", + [2, call_column, 2, call_column + "callee();".len()], + )); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.exact_project_targets, 1, "language={language}"); + assert_eq!( + output.calls[0].target.as_deref(), + Some("callee"), + "language={language}" + ); + assert_eq!( + output.calls[0].semantic_symbol.as_deref(), + Some(symbol), + "language={language}" + ); + } + } + + #[test] + fn language_owned_external_scip_symbols_use_reviewed_cost_registries() { + let cases = [ + ( + "cpp", + "cxx . . $ std/vector#size(abc).", + "size", + "O(1)", + "stdlib", + ), + ( + "typescript", + "scip-typescript npm typescript 5.9.3 lib/`lib.es2015.collection.d.ts`/Map#get().", + "get", + "O(N)", + "stdlib", + ), + ( + "csharp", + "scip-dotnet nuget System.Runtime 9.0.0.0 Text/StringBuilder#Append().", + "Append", + "O(N)", + "stdlib", + ), + ( + "csharp", + "scip-dotnet nuget System.Runtime 9.0.0.0 System/Array#Sort().", + "Sort", + "O(N log N)", + "stdlib", + ), + ( + "typescript", + "scip-typescript npm typescript 5.9.3 lib/`lib.es5.d.ts`/Array#push().", + "push", + "O(N)", + "stdlib", + ), + ( + "typescript", + "scip-typescript npm @types/node 22.13.4 `process.d.ts`/`\"process\"`/global/NodeJS/Process#cwd().", + "cwd", + "O(N)", + "stdlib", + ), + ( + "lua", + "scip-lua luarocks lua . table/insert().", + "insert", + "O(N)", + "stdlib", + ), + ]; + + for (language, symbol, message, expected_time, expected_scope) in cases { + let dir = tempdir().unwrap(); + let filename = format!( + "demo.{}", + if language == "csharp" { "cs" } else { language } + ); + let path = dir.path().join(&filename); + let source = format!("function caller() {{ value.{message}(); }}\n"); + fs::write(&path, &source).unwrap(); + let path = path.to_string_lossy().to_string(); + let column = source.find(message).unwrap(); + let index = json!({"documents": [{ + "relative_path": filename, + "occurrences": [canonical_occurrence( + [0, column, column + message.len()], symbol, 8 + )] + }]}); + let mut caller = method("caller", &path, "caller", [1, 1, 1, source.len()]); + caller.language = language.into(); + let mut output = ProfileOutput::default(); + output.methods = vec![caller]; + output.calls.push(call( + "caller", + &path, + message, + [1, column.saturating_sub(6), 1, column + message.len() + 2], + )); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some(expected_time), + "language={language}" + ); + assert_eq!( + output.calls[0].external_symbol_scope.as_deref(), + Some(expected_scope), + "language={language}" + ); + } + } + + #[test] + fn imports_exact_overload_definition_id_from_occurrence() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "class Demo {\n void caller(){ pick(1); }\n void pick(int x){}\n void pick(String x){}\n}\n").unwrap(); + let path = path.to_string_lossy().to_string(); + let int_symbol = "scip-java maven p Demo#pick()."; + let string_symbol = "scip-java maven p Demo#pick(+1)."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [ + canonical_occurrence([1, 16, 20], int_symbol, 0), + canonical_occurrence([2, 6, 10], int_symbol, 1), + canonical_occurrence([3, 6, 10], string_symbol, 1) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![ + method("caller", &path, "caller", [2, 1, 2, 25]), + method("pick-int", &path, "pick", [3, 1, 3, 20]), + method("pick-string", &path, "pick", [4, 1, 4, 23]), + ]; + output + .calls + .push(call("caller", &path, "pick", [2, 16, 2, 23])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!(stats.exact_project_targets, 1); + assert_eq!(output.calls[0].target.as_deref(), Some("pick-int")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(int_symbol)); + assert_eq!(output.calls[0].target_provenance.as_deref(), Some("scip")); + } + + #[test] + fn local_import_proof_rejects_a_contradictory_scip_self_target() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + let source = "void swap(){ using std::swap; swap(a,b); }\n"; + fs::write(&path, source).unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "cxx . . $ demo#swap()."; + let definition_column = source.find("swap").unwrap(); + let call_column = source.rfind("swap").unwrap(); + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence( + [0, definition_column, definition_column + "swap".len()], + symbol, + 1 + ), + canonical_occurrence( + [0, call_column, call_column + "swap".len()], + symbol, + 0 + ) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method( + "swap", + &path, + "swap", + [1, 0, 1, source.trim().len()], + )]; + let mut imported = call( + "swap", + &path, + "swap", + [1, call_column, 1, call_column + "swap(a,b)".len()], + ); + imported.lexical_symbol = Some("std::swap".to_string()); + imported.lexical_symbol_origin = Some("function_local_import".to_string()); + imported.known_time_complexity = Some("O(R)".to_string()); + output.calls = vec![imported]; + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert!(output.calls[0].target.is_none()); + assert!(output.calls[0].semantic_symbol.is_none()); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(R)") + ); + assert_eq!(output.calls[0].lexical_symbol.as_deref(), Some("std::swap")); + } + + #[test] + fn scip_project_targets_unlock_dependent_call_result_costs() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + let source = r#"template +auto make_dependent() -> std::shared_ptr> { return {}; } +template +void run_dependent() { + auto box = make_dependent(); + box->work(); +} +"#; + fs::write(&path, source).unwrap(); + let document = + crate::syntax::parse_file(path.clone(), crate::syntax::Language::Cpp).unwrap(); + let mut output = crate::profile::extract(&document, crate::profile::Profile::Espalier); + let work = output + .calls + .iter() + .find(|call| call.function == "run_dependent" && call.message == "work") + .unwrap(); + assert_eq!(work.known_time_complexity, None); + + let definition_line = source.lines().nth(1).unwrap(); + let call_line = source.lines().nth(4).unwrap(); + let definition_column = definition_line.find("make_dependent").unwrap(); + let call_column = call_line.find("make_dependent").unwrap(); + let symbol = "cxx . demo v1$ make_dependent(abc)."; + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence( + [1, definition_column, definition_column + "make_dependent".len()], + symbol, + 1, + ), + canonical_occurrence( + [4, call_column, call_column + "make_dependent".len()], + symbol, + 8, + ), + ] + }]}); + + apply_json(&mut output, &index.to_string()).unwrap(); + + let producer = output + .calls + .iter() + .find(|call| call.function == "run_dependent" && call.message == "make_dependent") + .unwrap(); + assert!(producer.target.is_some()); + let work = output + .calls + .iter() + .find(|call| call.function == "run_dependent" && call.message == "work") + .unwrap(); + assert_eq!(work.known_time_complexity.as_deref(), Some("O(R)")); + assert_eq!( + work.complexity_provenance.as_deref(), + Some("declared_call_result_candidate_join") + ); + } + + #[test] + fn later_index_preserves_method_symbols_from_earlier_documents() { + let dir = tempdir().unwrap(); + let first_path = dir.path().join("src/First.java"); + let second_path = dir.path().join("src/Second.java"); + fs::create_dir_all(first_path.parent().unwrap()).unwrap(); + fs::write(&first_path, "class First { void first(){} }\n").unwrap(); + fs::write(&second_path, "class Second { void second(){} }\n").unwrap(); + let first_path = first_path.to_string_lossy().to_string(); + let second_path = second_path.to_string_lossy().to_string(); + let first_symbol = "scip-java maven p First#first()."; + let second_symbol = "scip-java maven p Second#second()."; + let first_index = json!({"documents": [{ + "relative_path": "src/First.java", + "occurrences": [canonical_occurrence([0, 19, 24], first_symbol, 1)] + }]}); + let second_index = json!({"documents": [{ + "relative_path": "src/Second.java", + "occurrences": [canonical_occurrence([0, 20, 26], second_symbol, 1)] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![ + method("first", &first_path, "first", [1, 1, 1, 32]), + method("second", &second_path, "second", [1, 1, 1, 35]), + ]; + + apply_json(&mut output, &first_index.to_string()).unwrap(); + apply_json(&mut output, &second_index.to_string()).unwrap(); + + assert_eq!( + output.methods[0].semantic_symbol.as_deref(), + Some(first_symbol) + ); + assert_eq!( + output.methods[1].semantic_symbol.as_deref(), + Some(second_symbol) + ); + } + + #[test] + fn missing_outer_occurrence_does_not_steal_nested_call_identity() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "class Demo { void caller(){ wrap(inner()); } }\n").unwrap(); + let path = path.to_string_lossy().to_string(); + let inner_symbol = "scip-java maven p Demo#inner()."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [canonical_occurrence([0, 33, 38], inner_symbol, 0)] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method("caller", &path, "caller", [1, 1, 1, 48])]; + output + .calls + .push(call("caller", &path, "wrap", [1, 28, 1, 41])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.matched_occurrences, 0); + assert!(output.calls[0].semantic_symbol.is_none()); + } + + #[test] + fn nested_index_selectors_keep_the_outer_exact_identity() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nested_index.rb"); + let source = "def caller\n method_name_counts[method[:name]]\nend\n"; + fs::write(&path, source).unwrap(); + let path = path.to_string_lossy().to_string(); + let line = source.lines().nth(1).unwrap(); + let outer_open = line.find('[').unwrap(); + let inner_open = line.rfind('[').unwrap(); + let outer_end = line.rfind(']').unwrap() + 1; + let outer_symbol = "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`()."; + let inner_symbol = "nil-kill-runtime ruby ruby 3.2.3 Array#`[]`()."; + let index = json!({"documents": [{ + "relative_path": "nested_index.rb", + "occurrences": [ + canonical_occurrence([1, outer_open, outer_open + 1], outer_symbol, 0), + canonical_occurrence([1, inner_open, inner_open + 1], inner_symbol, 0) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method("caller", &path, "caller", [1, 0, 3, 3])]; + output.methods[0].language = "ruby".to_string(); + output + .calls + .push(call("caller", &path, "[]", [2, 2, 2, outer_end])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.matched_occurrences, 1); + assert_eq!( + output.calls[0].semantic_symbol.as_deref(), + Some(outer_symbol) + ); + } + + #[test] + fn call_prefix_selects_outer_identity_when_argument_has_same_spelled_call() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hmac.go"); + fs::write( + &path, + "package demo\nfunc verify(){ hasher := hmac.New(m.Hash.New, key) }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let outer = "scip-go gomod go std `crypto/hmac`/New()."; + let inner = "scip-go gomod go std crypto/Hash#New()."; + let index = json!({"documents": [{ + "relative_path": "hmac.go", + "occurrences": [ + canonical_occurrence([1, 30, 33], outer, 8), + canonical_occurrence([1, 41, 44], inner, 8) + ] + }]}); + let mut caller = method("verify", &path, "verify", [2, 1, 2, 58]); + caller.language = "go".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![caller]; + output + .calls + .push(call("verify", &path, "New", [2, 25, 2, 50])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.matched_occurrences, 1); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(outer)); + } + + #[test] + fn imports_go_method_symbols_without_parenthesized_descriptors() { + let dir = tempdir().unwrap(); + let path = dir.path().join("claims.go"); + fs::write( + &path, + "package demo\ntype Claims interface { GetAudience() }\nfunc verify(c Claims){ c.GetAudience() }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "scip-go gomod demo current demo/Claims#GetAudience."; + let index = json!({"documents": [{ + "relative_path": "claims.go", + "occurrences": [ + canonical_occurrence([1, 24, 35], symbol, 1), + canonical_occurrence([2, 25, 36], symbol, 8) + ] + }]}); + let mut declaration = method("audience", &path, "GetAudience", [2, 1, 2, 39]); + declaration.language = "go".into(); + let mut caller = method("verify", &path, "verify", [3, 1, 3, 42]); + caller.language = "go".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![declaration, caller]; + output + .calls + .push(call("verify", &path, "GetAudience", [3, 23, 3, 38])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.exact_project_targets, 1); + assert_eq!(output.calls[0].target.as_deref(), Some("audience")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!(output.methods[0].semantic_symbol.as_deref(), Some(symbol)); + } + + #[test] + fn imports_scip_implementation_relationships_as_closed_candidates() { + let dir = tempdir().unwrap(); + let path = dir.path().join("claims.go"); + fs::write( + &path, + "package demo\ntype MapClaims struct{}\nfunc (MapClaims) GetAudience() {}\nfunc verify(c Claims){ c.GetAudience() }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let interface = "scip-go gomod demo current demo/Claims#GetAudience."; + let implementation = "scip-go gomod demo current demo/MapClaims#GetAudience()."; + let index = json!({"documents": [{ + "relative_path": "claims.go", + "occurrences": [ + canonical_occurrence([2, 17, 28], implementation, 1), + canonical_occurrence([3, 25, 36], interface, 8) + ], + "symbols": [{ + "symbol": implementation, + "relationships": [{"symbol": interface, "is_implementation": true}] + }] + }]}); + let mut implementation_method = method("map-audience", &path, "GetAudience", [3, 1, 3, 34]); + implementation_method.language = "go".into(); + let mut caller = method("verify", &path, "verify", [4, 1, 4, 42]); + caller.language = "go".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![implementation_method, caller]; + output + .calls + .push(call("verify", &path, "GetAudience", [4, 23, 4, 38])); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target, None); + assert_eq!(output.calls[0].candidate_targets, ["map-audience"]); + assert_eq!( + output.calls[0].candidate_reason.as_deref(), + Some("scip_implementation_set") + ); + } + + #[test] + fn balanced_template_selector_ignores_qualified_template_arguments() { + assert_eq!(bare_message("plog::detail::operator<<"), "operator"); + assert_eq!(bare_message("Wrapper::target"), "target"); + assert!(occurrence_is_outer_selector( + "target()", + [0, 0, 0, 23], + [0, 0, 0, 6] + )); + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + fs::write( + &path, + "void target() {}\nvoid caller() { detail::target(); }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "cxx . . . detail/target()."; + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence([0, 5, 11], symbol, 1), + canonical_occurrence([1, 24, 30], symbol, 8) + ] + }]}); + let mut target = method("target", &path, "target", [1, 1, 1, 17]); + target.language = "cpp".into(); + let mut caller = method("caller", &path, "caller", [2, 1, 2, 58]); + caller.language = "cpp".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![target, caller]; + output.calls.push(call( + "caller", + &path, + "detail::target", + [2, 17, 2, 48], + )); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target.as_deref(), Some("target")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + } + + #[test] + fn multiple_scip_symbols_are_preserved_as_project_candidates() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + fs::write( + &path, + "void pick(int) {}\nvoid pick(long) {}\nvoid caller() { pick(1); }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let first = "cxx . . . pick(first)."; + let second = "cxx . . . pick(second)."; + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence([0, 5, 9], first, 1), + canonical_occurrence([1, 5, 9], second, 1), + canonical_occurrence([2, 16, 20], first, 8), + canonical_occurrence([2, 16, 20], second, 8) + ] + }]}); + let mut first_method = method("pick-int", &path, "pick", [1, 1, 1, 19]); + first_method.language = "cpp".into(); + let mut second_method = method("pick-long", &path, "pick", [2, 1, 2, 20]); + second_method.language = "cpp".into(); + let mut caller = method("caller", &path, "caller", [3, 1, 3, 28]); + caller.language = "cpp".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![first_method, second_method, caller]; + output + .calls + .push(call("caller", &path, "pick", [3, 16, 3, 23])); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target, None); + assert_eq!(output.calls[0].kind, "unresolved_call"); + assert_eq!( + output.calls[0].candidate_targets, + ["pick-int", "pick-long"], + "call={:?}", + output.calls[0] + ); + assert_eq!( + output.calls[0].candidate_reason.as_deref(), + Some("scip_project_candidate_set") + ); + assert_ne!( + output.calls[0].external_symbol_scope.as_deref(), + Some("dependency") + ); + } + + #[test] + fn duplicate_project_definitions_are_candidates_not_dependencies() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + fs::write( + &path, + "void reset() {}\nvoid reset() {}\nvoid caller() { reset(); }\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "cxx . . . reset()."; + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence([0, 5, 10], symbol, 1), + canonical_occurrence([1, 5, 10], symbol, 1), + canonical_occurrence([2, 16, 21], symbol, 8) + ] + }]}); + let mut first = method("reset-a", &path, "reset", [1, 1, 1, 17]); + first.language = "cpp".into(); + let mut second = method("reset-b", &path, "reset", [2, 1, 2, 17]); + second.language = "cpp".into(); + let mut caller = method("caller", &path, "caller", [3, 1, 3, 27]); + caller.language = "cpp".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![first, second, caller]; + output + .calls + .push(call("caller", &path, "reset", [3, 16, 3, 23])); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target, None); + assert_eq!(output.calls[0].candidate_targets, ["reset-a", "reset-b"]); + assert_eq!( + output.calls[0].candidate_reason.as_deref(), + Some("scip_project_candidate_set") + ); + assert_eq!( + output.calls[0].external_symbol_scope.as_deref(), + Some("project") + ); + } + + #[test] + fn converges_scip_proven_std_overloads_without_discarding_identities() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.cpp"); + fs::write(&path, "void caller() { std::move(value); }\n").unwrap(); + let path = path.to_string_lossy().to_string(); + let first = "cxx . . $ std/move(7316eb2979bdd03c)."; + let second = "cxx . . $ std/move(e35c19a1ba7baa26)."; + let index = json!({"documents": [{ + "relative_path": "demo.cpp", + "occurrences": [ + canonical_occurrence([0, 21, 25], first, 8), + canonical_occurrence([0, 21, 25], second, 8) + ] + }]}); + let mut caller = method("caller", &path, "caller", [1, 1, 1, 36]); + caller.language = "cpp".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![caller]; + output + .calls + .push(call("caller", &path, "std::move", [1, 17, 1, 32])); + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(stats.matched_occurrences, 1); + assert_eq!(stats.modeled_external_symbols, 1); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[0].known_space_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[0].complexity_candidates, + [first.to_string(), second.to_string()] + ); + } + + #[test] + fn exact_overload_target_removes_syntax_only_recursion_false_positive() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "class Demo {\n void get(int x){ get(x, false); }\n void get(int x, boolean b){}\n}\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let one_arg_symbol = "scip-java maven p Demo#get()."; + let two_arg_symbol = "scip-java maven p Demo#get(+1)."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [ + occurrence([1, 18, 21], two_arg_symbol, 0), + occurrence([1, 6, 9], one_arg_symbol, 1), + occurrence([2, 6, 9], two_arg_symbol, 1) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![ + method("get-one", &path, "get", [2, 1, 2, 35]), + method("get-two", &path, "get", [3, 1, 3, 31]), + ]; + let mut overload_call = call("get-one", &path, "get", [2, 18, 2, 31]); + overload_call.function = "get".into(); + overload_call.receiver = "self".into(); + overload_call.receiver_binding_kind = "implicit".into(); + overload_call.implicit_receiver = true; + output.calls.push(overload_call); + output.complexity_facts.push( + serde_json::from_value(json!({ + "path": path, + "owner": "Demo", + "function": "get", + "line": 2, + "span": [2, 1, 2, 35], + "parameters": ["x"], + "collection_parameters": [], + "iterations": [], + "recursion": { + "calls": 1, + "shrinking_calls": 0, + "halving_calls": 0, + "visited_guarded_calls": 0, + "loop_contained_shrinking_calls": 0, + "unknown_progress_calls": 1 + }, + "allocations": [], + "call_contexts": [] + })) + .unwrap(), + ); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target.as_deref(), Some("get-two")); + assert_eq!(output.complexity_facts[0].recursion, Default::default()); + } + + #[test] + fn exact_self_target_preserves_genuine_recursion() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "class Demo {\n void walk(int x){ walk(x - 1); }\n}\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "scip-java maven p Demo#walk()."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [ + occurrence([1, 19, 23], symbol, 0), + occurrence([1, 6, 10], symbol, 1) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method("walk", &path, "walk", [2, 1, 2, 37])]; + let mut recursive_call = call("walk", &path, "walk", [2, 19, 2, 30]); + recursive_call.function = "walk".into(); + recursive_call.receiver = "self".into(); + recursive_call.receiver_binding_kind = "implicit".into(); + recursive_call.implicit_receiver = true; + output.calls.push(recursive_call); + output.complexity_facts.push( + serde_json::from_value(json!({ + "path": path, + "owner": "Demo", + "function": "walk", + "line": 2, + "span": [2, 1, 2, 37], + "parameters": ["x"], + "collection_parameters": [], + "iterations": [], + "recursion": { + "calls": 1, + "shrinking_calls": 1, + "halving_calls": 0, + "visited_guarded_calls": 0, + "loop_contained_shrinking_calls": 0, + "unknown_progress_calls": 0 + }, + "allocations": [], + "call_contexts": [] + })) + .unwrap(), + ); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target.as_deref(), Some("walk")); + assert_eq!(output.complexity_facts[0].recursion.calls, 1); + assert_eq!(output.complexity_facts[0].recursion.shrinking_calls, 1); + } + + #[test] + fn imports_exact_and_modeled_world_jdk_costs_with_distinct_quality() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "class Demo { void caller(){ text.length(); list.size(); } }", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let string_symbol = "scip-java maven jdk 21 java/lang/String#length()."; + let list_symbol = "scip-java maven jdk 21 java/util/List#size()."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [ + occurrence([0, 33, 39], string_symbol, 0), + occurrence([0, 48, 52], list_symbol, 0) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 59])]; + output.calls = vec![ + call("caller", &path, "length", [1, 28, 1, 41]), + call("caller", &path, "size", [1, 43, 1, 54]), + ]; + + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!(stats.external_symbols, 2); + assert_eq!(stats.modeled_external_symbols, 2); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[1].known_time_complexity.as_deref(), + Some("O(1)") + ); + assert_eq!( + output.calls[1].complexity_bound_quality.as_deref(), + Some("upper_bound_modeled_world") + ); + assert!(output.calls[1] + .complexity_candidates + .iter() + .any(|candidate| candidate == "LinkedList")); + } + + #[test] + fn imports_qualified_static_call_nested_in_constructor_argument() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Util.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "class Util {\n void check(String format, Object args) {\n throw new IllegalArgumentException(String.format(format, args));\n }\n}\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let format_symbol = "scip-java maven jdk 21 java/lang/String#format()."; + let index = json!({"documents": [{ + "relative_path": "src/Util.java", + "occurrences": [ + occurrence([2, 46, 52], format_symbol, 0), + occurrence([2, 53, 59], "local 1", 0) + ] + }]}); + let mut output = ProfileOutput::default(); + output.methods = vec![method("check", &path, "check", [2, 2, 4, 3])]; + output + .calls + .push(call("check", &path, "format", [3, 39, 3, 66])); + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!( + output.calls[0].semantic_symbol.as_deref(), + Some(format_symbol) + ); + } + + #[test] + fn scip_local_type_prices_only_scalar_operator_facts() { + let dir = tempdir().unwrap(); + let scalar_path = dir.path().join("scalar.rs"); + let vector_path = dir.path().join("vector.rs"); + let scalar = "fn scalar(other: usize) -> bool { let opaque = factory(); opaque < other }\n"; + let vector = + "fn vector(right: Vec) -> bool { let opaque = factory(); opaque == right }\n"; + fs::write( + &scalar_path, + format!("fn factory() -> usize {{ 0 }}\n{scalar}"), + ) + .unwrap(); + fs::write( + &vector_path, + format!("fn factory() -> Vec {{ vec![] }}\n{vector}"), + ) + .unwrap(); + let scalar_document = + crate::syntax::parse_file(scalar_path, crate::syntax::Language::Rust).unwrap(); + let vector_document = + crate::syntax::parse_file(vector_path, crate::syntax::Language::Rust).unwrap(); + let mut output = crate::profile::merge( + vec![ + crate::profile::extract(&scalar_document, crate::profile::Profile::Espalier), + crate::profile::extract(&vector_document, crate::profile::Profile::Espalier), + ], + crate::profile::Profile::Espalier, + ); + let fact = output + .complexity_facts + .iter() + .find(|fact| fact.function == "scalar") + .unwrap(); + assert_eq!( + fact.call_contexts + .iter() + .find(|context| context.message == "<") + .unwrap() + .known_time_complexity, + None, + "source DFG deliberately does not infer an arbitrary call result" + ); + let scalar_column = scalar.rfind("opaque").unwrap(); + let vector_column = vector.rfind("opaque").unwrap(); + let index = json!({"documents": [ + { + "relative_path": "scalar.rs", + "occurrences": [ + occurrence( + [1, scalar_column, scalar_column + "opaque".len()], + "local 0", + 0 + ) + ], + "symbols": [{ + "symbol": "local 0", + "signature_documentation": {"text": "let opaque: usize"} + }] + }, + { + "relative_path": "vector.rs", + "occurrences": [ + occurrence( + [1, vector_column, vector_column + "opaque".len()], + "local 0", + 0 + ) + ], + "symbols": [{ + "symbol": "local 0", + "signature_documentation": {"text": "let opaque: Vec"} + }] + } + ]}); - fn canonical_occurrence(range: [usize; 3], symbol: &str, roles: u32) -> serde_json::Value { - json!({ - "range": range, - "TypedRange": null, - "symbol": symbol, - "symbol_roles": roles - }) + apply_json(&mut output, &index.to_string()).unwrap(); + + let context = output + .complexity_facts + .iter() + .find(|fact| fact.function == "scalar") + .unwrap() + .call_contexts + .iter() + .find(|context| context.message == "<") + .unwrap(); + assert_eq!(context.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(context.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(context.evidence_gap, None); + let vector_context = output + .complexity_facts + .iter() + .find(|fact| fact.function == "vector") + .unwrap() + .call_contexts + .iter() + .find(|context| context.message == "==") + .unwrap(); + assert_eq!( + vector_context.known_time_complexity, None, + "the document-local Vec declaration must not inherit scalar pricing" + ); } #[test] - fn longest_relative_document_path_wins_suffix_collisions() { - let documents = vec![ - Document { - relative_path: "lru.go".into(), - occurrences: Vec::new(), - symbols: Vec::new(), - }, - Document { - relative_path: "simplelru/lru.go".into(), - occurrences: Vec::new(), - symbols: Vec::new(), - }, - ]; + fn scip_local_receiver_types_reconcile_one_based_syntax_spans() { + let dir = tempdir().unwrap(); + let path = dir.path().join("Renderer.cs"); + let source = "class Renderer {\n string Render() {\n StringBuilder sb = Factory();\n return sb.ToString();\n }\n}\n"; + fs::write(&path, source).unwrap(); + let path = path.to_string_lossy().to_string(); + let receiver_column = source.lines().nth(3).unwrap().find("sb").unwrap(); + let index = json!({"documents": [{ + "relative_path": "Renderer.cs", + "occurrences": [ + occurrence( + [3, receiver_column, receiver_column + "sb".len()], + "local 0", + 0 + ) + ], + "symbols": [{ + "symbol": "local 0", + "signature_documentation": {"text": "StringBuilder? sb"} + }] + }]}); + let mut output = ProfileOutput::default(); + let mut caller = method("caller", &path, "Render", [2, 2, 5, 3]); + caller.language = "csharp".into(); + output.methods.push(caller); + let mut to_string = call( + "caller", + &path, + "ToString", + [ + 4, + receiver_column, + 4, + receiver_column + "sb.ToString()".len(), + ], + ); + to_string.receiver = "sb".into(); + output.calls.push(to_string); + + apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!( - select_document_for_path("/repo/simplelru/lru.go", &documents) - .map(|document| document.relative_path.as_str()), - Some("simplelru/lru.go") + output.calls[0].receiver_type.as_deref(), + Some("StringBuilder?") ); assert_eq!( - select_document_for_path("/repo/lru.go", &documents) - .map(|document| document.relative_path.as_str()), - Some("lru.go") + output.calls[0].receiver_type_origin.as_deref(), + Some("scip_local_declaration") + ); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(N)") ); } #[test] - fn imports_exact_project_targets_from_all_supported_compiler_indexes() { - let cases = [ - ("c", "demo.c", "cxx . demo v1$ callee(abc)."), - ("cpp", "demo.cpp", "cxx . demo v1$ Demo#callee(abc)."), - ("csharp", "Demo.cs", "scip-dotnet nuget . . Demo/Callee()."), - ( - "kotlin", - "Demo.kt", - "scip-java maven example/demo 1.0.0 demo/callee().", - ), - ( - "php", - "Demo.php", - "scip-php composer example/demo 1.0.0 callee().", - ), - ( - "lua", - "demo.lua", - "scip-lua luarocks example-demo workspace demo/L0C0/callee().", - ), - ("swift", "Demo.swift", "swift Demo callee()."), - ( - "typescript", - "demo.ts", - "scip-typescript npm demo 1.0.0 src/demo/callee().", - ), - ]; + fn scip_local_receiver_types_flow_into_unindexed_preprocessor_branches() { + let dir = tempdir().unwrap(); + let path = dir.path().join("Renderer.cs"); + let source = "class Renderer {\n string Render() {\n StringBuilder sb = Factory();\n return sb.ToString();\n }\n}\n"; + fs::write(&path, source).unwrap(); + let path = path.to_string_lossy().to_string(); + let declaration_column = source.lines().nth(2).unwrap().find("sb").unwrap(); + let receiver_column = source.lines().nth(3).unwrap().find("sb").unwrap(); + let index = json!({"documents": [{ + "relative_path": "Renderer.cs", + "occurrences": [ + occurrence( + [2, declaration_column, declaration_column + "sb".len()], + "local 0", + 1 + ) + ], + "symbols": [{ + "symbol": "local 0", + "documentation": ["```cs\nStringBuilder? sb\n```"] + }] + }]}); + let mut output = ProfileOutput::default(); + let mut caller = method("caller", &path, "Render", [2, 2, 5, 3]); + caller.language = "csharp".into(); + output.methods.push(caller); + let mut to_string = call( + "caller", + &path, + "ToString", + [ + 4, + receiver_column, + 4, + receiver_column + "sb.ToString()".len(), + ], + ); + to_string.receiver = "sb".into(); + output.calls.push(to_string); - for (language, filename, symbol) in cases { - let dir = tempdir().unwrap(); - let path = dir.path().join(filename); - let declaration = "function callee() {}"; - let caller = "function caller() { callee(); }"; - fs::write(&path, format!("{declaration}\n{caller}\n")).unwrap(); - let path = path.to_string_lossy().to_string(); - let declaration_column = declaration.find("callee").unwrap(); - let call_column = caller.find("callee").unwrap(); - let index = json!({"documents": [{ - "relative_path": filename, - "occurrences": [ - canonical_occurrence( - [0, declaration_column, declaration_column + "callee".len()], - symbol, - 1, - ), - canonical_occurrence( - [1, call_column, call_column + "callee".len()], - symbol, - 8, - ), - ] - }]}); - let mut callee = method("callee", &path, "callee", [1, 1, 1, declaration.len()]); - let mut caller_method = method("caller", &path, "caller", [2, 1, 2, caller.len()]); - callee.language = language.into(); - caller_method.language = language.into(); - let mut output = ProfileOutput::default(); - output.methods = vec![callee, caller_method]; - output.calls.push(call( - "caller", - &path, - "callee", - [2, call_column, 2, call_column + "callee();".len()], - )); + apply_json(&mut output, &index.to_string()).unwrap(); - let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!( + output.calls[0].receiver_type.as_deref(), + Some("StringBuilder?") + ); + assert_eq!( + output.calls[0].receiver_type_origin.as_deref(), + Some("scip_local_declaration") + ); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(N)") + ); + } - assert_eq!(stats.exact_project_targets, 1, "language={language}"); - assert_eq!( - output.calls[0].target.as_deref(), - Some("callee"), - "language={language}" - ); - assert_eq!( - output.calls[0].semantic_symbol.as_deref(), - Some(symbol), - "language={language}" - ); - } + #[test] + fn legacy_fenced_scip_documentation_recovers_only_the_signature() { + assert_eq!( + legacy_signature_documentation(&[ + "```cs\nStringBuilder? sb\n```\nA reusable buffer.".into() + ]) + .map(|signature| signature.text), + Some("StringBuilder? sb".into()) + ); + assert!( + legacy_signature_documentation(&["Narrative documentation only.".into()]).is_none() + ); } #[test] - fn language_owned_external_scip_symbols_use_reviewed_cost_registries() { - let cases = [ - ( - "cpp", - "cxx . . $ std/vector#size(abc).", - "size", - "O(1)", - "stdlib", - ), - ( - "typescript", - "scip-typescript npm typescript 5.9.3 lib/`lib.es2015.collection.d.ts`/Map#get().", - "get", - "O(N)", - "stdlib", - ), - ( - "csharp", - "scip-dotnet nuget System.Runtime 9.0.0.0 Text/StringBuilder#Append().", - "Append", - "O(N)", - "stdlib", - ), - ( - "csharp", - "scip-dotnet nuget System.Runtime 9.0.0.0 System/Array#Sort().", - "Sort", - "O(N log N)", - "stdlib", - ), - ( - "typescript", - "scip-typescript npm typescript 5.9.3 lib/`lib.es5.d.ts`/Array#push().", - "push", - "O(N)", - "stdlib", - ), - ( - "typescript", - "scip-typescript npm @types/node 22.13.4 `process.d.ts`/`\"process\"`/global/NodeJS/Process#cwd().", - "cwd", - "O(N)", - "stdlib", - ), - ( - "lua", - "scip-lua luarocks lua . table/insert().", - "insert", - "O(N)", - "stdlib", - ), - ]; + fn inactive_preprocessor_calls_use_closed_project_overload_sets() { + let dir = tempdir().unwrap(); + let path = dir.path().join("Renderer.cs"); + fs::write( + &path, + "class Renderer {\n#if ACTIVE\n Padding.Apply(output, builder, alignment);\n#else\n Padding.Apply(output, builder.ToString(), alignment);\n#endif\n}\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let mut string_overload = method("string", &path, "Apply", [10, 0, 10, 1]); + string_overload.language = "csharp".into(); + string_overload.owner = "Padding".into(); + string_overload.params = vec!["output".into(), "value".into(), "alignment".into()]; + let mut builder_overload = method("builder", &path, "Apply", [20, 0, 20, 1]); + builder_overload.language = "csharp".into(); + builder_overload.owner = "Padding".into(); + builder_overload.params = vec!["output".into(), "value".into(), "alignment".into()]; + let mut active = call("caller", &path, "Apply", [3, 2, 3, 45]); + active.receiver = "Padding".into(); + active.argument_count = 3; + active.target = Some("builder".into()); + active.semantic_symbol = Some("scip-dotnet nuget . . Padding#Apply(+1).".into()); + let mut inactive = call("caller", &path, "Apply", [5, 2, 5, 56]); + inactive.receiver = "Padding".into(); + inactive.argument_count = 3; + let mut output = ProfileOutput::default(); + output.methods = vec![string_overload, builder_overload]; + output.calls = vec![active, inactive]; - for (language, symbol, message, expected_time, expected_scope) in cases { - let dir = tempdir().unwrap(); - let filename = format!( - "demo.{}", - if language == "csharp" { "cs" } else { language } - ); - let path = dir.path().join(&filename); - let source = format!("function caller() {{ value.{message}(); }}\n"); - fs::write(&path, &source).unwrap(); - let path = path.to_string_lossy().to_string(); - let column = source.find(message).unwrap(); - let index = json!({"documents": [{ - "relative_path": filename, - "occurrences": [canonical_occurrence( - [0, column, column + message.len()], symbol, 8 - )] - }]}); - let mut caller = method("caller", &path, "caller", [1, 1, 1, source.len()]); - caller.language = language.into(); - let mut output = ProfileOutput::default(); - output.methods = vec![caller]; - output.calls.push(call( - "caller", - &path, - message, - [1, column.saturating_sub(6), 1, column + message.len() + 2], - )); + assert_eq!( + reconcile_inactive_preprocessor_project_calls(&mut output), + 1 + ); + assert_eq!( + output.calls[1].candidate_targets, + ["builder".to_string(), "string".to_string()] + ); + assert_eq!( + output.calls[1].candidate_reason.as_deref(), + Some("compiler_indexed_preprocessor_overload_set") + ); + } - apply_json(&mut output, &index.to_string()).unwrap(); + #[test] + fn csharp_constructor_delegation_candidates_exclude_the_source() { + let mut first = method("first", "/project/Event.cs", "Event", [1, 0, 2, 1]); + first.language = "csharp".into(); + first.kind = "instance".into(); + first.symbol_owner = Some("Demo.Event".into()); + let mut second = method("second", "/project/Event.cs", "Event", [3, 0, 4, 1]); + second.language = "csharp".into(); + second.kind = "instance".into(); + second.symbol_owner = Some("Demo.Event".into()); + let mut delegation = call("first", "/project/Event.cs", "this", [1, 10, 1, 20]); + delegation.receiver_symbol = Some("Demo.Event".into()); + delegation.constructor_target = Some("Event".into()); + let mut output = ProfileOutput::default(); + output.methods = vec![first, second]; + output.calls = vec![delegation]; - assert_eq!( - output.calls[0].known_time_complexity.as_deref(), - Some(expected_time), - "language={language}" - ); - assert_eq!( - output.calls[0].external_symbol_scope.as_deref(), - Some(expected_scope), - "language={language}" - ); - } + assert_eq!(reconcile_constructor_delegations(&mut output), 1); + assert_eq!(output.calls[0].target.as_deref(), Some("second")); + assert!(output.calls[0].candidate_targets.is_empty()); } #[test] - fn imports_exact_overload_definition_id_from_occurrence() { + fn scip_local_callable_invocations_are_parametric() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "class Demo {\n void caller(){ pick(1); }\n void pick(int x){}\n void pick(String x){}\n}\n").unwrap(); + let path = dir.path().join("Conversions.cs"); + let source = "class Conversions {\n object Run(string value) {\n Func? convertor = Find();\n return convertor(value);\n }\n}\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let int_symbol = "scip-java maven p Demo#pick()."; - let string_symbol = "scip-java maven p Demo#pick(+1)."; + let call_column = source.lines().nth(3).unwrap().find("convertor").unwrap(); let index = json!({"documents": [{ - "relative_path": "src/Demo.java", + "relative_path": "Conversions.cs", "occurrences": [ - canonical_occurrence([1, 16, 20], int_symbol, 0), - canonical_occurrence([2, 6, 10], int_symbol, 1), - canonical_occurrence([3, 6, 10], string_symbol, 1) - ] + occurrence( + [3, call_column, call_column + "convertor".len()], + "local 0", + 0 + ) + ], + "symbols": [{ + "symbol": "local 0", + "documentation": ["```cs\nFunc? convertor\n```"] + }] }]}); let mut output = ProfileOutput::default(); - output.methods = vec![ - method("caller", &path, "caller", [2, 1, 2, 25]), - method("pick-int", &path, "pick", [3, 1, 3, 20]), - method("pick-string", &path, "pick", [4, 1, 4, 23]), - ]; - output - .calls - .push(call("caller", &path, "pick", [2, 16, 2, 23])); + let mut caller = method("caller", &path, "Run", [2, 2, 5, 3]); + caller.language = "csharp".into(); + output.methods.push(caller); + let mut invocation = call( + "caller", + &path, + "convertor", + [4, call_column, 4, call_column + "convertor(value)".len()], + ); + invocation.receiver = "self".into(); + invocation.implicit_receiver = true; + output.calls.push(invocation); - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.exact_project_targets, 1); - assert_eq!(output.calls[0].target.as_deref(), Some("pick-int")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(int_symbol)); - assert_eq!(output.calls[0].target_provenance.as_deref(), Some("scip")); + apply_json(&mut output, &index.to_string()).unwrap(); + + assert!(output.calls[0].callback_receiver); + assert_eq!( + output.calls[0].known_time_complexity.as_deref(), + Some("O(C)") + ); + assert_eq!( + output.calls[0].complexity_provenance.as_deref(), + Some("scip_local_declared_callable_contract") + ); } #[test] - fn later_index_preserves_method_symbols_from_earlier_documents() { + fn csharp_recovery_methods_retain_compiler_proven_interface_dispatch() { + let owner = OwnerRecord { + id: "ilogger".into(), + name: "ILogger".into(), + kind: "interface".into(), + language: "csharp".into(), + path: "/project/ILogger.cs".into(), + line: 1, + span: [1, 0, 20, 1], + confidence: "high".into(), + symbol: Some("Serilog.ILogger".into()), + supertypes: Vec::new(), + requirements: Vec::new(), + }; + let mut recovered = method( + "verbose", + "/project/ILogger.cs", + "FEATURE_DEFAULT_INTERFACE", + [10, 0, 15, 1], + ); + recovered.language = "csharp".into(); + recovered.kind = "top".into(); + recovered.owner_id = "recovery-owner".into(); + recovered.source_export_eligible = false; + recovered.semantic_symbol = + Some("scip-dotnet nuget . . Serilog/ILogger#Verbose(+4).".into()); + assert_eq!( + syntax::external_symbol_owner("csharp", recovered.semantic_symbol.as_deref().unwrap()), + Some("ILogger".into()) + ); + + assert!(compiler_proven_abstract_project_target( + &[owner], + &[recovered.clone()], + "verbose" + )); + recovered.source_export_eligible = true; + assert!(!compiler_proven_abstract_project_target( + &[], + &[recovered], + "verbose" + )); + } + + #[test] + fn imports_callback_cost_as_a_parametric_contract() { let dir = tempdir().unwrap(); - let first_path = dir.path().join("src/First.java"); - let second_path = dir.path().join("src/Second.java"); - fs::create_dir_all(first_path.parent().unwrap()).unwrap(); - fs::write(&first_path, "class First { void first(){} }\n").unwrap(); - fs::write(&second_path, "class Second { void second(){} }\n").unwrap(); - let first_path = first_path.to_string_lossy().to_string(); - let second_path = second_path.to_string_lossy().to_string(); - let first_symbol = "scip-java maven p First#first()."; - let second_symbol = "scip-java maven p Second#second()."; - let first_index = json!({"documents": [{ - "relative_path": "src/First.java", - "occurrences": [canonical_occurrence([0, 19, 24], first_symbol, 1)] - }]}); - let second_index = json!({"documents": [{ - "relative_path": "src/Second.java", - "occurrences": [canonical_occurrence([0, 20, 26], second_symbol, 1)] + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "class Demo {\n Object run(java.util.function.Function f, Object x) {\n return f.apply(x);\n }\n}\n", + ) + .unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "scip-java maven jdk 21 java/util/function/Function#apply()."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.java", + "occurrences": [occurrence([2, 13, 18], symbol, 0)] }]}); let mut output = ProfileOutput::default(); - output.methods = vec![ - method("first", &first_path, "first", [1, 1, 1, 32]), - method("second", &second_path, "second", [1, 1, 1, 35]), - ]; + output.methods = vec![method("run", &path, "run", [2, 2, 4, 3])]; + output.calls = vec![call("run", &path, "apply", [3, 11, 3, 21])]; - apply_json(&mut output, &first_index.to_string()).unwrap(); - apply_json(&mut output, &second_index.to_string()).unwrap(); + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!(stats.modeled_external_symbols, 1); assert_eq!( - output.methods[0].semantic_symbol.as_deref(), - Some(first_symbol) + output.calls[0].known_time_complexity.as_deref(), + Some("O(C)") + ); + assert_eq!( + output.calls[0].known_space_complexity.as_deref(), + Some("O(S)") + ); + assert_eq!( + output.calls[0].complexity_bound_quality.as_deref(), + Some("upper_bound_parametric_callback_once") + ); + assert_eq!( + output.calls[0].external_symbol_scope.as_deref(), + Some("stdlib") + ); + assert_eq!(output.calls[0].complexity_missing_kind, None); + } + + #[test] + fn compiler_proven_java_costs_distinguish_exact_and_modeled_world_bounds() { + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "semanticdb maven jdk 21 java/util/List#size().", + "size" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")), + "real scip-java 0.12 indexes use the semanticdb symbol scheme" + ); + assert_eq!( + syntax::external_symbol_metadata( + "java", + "semanticdb maven jdk 21 java/util/function/Function#apply().", + ), + syntax::ExternalSymbolMetadata { + scope: "stdlib", + missing_cost_kind: "callback_cost_missing".to_string(), + parametric_cost: Some("callback_once".to_string()), + } + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/System#arraycopy().", + "arraycopy" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/nio/Buffer#clear().", + "clear" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/Optional#get().", + "get" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/String#startsWith().", + "startsWith" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/List#get().", + "get" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/Set#add().", + "add" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(N)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/Enum#name().", + "name" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/String#toLowerCase().", + "toLowerCase" + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(N)")) + ); + let object_equals = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/Objects#equals().", + "equals", + ) + .unwrap(); + assert_eq!((object_equals.time, object_equals.space), ("O(N)", "O(1)")); + assert_eq!(object_equals.bound_quality, "upper_bound_modeled_world"); + assert!(object_equals.assumption.is_some()); + let list = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/List#get().", + "get", + ) + .unwrap(); + assert_eq!(list.bound_quality, "upper_bound_modeled_world"); + assert!(list + .candidates + .iter() + .any(|candidate| candidate == "LinkedList")); + assert!(list.assumption.is_some()); + let file = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/io/File#toPath().", + "toPath", + ) + .unwrap(); + assert_eq!(file.bound_quality, "upper_bound_external_latency_excluded"); + assert!(file + .assumption + .is_some_and(|assumption| assumption.contains("latency is excluded"))); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/String#valueOf(+4).", + "valueOf", + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")) ); + let object_value_of = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/String#valueOf().", + "valueOf", + ) + .unwrap(); assert_eq!( - output.methods[1].semantic_symbol.as_deref(), - Some(second_symbol) + (object_value_of.time, object_value_of.space), + ("O(N)", "O(N)") ); - } - - #[test] - fn missing_outer_occurrence_does_not_steal_nested_call_identity() { - let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "class Demo { void caller(){ wrap(inner()); } }\n").unwrap(); - let path = path.to_string_lossy().to_string(); - let inner_symbol = "scip-java maven p Demo#inner()."; - let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [canonical_occurrence([0, 33, 38], inner_symbol, 0)] - }]}); - let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 1, 1, 48])]; - output - .calls - .push(call("caller", &path, "wrap", [1, 28, 1, 41])); - - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - - assert_eq!(stats.matched_occurrences, 0); - assert!(output.calls[0].semantic_symbol.is_none()); - } - - #[test] - fn call_prefix_selects_outer_identity_when_argument_has_same_spelled_call() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hmac.go"); - fs::write( - &path, - "package demo\nfunc verify(){ hasher := hmac.New(m.Hash.New, key) }\n", + assert_eq!(object_value_of.bound_quality, "upper_bound_modeled_world"); + let format = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/lang/String#format().", + "format", ) .unwrap(); - let path = path.to_string_lossy().to_string(); - let outer = "scip-go gomod go std `crypto/hmac`/New()."; - let inner = "scip-go gomod go std crypto/Hash#New()."; - let index = json!({"documents": [{ - "relative_path": "hmac.go", - "occurrences": [ - canonical_occurrence([1, 30, 33], outer, 8), - canonical_occurrence([1, 41, 44], inner, 8) - ] - }]}); - let mut caller = method("verify", &path, "verify", [2, 1, 2, 58]); - caller.language = "go".into(); - let mut output = ProfileOutput::default(); - output.methods = vec![caller]; - output - .calls - .push(call("verify", &path, "New", [2, 25, 2, 50])); - - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - - assert_eq!(stats.matched_occurrences, 1); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(outer)); - } - - #[test] - fn imports_go_method_symbols_without_parenthesized_descriptors() { - let dir = tempdir().unwrap(); - let path = dir.path().join("claims.go"); - fs::write( - &path, - "package demo\ntype Claims interface { GetAudience() }\nfunc verify(c Claims){ c.GetAudience() }\n", + assert_eq!((format.time, format.space), ("O(N)", "O(N)")); + assert_eq!(format.bound_quality, "upper_bound_modeled_world"); + assert!(format.assumption.is_some()); + assert!(!format.candidates.is_empty()); + let stream_filter = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/stream/Stream#filter().", + "filter", ) .unwrap(); - let path = path.to_string_lossy().to_string(); - let symbol = "scip-go gomod demo current demo/Claims#GetAudience."; - let index = json!({"documents": [{ - "relative_path": "claims.go", - "occurrences": [ - canonical_occurrence([1, 24, 35], symbol, 1), - canonical_occurrence([2, 25, 36], symbol, 8) - ] - }]}); - let mut declaration = method("audience", &path, "GetAudience", [2, 1, 2, 39]); - declaration.language = "go".into(); - let mut caller = method("verify", &path, "verify", [3, 1, 3, 42]); - caller.language = "go".into(); - let mut output = ProfileOutput::default(); - output.methods = vec![declaration, caller]; - output - .calls - .push(call("verify", &path, "GetAudience", [3, 23, 3, 38])); - - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - - assert_eq!(stats.exact_project_targets, 1); - assert_eq!(output.calls[0].target.as_deref(), Some("audience")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); - assert_eq!(output.methods[0].semantic_symbol.as_deref(), Some(symbol)); - } - - #[test] - fn imports_scip_implementation_relationships_as_closed_candidates() { - let dir = tempdir().unwrap(); - let path = dir.path().join("claims.go"); - fs::write( - &path, - "package demo\ntype MapClaims struct{}\nfunc (MapClaims) GetAudience() {}\nfunc verify(c Claims){ c.GetAudience() }\n", + assert_eq!((stream_filter.time, stream_filter.space), ("O(1)", "O(1)")); + assert_eq!(stream_filter.bound_quality, "upper_bound_exact_target"); + let stream_count = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/stream/Stream#count().", + "count", ) .unwrap(); - let path = path.to_string_lossy().to_string(); - let interface = "scip-go gomod demo current demo/Claims#GetAudience."; - let implementation = "scip-go gomod demo current demo/MapClaims#GetAudience()."; - let index = json!({"documents": [{ - "relative_path": "claims.go", - "occurrences": [ - canonical_occurrence([2, 17, 28], implementation, 1), - canonical_occurrence([3, 25, 36], interface, 8) - ], - "symbols": [{ - "symbol": implementation, - "relationships": [{"symbol": interface, "is_implementation": true}] - }] - }]}); - let mut implementation_method = method("map-audience", &path, "GetAudience", [3, 1, 3, 34]); - implementation_method.language = "go".into(); - let mut caller = method("verify", &path, "verify", [4, 1, 4, 42]); - caller.language = "go".into(); - let mut output = ProfileOutput::default(); - output.methods = vec![implementation_method, caller]; - output - .calls - .push(call("verify", &path, "GetAudience", [4, 23, 4, 38])); - - apply_json(&mut output, &index.to_string()).unwrap(); - - assert_eq!(output.calls[0].target, None); - assert_eq!(output.calls[0].candidate_targets, ["map-audience"]); + assert_eq!((stream_count.time, stream_count.space), ("O(N)", "O(1)")); + assert_eq!(stream_count.bound_quality, "upper_bound_modeled_world"); + assert!(stream_count.assumption.is_some()); assert_eq!( - output.calls[0].candidate_reason.as_deref(), - Some("scip_implementation_set") + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/Objects#hash().", + "hash", + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(N)", "O(1)")) ); - } - - #[test] - fn balanced_template_selector_ignores_qualified_template_arguments() { - assert_eq!(bare_message("plog::detail::operator<<"), "operator"); - assert_eq!(bare_message("Wrapper::target"), "target"); - assert!(occurrence_is_outer_selector( - "target()", - [0, 0, 0, 23], - [0, 0, 0, 6] - )); - let dir = tempdir().unwrap(); - let path = dir.path().join("demo.cpp"); - fs::write( - &path, - "void target() {}\nvoid caller() { detail::target(); }\n", + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/util/regex/Matcher#matches().", + "matches", + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(2^N)", "O(N)")) + ); + assert_eq!( + syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/awt/Color#getHSBColor().", + "getHSBColor", + ) + .map(|complexity| (complexity.time, complexity.space)), + Some(("O(1)", "O(1)")) + ); + let read_object = syntax::external_symbol_call_complexity( + "java", + "scip-java maven jdk 21 java/io/ObjectInputStream#readObject().", + "readObject", ) .unwrap(); - let path = path.to_string_lossy().to_string(); - let symbol = "cxx . . . detail/target()."; - let index = json!({"documents": [{ - "relative_path": "demo.cpp", - "occurrences": [ - canonical_occurrence([0, 5, 11], symbol, 1), - canonical_occurrence([1, 24, 30], symbol, 8) - ] - }]}); - let mut target = method("target", &path, "target", [1, 1, 1, 17]); - target.language = "cpp".into(); - let mut caller = method("caller", &path, "caller", [2, 1, 2, 58]); - caller.language = "cpp".into(); - let mut output = ProfileOutput::default(); - output.methods = vec![target, caller]; - output.calls.push(call( - "caller", - &path, - "detail::target", - [2, 17, 2, 48], - )); - - apply_json(&mut output, &index.to_string()).unwrap(); - - assert_eq!(output.calls[0].target.as_deref(), Some("target")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!((read_object.time, read_object.space), ("O(N)", "O(N)")); + assert_eq!( + read_object.bound_quality, + "upper_bound_external_latency_excluded" + ); + assert_eq!( + syntax::external_symbol_metadata( + "java", + "scip-java maven jdk 21 java/util/function/Function#apply().", + ), + syntax::ExternalSymbolMetadata { + scope: "stdlib", + missing_cost_kind: "callback_cost_missing".to_string(), + parametric_cost: Some("callback_once".to_string()), + } + ); + assert_eq!( + syntax::external_symbol_metadata( + "java", + "scip-java maven maven/acme/tool 1 acme/Tool#run().", + ) + .scope, + "dependency" + ); } #[test] - fn multiple_scip_symbols_are_preserved_as_project_candidates() { + fn ignores_same_name_non_callable_occurrence() { let dir = tempdir().unwrap(); - let path = dir.path().join("demo.cpp"); - fs::write( - &path, - "void pick(int) {}\nvoid pick(long) {}\nvoid caller() { pick(1); }\n", - ) - .unwrap(); + let path = dir.path().join("src/Demo.java"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "class Demo { void caller(){ value; } }").unwrap(); let path = path.to_string_lossy().to_string(); - let first = "cxx . . . pick(first)."; - let second = "cxx . . . pick(second)."; let index = json!({"documents": [{ - "relative_path": "demo.cpp", - "occurrences": [ - canonical_occurrence([0, 5, 9], first, 1), - canonical_occurrence([1, 5, 9], second, 1), - canonical_occurrence([2, 16, 20], first, 8), - canonical_occurrence([2, 16, 20], second, 8) - ] + "relative_path": "src/Demo.java", + "occurrences": [occurrence([0, 28, 33], "scip-java maven p Demo#value.", 0)] }]}); - let mut first_method = method("pick-int", &path, "pick", [1, 1, 1, 19]); - first_method.language = "cpp".into(); - let mut second_method = method("pick-long", &path, "pick", [2, 1, 2, 20]); - second_method.language = "cpp".into(); - let mut caller = method("caller", &path, "caller", [3, 1, 3, 28]); - caller.language = "cpp".into(); let mut output = ProfileOutput::default(); - output.methods = vec![first_method, second_method, caller]; - output - .calls - .push(call("caller", &path, "pick", [3, 16, 3, 23])); - - apply_json(&mut output, &index.to_string()).unwrap(); + output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 38])]; + output.calls = vec![call("caller", &path, "value", [1, 28, 1, 33])]; - assert_eq!(output.calls[0].target, None); - assert_eq!(output.calls[0].kind, "unresolved_call"); - assert_eq!( - output.calls[0].candidate_targets, - ["pick-int", "pick-long"], - "call={:?}", - output.calls[0] - ); - assert_eq!( - output.calls[0].candidate_reason.as_deref(), - Some("scip_project_candidate_set") - ); - assert_ne!( - output.calls[0].external_symbol_scope.as_deref(), - Some("dependency") - ); + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!(stats.unmatched_calls, 1); + assert_eq!(output.calls[0].semantic_symbol, None); } #[test] - fn duplicate_project_definitions_are_candidates_not_dependencies() { + fn local_binding_read_cannot_resolve_to_its_enclosing_method() { let dir = tempdir().unwrap(); - let path = dir.path().join("demo.cpp"); - fs::write( - &path, - "void reset() {}\nvoid reset() {}\nvoid caller() { reset(); }\n", - ) - .unwrap(); + let path = dir.path().join("visit.rs"); + let source = "fn visit() -> bool { let found = true; found }\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let symbol = "cxx . . . reset()."; + let declaration = source.find("found").unwrap(); + let read = source.rfind("found").unwrap(); let index = json!({"documents": [{ - "relative_path": "demo.cpp", + "relative_path": "visit.rs", "occurrences": [ - canonical_occurrence([0, 5, 10], symbol, 1), - canonical_occurrence([1, 5, 10], symbol, 1), - canonical_occurrence([2, 16, 21], symbol, 8) + occurrence([0, declaration, declaration + "found".len()], "local 0", 1), + occurrence([0, read, read + "found".len()], "local 0", 0) ] }]}); - let mut first = method("reset-a", &path, "reset", [1, 1, 1, 17]); - first.language = "cpp".into(); - let mut second = method("reset-b", &path, "reset", [2, 1, 2, 17]); - second.language = "cpp".into(); - let mut caller = method("caller", &path, "caller", [3, 1, 3, 27]); - caller.language = "cpp".into(); let mut output = ProfileOutput::default(); - output.methods = vec![first, second, caller]; - output - .calls - .push(call("caller", &path, "reset", [3, 16, 3, 23])); + output.methods = vec![method( + "visit", + &path, + "visit", + [1, 0, 1, source.trim_end().len()], + )]; + output.calls = vec![call( + "visit", + &path, + "found", + [1, read, 1, read + "found".len()], + )]; - apply_json(&mut output, &index.to_string()).unwrap(); + let stats = apply_json(&mut output, &index.to_string()).unwrap(); + assert_eq!(stats.unmatched_calls, 1); + assert_eq!(output.calls[0].semantic_symbol, None); assert_eq!(output.calls[0].target, None); - assert_eq!(output.calls[0].candidate_targets, ["reset-a", "reset-b"]); - assert_eq!( - output.calls[0].candidate_reason.as_deref(), - Some("scip_project_candidate_set") - ); - assert_eq!( - output.calls[0].external_symbol_scope.as_deref(), - Some("project") - ); + assert_ne!(output.calls[0].kind, "resolved_call"); } #[test] - fn converges_scip_proven_std_overloads_without_discarding_identities() { + fn imports_bounded_macro_cost_from_an_indexed_header_into_cfg_facts() { let dir = tempdir().unwrap(); - let path = dir.path().join("demo.cpp"); - fs::write(&path, "void caller() { std::move(value); }\n").unwrap(); - let path = path.to_string_lossy().to_string(); - let first = "cxx . . $ std/move(7316eb2979bdd03c)."; - let second = "cxx . . $ std/move(e35c19a1ba7baa26)."; - let index = json!({"documents": [{ - "relative_path": "demo.cpp", - "occurrences": [ - canonical_occurrence([0, 21, 25], first, 8), - canonical_occurrence([0, 21, 25], second, 8) - ] - }]}); - let mut caller = method("caller", &path, "caller", [1, 1, 1, 36]); - caller.language = "cpp".into(); - let mut output = ProfileOutput::default(); - output.methods = vec![caller]; - output + let header_path = dir.path().join("defs.h"); + let source_path = dir.path().join("main.c"); + let header = "#define VALUE_AT(buffer) ((buffer)->items[(buffer)->offset])\n"; + let source = "#include \"defs.h\"\nint read_value(Buffer *buffer) {\n return VALUE_AT(buffer);\n}\n"; + fs::write(&header_path, header).unwrap(); + fs::write(&source_path, source).unwrap(); + let document = + crate::syntax::parse_file(source_path.clone(), crate::syntax::Language::C).unwrap(); + let mut output = crate::profile::extract(&document, crate::profile::Profile::Espalier); + let macro_call = output .calls - .push(call("caller", &path, "std::move", [1, 17, 1, 32])); + .iter() + .find(|call| call.message == "VALUE_AT") + .unwrap(); + assert!( + !macro_call.preprocessor_callable, + "the source parser cannot see definitions from an included header" + ); + + let call_column = source.lines().nth(2).unwrap().find("VALUE_AT").unwrap(); + let symbol = "cxx . . $ `defs.h:1:9`!"; + let index = json!({"documents": [ + { + "relative_path": "main.c", + "occurrences": [ + occurrence( + [2, call_column, call_column + "VALUE_AT".len()], + symbol, + 0 + ) + ] + } + ]}); let stats = apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.matched_occurrences, 1); assert_eq!(stats.modeled_external_symbols, 1); + let call = output + .calls + .iter() + .find(|call| call.message == "VALUE_AT") + .unwrap(); + assert!(call.preprocessor_callable); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); assert_eq!( - output.calls[0].known_time_complexity.as_deref(), - Some("O(1)") - ); - assert_eq!( - output.calls[0].known_space_complexity.as_deref(), - Some("O(1)") - ); - assert_eq!( - output.calls[0].complexity_candidates, - [first.to_string(), second.to_string()] + call.complexity_provenance.as_deref(), + Some("compiler_indexed_macro_body") ); + let context = output + .complexity_facts + .iter() + .find(|fact| fact.function == "read_value") + .unwrap() + .call_contexts + .iter() + .find(|context| context.message == "VALUE_AT") + .unwrap(); + assert_eq!(context.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(context.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(context.evidence_gap, None); } #[test] - fn exact_overload_target_removes_syntax_only_recursion_false_positive() { + fn accepts_repeated_occurrences_when_semantic_identity_is_identical() { let dir = tempdir().unwrap(); let path = dir.path().join("src/Demo.java"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write( &path, - "class Demo {\n void get(int x){ get(x, false); }\n void get(int x, boolean b){}\n}\n", + "class Demo { void caller(){ value.append(\"a\").append(\"b\"); } }", ) .unwrap(); let path = path.to_string_lossy().to_string(); - let one_arg_symbol = "scip-java maven p Demo#get()."; - let two_arg_symbol = "scip-java maven p Demo#get(+1)."; + let symbol = "scip-java maven jdk 21 java/lang/StringBuilder#append(+1)."; let index = json!({"documents": [{ "relative_path": "src/Demo.java", "occurrences": [ - occurrence([1, 18, 21], two_arg_symbol, 0), - occurrence([1, 6, 9], one_arg_symbol, 1), - occurrence([2, 6, 9], two_arg_symbol, 1) + occurrence([0, 34, 40], symbol, 0), + occurrence([0, 46, 52], symbol, 0) ] }]}); let mut output = ProfileOutput::default(); - output.methods = vec![ - method("get-one", &path, "get", [2, 1, 2, 35]), - method("get-two", &path, "get", [3, 1, 3, 31]), - ]; - let mut overload_call = call("get-one", &path, "get", [2, 18, 2, 31]); - overload_call.function = "get".into(); - overload_call.receiver = "self".into(); - overload_call.receiver_binding_kind = "implicit".into(); - overload_call.implicit_receiver = true; - output.calls.push(overload_call); - output.complexity_facts.push( - serde_json::from_value(json!({ - "path": path, - "owner": "Demo", - "function": "get", - "line": 2, - "span": [2, 1, 2, 35], - "parameters": ["x"], - "collection_parameters": [], - "iterations": [], - "recursion": { - "calls": 1, - "shrinking_calls": 0, - "halving_calls": 0, - "visited_guarded_calls": 0, - "loop_contained_shrinking_calls": 0, - "unknown_progress_calls": 1 - }, - "allocations": [], - "call_contexts": [] - })) - .unwrap(), - ); + output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 65])]; + output + .calls + .push(call("caller", &path, "append", [1, 29, 1, 58])); apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].target.as_deref(), Some("get-two")); - assert_eq!(output.complexity_facts[0].recursion, Default::default()); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); } #[test] - fn exact_self_target_preserves_genuine_recursion() { + fn receiver_call_span_selects_outer_fluent_overload() { let dir = tempdir().unwrap(); let path = dir.path().join("src/Demo.java"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write( &path, - "class Demo {\n void walk(int x){ walk(x - 1); }\n}\n", + "class Demo { void caller(){ value.append(1).append(\"b\"); } }", ) .unwrap(); let path = path.to_string_lossy().to_string(); - let symbol = "scip-java maven p Demo#walk()."; + let inner = "scip-java maven jdk 21 java/lang/StringBuilder#append()."; + let outer = "scip-java maven jdk 21 java/lang/StringBuilder#append(+1)."; let index = json!({"documents": [{ "relative_path": "src/Demo.java", "occurrences": [ - occurrence([1, 19, 23], symbol, 0), - occurrence([1, 6, 10], symbol, 1) + occurrence([0, 34, 40], inner, 0), + occurrence([0, 44, 50], outer, 0) ] }]}); let mut output = ProfileOutput::default(); - output.methods = vec![method("walk", &path, "walk", [2, 1, 2, 37])]; - let mut recursive_call = call("walk", &path, "walk", [2, 19, 2, 30]); - recursive_call.function = "walk".into(); - recursive_call.receiver = "self".into(); - recursive_call.receiver_binding_kind = "implicit".into(); - recursive_call.implicit_receiver = true; - output.calls.push(recursive_call); - output.complexity_facts.push( - serde_json::from_value(json!({ - "path": path, - "owner": "Demo", - "function": "walk", - "line": 2, - "span": [2, 1, 2, 37], - "parameters": ["x"], - "collection_parameters": [], - "iterations": [], - "recursion": { - "calls": 1, - "shrinking_calls": 1, - "halving_calls": 0, - "visited_guarded_calls": 0, - "loop_contained_shrinking_calls": 0, - "unknown_progress_calls": 0 - }, - "allocations": [], - "call_contexts": [] - })) - .unwrap(), - ); + output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 62])]; + let mut outer_call = call("caller", &path, "append", [1, 28, 1, 55]); + outer_call.receiver_call_span = Some([1, 28, 1, 43]); + output.calls.push(outer_call); apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].target.as_deref(), Some("walk")); - assert_eq!(output.complexity_facts[0].recursion.calls, 1); - assert_eq!(output.complexity_facts[0].recursion.shrinking_calls, 1); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(outer)); } #[test] - fn imports_exact_and_modeled_world_jdk_costs_with_distinct_quality() { + fn java_source_order_selects_outer_same_spelled_call() { let dir = tempdir().unwrap(); let path = dir.path().join("src/Demo.java"); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - "class Demo { void caller(){ text.length(); list.size(); } }", - ) - .unwrap(); + fs::write(&path, "class Demo { void caller(){ pick(pick()); } }").unwrap(); let path = path.to_string_lossy().to_string(); - let string_symbol = "scip-java maven jdk 21 java/lang/String#length()."; - let list_symbol = "scip-java maven jdk 21 java/util/List#size()."; let index = json!({"documents": [{ "relative_path": "src/Demo.java", "occurrences": [ - occurrence([0, 33, 39], string_symbol, 0), - occurrence([0, 48, 52], list_symbol, 0) + occurrence([0, 28, 32], "scip-java maven p Demo#pick().", 0), + occurrence([0, 33, 37], "scip-java maven p Demo#pick(+1).", 0) ] }]}); let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 59])]; - output.calls = vec![ - call("caller", &path, "length", [1, 28, 1, 41]), - call("caller", &path, "size", [1, 43, 1, 54]), - ]; + output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 45])]; + output.calls = vec![call("caller", &path, "pick", [1, 28, 1, 39])]; let stats = apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.external_symbols, 2); - assert_eq!(stats.modeled_external_symbols, 2); - assert_eq!( - output.calls[0].known_time_complexity.as_deref(), - Some("O(1)") - ); - assert_eq!( - output.calls[1].known_time_complexity.as_deref(), - Some("O(1)") - ); + assert_eq!(stats.matched_occurrences, 1); assert_eq!( - output.calls[1].complexity_bound_quality.as_deref(), - Some("upper_bound_modeled_world") + output.calls[0].semantic_symbol.as_deref(), + Some("scip-java maven p Demo#pick().") ); - assert!(output.calls[1] - .complexity_candidates - .iter() - .any(|candidate| candidate == "LinkedList")); } #[test] - fn imports_qualified_static_call_nested_in_constructor_argument() { + fn selector_syntax_selects_outer_call_without_receiver_projection() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Util.java"); + let path = dir.path().join("src/demo.ts"); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - "class Util {\n void check(String format, Object args) {\n throw new IllegalArgumentException(String.format(format, args));\n }\n}\n", - ) - .unwrap(); + let source = "function caller() { resolve(param.transform).transform(); }\nfunction transform() {}\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let format_symbol = "scip-java maven jdk 21 java/lang/String#format()."; + let property = "scip-typescript npm demo 1 src/types.ts/Param#transform."; + let callable = "scip-typescript npm demo 1 src/demo.ts/Transform#transform."; + let property_column = source.find("transform").unwrap(); + let callable_column = + source[property_column + 1..].find("transform").unwrap() + property_column + 1; + let definition_column = source.lines().nth(1).unwrap().find("transform").unwrap(); let index = json!({"documents": [{ - "relative_path": "src/Util.java", + "relative_path": "src/demo.ts", "occurrences": [ - occurrence([2, 46, 52], format_symbol, 0), - occurrence([2, 53, 59], "local 1", 0) + canonical_occurrence([0, property_column, property_column + 9], property, 0), + canonical_occurrence([0, callable_column, callable_column + 9], callable, 0), + canonical_occurrence([1, definition_column, definition_column + 9], callable, 1) ] }]}); + let mut caller = method("caller", &path, "caller", [1, 0, 1, 60]); + let mut target = method("target", &path, "transform", [2, 0, 2, 23]); + caller.language = "typescript".into(); + target.language = "typescript".into(); let mut output = ProfileOutput::default(); - output.methods = vec![method("check", &path, "check", [2, 2, 4, 3])]; - output - .calls - .push(call("check", &path, "format", [3, 39, 3, 66])); + output.methods = vec![caller, target]; + output.calls = vec![call("caller", &path, "transform", [1, 20, 1, 56])]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!( - output.calls[0].semantic_symbol.as_deref(), - Some(format_symbol) - ); + assert_eq!(output.calls[0].target.as_deref(), Some("target")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(callable)); } #[test] - fn imports_callback_cost_as_a_parametric_contract() { + fn equivalent_external_declarations_converge_on_one_reviewed_cost() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); + let path = dir.path().join("src/demo.ts"); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - "class Demo {\n Object run(java.util.function.Function f, Object x) {\n return f.apply(x);\n }\n}\n", - ) - .unwrap(); + let source = "function caller(value: string) { value.split(','); }\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let symbol = "scip-java maven jdk 21 java/util/function/Function#apply()."; + let column = source.find("split").unwrap(); + let es5 = "scip-typescript npm typescript 5.9.3 lib/`lib.es5.d.ts`/String#split()."; + let symbols = "scip-typescript npm typescript 5.9.3 lib/`lib.es2015.symbol.wellknown.d.ts`/String#split()."; let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [occurrence([2, 13, 18], symbol, 0)] + "relative_path": "src/demo.ts", + "occurrences": [ + canonical_occurrence([0, column, column + 5], es5, 0), + canonical_occurrence([0, column, column + 5], symbols, 0) + ] }]}); + let mut caller = method("caller", &path, "caller", [1, 0, 1, source.len()]); + caller.language = "typescript".into(); let mut output = ProfileOutput::default(); - output.methods = vec![method("run", &path, "run", [2, 2, 4, 3])]; - output.calls = vec![call("run", &path, "apply", [3, 11, 3, 21])]; + output.methods = vec![caller]; + output.calls = vec![call("caller", &path, "split", [1, 33, 1, 49])]; - let stats = apply_json(&mut output, &index.to_string()).unwrap(); + apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.modeled_external_symbols, 1); assert_eq!( output.calls[0].known_time_complexity.as_deref(), - Some("O(C)") + Some("O(N)") ); assert_eq!( output.calls[0].known_space_complexity.as_deref(), - Some("O(S)") - ); - assert_eq!( - output.calls[0].complexity_bound_quality.as_deref(), - Some("upper_bound_parametric_callback_once") - ); - assert_eq!( - output.calls[0].external_symbol_scope.as_deref(), - Some("stdlib") + Some("O(N)") ); - assert_eq!(output.calls[0].complexity_missing_kind, None); + assert_eq!(output.calls[0].complexity_candidates.len(), 2); } #[test] - fn compiler_proven_java_costs_distinguish_exact_and_modeled_world_bounds() { - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/System#arraycopy().", - "arraycopy" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(1)")) - ); - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/nio/Buffer#clear().", - "clear" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(1)", "O(1)")) - ); - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/Optional#get().", - "get" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(1)", "O(1)")) - ); + fn csharp_property_symbol_maps_to_emitted_getter() { + let dir = tempdir().unwrap(); + let path = dir.path().join("src/Demo.cs"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let source = "class Demo { string Name { get; } void Caller() { Items.Last().Name; } }\n"; + fs::write(&path, source).unwrap(); + let path = path.to_string_lossy().to_string(); + let declaration_column = source.find("Name").unwrap(); + let call_column = source.rfind("Name").unwrap(); + let symbol = "scip-dotnet nuget . . Demo/Demo#Name."; + let index = json!({"documents": [{ + "relative_path": "src/Demo.cs", + "occurrences": [ + canonical_occurrence([0, declaration_column, declaration_column + 4], symbol, 1), + canonical_occurrence([0, call_column, call_column + 4], symbol, 0) + ] + }]}); + let mut getter = method("getter", &path, "Name", [1, 13, 1, 33]); + let mut caller = method("caller", &path, "Caller", [1, 34, 1, source.len()]); + getter.language = "csharp".into(); + caller.language = "csharp".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![getter, caller]; + output.calls = vec![call("caller", &path, "Name", [1, 50, 1, call_column + 4])]; + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target.as_deref(), Some("getter")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + } + + #[test] + fn rejects_non_utf8_scip_columns_instead_of_guessing() { + let mut output = ProfileOutput::default(); + let index = json!({ + "metadata": {"text_document_encoding": 2}, + "documents": [] + }); + let error = apply_json(&mut output, &index.to_string()).unwrap_err(); + assert!(error.to_string().contains("non-UTF-8")); + } + + #[test] + fn accepts_protobuf_json_camel_case_fields_and_utf8_name() { + let mut output = ProfileOutput::default(); + let index = json!({ + "metadata": { + "textDocumentEncoding": "UTF-8", + "toolInfo": {"name": "scip-java", "version": "0.12.3"} + }, + "documents": [{ + "relativePath": "Demo.swift", + "occurrences": [], + "symbols": [{ + "symbol": "swift Demo Child#", + "relationships": [{ + "symbol": "swift Demo Parent#", + "isImplementation": true + }] + }] + }] + }); + assert!(apply_json(&mut output, &index.to_string()).is_ok()); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/String#startsWith().", - "startsWith" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(1)")) + output.semantic_indexes, + vec![SemanticIndex { + tool: "scip-java".into(), + version: "0.12.3".into(), + }] ); - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/List#get().", - "get" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(1)")) + } + + #[test] + fn imports_the_available_swift_indexstore_json_shape() { + let dir = tempdir().unwrap(); + let path = dir.path().join("Sources/Demo.swift"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let declaration = "func callee() {}"; + let caller = "func caller() { callee() }"; + fs::write(&path, format!("{declaration}\n{caller}\n")).unwrap(); + let path = path.to_string_lossy().to_string(); + let declaration_column = declaration.find("callee").unwrap(); + let call_column = caller.find("callee").unwrap(); + let symbol = "swift Demo callee()."; + let index = json!({ + "metadata": {"textDocumentEncoding": "UTF-8"}, + "documents": [{ + "relativePath": "Sources/Demo.swift", + "language": "swift", + "symbols": [{"symbol": symbol}], + "occurrences": [ + {"range": [0, declaration_column, declaration_column + 6], "symbol": symbol, "symbolRoles": 1}, + {"range": [1, call_column, call_column + 6], "symbol": symbol, "symbolRoles": 8} + ] + }] + }); + let mut callee = method("callee", &path, "callee", [1, 0, 1, declaration.len()]); + let mut caller_method = method("caller", &path, "caller", [2, 0, 2, caller.len()]); + callee.language = "swift".into(); + caller_method.language = "swift".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![callee, caller_method]; + output.calls = vec![call( + "caller", + &path, + "callee", + [2, call_column, 2, call_column + 8], + )]; + + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].target.as_deref(), Some("callee")); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + } + + #[test] + fn runtime_scip_imports_observed_project_targets_as_an_open_candidate_set() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.rb"); + let declaration = " def callee; 1; end"; + let caller = " def caller; callee; end"; + fs::write(&path, format!("class Demo\n{declaration}\n{caller}\nend\n")).unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "nil-kill-runtime workspace demo abc Demo#callee()."; + let declaration_column = declaration.find("callee").unwrap(); + let call_column = caller.find("callee").unwrap(); + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [OBSERVED_OPEN_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "symbols": [{"symbol": symbol}], + "occurrences": [ + {"range": [1, declaration_column, declaration_column + 6], "symbol": symbol, "symbolRoles": 1}, + {"range": [2, call_column, call_column + 6], "symbol": symbol, "symbolRoles": 0} + ] + }] + }); + let mut callee = method("callee", &path, "callee", [2, 2, 2, declaration.len()]); + let mut caller_method = method("caller", &path, "caller", [3, 2, 3, caller.len()]); + callee.language = "ruby".into(); + caller_method.language = "ruby".into(); + let mut runtime_call = call( + "caller", + &path, + "callee", + [3, call_column, 3, call_column + 8], ); + runtime_call.owner = "Demo".into(); + runtime_call.function = "caller".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![callee, caller_method]; + output.calls = vec![runtime_call]; + + apply_json(&mut output, &index.to_string()).unwrap(); + + let call = &output.calls[0]; + assert_eq!(call.target, None); + assert_eq!(call.semantic_symbol.as_deref(), Some(symbol)); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/Set#add().", - "add" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(N)")) + call.target_provenance.as_deref(), + Some("runtime_scip_observed") ); + assert_eq!(call.candidate_targets, vec!["callee"]); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/Enum#name().", - "name" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(1)", "O(1)")) + call.candidate_reason.as_deref(), + Some("runtime_observed_candidate_set") ); + assert!(!call.consumer_closed_candidate_set); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/String#toLowerCase().", - "toLowerCase" - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(N)")) + call.unresolved_reason.as_deref(), + Some("runtime_observed_candidate_set_open") ); - let object_equals = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/Objects#equals().", - "equals", - ) - .unwrap(); - assert_eq!((object_equals.time, object_equals.space), ("O(N)", "O(1)")); - assert_eq!(object_equals.bound_quality, "upper_bound_modeled_world"); - assert!(object_equals.assumption.is_some()); - let list = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/List#get().", - "get", - ) - .unwrap(); - assert_eq!(list.bound_quality, "upper_bound_modeled_world"); - assert!(list - .candidates + assert_eq!(output.methods[0].semantic_symbol, None); + assert!(output + .semantic_indexes .iter() - .any(|candidate| candidate == "LinkedList")); - assert!(list.assumption.is_some()); - let file = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/io/File#toPath().", - "toPath", - ) - .unwrap(); - assert_eq!(file.bound_quality, "upper_bound_external_latency_excluded"); - assert!(file - .assumption - .is_some_and(|assumption| assumption.contains("latency is excluded"))); - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/String#valueOf(+4).", - "valueOf", - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(1)", "O(1)")) - ); - let object_value_of = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/String#valueOf().", - "valueOf", - ) - .unwrap(); - assert_eq!( - (object_value_of.time, object_value_of.space), - ("O(N)", "O(N)") - ); - assert_eq!(object_value_of.bound_quality, "upper_bound_modeled_world"); - let format = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/lang/String#format().", - "format", - ) - .unwrap(); - assert_eq!((format.time, format.space), ("O(N)", "O(N)")); - assert_eq!(format.bound_quality, "upper_bound_modeled_world"); - assert!(format.assumption.is_some()); - assert!(!format.candidates.is_empty()); - let stream_filter = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/stream/Stream#filter().", - "filter", - ) - .unwrap(); - assert_eq!((stream_filter.time, stream_filter.space), ("O(1)", "O(1)")); - assert_eq!(stream_filter.bound_quality, "upper_bound_exact_target"); - let stream_count = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/stream/Stream#count().", - "count", - ) - .unwrap(); - assert_eq!((stream_count.time, stream_count.space), ("O(N)", "O(1)")); - assert_eq!(stream_count.bound_quality, "upper_bound_modeled_world"); - assert!(stream_count.assumption.is_some()); - assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/Objects#hash().", - "hash", - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(N)", "O(N)")) + .any(|index| { index.tool == "nil-kill-runtime" && index.version == "1" })); + } + + #[test] + fn runtime_modeled_scip_closes_observed_project_targets_with_an_assumption() { + let dir = tempdir().unwrap(); + let path = dir.path().join("demo.rb"); + let declaration = " def callee; 1; end"; + let caller = " def caller; callee; end"; + fs::write(&path, format!("class Demo\n{declaration}\n{caller}\nend\n")).unwrap(); + let path = path.to_string_lossy().to_string(); + let symbol = "nil-kill-runtime workspace demo abc Demo#callee()."; + let declaration_column = declaration.find("callee").unwrap(); + let call_column = caller.find("callee").unwrap(); + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "symbols": [{"symbol": symbol}], + "occurrences": [ + {"range": [1, declaration_column, declaration_column + 6], "symbol": symbol, "symbolRoles": 1}, + {"range": [2, call_column, call_column + 6], "symbol": symbol, "symbolRoles": 0} + ] + }] + }); + let mut callee = method("callee", &path, "callee", [2, 2, 2, declaration.len()]); + let mut caller_method = method("caller", &path, "caller", [3, 2, 3, caller.len()]); + callee.language = "ruby".into(); + caller_method.language = "ruby".into(); + let mut runtime_call = call( + "caller", + &path, + "callee", + [3, call_column, 3, call_column + 8], ); + runtime_call.owner = "Demo".into(); + runtime_call.function = "caller".into(); + let mut output = ProfileOutput::default(); + output.methods = vec![callee, caller_method]; + output.calls = vec![runtime_call]; + + apply_json(&mut output, &index.to_string()).unwrap(); + + let call = &output.calls[0]; + assert_eq!(call.target, None); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/util/regex/Matcher#matches().", - "matches", - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(2^N)", "O(N)")) + call.target_provenance.as_deref(), + Some("runtime_scip_modeled") ); + assert_eq!(call.candidate_targets, vec!["callee"]); assert_eq!( - syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/awt/Color#getHSBColor().", - "getHSBColor", - ) - .map(|complexity| (complexity.time, complexity.space)), - Some(("O(1)", "O(1)")) + call.candidate_reason.as_deref(), + Some("runtime_modeled_observed_candidate_set") ); - let read_object = syntax::external_symbol_call_complexity( - "java", - "scip-java maven jdk 21 java/io/ObjectInputStream#readObject().", - "readObject", - ) - .unwrap(); - assert_eq!((read_object.time, read_object.space), ("O(N)", "O(N)")); + assert!(call.consumer_closed_candidate_set); assert_eq!( - read_object.bound_quality, - "upper_bound_external_latency_excluded" + call.complexity_bound_quality.as_deref(), + Some(RUNTIME_MODELED_QUALITY) ); assert_eq!( - syntax::external_symbol_metadata( - "java", - "scip-java maven jdk 21 java/util/function/Function#apply().", - ), - syntax::ExternalSymbolMetadata { - scope: "stdlib", - missing_cost_kind: "callback_cost_missing".to_string(), - parametric_cost: Some("callback_once".to_string()), - } + call.complexity_assumptions, + vec![RUNTIME_MODELED_ASSUMPTION] ); assert_eq!( - syntax::external_symbol_metadata( - "java", - "scip-java maven maven/acme/tool 1 acme/Tool#run().", - ) - .scope, - "dependency" + call.unresolved_reason.as_deref(), + Some("runtime_modeled_project_candidate_set_requires_summary") ); } #[test] - fn ignores_same_name_non_callable_occurrence() { + fn runtime_modeled_scip_accepts_an_exact_whole_call_anchor_for_a_ruby_writer() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "class Demo { void caller(){ value; } }").unwrap(); + let path = dir.path().join("demo.rb"); + let source = "def caller(target)\n target.format = :json\nend\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [occurrence([0, 28, 33], "scip-java maven p Demo#value.", 0)] - }]}); + let call_text = "target.format = :json"; + let call_column = source.lines().nth(1).unwrap().find(call_text).unwrap(); + let symbol = "nil-kill-runtime workspace demo abc Demo/FileCoverage#`format=`()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [{ + "range": [1, call_column, call_column + call_text.len()], + "symbol": symbol, + "symbolRoles": 0 + }] + }] + }); + let mut caller = method("caller", &path, "caller", [1, 0, 3, 3]); + caller.language = "ruby".into(); + let mut writer = call( + "caller", + &path, + "format=", + [2, call_column, 2, call_column + call_text.len()], + ); + writer.owner = "Demo".into(); + writer.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 38])]; - output.calls = vec![call("caller", &path, "value", [1, 28, 1, 33])]; + output.methods = vec![caller]; + output.calls = vec![writer]; - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.unmatched_calls, 1); - assert_eq!(output.calls[0].semantic_symbol, None); + apply_json(&mut output, &index.to_string()).unwrap(); + + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!( + output.calls[0].target_provenance.as_deref(), + Some("runtime_scip_modeled") + ); } #[test] - fn accepts_repeated_occurrences_when_semantic_identity_is_identical() { + fn runtime_modeled_scip_disambiguates_same_range_ruby_reader_and_writer_symbols() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - "class Demo { void caller(){ value.append(\"a\").append(\"b\"); } }", - ) - .unwrap(); + let path = dir.path().join("demo.rb"); + let source = "def caller(target)\n target.format = :json\nend\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let symbol = "scip-java maven jdk 21 java/lang/StringBuilder#append(+1)."; - let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [ - occurrence([0, 34, 40], symbol, 0), - occurrence([0, 46, 52], symbol, 0) - ] - }]}); + let line = source.lines().nth(1).unwrap(); + let call_column = line.find("target.format").unwrap(); + let selector_column = line.find("format").unwrap(); + let writer = + "nil-kill-runtime workspace demo abc Demo/FileCoverage#`format=`()."; + let reader = "nil-kill-runtime workspace demo abc Demo/FileCoverage#format()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [ + { + "range": [1, selector_column, selector_column + "format".len()], + "symbol": writer, + "symbolRoles": 0 + }, + { + "range": [1, selector_column, selector_column + "format".len()], + "symbol": reader, + "symbolRoles": 0 + } + ] + }] + }); + let mut caller = method("caller", &path, "caller", [1, 0, 3, 3]); + caller.language = "ruby".into(); + let mut call = call( + "caller", + &path, + "format=", + [2, call_column, 2, line.len()], + ); + call.owner = "Demo".into(); + call.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 65])]; - output - .calls - .push(call("caller", &path, "append", [1, 29, 1, 58])); + output.methods = vec![caller]; + output.calls = vec![call]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(writer)); + assert_eq!( + output.calls[0].target_provenance.as_deref(), + Some("runtime_scip_modeled") + ); + assert!( + output.calls[0] + .complexity_candidates + .iter() + .all(|candidate| candidate != reader), + "the same-range reader must not survive as a writer alternative" + ); } #[test] - fn receiver_call_span_selects_outer_fluent_overload() { + fn runtime_modeled_scip_matches_an_opaque_ruby_project_symbol_at_a_bracket_selector() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - "class Demo { void caller(){ value.append(1).append(\"b\"); } }", - ) - .unwrap(); + let path = dir.path().join("demo.rb"); + let source = "def caller(dataset)\n dataset[\"file\"]\nend\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let inner = "scip-java maven jdk 21 java/lang/StringBuilder#append()."; - let outer = "scip-java maven jdk 21 java/lang/StringBuilder#append(+1)."; - let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [ - occurrence([0, 34, 40], inner, 0), - occurrence([0, 44, 50], outer, 0) - ] - }]}); + let call_text = "dataset[\"file\"]"; + let call_column = source.lines().nth(1).unwrap().find(call_text).unwrap(); + let bracket_column = call_column + call_text.find('[').unwrap(); + let symbol = "fact-mine workspace project . Method#opaque()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [{ + "range": [1, bracket_column, bracket_column + 1], + "symbol": symbol, + "symbolRoles": 0 + }] + }] + }); + let mut caller = method("caller", &path, "caller", [1, 0, 3, 3]); + caller.language = "ruby".into(); + let mut index_call = call( + "caller", + &path, + "[]", + [2, call_column, 2, call_column + call_text.len()], + ); + index_call.owner = "Demo".into(); + index_call.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 62])]; - let mut outer_call = call("caller", &path, "append", [1, 28, 1, 55]); - outer_call.receiver_call_span = Some([1, 28, 1, 43]); - output.calls.push(outer_call); + output.methods = vec![caller]; + output.calls = vec![index_call]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(outer)); + assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!( + output.calls[0].target_provenance.as_deref(), + Some("runtime_scip_modeled") + ); } #[test] - fn java_source_order_selects_outer_same_spelled_call() { + fn runtime_modeled_scip_preserves_observed_anchor_when_static_target_outranks_it() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.java"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "class Demo { void caller(){ pick(pick()); } }").unwrap(); + let path = dir.path().join("demo.rb"); + let declaration = " def callee; 1; end"; + let caller = " def caller; callee; end"; + fs::write(&path, format!("class Demo\n{declaration}\n{caller}\nend\n")).unwrap(); let path = path.to_string_lossy().to_string(); - let index = json!({"documents": [{ - "relative_path": "src/Demo.java", - "occurrences": [ - occurrence([0, 28, 32], "scip-java maven p Demo#pick().", 0), - occurrence([0, 33, 37], "scip-java maven p Demo#pick(+1).", 0) - ] - }]}); + let symbol = "nil-kill-runtime workspace demo abc Demo#callee()."; + let declaration_column = declaration.find("callee").unwrap(); + let call_column = caller.find("callee").unwrap(); + let runtime_call_span = [3, call_column, 3, call_column + "callee;".len()]; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "2", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "symbols": [{"symbol": symbol}], + "occurrences": [ + {"range": [1, declaration_column, declaration_column + 6], "symbol": symbol, "symbolRoles": 1}, + {"range": [2, call_column, call_column + 6], "symbol": symbol, "symbolRoles": 0} + ] + }], + "_runtimeEvidence": { + "observedCallsiteAnchors": [{ + "relativePath": "demo.rb", + "range": [2, call_column, call_column + "callee;".len()] + }] + } + }); + let mut callee = method("callee", &path, "callee", [2, 2, 2, declaration.len()]); + let mut caller_method = method("caller", &path, "caller", [3, 2, 3, caller.len()]); + callee.language = "ruby".into(); + caller_method.language = "ruby".into(); + let mut static_call = call("caller", &path, "callee", runtime_call_span); + static_call.owner = "Demo".into(); + static_call.function = "caller".into(); + static_call.kind = "internal_call".into(); + static_call.target = Some("callee".into()); let mut output = ProfileOutput::default(); - output.methods = vec![method("caller", &path, "caller", [1, 13, 1, 45])]; - output.calls = vec![call("caller", &path, "pick", [1, 28, 1, 39])]; + output.methods = vec![callee, caller_method]; + output.calls = vec![static_call]; - let stats = apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(stats.matched_occurrences, 1); - assert_eq!( - output.calls[0].semantic_symbol.as_deref(), - Some("scip-java maven p Demo#pick().") - ); + apply_json(&mut output, &index.to_string()).unwrap(); + + let call = &output.calls[0]; + assert_eq!(call.target.as_deref(), Some("callee")); + assert!(call.semantic_symbol.is_none()); + assert!(call.runtime_evidence_observed); } #[test] - fn selector_syntax_selects_outer_call_without_receiver_projection() { + fn runtime_modeled_scip_prices_native_ruby_symbols() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/demo.ts"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let source = "function caller() { resolve(param.transform).transform(); }\nfunction transform() {}\n"; + let path = dir.path().join("demo.rb"); + let source = "def caller(values)\n values.length\nend\n"; fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let property = "scip-typescript npm demo 1 src/types.ts/Param#transform."; - let callable = "scip-typescript npm demo 1 src/demo.ts/Transform#transform."; - let property_column = source.find("transform").unwrap(); - let callable_column = - source[property_column + 1..].find("transform").unwrap() + property_column + 1; - let definition_column = source.lines().nth(1).unwrap().find("transform").unwrap(); - let index = json!({"documents": [{ - "relative_path": "src/demo.ts", - "occurrences": [ - canonical_occurrence([0, property_column, property_column + 9], property, 0), - canonical_occurrence([0, callable_column, callable_column + 9], callable, 0), - canonical_occurrence([1, definition_column, definition_column + 9], callable, 1) - ] - }]}); - let mut caller = method("caller", &path, "caller", [1, 0, 1, 60]); - let mut target = method("target", &path, "transform", [2, 0, 2, 23]); - caller.language = "typescript".into(); - target.language = "typescript".into(); + let symbol = "nil-kill-runtime ruby ruby 3.2.3 Array#length()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [ + {"range": [1, 9, 15], "symbol": symbol, "symbolRoles": 0} + ] + }] + }); + let mut caller_method = method("caller", &path, "caller", [1, 0, 3, 3]); + caller_method.language = "ruby".into(); + let mut runtime_call = call("caller", &path, "length", [2, 2, 2, 15]); + runtime_call.owner = "Object".into(); + runtime_call.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![caller, target]; - output.calls = vec![call("caller", &path, "transform", [1, 20, 1, 56])]; + output.methods = vec![caller_method]; + output.calls = vec![runtime_call]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].target.as_deref(), Some("target")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(callable)); + let call = &output.calls[0]; + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(call.external_symbol_scope.as_deref(), Some("stdlib")); + assert_eq!( + call.complexity_provenance.as_deref(), + Some("runtime_scip_modeled:conservative_external_candidate_max") + ); + assert_eq!( + call.complexity_bound_quality.as_deref(), + Some(RUNTIME_MODELED_QUALITY) + ); + assert_eq!( + call.complexity_assumptions, + vec![RUNTIME_MODELED_ASSUMPTION] + ); + assert!(call.consumer_closed_candidate_set); + assert_eq!(call.unresolved_reason, None); } #[test] - fn equivalent_external_declarations_converge_on_one_reviewed_cost() { + fn runtime_modeled_scip_reconciles_native_generated_readers_to_source_declarations() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/demo.ts"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let source = "function caller(value: string) { value.split(','); }\n"; + let path = dir.path().join("demo.rb"); + let source = "class Demo\n attr_reader :value\n def caller\n value\n end\nend\n"; fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let column = source.find("split").unwrap(); - let es5 = "scip-typescript npm typescript 5.9.3 lib/`lib.es5.d.ts`/String#split()."; - let symbols = "scip-typescript npm typescript 5.9.3 lib/`lib.es2015.symbol.wellknown.d.ts`/String#split()."; - let index = json!({"documents": [{ - "relative_path": "src/demo.ts", - "occurrences": [ - canonical_occurrence([0, column, column + 5], es5, 0), - canonical_occurrence([0, column, column + 5], symbols, 0) - ] - }]}); - let mut caller = method("caller", &path, "caller", [1, 0, 1, source.len()]); - caller.language = "typescript".into(); + let symbol = "nil-kill-runtime ruby ruby 3.2.3 Demo#value()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [ + {"range": [3, 4, 9], "symbol": symbol, "symbolRoles": 0} + ] + }] + }); + let mut reader = method("reader", &path, "value", [2, 2, 2, 20]); + reader.language = "ruby".into(); + reader.owner = "Demo".into(); + reader.dispatch_name = "value".into(); + reader.raw_source = "attr_reader :value".into(); + let mut caller_method = method("caller", &path, "caller", [3, 2, 5, 5]); + caller_method.language = "ruby".into(); + caller_method.owner = "Demo".into(); + let mut runtime_call = call("caller", &path, "value", [4, 4, 4, 9]); + runtime_call.owner = "Demo".into(); + runtime_call.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![caller]; - output.calls = vec![call("caller", &path, "split", [1, 33, 1, 49])]; + output.methods = vec![reader, caller_method]; + output.calls = vec![runtime_call]; apply_json(&mut output, &index.to_string()).unwrap(); + let call = &output.calls[0]; + assert_eq!(call.target.as_deref(), Some("reader")); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(1)")); assert_eq!( - output.calls[0].known_time_complexity.as_deref(), - Some("O(N)") + call.target_provenance.as_deref(), + Some("semantic_generated_declaration") ); assert_eq!( - output.calls[0].known_space_complexity.as_deref(), - Some("O(N)") + call.complexity_provenance.as_deref(), + Some("generated_callable_declaration") ); - assert_eq!(output.calls[0].complexity_candidates.len(), 2); } #[test] - fn csharp_property_symbol_maps_to_emitted_getter() { + fn runtime_modeled_scip_joins_ruby_operator_candidates() { let dir = tempdir().unwrap(); - let path = dir.path().join("src/Demo.cs"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let source = "class Demo { string Name { get; } void Caller() { Items.Last().Name; } }\n"; + let path = dir.path().join("demo.rb"); + let source = "def caller(values)\n values[:x]\nend\n"; fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let declaration_column = source.find("Name").unwrap(); - let call_column = source.rfind("Name").unwrap(); - let symbol = "scip-dotnet nuget . . Demo/Demo#Name."; - let index = json!({"documents": [{ - "relative_path": "src/Demo.cs", - "occurrences": [ - canonical_occurrence([0, declaration_column, declaration_column + 4], symbol, 1), - canonical_occurrence([0, call_column, call_column + 4], symbol, 0) - ] - }]}); - let mut getter = method("getter", &path, "Name", [1, 13, 1, 33]); - let mut caller = method("caller", &path, "Caller", [1, 34, 1, source.len()]); - getter.language = "csharp".into(); - caller.language = "csharp".into(); + let array = "nil-kill-runtime ruby ruby 3.2.3 Array#`[]`()."; + let hash = "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`()."; + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [RUNTIME_MODELED_AUTHORITY_ARGUMENT] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": "demo.rb", + "language": "ruby", + "occurrences": [ + {"range": [1, 8, 12], "symbol": array, "symbolRoles": 0}, + {"range": [1, 8, 12], "symbol": hash, "symbolRoles": 0} + ] + }] + }); + let mut caller_method = method("caller", &path, "caller", [1, 0, 3, 3]); + caller_method.language = "ruby".into(); + let mut runtime_call = call("caller", &path, "[]", [2, 2, 2, 12]); + runtime_call.owner = "Object".into(); + runtime_call.function = "caller".into(); let mut output = ProfileOutput::default(); - output.methods = vec![getter, caller]; - output.calls = vec![call("caller", &path, "Name", [1, 50, 1, call_column + 4])]; + output.methods = vec![caller_method]; + output.calls = vec![runtime_call]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].target.as_deref(), Some("getter")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); - } - - #[test] - fn rejects_non_utf8_scip_columns_instead_of_guessing() { - let mut output = ProfileOutput::default(); - let index = json!({ - "metadata": {"text_document_encoding": 2}, - "documents": [] - }); - let error = apply_json(&mut output, &index.to_string()).unwrap_err(); - assert!(error.to_string().contains("non-UTF-8")); + let call = &output.calls[0]; + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + call.complexity_candidates, + vec![array.to_string(), hash.to_string()] + ); + assert!(call.consumer_closed_candidate_set); + assert_eq!(call.unresolved_reason, None); } #[test] - fn accepts_protobuf_json_camel_case_fields_and_utf8_name() { - let mut output = ProfileOutput::default(); - let index = json!({ - "metadata": {"textDocumentEncoding": "UTF-8"}, - "documents": [{ - "relativePath": "Demo.swift", - "occurrences": [], - "symbols": [{ - "symbol": "swift Demo Child#", - "relationships": [{ - "symbol": "swift Demo Parent#", - "isImplementation": true - }] - }] - }] - }); - assert!(apply_json(&mut output, &index.to_string()).is_ok()); + fn runtime_candidate_complexity_order_is_asymptotically_conservative() { + assert!(conservative_complexity_rank("O(N log N)") > conservative_complexity_rank("O(N)")); + assert!( + conservative_complexity_rank("O(N^20)") > conservative_complexity_rank("O(N^2 log N)") + ); + assert!(conservative_complexity_rank("O(2^N)") > conservative_complexity_rank("O(N^20)")); + assert!(conservative_complexity_rank("O(N!)") > conservative_complexity_rank("O(2^N)")); } #[test] - fn imports_the_available_swift_indexstore_json_shape() { + fn resolved_static_identity_outranks_a_later_runtime_observation() { let dir = tempdir().unwrap(); - let path = dir.path().join("Sources/Demo.swift"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let declaration = "func callee() {}"; - let caller = "func caller() { callee() }"; - fs::write(&path, format!("{declaration}\n{caller}\n")).unwrap(); + let path = dir.path().join("demo.rb"); + let source = "def caller\n value.call\nend\n"; + fs::write(&path, source).unwrap(); let path = path.to_string_lossy().to_string(); - let declaration_column = declaration.find("callee").unwrap(); - let call_column = caller.find("callee").unwrap(); - let symbol = "swift Demo callee()."; + let runtime_symbol = "nil-kill-runtime rubygems demo 1 Other#call()."; let index = json!({ - "metadata": {"textDocumentEncoding": "UTF-8"}, + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": [OBSERVED_OPEN_AUTHORITY_ARGUMENT] + } + }, "documents": [{ - "relativePath": "Sources/Demo.swift", - "language": "swift", - "symbols": [{"symbol": symbol}], + "relativePath": "demo.rb", "occurrences": [ - {"range": [0, declaration_column, declaration_column + 6], "symbol": symbol, "symbolRoles": 1}, - {"range": [1, call_column, call_column + 6], "symbol": symbol, "symbolRoles": 8} + {"range": [1, 8, 12], "symbol": runtime_symbol, "symbolRoles": 0} ] }] }); - let mut callee = method("callee", &path, "callee", [1, 0, 1, declaration.len()]); - let mut caller_method = method("caller", &path, "caller", [2, 0, 2, caller.len()]); - callee.language = "swift".into(); - caller_method.language = "swift".into(); + let mut caller_method = method("caller", &path, "caller", [1, 0, 3, 3]); + caller_method.language = "ruby".into(); + let mut runtime_call = call("caller", &path, "call", [2, 2, 2, 12]); + runtime_call.target = Some("compiler-target".into()); + runtime_call.semantic_symbol = Some("compiler symbol".into()); + runtime_call.target_provenance = Some("source_exact".into()); let mut output = ProfileOutput::default(); - output.methods = vec![callee, caller_method]; - output.calls = vec![call( - "caller", - &path, - "callee", - [2, call_column, 2, call_column + 8], - )]; + output.methods = vec![caller_method]; + output.calls = vec![runtime_call]; apply_json(&mut output, &index.to_string()).unwrap(); - assert_eq!(output.calls[0].target.as_deref(), Some("callee")); - assert_eq!(output.calls[0].semantic_symbol.as_deref(), Some(symbol)); + assert_eq!(output.calls[0].target.as_deref(), Some("compiler-target")); + assert_eq!( + output.calls[0].semantic_symbol.as_deref(), + Some("compiler symbol") + ); + assert_eq!( + output.calls[0].target_provenance.as_deref(), + Some("source_exact") + ); } #[test] @@ -2542,4 +5857,13 @@ mod tests { assert_eq!(occurrence.span(), Some([0, 0, 0, 4])); } + + #[test] + fn reads_indented_preprocessor_define_directives() { + let source = "#if ENABLED\n# define WRAP(value) value\n#endif\n"; + assert_eq!( + preprocessor_definition_source(source, 1).as_deref(), + Some("# define WRAP(value) value") + ); + } } diff --git a/gems/fact-mine/src/scip_emit.rs b/gems/fact-mine/src/scip_emit.rs new file mode 100644 index 000000000..f10934a5e --- /dev/null +++ b/gems/fact-mine/src/scip_emit.rs @@ -0,0 +1,363 @@ +//! Emitting the runtime SCIP index for a collect, and attesting what it covers. +//! +//! The overlay itself is `runtime-scip`; this decides what it should be run +//! over and records what the answer was derived from. Both are questions about +//! a directory of artifacts, not about any interpreter. + +use anyhow::{Context, Result}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +pub const SCHEMA_VERSION: i64 = 1; +pub const TOOL_NAME: &str = "nil-kill-runtime"; +pub const TOOL_VERSION: &str = "2"; +pub const AUTHORITY: &str = "runtime-modeled-world"; + +fn authority_argument() -> String { + format!("--fact-mine-index-authority={AUTHORITY}") +} + +/// An index over nothing, so a collect that observed nothing still writes a +/// well-formed answer rather than no answer. +pub fn empty_index(root: &Path) -> Value { + json!({ + "metadata": { + "version": 0, + "toolInfo": {"name": TOOL_NAME, "version": TOOL_VERSION, "arguments": [authority_argument()]}, + "projectRoot": project_root_uri(root), + "textDocumentEncoding": 1, + }, + "documents": [], + "externalSymbols": [], + "_runtimeEvidence": { + "schema": "factmine.runtime.v1", + "observedCallSites": 0, + "inferredCallSites": 0, + "typedReceivers": 0, + "emittedOccurrences": 0, + }, + }) +} + +fn project_root_uri(root: &Path) -> String { + let mut encoded = String::from("file://"); + for byte in root.to_string_lossy().bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + encoded.push(byte as char) + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +fn under_root(path: &Path, root: &Path) -> bool { + path.starts_with(root) && path != root +} + +/// A runtime call can cross from the selected product source into a sibling +/// workspace implementation. Its declaration identity is already attested by +/// the event, but FactMine cannot emit a definition occurrence unless that file +/// is in the source set. Only workspace-owned files under this root qualify: +/// dependency implementations stay external. +fn workspace_callee_source(event: &Value, root: &Path) -> Option { + let callee = event.get("callee")?; + if callee["package_manager"].as_str() != Some("workspace") { + return None; + } + let path = callee["path"].as_str().filter(|path| !path.is_empty())?; + let absolute = root.join(path); + (under_root(&absolute, root) && absolute.is_file()).then_some(absolute) +} + +fn evidence_workspace_sources(evidence: &Value, root: &Path) -> Vec { + evidence["anchors"] + .as_array() + .into_iter() + .flatten() + .flat_map(|anchor| anchor["executions"].as_array().cloned().unwrap_or_default()) + .filter_map(|bucket| { + let target = bucket.get("target")?; + if target["source_role"].as_str() != Some("PRODUCTION") + || target["package_manager"].as_str() != Some("workspace") + { + return None; + } + let relative = target["definition"]["relative_path"] + .as_str() + .filter(|path| !path.is_empty())?; + let absolute = root.join(relative); + (under_root(&absolute, root) && absolute.is_file()).then_some(absolute) + }) + .collect() +} + +/// The files the overlay should run over. +pub fn runtime_sources( + files: &[PathBuf], + events: &[Value], + evidence: &Value, + plan: &Value, + root: &Path, +) -> Vec { + let mut sources: Vec = + files.iter().map(|path| root.join(path)).collect(); + let documents = plan["documents"].as_array().cloned().unwrap_or_default(); + if sources.is_empty() { + sources.extend( + events + .iter() + .filter_map(|event| event["callsite"]["path"].as_str()) + .map(|path| root.join(path)), + ); + sources.extend( + documents + .iter() + .filter_map(|document| document["relative_path"].as_str()) + .map(|path| root.join(path)), + ); + } + sources.extend(events.iter().filter_map(|event| workspace_callee_source(event, root))); + sources.extend(evidence_workspace_sources(evidence, root)); + + // Only files a traced language could have produced. + let mut extensions = BTreeSet::new(); + for language in events + .iter() + .filter_map(|event| event["language"].as_str()) + .chain(documents.iter().filter_map(|document| document["language"].as_str())) + { + if language == "ruby" { + extensions.insert("rb"); + } + } + let mut selected = sources + .into_iter() + .filter(|path| path.is_file()) + .filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extensions.contains(extension)) + }) + .collect::>(); + selected.sort(); + selected.dedup(); + selected +} + +/// What the index covers and what it was derived from, so a later reader can +/// tell whether it still applies. +pub fn attestation( + events: &[Value], + documents: usize, + invalid_events: usize, + inferred_events: i64, + excluded_events: usize, + evidence_runs: &[String], + evidence_environment: &BTreeMap, + environment: &BTreeMap, + runtime_claims: &BTreeMap, +) -> Value { + let mut runs = evidence_runs + .iter() + .filter(|id| !id.is_empty()) + .cloned() + .collect::>(); + runs.sort(); + runs.dedup(); + + let mut claims: BTreeMap = BTreeMap::new(); + claims.insert("runtime_scip.authority".into(), AUTHORITY.into()); + claims.insert( + "runtime_scip.closure_assumption".into(), + "observed call targets exhaust the attested workload and runtime environment".into(), + ); + claims.insert("runtime_scip.producer".into(), format!("{TOOL_NAME}@{TOOL_VERSION}")); + claims.insert("runtime_scip.event_schema".into(), SCHEMA_VERSION.to_string()); + claims.insert("runtime_scip.event_count".into(), events.len().to_string()); + claims.insert("runtime_scip.document_count".into(), documents.to_string()); + claims.insert("runtime_scip.invalid_event_count".into(), invalid_events.to_string()); + claims.insert( + "runtime_scip.excluded_nonproduction_event_count".into(), + excluded_events.to_string(), + ); + claims.insert("runtime_scip.inferred_event_count".into(), inferred_events.to_string()); + claims.insert( + "runtime_scip.inference".into(), + "FactMine normalized CFG/DFG overlaid with observed runtime value domains".into(), + ); + claims.insert("runtime_scip.run_ids_sha256".into(), digest(&runs.join("\n"))); + claims.extend(evidence_environment.clone()); + claims.extend(runtime_claims.clone()); + claims.extend(environment.clone()); + + json!({ + "schema": "fact-mine.semantic-environment.v1", + "claims": claims.into_iter().map(|(key, value)| (key, json!(value))).collect::>(), + }) +} + +fn digest(value: &str) -> String { + use sha2::{Digest, Sha256}; + format!("sha256:{:x}", Sha256::digest(value.as_bytes())) +} + +/// Everything a collect's SCIP stage produces. +pub struct Emitted { + pub index: Value, + pub attestation: Value, + pub events: usize, + pub invalid_events: usize, + pub inferred_events: i64, + pub documents: usize, + pub occurrences: usize, + /// Anchors whose executions carried an observed value, as against a target. + pub observations: usize, +} + +pub fn emit( + root: &Path, + runtime_dir: &Path, + evidence_path: &Path, + plan: &Value, + files: &[PathBuf], + environment: &BTreeMap, + overlay: impl FnOnce(&Path, &[PathBuf]) -> Result, +) -> Result { + let rows = crate::trace_document::read_shard(runtime_dir); + let raw = crate::runtime_protocol::read_json(evidence_path) + .with_context(|| format!("unreadable evidence {}", evidence_path.display()))?; + let evidence: Value = serde_json::from_str(&raw)?; + + let sources = runtime_sources(files, &rows.calls, &evidence, plan, root); + let index = + if sources.is_empty() { empty_index(root) } else { overlay(evidence_path, &sources)? }; + + let documents = index["documents"].as_array().cloned().unwrap_or_default(); + let inferred_events = index["_runtimeEvidence"]["inferredCallSites"].as_i64().unwrap_or(0); + + let runs = evidence["runs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|run| run["id"].as_str().map(str::to_string)) + .collect::>(); + let evidence_environment = evidence["environment"] + .as_array() + .into_iter() + .flatten() + .filter_map(|claim| { + Some((claim["key"].as_str()?.to_string(), claim["value"].as_str()?.to_string())) + }) + .collect::>(); + + // The claims the traced runtime made about itself, from the document that + // made them, so the attestation says what observed rather than what emitted. + let (runtime, _) = crate::trace_document::runtime_of(runtime_dir)?; + let runtime_claims = crate::trace_document::environment_claims(&runtime, root) + .into_iter() + .collect::>(); + + let counted = |field: &str| { + evidence["anchors"] + .as_array() + .into_iter() + .flatten() + .filter(|anchor| { + anchor["executions"] + .as_array() + .into_iter() + .flatten() + .any(|bucket| bucket.get(field).is_some_and(|value| !value.is_null())) + }) + .count() + }; + Ok(Emitted { + observations: counted("value"), + occurrences: documents + .iter() + .map(|document| document["occurrences"].as_array().map_or(0, Vec::len)) + .sum(), + documents: documents.len(), + attestation: attestation( + &rows.calls, + documents.len(), + rows.invalid_calls, + inferred_events, + 0, + &runs, + &evidence_environment, + environment, + &runtime_claims, + ), + index, + events: rows.calls.len(), + invalid_events: rows.invalid_calls, + inferred_events, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(callee_path: &str, package_manager: &str) -> Value { + json!({ + "language": "ruby", + "callsite": {"path": "worker.rb", "line": 3}, + "callee": {"path": callee_path, "package_manager": package_manager}, + }) + } + + /// A call can cross from the selected source into a sibling workspace file. + /// That declaration has to be in the source set or FactMine cannot emit a + /// definition for it -- but a dependency's implementation stays external. + #[test] + fn trusted_workspace_declarations_are_included_and_dependencies_are_not() { + let root = tempfile::tempdir().expect("tempdir"); + let root_path = root.path(); + let write = |relative: &str| { + let path = root_path.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, "# source\n").expect("write"); + path + }; + let source = write("worker.rb"); + let workspace = write("tools/workspace_helper.rb"); + let dependency = write("vendor/dependency.rb"); + + let selected = runtime_sources( + &[source.clone()], + &[ + event("tools/workspace_helper.rb", "workspace"), + event("vendor/dependency.rb", "rubygems"), + ], + &json!({}), + &json!({}), + root_path, + ); + + // Sorted, so the set is stable whatever order the events arrived in. + assert_eq!(selected, vec![workspace, source]); + assert!(!selected.contains(&dependency)); + } + + /// Only files a traced language could have produced. + #[test] + fn a_file_no_traced_language_owns_is_left_out() { + let root = tempfile::tempdir().expect("tempdir"); + let readme = root.path().join("README.md"); + std::fs::write(&readme, "# not source\n").expect("write"); + + assert!(runtime_sources( + &[readme], + &[event("worker.rb", "workspace")], + &json!({}), + &json!({}), + root.path() + ) + .is_empty()); + } +} diff --git a/gems/fact-mine/src/shard_runner.rs b/gems/fact-mine/src/shard_runner.rs new file mode 100644 index 000000000..be8b5da32 --- /dev/null +++ b/gems/fact-mine/src/shard_runner.rs @@ -0,0 +1,98 @@ +//! Running the traced programs. +//! +//! One process per shard, several at a time, each told through its environment +//! where to write and which run it is. Their output is the workload's own -- +//! test results a person is watching -- so it goes straight to the terminal +//! rather than being captured and replayed. +//! +//! The first failure stops the rest unless the caller asked to continue: a +//! shard that did not run leaves no evidence, and evidence that is silently +//! missing is worse than a collect that stops and says so. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Mutex; + +#[derive(Debug, Deserialize)] +pub struct Shard { + pub id: String, + pub command: Vec, + /// A null value means "unset this variable", which is what a nil in the + /// caller's environment hash has always meant. + #[serde(default)] + pub env: std::collections::BTreeMap>, +} + +#[derive(Debug, Deserialize)] +pub struct Plan { + pub shards: Vec, + #[serde(default = "one")] + pub jobs: usize, + #[serde(default)] + pub continue_on_error: bool, + /// Echoed with each shard so the terminal shows what a person could rerun. + #[serde(default)] + pub banner: String, +} + +fn one() -> usize { + 1 +} + +/// The shards that failed, in the order they were scheduled. +pub fn run(plan: &Plan) -> Result> { + let next = AtomicUsize::new(0); + let stop = AtomicBool::new(false); + let failed: Mutex> = Mutex::new(Vec::new()); + let total = plan.shards.len(); + let jobs = plan.jobs.max(1).min(total.max(1)); + + std::thread::scope(|scope| { + for _ in 0..jobs { + scope.spawn(|| { + loop { + if stop.load(Ordering::SeqCst) { + return; + } + let at = next.fetch_add(1, Ordering::SeqCst); + let Some(shard) = plan.shards.get(at) else { return }; + let Some((program, arguments)) = shard.command.split_first() else { + continue; + }; + println!("[{}/{total}] {}{}", at + 1, plan.banner, shard.command.join(" ")); + let mut process = Command::new(program); + process.args(arguments); + for (key, value) in &shard.env { + match value { + Some(value) => process.env(key, value), + None => process.env_remove(key), + }; + } + let status = process + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status(); + let ok = matches!(status, Ok(status) if status.success()); + if !ok { + failed.lock().expect("failures").push((at, shard.id.clone())); + if !plan.continue_on_error { + stop.store(true, Ordering::SeqCst); + } + } + } + }); + } + }); + + let mut failures = failed.into_inner().expect("failures"); + failures.sort_by_key(|(at, _)| *at); + Ok(failures.into_iter().map(|(_, id)| id).collect()) +} + +pub fn run_file(path: &std::path::Path) -> Result> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("unreadable shard plan {}", path.display()))?; + run(&serde_json::from_str(&raw)?) +} diff --git a/gems/fact-mine/src/snapshot.rs b/gems/fact-mine/src/snapshot.rs new file mode 100644 index 000000000..493b14a94 --- /dev/null +++ b/gems/fact-mine/src/snapshot.rs @@ -0,0 +1,711 @@ +//! Which shards an incremental collect has to rerun. +//! +//! The question is answered by comparing two manifests: what the sources, +//! functions, tests and workload were last time against what they are now. A +//! shard reruns when a test it owns changed or a function it depended on did. +//! +//! Everything that cannot be attributed to a specific shard falls back to a +//! full collect. That is the whole safety property: it is always sound to rerun +//! everything, and never sound to skip a shard whose evidence might be stale. + +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; + +fn strings(value: &Value) -> Vec { + value + .as_object() + .into_iter() + .flatten() + .map(|(key, _)| key.clone()) + .collect() +} + +fn table<'a>(value: &'a Value, field: &str) -> &'a Value { + value.get(field).unwrap_or(&Value::Null) +} + +fn sorted(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + values +} + +/// Keys present in `current` whose value differs from `previous`. +fn changed(current: &Value, previous: &Value) -> Vec { + current + .as_object() + .into_iter() + .flatten() + .filter(|(key, value)| previous.get(key.as_str()) != Some(*value)) + .map(|(key, _)| key.clone()) + .collect() +} + +fn missing(previous: &Value, current: &Value) -> Vec { + let present = current.as_object().map(|map| { + map.keys().cloned().collect::>() + }).unwrap_or_default(); + strings(previous).into_iter().filter(|key| !present.contains(key)).collect() +} + +pub struct Increment<'a> { + pub manifest: &'a Value, + pub current_hashes: &'a Value, + pub current_environment: &'a Value, + pub functions: &'a Value, + pub workload: &'a Value, + pub trace_plan_digest: &'a str, +} + +pub fn select(input: &Increment<'_>) -> Value { + let manifest = input.manifest; + let previous_hashes = table(manifest, "source_hashes"); + let changed_files = changed(input.current_hashes, previous_hashes); + let deleted_files = missing(previous_hashes, input.current_hashes); + + let previous_functions = table(manifest, "functions"); + let previous_workload = table(manifest, "workload"); + let previous_tests = table(previous_workload, "tests"); + let current_tests = table(input.workload, "tests"); + let changed_tests = changed(current_tests, previous_tests); + let deleted_tests = missing(previous_tests, current_tests); + let support_changed = + table(previous_workload, "support_files") != table(input.workload, "support_files"); + + let added_functions = missing(input.functions, previous_functions); + let deleted_functions = missing(previous_functions, input.functions); + let changed_functions = input + .functions + .as_object() + .into_iter() + .flatten() + .filter(|(key, function)| { + previous_functions.get(key.as_str()).is_some_and(|before| { + before["fingerprint"] != function["fingerprint"] + }) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + + // A source edit that some function already accounts for is not a residual + // change; one that no function explains means something moved that shard + // selection cannot see. + let path_of = |keys: &[String], table: &Value| { + keys.iter() + .filter_map(|key| table[key.as_str()]["path"].as_str().map(str::to_string)) + .collect::>() + }; + let mut function_changed_paths = path_of(&changed_functions, input.functions); + function_changed_paths.extend(path_of(&added_functions, input.functions)); + function_changed_paths.extend(path_of(&deleted_functions, previous_functions)); + let residual_source_changes = sorted( + changed_files + .iter() + .chain(&deleted_files) + .filter(|path| !function_changed_paths.contains(path)) + .cloned() + .collect(), + ); + + let environment_changed = *input.current_environment != *table(manifest, "environment"); + let command_changed = + previous_workload["command_digest"] != input.workload["command_digest"]; + let mode_changed = previous_workload["mode"] != input.workload["mode"]; + let trace_plan_changed = + manifest["trace_plan_digest"].as_str().unwrap_or_default() != input.trace_plan_digest; + // A source edit necessarily changes FactMine's anchor digests, and function + // and test selection plus evidence rebasing already cover that. A plan that + // changed with identical sources is different: the analyzer's demand moved + // and no source dependency says which shards it touched. + let unexplained_trace_plan_changed = + trace_plan_changed && changed_files.is_empty() && deleted_files.is_empty(); + + let current_shards = table(input.workload, "shards"); + let deleted_shards = sorted(missing(table(previous_workload, "shards"), current_shards)); + + let mut selected: Vec = changed_tests + .iter() + .filter_map(|path| { + current_shards.as_object().into_iter().flatten().find_map(|(id, shard)| { + (shard["test_path"].as_str() == Some(path.as_str())).then(|| id.clone()) + }) + }) + .collect(); + let dependencies = table(manifest, "dependencies"); + for function_key in &changed_functions { + for (shard_id, keys) in dependencies.as_object().into_iter().flatten() { + let depends = keys + .as_array() + .into_iter() + .flatten() + .any(|key| key.as_str() == Some(function_key.as_str())); + if depends { + selected.push(shard_id.clone()); + } + } + } + + let uncertain = environment_changed + || command_changed + || mode_changed + || unexplained_trace_plan_changed + || support_changed + || !added_functions.is_empty() + || !deleted_functions.is_empty() + || !residual_source_changes.is_empty(); + // An opaque workload has no test-to-shard mapping, so any change at all + // means every shard is suspect. + let opaque_fallback = input.workload["mode"].as_str() == Some("opaque") + && (!changed_functions.is_empty() + || !changed_tests.is_empty() + || !deleted_tests.is_empty()); + let fallback_full = uncertain || opaque_fallback; + if fallback_full { + selected = strings(current_shards); + } + let selected = sorted(selected); + + let rebuild = !selected.is_empty() + || !deleted_shards.is_empty() + || !changed_functions.is_empty() + || !added_functions.is_empty() + || !deleted_functions.is_empty() + || fallback_full; + + let mut out = Map::new(); + out.insert("selected_shards".into(), json!(selected)); + out.insert("deleted_shards".into(), json!(deleted_shards)); + out.insert("changed_tests".into(), json!(sorted(changed_tests))); + out.insert("deleted_tests".into(), json!(sorted(deleted_tests))); + out.insert("changed_functions".into(), json!(sorted(changed_functions))); + out.insert("added_functions".into(), json!(sorted(added_functions))); + out.insert("deleted_functions".into(), json!(sorted(deleted_functions))); + out.insert("changed_files".into(), json!(sorted(changed_files))); + out.insert("deleted_files".into(), json!(sorted(deleted_files))); + out.insert("residual_source_changes".into(), json!(residual_source_changes)); + out.insert("support_changed".into(), json!(support_changed)); + out.insert("environment_changed".into(), json!(environment_changed)); + out.insert("command_changed".into(), json!(command_changed)); + out.insert("trace_plan_changed".into(), json!(trace_plan_changed)); + out.insert("unexplained_trace_plan_changed".into(), json!(unexplained_trace_plan_changed)); + out.insert("uncertain_closure".into(), json!(false)); + out.insert("fallback_full".into(), json!(fallback_full)); + out.insert("rebuild".into(), json!(rebuild)); + out.insert("current_hashes".into(), input.current_hashes.clone()); + out.insert("environment".into(), input.current_environment.clone()); + out.insert("functions".into(), input.functions.clone()); + out.insert("workload".into(), input.workload.clone()); + out.insert("trace_plan_digest".into(), json!(input.trace_plan_digest)); + Value::Object(out) +} + + +// ------------------------------------------------------------- the manifest +// +// What a collect has to remember so the next one can be incremental: the +// fingerprints it decided from, the workload it ran, and which functions and +// callsites each shard reached. + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +pub const MANIFEST: &str = "runtime-snapshot.json.gz"; +pub const SCHEMA: &str = "nil-kill.runtime-snapshot.v1"; + +fn manifest_path(runtime_dir: &Path) -> PathBuf { + runtime_dir.join(MANIFEST) +} + +/// The stored manifest, or why it cannot be used. A snapshot written under a +/// different fingerprint scheme is not stale, it is unreadable: its digests +/// mean something else, and comparing them would skip shards that changed. +pub fn load(runtime_dir: &Path) -> Result { + let path = manifest_path(runtime_dir); + if !path.is_file() { + bail!("no runtime snapshot at {}; run a full collect first", path.display()); + } + let manifest: Value = serde_json::from_str(&read_gz(&path)?) + .with_context(|| format!("{} is not readable", path.display()))?; + if manifest["schema"] != json!(SCHEMA) + || manifest["fingerprint_scheme"] != json!(crate::source_fingerprint::SCHEME) + { + bail!("runtime snapshot fingerprint contract is unsupported; run a full collect"); + } + Ok(manifest) +} + +fn read_gz(path: &Path) -> Result { + use std::io::Read; + let bytes = std::fs::read(path)?; + let mut text = String::new(); + if bytes.starts_with(&[0x1f, 0x8b]) { + flate2::read::GzDecoder::new(&bytes[..]).read_to_string(&mut text)?; + } else { + text = String::from_utf8(bytes)?; + } + Ok(text) +} + +fn write_gz(path: &Path, text: &str) -> Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + let file = std::fs::File::create(&temporary)?; + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + encoder.write_all(text.as_bytes())?; + encoder.finish()?; + std::fs::rename(&temporary, path)?; + Ok(()) +} + +pub fn relative(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .map(|rest| rest.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()) +} + +/// What each source file says, keyed by its path relative to the root. +pub fn source_hashes(files: &[PathBuf], root: &Path) -> Value { + let mut hashes = Map::new(); + let mut sorted_files = files.to_vec(); + sorted_files.sort(); + for path in sorted_files { + if !path.is_file() { + continue; + } + if let Some(fingerprint) = crate::source_fingerprint::of_file(&path) { + hashes.insert(relative(&path, root), json!(fingerprint)); + } + } + Value::Object(hashes) +} + +/// The runtime the evidence was collected against. +/// +/// Ruby answered this with its own `RUBY_VERSION`, which needed a Ruby to ask. +/// The interpreter the workload actually runs is on disk, so digesting it says +/// the same thing without one -- and says it more exactly: a rebuilt patch +/// release is a different binary, and re-collecting is the conservative answer. +pub fn environment(root: &Path, commands: &[Vec]) -> Value { + use sha2::{Digest, Sha256}; + let mut claims = Map::new(); + claims.insert("runtime.language".into(), json!("ruby")); + if let Some(interpreter) = commands.iter().find_map(|command| interpreter_of(command)) { + if let Ok(bytes) = std::fs::read(&interpreter) { + claims.insert( + "runtime.interpreter.sha256".into(), + json!(format!("sha256:{:x}", Sha256::digest(&bytes))), + ); + } + } + if let Ok(bytes) = std::fs::read(root.join("Gemfile.lock")) { + claims.insert( + "runtime.lockfile.Gemfile.lock.sha256".into(), + json!(format!("sha256:{:x}", Sha256::digest(&bytes))), + ); + } + Value::Object(claims) +} + +/// The interpreter a command runs under, resolved the way the shell would. +fn interpreter_of(command: &[String]) -> Option { + let name = command.iter().find(|part| { + let base = Path::new(part).file_name().map(|n| n.to_string_lossy().to_string()); + base.is_some_and(|base| base == "ruby" || base.starts_with("ruby")) + })?; + let path = Path::new(name); + if path.is_absolute() { + return path.is_file().then(|| path.to_path_buf()); + } + std::env::var("PATH").ok()?.split(':').map(|dir| Path::new(dir).join(path)).find(|candidate| candidate.is_file()) +} + +fn identity(parts: &[&Value]) -> String { + use sha2::{Digest, Sha256}; + let joined = parts + .iter() + .map(|part| serde_json::to_string(part).unwrap_or_default()) + .collect::>() + .join("\u{0}"); + format!("sha256:{:x}", Sha256::digest(joined.as_bytes())) +} + +pub struct Written<'a> { + pub runtime_dir: &'a Path, + pub root: &'a Path, + pub evidence: &'a Path, + pub dependencies: Value, + pub callsites: Value, +} + +pub fn write_full(into: &Written<'_>, selection: &Value, created_at: &str) -> Result { + let hashes = selection["current_hashes"].clone(); + let environment = selection["environment"].clone(); + let workload_digest = selection["workload"]["command_digest"].clone(); + let evidence_digest = { + use sha2::{Digest, Sha256}; + json!(format!("{:x}", Sha256::digest(std::fs::read(into.evidence)?))) + }; + let snapshot_id = identity(&[ + &json!("full"), + &hashes, + &environment, + &workload_digest, + &evidence_digest, + ]); + let mut changed_paths = hashes + .as_object() + .into_iter() + .flatten() + .map(|(key, _)| key.clone()) + .collect::>(); + changed_paths.sort(); + let manifest = json!({ + "schema": SCHEMA, + "fingerprint_scheme": crate::source_fingerprint::SCHEME, + "snapshot_id": snapshot_id, + "base_full_snapshot_id": snapshot_id, + "parent_snapshot_id": Value::Null, + "generation": 0, + "mode": "full", + "complete": true, + "potentially_stale": false, + "source_hashes": hashes, + "environment": environment, + "workload_digest": workload_digest, + "trace_plan_digest": selection["trace_plan_digest"], + "functions": selection["functions"], + "workload": selection["workload"], + "dependencies": into.dependencies, + "callsites": into.callsites, + "changed_paths": changed_paths, + "deleted_paths": [], + "created_at": created_at, + "evidence": relative(into.evidence, into.root), + }); + write_manifest(into.runtime_dir, &manifest)?; + Ok(manifest) +} + +pub fn write_incremental( + into: &Written<'_>, + previous: &Value, + selection: &Value, + created_at: &str, +) -> Result { + let parent = previous["snapshot_id"].clone(); + let generation = previous["generation"].as_i64().unwrap_or_default() + 1; + let snapshot_id = identity(&[ + &json!("fast"), + &selection["current_hashes"], + &selection["functions"], + &selection["workload"], + &parent, + &json!(generation), + ]); + let uncertain = selection["uncertain_closure"].as_bool().unwrap_or(false); + let manifest = json!({ + "schema": SCHEMA, + "fingerprint_scheme": crate::source_fingerprint::SCHEME, + "snapshot_id": snapshot_id, + "base_full_snapshot_id": previous["base_full_snapshot_id"], + "parent_snapshot_id": parent, + "generation": generation, + "mode": "fast", + "complete": !uncertain, + "potentially_stale": uncertain, + "source_hashes": selection["current_hashes"], + "environment": selection["environment"], + "workload_digest": selection["workload"]["command_digest"], + "trace_plan_digest": selection["trace_plan_digest"], + "functions": selection["functions"], + "workload": selection["workload"], + "dependencies": into.dependencies, + "callsites": into.callsites, + "changed_functions": selection["changed_functions"], + "added_functions": selection["added_functions"], + "deleted_functions": selection["deleted_functions"], + "changed_tests": selection["changed_tests"], + "deleted_tests": selection["deleted_tests"], + "changed_files": selection["changed_files"], + "deleted_files": selection["deleted_files"], + "residual_source_changes": selection["residual_source_changes"], + "selected_shards": selection["selected_shards"], + "fallback_full": selection["fallback_full"], + "support_changed": selection["support_changed"], + "created_at": created_at, + "evidence": relative(into.evidence, into.root), + }); + write_manifest(into.runtime_dir, &manifest)?; + Ok(manifest) +} + +/// A collect that could not finish leaves the previous evidence in place and +/// says so on the manifest, so the next reader knows it is looking at evidence +/// older than the source beside it. +pub fn mark_stale( + runtime_dir: &Path, + previous: &Value, + reason: &str, + selection: &Value, + stale_at: &str, +) -> Result<()> { + let mut manifest = previous.clone(); + let entries = manifest.as_object_mut().context("manifest is not an object")?; + entries.insert("complete".into(), json!(false)); + entries.insert("potentially_stale".into(), json!(true)); + entries.insert("stale_reason".into(), json!(reason)); + entries.insert( + "attempted_changed_functions".into(), + selection["changed_functions"].clone(), + ); + entries.insert("attempted_changed_tests".into(), selection["changed_tests"].clone()); + entries.insert("attempted_selected_shards".into(), selection["selected_shards"].clone()); + entries.insert("stale_at".into(), json!(stale_at)); + write_manifest(runtime_dir, &manifest) +} + +fn write_manifest(runtime_dir: &Path, manifest: &Value) -> Result<()> { + write_gz( + &manifest_path(runtime_dir), + &(serde_json::to_string_pretty(manifest)? + "\n"), + ) +} + +/// What a full collect claims, in the shape `select` would have produced: every +/// shard runs, every function is new, and nothing was carried over. +pub fn full_selection( + files: &[PathBuf], + root: &Path, + functions: &Value, + workload: &Value, + trace_plan_digest: &str, + commands: &[Vec], +) -> Value { + json!({ + "selected_shards": strings(&workload["shards"]), + "deleted_shards": [], + "changed_functions": strings(functions), + "added_functions": [], + "deleted_functions": [], + "changed_tests": strings(&workload["tests"]), + "deleted_tests": [], + "changed_files": [], + "deleted_files": [], + "residual_source_changes": [], + "support_changed": false, + "environment_changed": false, + "command_changed": false, + "trace_plan_changed": false, + "unexplained_trace_plan_changed": false, + "uncertain_closure": false, + "fallback_full": true, + "rebuild": true, + "current_hashes": source_hashes(files, root), + "environment": environment(root, commands), + "functions": functions, + "workload": workload, + "trace_plan_digest": trace_plan_digest, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn function(path: &str, fingerprint: &str) -> Value { + json!({"path": path, "fingerprint": fingerprint}) + } + + fn manifest(digest: &str) -> Value { + json!({ + "source_hashes": {"lib/app.rb": "a"}, + "functions": {"f": function("lib/app.rb", "1")}, + "dependencies": {"shard-a": ["f"]}, + "environment": {}, + "trace_plan_digest": digest, + "workload": { + "mode": "test_files", + "command_digest": "w", + "tests": {"test/app_test.rb": "t"}, + "support_files": {}, + "shards": {"shard-a": {"test_path": "test/app_test.rb"}}, + }, + }) + } + + fn select_with(manifest: &Value, hashes: Value, functions: Value, digest: &str) -> Value { + select(&Increment { + manifest, + current_hashes: &hashes, + current_environment: &json!({}), + functions: &functions, + workload: &manifest["workload"], + trace_plan_digest: digest, + }) + } + + #[test] + fn nothing_changed_means_nothing_to_rerun() { + let stored = manifest("plan"); + let out = select_with( + &stored, + json!({"lib/app.rb": "a"}), + json!({"f": function("lib/app.rb", "1")}), + "plan", + ); + + assert_eq!(out["rebuild"], json!(false)); + assert_eq!(out["selected_shards"], json!([])); + assert_eq!(out["fallback_full"], json!(false)); + } + + #[test] + fn a_changed_function_reruns_only_the_shards_that_depended_on_it() { + let mut stored = manifest("plan"); + stored["workload"]["shards"]["shard-b"] = json!({"test_path": "test/other_test.rb"}); + stored["dependencies"]["shard-b"] = json!(["other"]); + + let out = select_with( + &stored, + json!({"lib/app.rb": "b"}), + json!({"f": function("lib/app.rb", "2")}), + "plan", + ); + + // The source edit is explained by the function whose fingerprint moved, + // so it is not a residual change and does not force a full collect. + assert_eq!(out["changed_functions"], json!(["f"])); + assert_eq!(out["residual_source_changes"], json!([])); + assert_eq!(out["fallback_full"], json!(false)); + assert_eq!(out["selected_shards"], json!(["shard-a"])); + } + + #[test] + fn a_plan_that_moved_with_no_source_change_reruns_everything() { + // The analyzer's demand moved and no source dependency says which + // shards it touched, so none of them can be trusted. + let stored = manifest("plan"); + let out = select_with( + &stored, + json!({"lib/app.rb": "a"}), + json!({"f": function("lib/app.rb", "1")}), + "different-plan", + ); + + assert_eq!(out["trace_plan_changed"], json!(true)); + assert_eq!(out["unexplained_trace_plan_changed"], json!(true)); + assert_eq!(out["fallback_full"], json!(true)); + assert_eq!(out["selected_shards"], json!(["shard-a"])); + } + + #[test] + fn a_plan_change_a_source_edit_explains_is_not_a_full_retrace() { + let stored = manifest("plan"); + let out = select_with( + &stored, + json!({"lib/app.rb": "b"}), + json!({"f": function("lib/app.rb", "2")}), + "source-updated-plan", + ); + + assert_eq!(out["trace_plan_changed"], json!(true)); + assert_eq!(out["unexplained_trace_plan_changed"], json!(false)); + assert_eq!(out["fallback_full"], json!(false)); + } + + #[test] + fn a_source_edit_no_function_explains_forces_a_full_collect() { + // Something moved that shard selection cannot attribute -- a constant, + // a require, top-level code -- so every shard is suspect. + let stored = manifest("plan"); + let out = select_with( + &stored, + json!({"lib/app.rb": "b"}), + json!({"f": function("lib/app.rb", "1")}), + "plan", + ); + + assert_eq!(out["residual_source_changes"], json!(["lib/app.rb"])); + assert_eq!(out["fallback_full"], json!(true)); + assert_eq!(out["selected_shards"], json!(["shard-a"])); + } + + #[test] + fn a_changed_test_reruns_its_own_shard() { + let stored = manifest("plan"); + let mut workload = stored["workload"].clone(); + workload["tests"]["test/app_test.rb"] = json!("t2"); + let out = select(&Increment { + manifest: &stored, + current_hashes: &json!({"lib/app.rb": "a"}), + current_environment: &json!({}), + functions: &json!({"f": function("lib/app.rb", "1")}), + workload: &workload, + trace_plan_digest: "plan", + }); + + assert_eq!(out["changed_tests"], json!(["test/app_test.rb"])); + assert_eq!(out["selected_shards"], json!(["shard-a"])); + assert_eq!(out["fallback_full"], json!(false)); + } + + #[test] + fn a_changed_support_file_reruns_every_shard() { + let stored = manifest("plan"); + let mut workload = stored["workload"].clone(); + workload["support_files"] = json!({"test/test_helper.rb": "h"}); + let out = select(&Increment { + manifest: &stored, + current_hashes: &json!({"lib/app.rb": "a"}), + current_environment: &json!({}), + functions: &json!({"f": function("lib/app.rb", "1")}), + workload: &workload, + trace_plan_digest: "plan", + }); + + assert_eq!(out["support_changed"], json!(true)); + assert_eq!(out["fallback_full"], json!(true)); + } + + #[test] + fn a_deleted_test_drops_its_shard_without_rerunning_it() { + let stored = manifest("plan"); + let mut workload = stored["workload"].clone(); + workload["tests"] = json!({}); + workload["shards"] = json!({}); + let out = select(&Increment { + manifest: &stored, + current_hashes: &json!({"lib/app.rb": "a"}), + current_environment: &json!({}), + functions: &json!({"f": function("lib/app.rb", "1")}), + workload: &workload, + trace_plan_digest: "plan", + }); + + assert_eq!(out["deleted_tests"], json!(["test/app_test.rb"])); + assert_eq!(out["deleted_shards"], json!(["shard-a"])); + assert_eq!(out["selected_shards"], json!([])); + assert_eq!(out["rebuild"], json!(true)); + } + + #[test] + fn an_opaque_workload_reruns_everything_any_change_touches() { + // No test-to-shard mapping exists, so nothing says a change missed a + // given command. + let mut stored = manifest("plan"); + stored["workload"]["mode"] = json!("opaque"); + let out = select_with( + &stored, + json!({"lib/app.rb": "b"}), + json!({"f": function("lib/app.rb", "2")}), + "plan", + ); + + assert_eq!(out["fallback_full"], json!(true)); + assert_eq!(out["selected_shards"], json!(["shard-a"])); + } +} diff --git a/gems/fact-mine/src/sorbet_sig.rs b/gems/fact-mine/src/sorbet_sig.rs new file mode 100644 index 000000000..56e079ae7 --- /dev/null +++ b/gems/fact-mine/src/sorbet_sig.rs @@ -0,0 +1,194 @@ +//! Reading a Sorbet signature well enough to decide whether the runtime needs +//! to watch a value. +//! +//! The collector traces what the types do not already pin down. A parameter +//! annotated `String` needs no runtime sample; one annotated `T.untyped`, or +//! `T::Array[T.untyped]`, tells you nothing and has to be observed. That +//! judgement drives the trace plan, so it decides how much work every traced +//! process does. +//! +//! Ported from nil-kill's `util.rb`, whose behaviour these tests pin. The +//! scanning is bracket-depth counting rather than parsing: a signature is an +//! ordinary Ruby method call, and the only structure that matters is where the +//! top-level commas fall. + +/// The argument text of `name(...)` within `source`, respecting nesting. +/// +/// Returns `None` when the call is absent or its parentheses never close, so a +/// truncated signature reads as "no annotation" rather than as an empty one. +pub fn call_args<'a>(source: &'a str, name: &str) -> Option<&'a str> { + let needle = format!("{name}("); + let idx = source.find(&needle)?; + let start = idx + needle.len(); + let mut depth = 1usize; + for (offset, ch) in source[start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(&source[start..start + offset]); + } + } + _ => {} + } + } + None +} + +/// Split on commas that are not inside brackets, dropping empties. +pub fn split_top_level(source: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (idx, ch) in source.char_indices() { + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(source[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + parts.push(source[start..].trim()); + parts.into_iter().filter(|part| !part.is_empty()).collect() +} + +/// Each `name: Type` pair declared in the signature's `params(...)`. +pub fn param_entries(sig: &str) -> Vec<(String, String)> { + let Some(params) = call_args(sig, "params") else { + return Vec::new(); + }; + split_top_level(params) + .into_iter() + .filter_map(|entry| { + // `name: Type` -- the first colon separates them, and the type may + // itself contain colons (`T::Array[...]`). + let colon = entry.find(':')?; + let name = entry[..colon].trim(); + let type_text = entry[colon + 1..].trim(); + if name.is_empty() || type_text.is_empty() { + return None; + } + Some((name.to_string(), type_text.to_string())) + }) + .collect() +} + +pub fn return_type(sig: &str) -> Option<&str> { + call_args(sig, "returns") +} + +pub fn useful_type(type_text: &str) -> bool { + !type_text.is_empty() && type_text != "T.untyped" +} + +/// A type that names a container but leaves its contents untyped tells the +/// runtime nothing about what flows through it. +pub fn weak_type(type_text: &str) -> bool { + if type_text.contains("T.untyped") { + // Only the parametric-container form is weak by shape; a bare + // `T.untyped` anywhere is weak outright. + if !type_text.starts_with("T::") { + return true; + } + } + for container in ["Array", "Hash", "Enumerable", "Set"] { + let prefix = format!("T::{container}"); + if type_text.starts_with(&prefix) { + let rest = &type_text[prefix.len()..]; + // `\b` in the original: the container name must end here. + if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') { + continue; + } + if let Some(open) = rest.find('[') { + if rest[open..].starts_with("[T.untyped") { + return true; + } + } + } + } + type_text.contains("T.untyped") +} + +/// Whether the static type is specific enough that the runtime need not sample. +pub fn strong_trace_type(type_text: &str) -> bool { + useful_type(type_text) && !weak_type(type_text) +} + +#[cfg(test)] +#[path = "sorbet_sig_parity.rs"] +mod parity; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arguments_are_read_to_the_matching_paren_not_the_first() { + let sig = "sig { params(a: T::Hash[String, Integer], b: String).returns(T.nilable(Foo)) }"; + assert_eq!( + call_args(sig, "params"), + Some("a: T::Hash[String, Integer], b: String") + ); + assert_eq!(call_args(sig, "returns"), Some("T.nilable(Foo)")); + } + + #[test] + fn an_unclosed_call_reads_as_no_annotation() { + assert_eq!(call_args("params(a: String", "params"), None); + assert_eq!(call_args("sig {}", "params"), None); + } + + #[test] + fn splitting_ignores_commas_inside_brackets() { + assert_eq!( + split_top_level("a: T::Hash[String, Integer], b: T.any(A, B), c: Int"), + vec!["a: T::Hash[String, Integer]", "b: T.any(A, B)", "c: Int"] + ); + assert_eq!(split_top_level(""), Vec::<&str>::new()); + assert_eq!(split_top_level("a, , b"), vec!["a", "b"]); + } + + #[test] + fn parameters_keep_the_colons_inside_their_types() { + let sig = "sig { params(rows: T::Array[T::Hash[Symbol, String]], n: Integer).void }"; + assert_eq!( + param_entries(sig), + vec![ + ("rows".to_string(), "T::Array[T::Hash[Symbol, String]]".to_string()), + ("n".to_string(), "Integer".to_string()), + ] + ); + } + + #[test] + fn a_signature_without_params_yields_none() { + assert!(param_entries("sig { returns(String) }").is_empty()); + assert!(param_entries("").is_empty()); + } + + #[test] + fn an_untyped_annotation_is_never_worth_trusting() { + assert!(!strong_trace_type("T.untyped")); + assert!(!strong_trace_type("")); + assert!(!strong_trace_type("T.nilable(T.untyped)")); + } + + #[test] + fn a_container_of_untyped_is_weak_but_a_typed_one_is_not() { + assert!(weak_type("T::Array[T.untyped]")); + assert!(weak_type("T::Hash[T.untyped, String]")); + assert!(!weak_type("T::Array[String]")); + assert!(strong_trace_type("T::Array[String]")); + assert!(strong_trace_type("String")); + } + + #[test] + fn a_class_whose_name_merely_starts_with_a_container_name_is_not_a_container() { + assert!(!weak_type("T::ArrayLike[String]")); + assert!(strong_trace_type("T::SetLike[Foo]")); + } +} diff --git a/gems/fact-mine/src/sorbet_sig_parity.rs b/gems/fact-mine/src/sorbet_sig_parity.rs new file mode 100644 index 000000000..b8cb8ad06 --- /dev/null +++ b/gems/fact-mine/src/sorbet_sig_parity.rs @@ -0,0 +1,286 @@ + +// Verdicts taken from nil-kill's Ruby implementation over every Sorbet +// signature in this workspace. The port has to agree with the code it +// replaces on real input, not just on cases chosen to illustrate the rules. +use super::*; + + #[test] + fn every_corpus_type_gets_the_same_verdict_as_ruby() { + let cases: &[(&str, bool)] = &[ + ("CapturedOutput", true), + ("CohortContribution", true), + ("CommandLimits", true), + ("CommandResult", true), + ("CommandRunner", true), + ("ContributionAnalysis", true), + ("Corpus", true), + ("CounterfactualProvenance", true), + ("CounterfactualRequest", true), + ("CounterfactualResult", true), + ("Coverage", true), + ("EvidenceCompleteness", true), + ("EvidenceGate", true), + ("EvidenceReport", true), + ("EvidenceScope", true), + ("FieldTypes", true), + ("FindingKind", true), + ("Float", true), + ("IO", true), + ("Integer", true), + ("MatrixCheck", true), + ("OracleExecutionRequest", true), + ("OracleExecutionResult", true), + ("OracleFact", true), + ("OracleFacts", true), + ("OracleMutationKind", true), + ("OracleMutationPlan", true), + ("OracleRewriteAdapter", true), + ("OracleSensitivity", true), + ("OracleSensitivityAnalysis", true), + ("Regexp", true), + ("ReviewFindingKind", true), + ("SourceSpan", true), + ("StabilityAnalysis", true), + ("String", true), + ("SubsumptionAnalysis", true), + ("T.nilable(CohortEvidenceVector)", true), + ("T.nilable(CommandResult)", true), + ("T.nilable(ContributionAnalysis)", true), + ("T.nilable(CounterfactualProvenance)", true), + ("T.nilable(CounterfactualResult)", true), + ("T.nilable(EvidenceScope)", true), + ("T.nilable(IO)", true), + ("T.nilable(Integer)", true), + ("T.nilable(OracleKind)", true), + ("T.nilable(OracleMutationPlan)", true), + ("T.nilable(OracleRewrite)", true), + ("T.nilable(OracleSensitivityAnalysis)", true), + ("T.nilable(Process::Status)", true), + ("T.nilable(SourceSpan)", true), + ("T.nilable(StabilityAnalysis)", true), + ("T.nilable(String)", true), + ("T.nilable(T::Array[Integer])", true), + ("T.nilable(T::Array[String])", true), + ("T.nilable(T::Boolean)", true), + ("T.nilable(Thread)", true), + ("T.nilable([Integer, Integer])", true), + ("T.proc.params(archived_source_path: String, resolved_revision: String).returns(T.untyped)", false), + ("T.proc.params(node: T.untyped).void", false), + ("T.untyped", false), + ("T::Array[EquivalentMutantGroup]", true), + ("T::Array[EvidenceVector]", true), + ("T::Array[FindingKind]", true), + ("T::Array[FrontierRanking]", true), + ("T::Array[IndexedKillSet]", true), + ("T::Array[Integer]", true), + ("T::Array[KillTrial]", true), + ("T::Array[OracleFact]", true), + ("T::Array[OracleMutationPlan]", true), + ("T::Array[OracleRewrite]", true), + ("T::Array[OracleTrial]", true), + ("T::Array[RerunCandidate]", true), + ("T::Array[ReviewFinding]", true), + ("T::Array[String]", true), + ("T::Array[SubsumptionRelation]", true), + ("T::Array[T.untyped]", false), + ("T::Array[T::Hash[String, String]]", true), + ("T::Array[T::Hash[String, T.untyped]]", false), + ("T::Array[TestContribution]", true), + ("T::Boolean", true), + ("T::Hash[String, Float]", true), + ("T::Hash[String, Integer]", true), + ("T::Hash[String, String]", true), + ("T::Hash[String, T.untyped]", false), + ("T::Hash[String, T::Array[String]]", true), + ("T::Hash[String, T::Array[T::Boolean]]", true), + ("T::Hash[String, T::Set[String]]", true), + ("T::Hash[[String, String], T::Array[T::Boolean]]", true), + ("T::Set[String]", true), + ("TYPE", true), + ("TestContribution", true), + ("TestObservation", true), + ("TestOutcome", true), + ("TestResultParser", true), + ("Thread", true), + ("[String, OracleRewrite]", true), + ("[String, String]", true), + ("[T::Boolean, T.nilable(String)]", true), + ("[T::Hash[[String, String], T::Array[T::Boolean]], T::Boolean]", true), + ]; + let mut disagreed = Vec::new(); + for (type_text, expected) in cases { + if strong_trace_type(type_text) != *expected { + disagreed.push(*type_text); + } + } + assert!(disagreed.is_empty(), "disagreed with Ruby on: {disagreed:?}"); + } + + #[test] + fn every_corpus_signature_parses_to_the_same_parameters_as_ruby() { + let cases: &[(&str, &[(&str, &str)], &str)] = &[ + ("sig do\n params(\n fact: OracleFact,\n original: T::Array[String],\n trials: T::Array[OracleTrial],\n rewrite: T.nilable(OracleRewrite),\n original_known: T::Boolean,\n expected_trial_ids: T::Array[String],\n min_trials: Integer,\n control_verified: T::Boolean,\n control_reason: T.nilable(String),\n ).returns(OracleSensitivity)\n end", &[("fact", "OracleFact"), ("original", "T::Array[String]"), ("trials", "T::Array[OracleTrial]"), ("rewrite", "T.nilable(OracleRewrite)"), ("original_known", "T::Boolean"), ("expected_trial_ids", "T::Array[String]"), ("min_trials", "Integer"), ("control_verified", "T::Boolean"), ("control_reason", "T.nilable(String)")], "OracleSensitivity"), + ("sig do\n params(original: T::Array[String], trials: T::Array[OracleTrial], expected_trial_ids: T::Array[String], min_trials: Integer).returns(T.nilable(String))\n end", &[("original", "T::Array[String]"), ("trials", "T::Array[OracleTrial]"), ("expected_trial_ids", "T::Array[String]"), ("min_trials", "Integer")], "T.nilable(String)"), + ("sig do\n abstract.params(\n command: T::Array[String],\n chdir: String,\n limits: CommandLimits,\n ).returns(CommandResult)\n end", &[("command", "T::Array[String]"), ("chdir", "String"), ("limits", "CommandLimits")], "CommandResult"), + ("sig do\n abstract.params(fact: OracleFact, plan: OracleMutationPlan, source: String, language: String).returns([String, OracleRewrite])\n end", &[("fact", "OracleFact"), ("plan", "OracleMutationPlan"), ("source", "String"), ("language", "String")], "[String, OracleRewrite]"), + ("sig do\n abstract.params(test_id: String, source_path: String, language: String).returns(T::Array[OracleFact])\n end", &[("test_id", "String"), ("source_path", "String"), ("language", "String")], "T::Array[OracleFact]"), + ("sig do\n override.params(\n command: T::Array[String],\n chdir: String,\n limits: CommandLimits,\n ).returns(CommandResult)\n end", &[("command", "T::Array[String]"), ("chdir", "String"), ("limits", "CommandLimits")], "CommandResult"), + ("sig do\n override.params(fact: OracleFact, plan: OracleMutationPlan, source: String, language: String).returns([String, OracleRewrite])\n end", &[("fact", "OracleFact"), ("plan", "OracleMutationPlan"), ("source", "String"), ("language", "String")], "[String, OracleRewrite]"), + ("sig do\n override.params(test_id: String, source_path: String, language: String).returns(T::Array[OracleFact])\n end", &[("test_id", "String"), ("source_path", "String"), ("language", "String")], "T::Array[OracleFact]"), + ("sig do\n params(\n contribution: TestContribution,\n all_contributions: T::Array[TestContribution],\n covered_sets: T::Hash[String, T::Set[String]],\n killed_sets: T::Hash[String, T::Set[String]],\n ).returns(T::Array[String])\n end", &[("contribution", "TestContribution"), ("all_contributions", "T::Array[TestContribution]"), ("covered_sets", "T::Hash[String, T::Set[String]]"), ("killed_sets", "T::Hash[String, T::Set[String]]")], "T::Array[String]"), + ("sig do\n params(\n contributions: ContributionAnalysis,\n subsumption: SubsumptionAnalysis,\n counterfactual_test_ids: T::Array[String],\n ).returns(T.nilable(CohortEvidenceVector))\n end", &[("contributions", "ContributionAnalysis"), ("subsumption", "SubsumptionAnalysis"), ("counterfactual_test_ids", "T::Array[String]")], "T.nilable(CohortEvidenceVector)"), + ("sig do\n params(\n contributions: ContributionAnalysis,\n subsumption: SubsumptionAnalysis,\n stability: T.nilable(StabilityAnalysis),\n counterfactual: T.nilable(CounterfactualResult),\n oracle_sensitivity: T.nilable(OracleSensitivityAnalysis),\n counterfactual_test_ids: T::Array[String],\n cohort: T.nilable(CohortEvidenceVector),\n vectors: T::Array[EvidenceVector],\n high_cost_ms: Float,\n gate: EvidenceGate,\n ).returns(T::Array[ReviewFinding])\n end", &[("contributions", "ContributionAnalysis"), ("subsumption", "SubsumptionAnalysis"), ("stability", "T.nilable(StabilityAnalysis)"), ("counterfactual", "T.nilable(CounterfactualResult)"), ("oracle_sensitivity", "T.nilable(OracleSensitivityAnalysis)"), ("counterfactual_test_ids", "T::Array[String]"), ("cohort", "T.nilable(CohortEvidenceVector)"), ("vectors", "T::Array[EvidenceVector]"), ("high_cost_ms", "Float"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig do\n params(\n contributions: ContributionAnalysis,\n subsumption: SubsumptionAnalysis,\n stability: T.nilable(StabilityAnalysis),\n counterfactual: T.nilable(CounterfactualResult),\n oracle_sensitivity: T.nilable(OracleSensitivityAnalysis),\n counterfactual_test_ids: T::Array[String],\n runtimes: T::Hash[String, Float],\n cohort: T.nilable(CohortEvidenceVector),\n gate: EvidenceGate,\n ).returns(T::Array[EvidenceVector])\n end", &[("contributions", "ContributionAnalysis"), ("subsumption", "SubsumptionAnalysis"), ("stability", "T.nilable(StabilityAnalysis)"), ("counterfactual", "T.nilable(CounterfactualResult)"), ("oracle_sensitivity", "T.nilable(OracleSensitivityAnalysis)"), ("counterfactual_test_ids", "T::Array[String]"), ("runtimes", "T::Hash[String, Float]"), ("cohort", "T.nilable(CohortEvidenceVector)"), ("gate", "EvidenceGate")], "T::Array[EvidenceVector]"), + ("sig do\n params(\n contributions: ContributionAnalysis,\n subsumption: SubsumptionAnalysis,\n stability: T.nilable(StabilityAnalysis),\n counterfactual: T.nilable(CounterfactualResult),\n oracle_sensitivity: T.nilable(OracleSensitivityAnalysis),\n counterfactual_test_ids: T::Array[String],\n runtimes: T::Hash[String, Float],\n high_cost_ms: Float,\n cost_comparable: T::Boolean,\n ).returns(EvidenceReport)\n end", &[("contributions", "ContributionAnalysis"), ("subsumption", "SubsumptionAnalysis"), ("stability", "T.nilable(StabilityAnalysis)"), ("counterfactual", "T.nilable(CounterfactualResult)"), ("oracle_sensitivity", "T.nilable(OracleSensitivityAnalysis)"), ("counterfactual_test_ids", "T::Array[String]"), ("runtimes", "T::Hash[String, Float]"), ("high_cost_ms", "Float"), ("cost_comparable", "T::Boolean")], "EvidenceReport"), + ("sig do\n params(\n contributions: ContributionAnalysis,\n subsumption: SubsumptionAnalysis,\n stability: T.nilable(StabilityAnalysis),\n counterfactual: T.nilable(CounterfactualResult),\n oracle_sensitivity: T.nilable(OracleSensitivityAnalysis),\n ).void\n end", &[("contributions", "ContributionAnalysis"), ("subsumption", "SubsumptionAnalysis"), ("stability", "T.nilable(StabilityAnalysis)"), ("counterfactual", "T.nilable(CounterfactualResult)"), ("oracle_sensitivity", "T.nilable(OracleSensitivityAnalysis)")], ""), + ("sig do\n params(\n corpus: Corpus,\n scope: T.nilable(EvidenceScope),\n revision: String,\n repository: T.nilable(String),\n max_distinct_kill_sets: Integer,\n max_relation_checks: Integer,\n ).void\n end", &[("corpus", "Corpus"), ("scope", "T.nilable(EvidenceScope)"), ("revision", "String"), ("repository", "T.nilable(String)"), ("max_distinct_kill_sets", "Integer"), ("max_relation_checks", "Integer")], ""), + ("sig do\n params(\n counterfactual: T.nilable(CounterfactualResult),\n test_ids: T::Array[String],\n cohort: T.nilable(CohortEvidenceVector),\n gate: EvidenceGate,\n ).returns(T::Array[ReviewFinding])\n end", &[("counterfactual", "T.nilable(CounterfactualResult)"), ("test_ids", "T::Array[String]"), ("cohort", "T.nilable(CohortEvidenceVector)"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig do\n params(\n counterfactual: T.nilable(CounterfactualResult),\n test_ids: T::Array[String],\n test_id: String,\n cohort: T.nilable(CohortEvidenceVector),\n ).returns(T.nilable(T::Boolean))\n end", &[("counterfactual", "T.nilable(CounterfactualResult)"), ("test_ids", "T::Array[String]"), ("test_id", "String"), ("cohort", "T.nilable(CohortEvidenceVector)")], "T.nilable(T::Boolean)"), + ("sig do\n params(\n facts: OracleFacts,\n original_kills: T::Hash[String, T::Array[String]],\n disabled_trials: T::Array[OracleTrial],\n rewrites: T::Array[OracleRewrite],\n scope: T.nilable(EvidenceScope),\n trial_ids: T.nilable(T::Array[String]),\n min_trials: Integer,\n execution_results: T::Array[T::Hash[String, T.untyped]],\n ).returns(OracleSensitivityAnalysis)\n end", &[("facts", "OracleFacts"), ("original_kills", "T::Hash[String, T::Array[String]]"), ("disabled_trials", "T::Array[OracleTrial]"), ("rewrites", "T::Array[OracleRewrite]"), ("scope", "T.nilable(EvidenceScope)"), ("trial_ids", "T.nilable(T::Array[String])"), ("min_trials", "Integer"), ("execution_results", "T::Array[T::Hash[String, T.untyped]]")], "OracleSensitivityAnalysis"), + ("sig do\n params(\n frontier: T::Array[String],\n contributions: T.nilable(ContributionAnalysis),\n ).returns(T::Array[FrontierRanking])\n end", &[("frontier", "T::Array[String]"), ("contributions", "T.nilable(ContributionAnalysis)")], "T::Array[FrontierRanking]"), + ("sig do\n params(\n frontier: T::Array[String],\n contributions: T.nilable(ContributionAnalysis),\n ).returns(T::Array[String])\n end", &[("frontier", "T::Array[String]"), ("contributions", "T.nilable(ContributionAnalysis)")], "T::Array[String]"), + ("sig do\n params(\n head: CommandResult,\n reason: String,\n repository_status: CommandResult,\n revision_resolution: CommandResult,\n baseline_head: T.nilable(CommandResult),\n worktree: T.nilable(CommandResult),\n reverse_patch: T.nilable(CommandResult),\n build: T.nilable(CommandResult),\n new_tests: T.nilable(CommandResult),\n baseline_tests: T.nilable(CommandResult),\n scope: T.nilable(EvidenceScope),\n provenance: T.nilable(CounterfactualProvenance),\n ).returns(CounterfactualResult)\n end", &[("head", "CommandResult"), ("reason", "String"), ("repository_status", "CommandResult"), ("revision_resolution", "CommandResult"), ("baseline_head", "T.nilable(CommandResult)"), ("worktree", "T.nilable(CommandResult)"), ("reverse_patch", "T.nilable(CommandResult)"), ("build", "T.nilable(CommandResult)"), ("new_tests", "T.nilable(CommandResult)"), ("baseline_tests", "T.nilable(CommandResult)"), ("scope", "T.nilable(EvidenceScope)"), ("provenance", "T.nilable(CounterfactualProvenance)")], "CounterfactualResult"), + ("sig do\n params(\n kill_sets: T::Array[IndexedKillSet],\n relations: T::Array[SubsumptionRelation],\n ).returns(T::Array[String])\n end", &[("kill_sets", "T::Array[IndexedKillSet]"), ("relations", "T::Array[SubsumptionRelation]")], "T::Array[String]"), + ("sig do\n params(\n new_test_ids: T::Array[String],\n baseline_test_ids: T::Array[String],\n ).returns(ContributionAnalysis)\n end", &[("new_test_ids", "T::Array[String]"), ("baseline_test_ids", "T::Array[String]")], "ContributionAnalysis"), + ("sig do\n params(\n patch_path: String,\n patch_sha256: T.nilable(String),\n resolved_revision: T.nilable(String),\n clean_worktree: T::Boolean,\n worktree_path: T.nilable(String),\n ).returns(CounterfactualProvenance)\n end", &[("patch_path", "String"), ("patch_sha256", "T.nilable(String)"), ("resolved_revision", "T.nilable(String)"), ("clean_worktree", "T::Boolean"), ("worktree_path", "T.nilable(String)")], "CounterfactualProvenance"), + ("sig do\n params(\n repository: String,\n revision: String,\n source_path: String,\n block: T.proc.params(archived_source_path: String, resolved_revision: String).returns(T.untyped),\n ).returns(T.untyped)\n end", &[("repository", "String"), ("revision", "String"), ("source_path", "String"), ("block", "T.proc.params(archived_source_path: String, resolved_revision: String).returns(T.untyped)")], "T.untyped"), + ("sig do\n params(\n revision: String,\n selection_scope: String,\n mutants: T::Array[T::Hash[String, T.untyped]],\n test_ids: T::Array[String],\n repository: T.nilable(String),\n ).returns(EvidenceScope)\n end", &[("revision", "String"), ("selection_scope", "String"), ("mutants", "T::Array[T::Hash[String, T.untyped]]"), ("test_ids", "T::Array[String]"), ("repository", "T.nilable(String)")], "EvidenceScope"), + ("sig do\n params(\n test_id: String,\n observations: T::Hash[[String, String], T::Array[T::Boolean]],\n ).returns(T::Array[String])\n end", &[("test_id", "String"), ("observations", "T::Hash[[String, String], T::Array[T::Boolean]]")], "T::Array[String]"), + ("sig do\n params(\n test_id: String,\n observations: T::Hash[[String, String], T::Array[T::Boolean]],\n ).returns(T::Hash[String, T::Array[T::Boolean]])\n end", &[("test_id", "String"), ("observations", "T::Hash[[String, String], T::Array[T::Boolean]]")], "T::Hash[String, T::Array[T::Boolean]]"), + ("sig do\n params(\n test_ids: T::Array[String],\n mutant_ids: T::Array[String],\n trial_ids: T::Array[Integer],\n observations: T::Hash[[String, String], T::Array[T::Boolean]],\n duplicate_observations: T::Boolean,\n ).returns(MatrixCheck)\n end", &[("test_ids", "T::Array[String]"), ("mutant_ids", "T::Array[String]"), ("trial_ids", "T::Array[Integer]"), ("observations", "T::Hash[[String, String], T::Array[T::Boolean]]"), ("duplicate_observations", "T::Boolean")], "MatrixCheck"), + ("sig do\n params(\n trials: T::Array[KillTrial],\n test_ids: T.nilable(T::Array[String]),\n mutant_ids: T.nilable(T::Array[String]),\n trial_ids: T.nilable(T::Array[Integer]),\n ).returns(StabilityAnalysis)\n end", &[("trials", "T::Array[KillTrial]"), ("test_ids", "T.nilable(T::Array[String])"), ("mutant_ids", "T.nilable(T::Array[String])"), ("trial_ids", "T.nilable(T::Array[Integer])")], "StabilityAnalysis"), + ("sig do\n params(\n trials: T::Array[KillTrial],\n test_ids: T::Array[String],\n mutant_ids: T::Array[String],\n trial_ids: T::Array[Integer],\n ).returns([T::Hash[[String, String], T::Array[T::Boolean]], T::Boolean])\n end", &[("trials", "T::Array[KillTrial]"), ("test_ids", "T::Array[String]"), ("mutant_ids", "T::Array[String]"), ("trial_ids", "T::Array[Integer]")], "[T::Hash[[String, String], T::Array[T::Boolean]], T::Boolean]"), + ("sig do\n params(\n vectors: T::Array[EvidenceVector],\n threshold: Float,\n gate: EvidenceGate,\n ).returns(T::Array[ReviewFinding])\n end", &[("vectors", "T::Array[EvidenceVector]"), ("threshold", "Float"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig do\n params(revision: String, selection_scope: String, repository: T.nilable(String)).returns(EvidenceScope)\n end", &[("revision", "String"), ("selection_scope", "String"), ("repository", "T.nilable(String)")], "EvidenceScope"), + ("sig do\n params(stability: T.nilable(StabilityAnalysis), gate: EvidenceGate).returns(T::Array[ReviewFinding])\n end", &[("stability", "T.nilable(StabilityAnalysis)"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig do\")\n end", &[], ""), + ("sig { ... .void }", &[], ""), + ("sig { abstract.params(result: CommandResult).returns(TestOutcome) }", &[("result", "CommandResult")], "TestOutcome"), + ("sig { override.params(result: CommandResult).returns(TestOutcome) }", &[("result", "CommandResult")], "TestOutcome"), + ("sig { params(analysis: ContributionAnalysis).returns(T::Array[RerunCandidate]) }", &[("analysis", "ContributionAnalysis")], "T::Array[RerunCandidate]"), + ("sig { params(artifact: OracleFacts).void }", &[("artifact", "OracleFacts")], ""), + ("sig { params(binary: String, runner: T.untyped, framework: T.nilable(String), source_identity: T.nilable(String)).void }", &[("binary", "String"), ("runner", "T.untyped"), ("framework", "T.nilable(String)"), ("source_identity", "T.nilable(String)")], ""), + ("sig { params(call: T.untyped, calls: T::Array[T.untyped], framework: String).returns(SourceSpan) }", &[("call", "T.untyped"), ("calls", "T::Array[T.untyped]"), ("framework", "String")], "SourceSpan"), + ("sig { params(call: T.untyped, test_id: String, source: String, framework: String).returns(T::Boolean) }", &[("call", "T.untyped"), ("test_id", "String"), ("source", "String"), ("framework", "String")], "T::Boolean"), + ("sig { params(command: T::Array[String], chdir: String).returns(CommandResult) }", &[("command", "T::Array[String]"), ("chdir", "String")], "CommandResult"), + ("sig { params(command_runner: CommandRunner, adapter: OracleRewriteAdapter, parser: TestResultParser).void }", &[("command_runner", "CommandRunner"), ("adapter", "OracleRewriteAdapter"), ("parser", "TestResultParser")], ""), + ("sig { params(complete: T.nilable(T::Boolean)).returns(T.nilable(String)) }", &[("complete", "T.nilable(T::Boolean)")], "T.nilable(String)"), + ("sig { params(contribution: TestContribution, gate: EvidenceGate).returns(T::Array[ReviewFinding]) }", &[("contribution", "TestContribution"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig { params(contributions: ContributionAnalysis, cohort_vector: T.nilable(CohortEvidenceVector), gate: EvidenceGate).returns(T::Array[ReviewFinding]) }", &[("contributions", "ContributionAnalysis"), ("cohort_vector", "T.nilable(CohortEvidenceVector)"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig { params(contributions: ContributionAnalysis, cost_comparable: T::Boolean).returns(EvidenceGate) }", &[("contributions", "ContributionAnalysis"), ("cost_comparable", "T::Boolean")], "EvidenceGate"), + ("sig { params(contributions: ContributionAnalysis, gate: EvidenceGate).returns(T::Array[ReviewFinding]) }", &[("contributions", "ContributionAnalysis"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig { params(contributions: T.nilable(ContributionAnalysis)).returns(SubsumptionAnalysis) }", &[("contributions", "T.nilable(ContributionAnalysis)")], "SubsumptionAnalysis"), + ("sig { params(contributions: T.nilable(ContributionAnalysis), reason: String).returns(SubsumptionAnalysis) }", &[("contributions", "T.nilable(ContributionAnalysis)"), ("reason", "String")], "SubsumptionAnalysis"), + ("sig { params(corpus: Corpus, scope: T.nilable(EvidenceScope), revision: String, repository: T.nilable(String)).void }", &[("corpus", "Corpus"), ("scope", "T.nilable(EvidenceScope)"), ("revision", "String"), ("repository", "T.nilable(String)")], ""), + ("sig { params(corpus: Corpus, threshold: Integer, scope: T.nilable(EvidenceScope), revision: String, repository: T.nilable(String)).void }", &[("corpus", "Corpus"), ("threshold", "Integer"), ("scope", "T.nilable(EvidenceScope)"), ("revision", "String"), ("repository", "T.nilable(String)")], ""), + ("sig { params(counterfactual: T.nilable(CounterfactualResult)).returns(T::Boolean) }", &[("counterfactual", "T.nilable(CounterfactualResult)")], "T::Boolean"), + ("sig { params(fact: OracleFact).returns(T.nilable(OracleMutationPlan)) }", &[("fact", "OracleFact")], "T.nilable(OracleMutationPlan)"), + ("sig { params(fact: OracleFact).returns(T::Array[OracleMutationPlan]) }", &[("fact", "OracleFact")], "T::Array[OracleMutationPlan]"), + ("sig { params(facts: OracleFacts, scope: T.nilable(EvidenceScope)).void }", &[("facts", "OracleFacts"), ("scope", "T.nilable(EvidenceScope)")], ""), + ("sig { params(finding: FindingKind).returns(T::Boolean) }", &[("finding", "FindingKind")], "T::Boolean"), + ("sig { params(framework: String, call: T.untyped).returns(T.nilable(OracleKind)) }", &[("framework", "String"), ("call", "T.untyped")], "T.nilable(OracleKind)"), + ("sig { params(framework: String, call: T.untyped).returns(T::Boolean) }", &[("framework", "String"), ("call", "T.untyped")], "T::Boolean"), + ("sig { params(grammar_paths: T::Hash[String, String], framework: T.nilable(String), tree_sitter: T.untyped, source_identity: T.nilable(String)).void }", &[("grammar_paths", "T::Hash[String, String]"), ("framework", "T.nilable(String)"), ("tree_sitter", "T.untyped"), ("source_identity", "T.nilable(String)")], ""), + ("sig { params(input_completeness: String, blocker_kinds: T::Array[String]).returns(Coverage) }", &[("input_completeness", "String"), ("blocker_kinds", "T::Array[String]")], "Coverage"), + ("sig { params(input_completeness: T.nilable(String), blocker_kinds: T::Array[String]).returns(Coverage) }", &[("input_completeness", "T.nilable(String)"), ("blocker_kinds", "T::Array[String]")], "Coverage"), + ("sig { params(io: IO, limit: Integer).returns(CapturedOutput) }", &[("io", "IO"), ("limit", "Integer")], "CapturedOutput"), + ("sig { params(io: T.nilable(IO)).void }", &[("io", "T.nilable(IO)")], ""), + ("sig { params(kill_sets: T::Array[IndexedKillSet]).returns(T::Array[EquivalentMutantGroup]) }", &[("kill_sets", "T::Array[IndexedKillSet]")], "T::Array[EquivalentMutantGroup]"), + ("sig { params(kill_sets: T::Array[IndexedKillSet]).returns(T::Array[SubsumptionRelation]) }", &[("kill_sets", "T::Array[IndexedKillSet]")], "T::Array[SubsumptionRelation]"), + ("sig { params(kind: ReviewFindingKind).returns(String) }", &[("kind", "ReviewFindingKind")], "String"), + ("sig { params(left: Integer, right: Integer).returns(T::Boolean) }", &[("left", "Integer"), ("right", "Integer")], "T::Boolean"), + ("sig { params(left: T::Set[String], right: T::Set[String]).returns(T::Boolean) }", &[("left", "T::Set[String]"), ("right", "T::Set[String]")], "T::Boolean"), + ("sig { params(line: String, column: Integer).returns(T.nilable(Integer)) }", &[("line", "String"), ("column", "Integer")], "T.nilable(Integer)"), + ("sig { params(mutation: OracleMutationKind, original: String, language: String, framework: String).returns(String) }", &[("mutation", "OracleMutationKind"), ("original", "String"), ("language", "String"), ("framework", "String")], "String"), + ("sig { params(new_test_ids: T::Array[String], baseline_test_ids: T::Array[String]).returns(CohortContribution) }", &[("new_test_ids", "T::Array[String]"), ("baseline_test_ids", "T::Array[String]")], "CohortContribution"), + ("sig { params(new_test_ids: T::Array[String], baseline_test_ids: T::Array[String]).void }", &[("new_test_ids", "T::Array[String]"), ("baseline_test_ids", "T::Array[String]")], ""), + ("sig { params(node: T.untyped).returns(SourceSpan) }", &[("node", "T.untyped")], "SourceSpan"), + ("sig { params(node: T.untyped, block: T.proc.params(node: T.untyped).void).void }", &[("node", "T.untyped"), ("block", "T.proc.params(node: T.untyped).void")], ""), + ("sig { params(node: T.untyped, framework: String).returns(T.untyped) }", &[("node", "T.untyped"), ("framework", "String")], "T.untyped"), + ("sig { params(node: T.untyped, framework: String).returns(T::Boolean) }", &[("node", "T.untyped"), ("framework", "String")], "T::Boolean"), + ("sig { params(oracle: T.nilable(OracleSensitivityAnalysis), gate: EvidenceGate).returns(T::Array[ReviewFinding]) }", &[("oracle", "T.nilable(OracleSensitivityAnalysis)"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig { params(original: String, language: String, framework: String).returns(String) }", &[("original", "String"), ("language", "String"), ("framework", "String")], "String"), + ("sig { params(original: String, method: String).returns(T::Array[String]) }", &[("original", "String"), ("method", "String")], "T::Array[String]"), + ("sig { params(original: String, method: String, actual_index: Integer, language: String).returns(String) }", &[("original", "String"), ("method", "String"), ("actual_index", "Integer"), ("language", "String")], "String"), + ("sig { params(original: String, method: String, matcher_pattern: Regexp, language: String).returns(String) }", &[("original", "String"), ("method", "String"), ("matcher_pattern", "Regexp"), ("language", "String")], "String"), + ("sig { params(original: T::Array[String], trials: T::Array[OracleTrial], expected_trial_ids: T::Array[String]).returns(T::Boolean) }", &[("original", "T::Array[String]"), ("trials", "T::Array[OracleTrial]"), ("expected_trial_ids", "T::Array[String]")], "T::Boolean"), + ("sig { params(other: T.nilable(EvidenceScope)).returns(T::Boolean) }", &[("other", "T.nilable(EvidenceScope)")], "T::Boolean"), + ("sig { params(path: String).returns(OracleFacts) }", &[("path", "String")], "OracleFacts"), + ("sig { params(path: String).returns(String) }", &[("path", "String")], "String"), + ("sig { params(path: String).returns(T.nilable(String)) }", &[("path", "String")], "T.nilable(String)"), + ("sig { params(path: String).void }", &[("path", "String")], ""), + ("sig { params(raw: T.untyped).returns(SourceSpan) }", &[("raw", "T.untyped")], "SourceSpan"), + ("sig { params(reason: String).returns(CommandResult) }", &[("reason", "String")], "CommandResult"), + ("sig { params(report: T.untyped).returns(Corpus) }", &[("report", "T.untyped")], "Corpus"), + ("sig { params(repository: String, revision: String).returns(String) }", &[("repository", "String"), ("revision", "String")], "String"), + ("sig { params(request: CounterfactualRequest, runner: CommandRunner).void }", &[("request", "CounterfactualRequest"), ("runner", "CommandRunner")], ""), + ("sig { params(request: OracleExecutionRequest).returns(OracleExecutionResult) }", &[("request", "OracleExecutionRequest")], "OracleExecutionResult"), + ("sig { params(request: OracleExecutionRequest).void }", &[("request", "OracleExecutionRequest")], ""), + ("sig { params(request: OracleExecutionRequest, directory: String).returns(String) }", &[("request", "OracleExecutionRequest"), ("directory", "String")], "String"), + ("sig { params(result: CommandResult).returns(TestOutcome) }", &[("result", "CommandResult")], "TestOutcome"), + ("sig { params(result: OracleSensitivity).returns(T::Boolean) }", &[("result", "OracleSensitivity")], "T::Boolean"), + ("sig { params(revision: String, repository: T.nilable(String)).returns(String) }", &[("revision", "String"), ("repository", "T.nilable(String)")], "String"), + ("sig { params(rewrite: T.nilable(OracleRewrite)).returns(T.nilable(String)) }", &[("rewrite", "T.nilable(OracleRewrite)")], "T.nilable(String)"), + ("sig { params(root: String).returns(FieldTypes) }", &[("root", "String")], "FieldTypes"), + ("sig { params(root: String, path: String).returns(String) }", &[("root", "String"), ("path", "String")], "String"), + ("sig { params(row: T.untyped).returns(OracleFact) }", &[("row", "T.untyped")], "OracleFact"), + ("sig { params(row: T.untyped).returns(SourceSpan) }", &[("row", "T.untyped")], "SourceSpan"), + ("sig { params(rows: T::Array[T.untyped], metadata: T::Hash[String, T.untyped]).returns(OracleFacts) }", &[("rows", "T::Array[T.untyped]"), ("metadata", "T::Hash[String, T.untyped]")], "OracleFacts"), + ("sig { params(rows: T::Array[T::Hash[String, T.untyped]]).returns([T::Boolean, T.nilable(String)]) }", &[("rows", "T::Array[T::Hash[String, T.untyped]]")], "[T::Boolean, T.nilable(String)]"), + ("sig { params(source: String, language: String, override: T.nilable(String)).returns(T.nilable(String)) }", &[("source", "String"), ("language", "String"), ("override", "T.nilable(String)")], "T.nilable(String)"), + ("sig { params(source: String, span: T.nilable(SourceSpan)).returns(T.nilable([Integer, Integer])) }", &[("source", "String"), ("span", "T.nilable(SourceSpan)")], "T.nilable([Integer, Integer])"), + ("sig { params(stability: T.nilable(StabilityAnalysis)).returns(T::Boolean) }", &[("stability", "T.nilable(StabilityAnalysis)")], "T::Boolean"), + ("sig { params(subsumption: SubsumptionAnalysis).returns(T::Boolean) }", &[("subsumption", "SubsumptionAnalysis")], "T::Boolean"), + ("sig { params(subsumption: SubsumptionAnalysis, gate: EvidenceGate).returns(T::Array[ReviewFinding]) }", &[("subsumption", "SubsumptionAnalysis"), ("gate", "EvidenceGate")], "T::Array[ReviewFinding]"), + ("sig { params(test: TestObservation, unique_kills: T::Array[String]).returns(T::Array[FindingKind]) }", &[("test", "TestObservation"), ("unique_kills", "T::Array[String]")], "T::Array[FindingKind]"), + ("sig { params(test_id: String, source: String, line: Integer, framework: String).returns(T::Boolean) }", &[("test_id", "String"), ("source", "String"), ("line", "Integer"), ("framework", "String")], "T::Boolean"), + ("sig { params(test_ids: T::Array[String]).returns(T::Set[String]) }", &[("test_ids", "T::Array[String]")], "T::Set[String]"), + ("sig { params(test_ids: T::Array[String], cohort: T.nilable(CohortEvidenceVector)).returns(T::Boolean) }", &[("test_ids", "T::Array[String]"), ("cohort", "T.nilable(CohortEvidenceVector)")], "T::Boolean"), + ("sig { params(text: String).returns(T::Array[String]) }", &[("text", "String")], "T::Array[String]"), + ("sig { params(text: String).returns([String, String]) }", &[("text", "String")], "[String, String]"), + ("sig { params(thread: T.nilable(Thread)).void }", &[("thread", "T.nilable(Thread)")], ""), + ("sig { params(thread: Thread, io: IO).returns(CapturedOutput) }", &[("thread", "Thread"), ("io", "IO")], "CapturedOutput"), + ("sig { params(value: T.untyped).returns(Float) }", &[("value", "T.untyped")], "Float"), + ("sig { params(value: T.untyped).returns(String) }", &[("value", "T.untyped")], "String"), + ("sig { params(value: T.untyped).returns(T.nilable(Integer)) }", &[("value", "T.untyped")], "T.nilable(Integer)"), + ("sig { params(value: T.untyped).returns(T.nilable(SourceSpan)) }", &[("value", "T.untyped")], "T.nilable(SourceSpan)"), + ("sig { params(value: T.untyped, label: String).returns(String) }", &[("value", "T.untyped"), ("label", "String")], "String"), + ("sig { params(values: T.untyped).returns(T::Array[String]) }", &[("values", "T.untyped")], "T::Array[String]"), + ("sig { params(wait_thread: T.nilable(Thread)).void }", &[("wait_thread", "T.nilable(Thread)")], ""), + ("sig { params(wait_thread: Thread, signal: String).void }", &[("wait_thread", "Thread"), ("signal", "String")], ""), + ("sig { params(wait_thread: Thread, timeout_seconds: Float).returns(T.nilable(Process::Status)) }", &[("wait_thread", "Thread"), ("timeout_seconds", "Float")], "T.nilable(Process::Status)"), + ("sig { returns(#{candidate[\"type\"]}", &[], ""), + ("sig { returns(#{type}", &[], ""), + ("sig { returns(CounterfactualResult) }", &[], "CounterfactualResult"), + ("sig { returns(Coverage) }", &[], "Coverage"), + ("sig { returns(EvidenceCompleteness) }", &[], "EvidenceCompleteness"), + ("sig { returns(Integer) }", &[], "Integer"), + ("sig { returns(String) }", &[], "String"), + ("sig { returns(T.nilable(String)) }", &[], "T.nilable(String)"), + ("sig { returns(T::Array[IndexedKillSet]) }", &[], "T::Array[IndexedKillSet]"), + ("sig { returns(T::Array[OracleFact]) }", &[], "T::Array[OracleFact]"), + ("sig { returns(T::Array[T::Hash[String, String]]) }", &[], "T::Array[T::Hash[String, String]]"), + ("sig { returns(T::Boolean) }", &[], "T::Boolean"), + ("sig { returns(T::Hash[String, Integer]) }", &[], "T::Hash[String, Integer]"), + ("sig { returns(T::Hash[String, T.untyped]) }", &[], "T::Hash[String, T.untyped]"), + ("sig { returns(T::Hash[String, T::Array[String]]) }", &[], "T::Hash[String, T::Array[String]]"), + ("sig { returns(TYPE) }", &[], "TYPE"), + ("sig { void }", &[], ""), + ("sig{\")\n return nil unless stripped.start_with?(\"#\") || stripped.empty? || stripped == \"end\"\n end\n nil\n end\n\n def typed_ivar_initialized_before?(path, line_number, ivar)\n lines = StaticAnalysis.source_lines(File.join(root, path))\n return true if lines.first(line_number - 1).any? { |prior| typed_ivar_line?(prior, ivar) }", &[], ""), + ]; + let mut disagreed = Vec::new(); + for (sig, expected_params, expected_return) in cases { + let got: Vec<(String, String)> = param_entries(sig); + let want: Vec<(String, String)> = expected_params + .iter() + .map(|(n, t)| (n.to_string(), t.to_string())) + .collect(); + if got != want { + disagreed.push(format!("params {sig}: {got:?} != {want:?}")); + } + if return_type(sig).unwrap_or("") != *expected_return { + disagreed.push(format!("return {sig}")); + } + } + assert!(disagreed.is_empty(), "{} disagreements: {:?}", disagreed.len(), disagreed); + } diff --git a/gems/fact-mine/src/source_fingerprint.rs b/gems/fact-mine/src/source_fingerprint.rs new file mode 100644 index 000000000..e9c0c52cd --- /dev/null +++ b/gems/fact-mine/src/source_fingerprint.rs @@ -0,0 +1,161 @@ +//! A digest of what a file *says*, not how it is written. +//! +//! An incremental collect asks one question of every source file: is this the +//! same code it was last time. Digesting the bytes answers a different +//! question, because reindenting a file, rewrapping a line or editing a comment +//! would each retrace the shards that file feeds -- and none of them can change +//! what the program does. +//! +//! So the digest is taken over the parse tree instead: node kinds and the token +//! text that carries meaning, with positions and comments left out. Two files +//! that parse the same have the same fingerprint however they are laid out. +//! +//! Magic comments are the exception that has to be read as code. A +//! `# frozen_string_literal: true` is a comment to the grammar and a directive +//! to the VM, so the leading ones are digested verbatim. + +use crate::syntax::{parser_grammar::grammar_for_language, Language}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +/// Bump when the digest of unchanged source would change. Every stored +/// snapshot then invalidates and the next incremental collect is a full one, +/// which is correct and one-time -- silently reusing digests from another +/// scheme would skip shards that needed rerunning. +pub const SCHEME: &str = "fact-mine-normalized-tree-v1"; + +/// Comment-like grammar nodes, whose content cannot change behaviour. +fn is_comment(kind: &str) -> bool { + kind.contains("comment") +} + +/// Leading `# key: value` lines, which the VM reads as directives. +fn magic_comments(source: &str) -> Vec<&str> { + source + .lines() + .take(2) + .filter(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with('#') + && trimmed[1..] + .split_once(':') + .is_some_and(|(key, _)| { + let key = key.trim(); + !key.is_empty() + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }) + }) + .collect() +} + +fn walk(node: Node<'_>, source: &[u8], into: &mut Vec) { + let kind = node.kind(); + if is_comment(kind) { + return; + } + if node.child_count() == 0 { + // A leaf carries its own text: an identifier, a literal, an operator. + // Two trees of identical shape over different names are different + // programs. + into.extend_from_slice(kind.as_bytes()); + into.push(0); + into.extend_from_slice(node.utf8_text(source).unwrap_or_default().as_bytes()); + into.push(0); + return; + } + into.extend_from_slice(kind.as_bytes()); + into.push(b'('); + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk(child, source, into); + } + into.push(b')'); +} + +/// The fingerprint of a file's source, or `None` where it cannot be parsed -- +/// an unknown language, an unreadable file. The caller digests the bytes then, +/// which is stricter and never wrong, only noisier. +pub fn of_source(source: &str, language: Language) -> Option { + let mut parser = Parser::new(); + parser.set_language(&grammar_for_language(language)).ok()?; + let tree = parser.parse(crate::ast::parse_buffer(source, language), None)?; + + let mut shape = Vec::new(); + for line in magic_comments(source) { + shape.extend_from_slice(line.trim().as_bytes()); + shape.push(b'\n'); + } + shape.push(0); + walk(tree.root_node(), source.as_bytes(), &mut shape); + Some(format!("{:x}", Sha256::digest(&shape))) +} + +/// The fingerprint of a file on disk. Falls back to the byte digest where the +/// file's language has no grammar here. +pub fn of_file(path: &Path) -> Option { + let source = std::fs::read_to_string(path).ok()?; + match Language::for_path(path).and_then(|language| of_source(&source, language)) { + Some(fingerprint) => Some(fingerprint), + None => Some(format!("{:x}", Sha256::digest(source.as_bytes()))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ruby(source: &str) -> String { + of_source(source, Language::Ruby).expect("ruby fingerprint") + } + + #[test] + fn reformatting_is_not_an_edit() { + let original = "class App\n def value\n 1 + 2\n end\nend\n"; + let reformatted = "class App\n\n def value\n 1 + 2\n end\n\nend\n"; + + assert_eq!(ruby(original), ruby(reformatted)); + } + + #[test] + fn a_comment_is_not_an_edit() { + let bare = "def value\n 1\nend\n"; + let commented = "# explains the value\ndef value\n 1 # inline\nend\n"; + + assert_eq!(ruby(bare), ruby(commented)); + } + + #[test] + fn a_magic_comment_is_an_edit() { + // It is a comment to the grammar and a directive to the VM. + let plain = "def value\n \"a\"\nend\n"; + let frozen = "# frozen_string_literal: true\ndef value\n \"a\"\nend\n"; + + assert_ne!(ruby(plain), ruby(frozen)); + // ... and only where the VM reads it, which is the top of the file. + let late = "def value\n \"a\"\nend\n# frozen_string_literal: true\n"; + assert_eq!(ruby(plain), ruby(late)); + } + + #[test] + fn changing_what_the_code_does_is_an_edit() { + let before = "def value\n 1 + 2\nend\n"; + assert_ne!(ruby(before), ruby("def value\n 1 - 2\nend\n")); + assert_ne!(ruby(before), ruby("def value\n 1 + 3\nend\n")); + // A rename is a change: the tree shape is identical and the names + // are not. + assert_ne!(ruby(before), ruby("def other\n 1 + 2\nend\n")); + } + + #[test] + fn a_file_that_cannot_be_parsed_still_has_a_fingerprint() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("notes.txt"); + std::fs::write(&path, "not source at all").expect("write"); + + let first = of_file(&path).expect("fingerprint"); + assert_eq!(first, of_file(&path).expect("fingerprint")); + std::fs::write(&path, "different").expect("write"); + assert_ne!(first, of_file(&path).expect("fingerprint")); + } +} diff --git a/gems/fact-mine/src/syntax.rs b/gems/fact-mine/src/syntax.rs index 90ac55f9f..fee7fd8d2 100644 --- a/gems/fact-mine/src/syntax.rs +++ b/gems/fact-mine/src/syntax.rs @@ -68,6 +68,25 @@ pub(crate) fn external_symbol_call_complexity( normalized_behavior::behavior(language).external_symbol_call_complexity(symbol, message) } +/// Price a compiler-indexed preprocessor definition through its owning +/// language adapter. The SCIP importer supplies the exact definition text; +/// generic ingestion never interprets C/C++ preprocessor grammar. +pub(crate) fn preprocessor_definition_call_complexity( + language: &str, + definition: &str, +) -> Option { + let language = Language::parse(language).ok()?; + normalized_behavior::behavior(language).preprocessor_definition_call_complexity(definition) +} + +pub(crate) fn preprocessor_definition_location( + language: &str, + symbol: &str, +) -> Option<(String, usize)> { + let language = Language::parse(language).ok()?; + normalized_behavior::behavior(language).preprocessor_definition_location(symbol) +} + /// Classify an exact external symbol at the language boundary. Shared SCIP /// ingestion and diagnostics consume only these normalized values. pub(crate) fn external_symbol_metadata(language: &str, symbol: &str) -> ExternalSymbolMetadata { @@ -100,6 +119,18 @@ pub(crate) fn scip_noncall_access_is_callable(language: &str, symbol: &str) -> b .unwrap_or(false) } +pub(crate) fn scip_occurrence_matches_call( + language: &str, + symbol: &str, + source_text: &str, + message: &str, +) -> bool { + Language::parse(language) + .ok() + .map(normalized_behavior::behavior) + .is_some_and(|behavior| behavior.scip_occurrence_matches_call(symbol, source_text, message)) +} + /// Shared algebra for calls whose target identity is proven but whose cost is /// parameterized by callback/implementation work. pub(crate) fn parametric_call_complexity(kind: &str) -> Option<(&'static str, &'static str)> { @@ -108,6 +139,15 @@ pub(crate) fn parametric_call_complexity(kind: &str) -> Option<(&'static str, &' "callback_linear" => Some(("O(N*C)", "O(N*S)")), "callback_sort" => Some(("O(N log N*C)", "O(N+S)")), "reflective_once" => Some(("O(R)", "O(S)")), + // A native dynamic-language primitive has a concrete scan/materialize + // phase after exactly one user-overridable coercion (for example + // String#to_str or File#to_path). These are language-neutral algebra + // atoms, not Ruby-specific behavior. + "coercive_linear_scan" => Some(("O(N+C)", "O(S)")), + "coercive_linear_materialize" => Some(("O(N+C)", "O(N+S)")), + // A loader executes the selected program body. `R` is its open + // target cost; `N` covers lookup/path processing done by the loader. + "loader_once" => Some(("O(N+R)", "O(N+S)")), _ => None, } } @@ -274,8 +314,18 @@ pub struct Document { /// `normalization_call_origins` but not here. #[serde(default)] pub call_raw_origin_projections: Vec, + /// Exact selector span for each normalized call. Enclosing call spans may + /// include a multiline callback body, while SCIP references must anchor + /// the callable selector itself. + #[serde(default)] + pub call_selector_projections: Vec, #[serde(default)] pub call_receiver_projections: Vec, + /// Complete executable range for a normalized call. This differs from + /// the call's semantic span when an attached callback body belongs to the + /// invocation. + #[serde(default)] + pub call_execution_projections: Vec, #[serde(default)] pub state_declarations: Vec, #[serde(default)] @@ -330,6 +380,8 @@ pub struct Document { #[serde(default)] pub flow_types: Vec, #[serde(default)] + pub callback_bindings: Vec, + #[serde(default)] pub protocol_method_effects: Vec, #[serde(default)] pub protocol_call_paths: Vec, @@ -359,6 +411,10 @@ pub struct Document { pub method_param_types: BTreeMap>, #[serde(default)] pub method_local_types: BTreeMap>, + /// Source-proven C++ type template parameters, scoped to one normalized + /// callable identity. Empty for languages without type templates. + #[serde(default)] + pub method_template_types: BTreeMap>, #[serde(default)] pub state_param_origins: Vec, #[serde(default)] @@ -413,6 +469,8 @@ pub struct SymbolScope { pub explicit_imports: BTreeMap, #[serde(default)] pub preprocessor_callables: BTreeSet, + #[serde(default)] + pub preprocessor_definitions: BTreeMap>, /// Language-owned namespace enclosing a particular declaration span. /// File-level namespaces are insufficient for languages such as C++ /// where one translation unit may contain several namespace blocks. @@ -436,6 +494,9 @@ pub struct FunctionDef { pub params: Vec, #[serde(default)] pub callback_params: Vec, + /// Language-owned proof that this declaration contains an executable body. + #[serde(default)] + pub source_export_eligible: bool, #[serde(default)] pub signature: String, } @@ -450,6 +511,7 @@ impl FunctionDef { line: usize, span: Span, params: Vec, + declaration_source: String, ) -> Self { Self { file, @@ -460,7 +522,10 @@ impl FunctionDef { span, body: RawNode { kind: "SYNTHETIC_ACCESSOR".to_string(), - text: String::new(), + // Preserve the language-owned declaration witness. The + // shared profile never interprets it; the adapter's + // generated-callable contract does. + text: declaration_source, span, named: false, field_name: None, @@ -469,6 +534,7 @@ impl FunctionDef { visibility: Some("public".to_string()), params, callback_params: Vec::new(), + source_export_eligible: true, signature: String::new(), } } @@ -488,6 +554,12 @@ pub struct OwnerDef { /// them before any hierarchy traversal. #[serde(default)] pub supertypes: Vec, + /// For an abstract-dispatch type (interface/protocol) in a structurally-typed + /// language, the method names it requires. Used to compute which concrete + /// types satisfy it. Empty for concrete types and nominal-satisfaction + /// languages (which express conformance through `supertypes`). + #[serde(default)] + pub requirements: Vec, pub line: usize, pub span: Span, } @@ -525,6 +597,21 @@ pub struct CallReceiverProjection { pub receiver_call_span: Span, } +/// The callable selector emitted for a normalized semantic call. This stays +/// separate from `CallSite::span`: the latter describes the full invocation +/// for CFG/complexity analysis and may include a block body. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CallSelectorProjection { + pub call_span: Span, + pub selector_span: Span, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CallExecutionProjection { + pub call_span: Span, + pub execution_span: Span, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct StateDeclaration { pub field: String, @@ -749,6 +836,19 @@ pub fn parse_files(files: &[PathBuf], language: Language) -> Result String { + format!("") +} + +/// Whether a function name is one of those synthetic names. The `:` inside it +/// separates row from column, not a namespace from a member, so qualified-name +/// handling must leave it alone. +pub(crate) fn is_lambda_function_name(name: &str) -> bool { + name.starts_with("') +} + pub(crate) fn protocol_method_effects(document: &Document) -> Vec { document.protocol_method_effects.clone() } @@ -817,6 +917,22 @@ mod tests { })); } + #[test] + fn parametric_contract_algebra_keeps_coercion_and_loader_work_symbolic() { + assert_eq!( + parametric_call_complexity("coercive_linear_scan"), + Some(("O(N+C)", "O(S)")) + ); + assert_eq!( + parametric_call_complexity("coercive_linear_materialize"), + Some(("O(N+C)", "O(N+S)")) + ); + assert_eq!( + parametric_call_complexity("loader_once"), + Some(("O(N+R)", "O(N+S)")) + ); + } + #[test] fn parser_recovery_is_retained_as_input_coverage_evidence() { let doc = document("def broken(\n", Language::Ruby); diff --git a/gems/fact-mine/src/syntax/c.rs b/gems/fact-mine/src/syntax/c.rs index 3c0356042..686b2769c 100644 --- a/gems/fact-mine/src/syntax/c.rs +++ b/gems/fact-mine/src/syntax/c.rs @@ -43,6 +43,10 @@ const C_EFFECT_LEXICON: EffectLexicon = EffectLexicon { }; const C_NIL_PREDICATES: &[&str] = &["isNull", "is_null"]; +const C_PRIMITIVE_OPERATORS: &[&str] = &[ + "==", "!=", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "&", "|", "^", "<<", ">>", "~", + "&&", "||", "!", +]; const C_NON_NIL_PREDICATES: &[&str] = &["isSome", "is_some", "present"]; const C_GUARD_MIDS: &[&str] = &["isNull", "is_null"]; @@ -52,13 +56,23 @@ fn scip_clang_parts(symbol: &str) -> Option<(&str, &str)> { } pub(crate) fn external_symbol_metadata(symbol: &str) -> ExternalSymbolMetadata { - let Some((package, _descriptor)) = scip_clang_parts(symbol) else { + let Some((package, descriptor)) = scip_clang_parts(symbol) else { return ExternalSymbolMetadata { scope: "dynamic", missing_cost_kind: "callback_or_function_value_origin_unknown".to_string(), parametric_cost: None, }; }; + // At a compiler-proven call site, a field descriptor denotes invocation + // through a function-pointer member. The pointed-to implementation is + // open, but the invocation count is exactly once. + if descriptor.contains('#') && descriptor.ends_with('.') { + return ExternalSymbolMetadata { + scope: "external", + missing_cost_kind: "callback_cost_missing".to_string(), + parametric_cost: Some("callback_once".to_string()), + }; + } // Clang's SCIP symbols intentionally do not claim that an un-packaged C // global came from libc rather than a project header. Preserve that // uncertainty instead of turning a familiar spelling into fake proof. @@ -78,6 +92,52 @@ pub(crate) fn external_symbol_owner(symbol: &str) -> Option { scip_descriptor_owner(descriptor) } +pub(crate) fn external_symbol_call_complexity( + symbol: &str, + message: &str, +) -> Option { + let (package, descriptor) = scip_clang_parts(symbol)?; + if package != "." || descriptor.contains('#') || descriptor.starts_with('`') { + return None; + } + let indexed_name = descriptor.split('(').next()?.trim_matches('`'); + if indexed_name != message { + return None; + } + if matches!( + message, + "__builtin_assume" + | "__builtin_choose_expr" + | "__builtin_constant_p" + | "__builtin_dynamic_object_size" + | "__builtin_expect" + | "__builtin_object_size" + | "__builtin_offsetof" + | "__builtin_types_compatible_p" + | "__builtin_unreachable" + ) { + return Some(super::ExternalCallComplexity { + time: "O(1)", + space: "O(1)", + provenance: "compiler_intrinsic_exact_contract", + bound_quality: "upper_bound_exact_target", + candidates: vec![symbol.to_string()], + assumption: None, + }); + } + let complexity = configured_intrinsic_call_complexity("c", None, message)?; + Some(super::ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "compiler_symbol_c_runtime_registry", + bound_quality: "upper_bound_modeled_world", + candidates: vec![symbol.to_string()], + assumption: Some(format!( + "unpackaged external C declaration `{message}` follows the reviewed ISO/POSIX runtime contract" + )), + }) +} + // CFG-SPECIFIC START: C control-flow vocabulary. const C_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { iterator_messages: &[], @@ -88,6 +148,67 @@ const C_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { struct CNormalizedBehavior; impl NormalizedLanguageBehavior for CNormalizedBehavior { + fn constant_condition_truth(&self, node: &Node) -> Option { + match node.text.trim().trim_matches(['(', ')']).trim() { + "0" => Some(false), + "1" => Some(true), + _ => None, + } + } + + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + // C-family indexers render a local as `Type name` - the type leads. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_end_matches(';').trim(); + let (declared, _name) = text.rsplit_once(char::is_whitespace)?; + let declared = declared.trim(); + (!declared.is_empty() && !declared.contains('=')).then(|| declared.to_string()) + } + + // C declares `Ret name(T a)`, not `name(a: T) -> Ret`. + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + let signature = unwrap_return_type_macro(signature); + super::normalized_behavior::parse_prefix_return_declarator(&signature) + } + + fn parameter_list_source(&self, source: &str) -> String { + let header = source.split('{').next().unwrap_or(source); + let mut groups = Vec::new(); + let mut start = None; + let mut depth = 0usize; + for (index, ch) in header.char_indices() { + match ch { + '(' => { + if depth == 0 { + start = Some(index); + } + depth += 1; + } + ')' if depth > 0 => { + depth -= 1; + if depth == 0 { + if let Some(open) = start.take() { + groups.push(header[open + 1..index].to_string()); + } + } + } + _ => {} + } + } + groups.pop().unwrap_or_default() + } + fn nullable_operation(&self, node: &Node) -> Option { if node.r#type == "VCALL" { return local_call_subject(node).map(|subject| NormalizedNullableOperation { @@ -139,6 +260,25 @@ impl NormalizedLanguageBehavior for CNormalizedBehavior { external_symbol_metadata(symbol) } + fn external_symbol_call_complexity( + &self, + symbol: &str, + message: &str, + ) -> Option { + external_symbol_call_complexity(symbol, message) + } + + fn preprocessor_definition_call_complexity( + &self, + definition: &str, + ) -> Option { + preprocessor_definition_call_complexity(definition) + } + + fn preprocessor_definition_location(&self, symbol: &str) -> Option<(String, usize)> { + preprocessor_definition_location(symbol) + } + fn external_symbol_owner(&self, symbol: &str) -> Option { external_symbol_owner(symbol) } @@ -165,6 +305,18 @@ impl NormalizedLanguageBehavior for CNormalizedBehavior { receiver: Option<&str>, message: &str, ) -> Option { + // C has no operator overloading: arithmetic, comparison, bitwise, + // shift, logical and unary operators - plus array subscript `[]` + // (pointer arithmetic) - are constant-time. Unary forms carry a + // trailing `@` (e.g. `-@`). Without this they are recorded as + // unresolved call targets, wrongly marking O(1) functions incomplete. + let operator = message.strip_suffix('@').unwrap_or(message); + if operator == "[]" || C_PRIMITIVE_OPERATORS.contains(&operator) { + return Some(NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }); + } // The generic call extractor represents a bare C function as a // synthetic `self` receiver. C has no instance dispatch here, so // translate only that parser sentinel back to a free intrinsic. @@ -209,7 +361,8 @@ impl NormalizedLanguageBehavior for CNormalizedBehavior { } fn property_read_call(&self, node: &Node, parts: &NormalizedCallParts) -> bool { - node.r#type != "VCALL" && parts.arguments.is_empty() && !node.text.contains('(') + parts.arguments.is_empty() + && c_member_selector_is_invoked(&node.text, &parts.message) == Some(false) } fn owner_name_span(&self, _name: &str, node: &Node, default_span: Span) -> Option { @@ -420,6 +573,204 @@ impl NormalizedLanguageBehavior for CNormalizedBehavior { } } +pub(crate) fn preprocessor_definition_call_complexity( + definition: &str, +) -> Option { + let definition = definition.replace("\\\r\n", " ").replace("\\\n", " "); + let directive = definition.trim_start().strip_prefix('#')?.trim_start(); + let tail = directive.strip_prefix("define")?; + if tail + .chars() + .next() + .is_some_and(|character| !character.is_whitespace()) + { + return None; + } + let tail = tail.trim_start(); + let name_end = tail + .find(|ch: char| !(ch == '_' || ch.is_ascii_alphanumeric())) + .unwrap_or(tail.len()); + if name_end == 0 { + return None; + } + let after_name = &tail[name_end..]; + let body = if after_name.starts_with('(') { + let close = matching_delimiter(after_name, '(', ')')?; + after_name.get(close + 1..)?.trim() + } else { + after_name.trim() + }; + if body.is_empty() || body.contains('#') { + return None; + } + + let loop_count = identifier_tokens(body) + .filter(|token| matches!(*token, "for" | "while" | "do")) + .count(); + if loop_count > 1 { + return None; + } + let calls = call_like_identifiers(body) + .filter(|name| { + !matches!( + *name, + "sizeof" | "_Alignof" | "for" | "while" | "if" | "switch" + ) + }) + .collect::>(); + let configured = calls + .iter() + .filter_map(|name| configured_intrinsic_call_complexity("c", None, name)) + .collect::>(); + let parametric = configured.len() != calls.len(); + let call_time = if parametric { + "O(C)" + } else { + configured + .iter() + .map(|complexity| complexity.time) + .max_by_key(|complexity| macro_complexity_rank(complexity)) + .unwrap_or("O(1)") + }; + let time = if loop_count == 1 { + match call_time { + "O(1)" => "O(N)", + "O(log N)" => "O(N log N)", + "O(N)" => "O(N^2)", + "O(N log N)" => "O(N^2 log N)", + "O(C)" => "O(N*C)", + _ => return None, + } + } else { + call_time + }; + let space = if parametric { + "O(C)" + } else { + configured + .iter() + .map(|complexity| complexity.space) + .max_by_key(|complexity| macro_complexity_rank(complexity)) + .unwrap_or("O(1)") + }; + + Some(super::ExternalCallComplexity { + time, + space, + provenance: "compiler_indexed_macro_body", + bound_quality: if parametric { + if loop_count == 1 { + "upper_bound_parametric_callback_linear" + } else { + "upper_bound_parametric_callback_once" + } + } else { + "upper_bound_compiler_indexed_macro_body" + }, + candidates: calls.into_iter().map(str::to_string).collect(), + assumption: None, + }) +} + +pub(crate) fn preprocessor_definition_location(symbol: &str) -> Option<(String, usize)> { + let (_package, descriptor) = scip_clang_parts(symbol)?; + let location = descriptor.strip_prefix('`')?.strip_suffix("`!")?; + let mut parts = location.rsplitn(3, ':'); + let _column = parts.next()?.parse::().ok()?; + let line = parts.next()?.parse::().ok()?; + let path = parts.next()?.to_string(); + (!path.is_empty() && line > 0).then_some((path, line)) +} + +fn unwrap_return_type_macro(signature: &str) -> String { + let signature = signature.trim(); + let Some(open) = signature.find('(') else { + return signature.to_string(); + }; + let macro_name = signature[..open].trim(); + if macro_name.is_empty() + || !macro_name + .chars() + .all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit()) + { + return signature.to_string(); + } + let Some(close) = matching_delimiter(&signature[open..], '(', ')') else { + return signature.to_string(); + }; + let close = open + close; + let wrapped = signature[open + 1..close].trim(); + let suffix = signature[close + 1..].trim_start(); + if wrapped.is_empty() || !suffix.contains('(') { + return signature.to_string(); + } + format!("{wrapped} {suffix}") +} + +fn macro_complexity_rank(complexity: &str) -> usize { + match complexity { + "O(1)" => 0, + "O(log N)" => 1, + "O(N)" => 2, + "O(N log N)" => 3, + "O(N^2)" => 4, + "O(N^2 log N)" => 5, + _ => usize::MAX, + } +} + +fn matching_delimiter(source: &str, open: char, close: char) -> Option { + let mut depth = 0usize; + for (index, ch) in source.char_indices() { + if ch == open { + depth += 1; + } else if ch == close { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(index); + } + } + } + None +} + +fn identifier_tokens(source: &str) -> impl Iterator { + source + .split(|ch: char| !(ch == '_' || ch.is_ascii_alphanumeric())) + .filter(|token| { + token + .chars() + .next() + .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) + }) +} + +fn call_like_identifiers(source: &str) -> impl Iterator { + identifier_tokens(source).filter(|identifier| { + source.match_indices(identifier).any(|(index, _)| { + source[index + identifier.len()..] + .trim_start() + .starts_with('(') + }) + }) +} + +fn c_member_selector_is_invoked(source: &str, message: &str) -> Option { + let selector = message.trim(); + if selector.is_empty() { + return None; + } + ["->", "."] + .into_iter() + .filter_map(|separator| { + source + .rfind(&format!("{separator}{selector}")) + .map(|offset| offset + separator.len() + selector.len()) + }) + .max() + .map(|offset| source[offset..].trim_start().starts_with('(')) +} + fn local_call_subject(node: &Node) -> Option { match node.children.first()? { Child::Symbol(subject) | Child::String(subject) => { @@ -613,6 +964,74 @@ mod tests { CNormalizedBehavior.parameter_type_from_signature("Widget * _Nullable value"), Some("Widget * _Nullable".to_string()) ); + assert_eq!( + CNormalizedBehavior + .parameter_list_source("CJSON_PUBLIC(void) cJSON_Delete(cJSON *item) { body(); }"), + "cJSON *item" + ); + let signature = + CNormalizedBehavior.parse_signature("CJSON_PUBLIC(void) cJSON_Delete(cJSON *item)"); + assert_eq!(signature.return_type.as_deref(), Some("void")); + assert_eq!( + signature.params, + vec![("item".to_string(), "cJSON".to_string())] + ); + } + + #[test] + fn compiler_indexed_macro_bodies_are_priced_only_when_bounded() { + let constant = preprocessor_definition_call_complexity( + "# define buffer_at_offset(buffer) ((buffer)->content + (buffer)->offset)", + ) + .unwrap(); + assert_eq!((constant.time, constant.space), ("O(1)", "O(1)")); + + let traversal = preprocessor_definition_call_complexity( + "#define EACH(item, list) for (item = (list)->head; item; item = item->next)", + ) + .unwrap(); + assert_eq!((traversal.time, traversal.space), ("O(N)", "O(1)")); + let cjson_traversal = preprocessor_definition_call_complexity( + "#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)", + ) + .unwrap(); + assert_eq!( + (cjson_traversal.time, cjson_traversal.space), + ("O(N)", "O(1)") + ); + assert_eq!( + CNormalizedBehavior.preprocessor_definition_location("cxx . . $ `cJSON.h:296:9`!"), + Some(("cJSON.h".to_string(), 296)) + ); + + let callback = + preprocessor_definition_call_complexity("#define INVOKE(callback) callback()").unwrap(); + assert_eq!((callback.time, callback.space), ("O(C)", "O(C)")); + let copy = preprocessor_definition_call_complexity( + "#define COPY(target, source, size) memcpy(target, source, size)", + ) + .unwrap(); + assert_eq!((copy.time, copy.space), ("O(N)", "O(1)")); + + let callback = external_symbol_metadata("cxx . . $ internal_hooks#allocate."); + assert_eq!(callback.parametric_cost.as_deref(), Some("callback_once")); + let strlen = + external_symbol_call_complexity("cxx . . $ strlen(751346f8406fd082).", "strlen") + .unwrap(); + assert_eq!((strlen.time, strlen.space), ("O(N)", "O(1)")); + assert_eq!(strlen.bound_quality, "upper_bound_modeled_world"); + let offsetof = external_symbol_call_complexity( + "cxx . . $ __builtin_offsetof(751346f8406fd082).", + "__builtin_offsetof", + ) + .unwrap(); + assert_eq!((offsetof.time, offsetof.space), ("O(1)", "O(1)")); + assert_eq!(offsetof.bound_quality, "upper_bound_exact_target"); + assert!(external_symbol_call_complexity( + "cxx . . $ project_strlen(751346f8406fd082).", + "strlen" + ) + .is_none()); } #[test] @@ -695,6 +1114,22 @@ mod tests { arguments: Vec::new(), } )); + assert!(b.property_read_call( + &node("VCALL", "(*execute_data).This.u2.num_args"), + &NormalizedCallParts { + receiver: "execute_data".to_string(), + message: "This".to_string(), + arguments: Vec::new(), + } + )); + assert!(!b.property_read_call( + &node("CALL", "callbacks.run()"), + &NormalizedCallParts { + receiver: "callbacks".to_string(), + message: "run".to_string(), + arguments: Vec::new(), + } + )); // 6. owner_name_span let struct_node = node("STRUCT", "struct MyStruct {\n int x;\n}"); diff --git a/gems/fact-mine/src/syntax/cfg/branches.rs b/gems/fact-mine/src/syntax/cfg/branches.rs index 53cb08d8e..b2ab23cc4 100644 --- a/gems/fact-mine/src/syntax/cfg/branches.rs +++ b/gems/fact-mine/src/syntax/cfg/branches.rs @@ -60,7 +60,7 @@ pub(crate) fn from_node(node: &Node) -> Option> { } pub(crate) fn find_by_span(node: &Node, target: Span) -> Option<&Node> { - if span(node) == target { + if span(node) == target && !matches!(node.r#type.as_str(), "SCOPE" | "BLOCK") { return Some(node); } @@ -68,6 +68,7 @@ pub(crate) fn find_by_span(node: &Node, target: Span) -> Option<&Node> { .iter() .filter_map(ast::node) .find_map(|child| find_by_span(child, target)) + .or_else(|| (span(node) == target).then_some(node)) } fn body_nodes(body: Option<&Node>) -> Vec<&Node> { @@ -110,6 +111,35 @@ mod tests { assert_eq!(branch.kind.else_edge_kind(), "branch_true"); } + #[test] + fn find_by_span_prefers_the_statement_inside_an_equal_span_scope() { + let statement = node( + "ITER", + 4, + 4, + 6, + 7, + "rows.map { |row| row.kind }", + Vec::new(), + ); + let scope = node( + "SCOPE", + 4, + 4, + 6, + 7, + "rows.map { |row| row.kind }", + vec![statement], + ); + + assert_eq!( + find_by_span(&scope, [4, 4, 6, 7]) + .expect("exact statement") + .r#type, + "ITER" + ); + } + fn branch_node(kind: &str) -> Node { node( kind, diff --git a/gems/fact-mine/src/syntax/cfg/effects.rs b/gems/fact-mine/src/syntax/cfg/effects.rs index 8aed295dc..4b2b6a83f 100644 --- a/gems/fact-mine/src/syntax/cfg/effects.rs +++ b/gems/fact-mine/src/syntax/cfg/effects.rs @@ -1,5 +1,6 @@ use super::{ - branches, loops, ControlFlowFacts, ControlFlowNode, ControlFlowProfile, NodeEffect, Place, + branches, loops, CallbackBindingFact, ControlFlowFacts, ControlFlowNode, ControlFlowProfile, + NodeEffect, Place, }; use crate::ast::{self, Child, Node}; use crate::syntax::normalized_behavior::NormalizedLanguageBehavior; @@ -27,13 +28,23 @@ struct RawEffect { write_value_hints: BTreeMap, write_sources: BTreeMap, write_call_sources: BTreeMap, + write_call_source_sets: BTreeMap>, + write_sequence_projections: BTreeMap, write_nullable_contracts: BTreeMap, + callback_bindings: Vec, return_state_hint: Option, unknown_call: bool, complete: bool, unknown_reasons: Vec, } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct RawCallbackBinding { + name: String, + position: usize, + span: Span, +} + impl RawEffect { fn record_place(&mut self, name: String, kind: &str) { self.place_kinds @@ -55,6 +66,7 @@ pub(crate) fn extract( let mut declaration_spans = BTreeMap::<(GraphKey, String), Span>::new(); let mut method_spans = BTreeMap::::new(); let mut graph_key_by_node = BTreeMap::new(); + let mut lambda_entry_by_graph: BTreeMap = BTreeMap::new(); let duplicate_functions = methods .iter() .fold(BTreeMap::new(), |mut counts, method| { @@ -83,6 +95,9 @@ pub(crate) fn extract( }; if node.kind == "entry" { + if method.node.r#type == "LAMBDA" { + lambda_entry_by_graph.insert(graph_key.clone(), (node.id.clone(), node.span)); + } raw.writes.extend(method.params.iter().cloned()); for name in &method.params { raw.record_place(name.clone(), "local"); @@ -112,6 +127,7 @@ pub(crate) fn extract( target, &mut raw, behavior.function_value_calls_are_local_reads(), + Some(behavior), ); for (name, producer_span) in raw.write_call_sources.clone() { let contract = [ @@ -127,7 +143,7 @@ pub(crate) fn extract( .insert(name, contract.to_string()); } } - apply_normalized_local_contract(method, node, &mut raw); + apply_normalized_local_contract(method, node, target, &mut raw, behavior); let declared_candidates = raw .writes .iter() @@ -177,6 +193,85 @@ pub(crate) fn extract( raw_by_node.insert(node.id.clone(), raw); } + // Index the local place names used in each graph so captures can be told + // apart from a lambda's own locals. + let mut graph_local_names: BTreeMap> = BTreeMap::new(); + for (node_id, node_graph_key) in &graph_key_by_node { + let raw = &raw_by_node[node_id]; + let names = graph_local_names.entry(node_graph_key.clone()).or_default(); + for name in raw.reads.iter().chain(raw.writes.iter()) { + if raw + .place_kinds + .get(name) + .is_some_and(|kind| kind == "local") + { + names.insert(name.clone()); + } + } + } + + // A lambda's free variables (captured from the enclosing scope) are inputs + // to the closure. In the lambda's own graph they are read with no local + // definition, which would strand them as disconnected reads. A capture is a + // local read with no local write in the lambda that is also a local of an + // enclosing method -- this excludes the lambda's own multi-assign locals, + // whose declarations are recorded as reads rather than writes. Seed each + // capture as a definition at the lambda's entry node, exactly as params are. + for (graph_key, (entry_id, entry_span)) in &lambda_entry_by_graph { + let lambda_span = method_spans[graph_key]; + let enclosing_locals = graph_local_names + .iter() + .filter(|(other_key, _)| { + *other_key != graph_key && span_contains(method_spans[*other_key], lambda_span) + }) + .flat_map(|(_, names)| names.iter().cloned()) + .collect::>(); + let local_reads = graph_local_names + .get(graph_key) + .cloned() + .unwrap_or_default(); + let local_writes = graph_key_by_node + .iter() + .filter(|(_, node_graph_key)| *node_graph_key == graph_key) + .flat_map(|(node_id, _)| { + let raw = &raw_by_node[node_id]; + raw.writes + .iter() + .filter(|name| { + raw.place_kinds + .get(*name) + .is_some_and(|kind| kind == "local") + }) + .cloned() + .collect::>() + }) + .collect::>(); + let captures = local_reads + .difference(&local_writes) + .filter(|name| enclosing_locals.contains(*name)) + .cloned() + .collect::>(); + if captures.is_empty() { + continue; + } + let entry_raw = raw_by_node + .get_mut(entry_id) + .expect("lambda entry node has a raw effect"); + for name in &captures { + entry_raw.writes.insert(name.clone()); + entry_raw.record_place(name.clone(), "local"); + } + let places = all_places.entry(graph_key.clone()).or_default(); + for name in &captures { + places + .entry(name.clone()) + .or_insert_with(|| "local".to_string()); + declaration_spans + .entry((graph_key.clone(), name.clone())) + .or_insert(*entry_span); + } + } + let mut place_id_by_name = BTreeMap::new(); for (graph_key, places) in all_places { let (file, owner, function, discriminator_line, discriminator_column) = &graph_key; @@ -219,6 +314,17 @@ pub(crate) fn extract( format!("place:{}#{}:unknown:{}", node.owner, node.function, name) }) }; + for binding in &raw.callback_bindings { + facts.callback_bindings.push(CallbackBindingFact { + node_id: node.id.clone(), + file: node.file.clone(), + function: node.function.clone(), + owner: node.owner.clone(), + place_id: id_for(&binding.name), + position: binding.position, + span: binding.span, + }); + } facts.effects.push(NodeEffect { node_id: node.id.clone(), file: node.file.clone(), @@ -247,6 +353,16 @@ pub(crate) fn extract( .into_iter() .map(|(target, producer_span)| (id_for(&target), producer_span)) .collect(), + write_call_source_sets: raw + .write_call_source_sets + .into_iter() + .map(|(target, producer_spans)| (id_for(&target), producer_spans)) + .collect(), + write_sequence_projections: raw + .write_sequence_projections + .into_iter() + .map(|(target, position)| (id_for(&target), position)) + .collect(), write_nullable_contracts: raw .write_nullable_contracts .into_iter() @@ -272,20 +388,57 @@ pub(crate) fn extract( fn apply_normalized_local_contract( method: &MethodSummary, node: &ControlFlowNode, + target: &Node, effect: &mut RawEffect, + behavior: &dyn NormalizedLanguageBehavior, ) { - let contracts = crate::syntax::local_flow::local_contract_assignments(method); + // Compound CFG nodes deliberately summarize their control expression, + // while the assignments in their bodies have their own child nodes. + // A local-flow statement may span the whole compound expression and list + // nested writes; treating one of those as a write on the loop/branch node + // kills the real reaching definitions at the next iteration. + if node.role != "linear_statement" { + return; + } let Some(statement) = method .statements .iter() - .find(|statement| statement.span == node.span && statement.writes.len() == 1) + .find(|statement| statement.span == node.span) else { return; }; + if statement.writes.len() != 1 { + return; + } let name = statement.writes.iter().next().expect("single local write"); - if contracts.get(name).is_some_and(|value| value == "nil") - && !effect.write_value_hints.contains_key(name) - { + if !plain_local_name(name) { + return; + } + if effect.place_kinds.iter().any(|(raw_name, kind)| { + kind != "local" + && (raw_name == name + || raw_name.trim_start_matches(|character: char| { + !character.is_alphanumeric() && character != '_' + }) == name) + }) { + return; + } + // The syntax tree owns producer relations. When it proves this exact + // statement is a direct call-result assignment, retain the normalized + // local-flow target even if an equal parser span hid the write node. + if !effect.write_call_sources.contains_key(name) { + if let Some(span) = direct_call_result_span(target) { + effect.writes.insert(name.clone()); + effect.record_place(name.clone(), "local"); + effect.write_call_sources.insert(name.clone(), span); + } + } + let Some(value) = + crate::syntax::local_flow::raw_local_assignment_source(name, &statement.source) + else { + return; + }; + if value == "nil" && !effect.write_value_hints.contains_key(name) { effect.writes.insert(name.clone()); effect.record_place(name.clone(), "local"); effect @@ -294,9 +447,22 @@ fn apply_normalized_local_contract( effect .write_value_hints .insert(name.clone(), "nil".to_string()); + } else if !effect.write_type_hints.contains_key(name) { + if let Some(type_name) = behavior.local_assignment_type_hint(&value) { + effect.writes.insert(name.clone()); + effect.record_place(name.clone(), "local"); + effect.write_type_hints.insert(name.clone(), type_name); + } } } +fn plain_local_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) +} + fn method_for_node<'a>( methods: &'a [MethodSummary], node: &ControlFlowNode, @@ -368,7 +534,7 @@ fn effect_target<'a>(node: &'a Node, role: &str, profile: &ControlFlowProfile) - fn collect_control_bindings(node: &Node, role: &str, effect: &mut RawEffect) { if role == "for_loop" && node.r#type == "FOR" { if let Some(target) = node.children.first().and_then(ast::node) { - collect(target, effect, false); + collect(target, effect, false, None); } return; } @@ -387,7 +553,37 @@ fn collect_scope_bindings(scope: Option<&Node>, effect: &mut RawEffect) { None }; if let Some(args) = args { - collect(args, effect, false); + let mut names = Vec::new(); + collect_binding_names(args, &mut names); + let span = [ + scope.first_lineno, + scope.first_column, + scope.last_lineno, + scope.last_column, + ]; + for (position, name) in names.into_iter().enumerate() { + let binding = RawCallbackBinding { + name, + position, + span, + }; + if !effect.callback_bindings.contains(&binding) { + effect.callback_bindings.push(binding); + } + } + collect(args, effect, false, None); + } +} + +fn collect_binding_names(node: &Node, names: &mut Vec) { + if WRITE_TYPES.contains(&node.r#type.as_str()) { + if let Some(name) = node_name(node) { + names.push(name); + } + return; + } + for child in node.children.iter().filter_map(ast::node) { + collect_binding_names(child, names); } } @@ -396,8 +592,16 @@ fn collect_nested_bindings(node: &Node, effect: &mut RawEffect) { return; } match node.r#type.as_str() { - "ITER" => collect_scope_bindings(node.children.get(1).and_then(ast::node), effect), - "LAMBDA" => collect_scope_bindings(node.children.first().and_then(ast::node), effect), + // Nested callbacks can share one compound CFG node. Retain their + // exact regions and restart positions for each scope: + // `rows.map { |row| ... }.sort_by { |result| ... }` has two position + // zero bindings, not one two-argument callback. + "ITER" => { + collect_scope_bindings(node.children.get(1).and_then(ast::node), effect); + } + "LAMBDA" => { + collect_scope_bindings(node.children.first().and_then(ast::node), effect); + } _ => {} } for child in node.children.iter().filter_map(ast::node) { @@ -405,10 +609,62 @@ fn collect_nested_bindings(node: &Node, effect: &mut RawEffect) { } } -fn collect(node: &Node, effect: &mut RawEffect, function_value_calls_are_local_reads: bool) { +fn collect( + node: &Node, + effect: &mut RawEffect, + function_value_calls_are_local_reads: bool, + behavior: Option<&dyn NormalizedLanguageBehavior>, +) { if NESTED_SCOPE_TYPES.contains(&node.r#type.as_str()) { return; } + // MASGN is a normalized AST contract shared by adapters: child zero is + // the produced value and child one is the ordered target list. Preserve + // the positional projection alongside the ordinary call-result edge so + // the generic CFG/DFG overlay can type each destructured binding. + if node.r#type == "MASGN" { + let value = node.children.first().and_then(ast::node); + let targets = node + .children + .get(1) + .and_then(ast::node) + .into_iter() + .flat_map(|list| list.children.iter().filter_map(ast::node)) + .collect::>(); + let producer_spans = value.and_then(|value| call_result_source_spans(value, behavior)); + for (position, target) in targets.into_iter().enumerate() { + let Some(name) = node_name(target) else { + effect.complete = false; + effect + .unknown_reasons + .push("MASGN target has no normalized name".to_string()); + continue; + }; + effect.writes.insert(name.clone()); + effect.record_place(name.clone(), place_kind_for_node(&target.r#type)); + if let Some(producer_spans) = producer_spans.as_ref() { + if producer_spans.len() == 1 { + effect + .write_call_sources + .insert(name.clone(), producer_spans[0]); + } else if !producer_spans.is_empty() { + effect + .write_call_source_sets + .insert(name.clone(), producer_spans.clone()); + } + effect.write_sequence_projections.insert(name, position); + } + } + if let Some(value) = value { + collect( + value, + effect, + function_value_calls_are_local_reads, + behavior, + ); + } + return; + } if WRITE_TYPES.contains(&node.r#type.as_str()) { if let Some(name) = node_name(node) { effect.writes.insert(name.clone()); @@ -430,10 +686,14 @@ fn collect(node: &Node, effect: &mut RawEffect, function_value_calls_are_local_r } } else if let Some(source) = direct_read_name(rhs) { effect.write_sources.insert(name, source); - } else if let Some(producer_span) = direct_call_result_span(rhs) { - effect.write_call_sources.insert(name, producer_span); + } else if let Some(producer_spans) = call_result_source_spans(rhs, behavior) { + if producer_spans.len() == 1 { + effect.write_call_sources.insert(name, producer_spans[0]); + } else { + effect.write_call_source_sets.insert(name, producer_spans); + } } - collect(rhs, effect, function_value_calls_are_local_reads); + collect(rhs, effect, function_value_calls_are_local_reads, behavior); } } else { effect.complete = false; @@ -461,7 +721,12 @@ fn collect(node: &Node, effect: &mut RawEffect, function_value_calls_are_local_r effect.unknown_call = true; } for child in node.children.iter().filter_map(ast::node) { - collect(child, effect, function_value_calls_are_local_reads); + collect( + child, + effect, + function_value_calls_are_local_reads, + behavior, + ); } } @@ -529,6 +794,11 @@ fn direct_return_state_hint(node: &Node) -> Option { /// preserving assignment edges. fn direct_call_result_span(node: &Node) -> Option { match node.r#type.as_str() { + "ITER" => node + .children + .first() + .and_then(ast::node) + .and_then(direct_call_result_span), "PAREN" | "BEGIN" | "EXPRESSION_LIST" => { let mut children = node.children.iter().filter_map(ast::node); let only = children.next()?; @@ -546,6 +816,26 @@ fn direct_call_result_span(node: &Node) -> Option { } } +/// Return all direct call producers of a value-preserving assignment. The +/// shared CFG understands direct calls and transparent grouping; an adapter +/// explicitly vouches for any native compound expression whose result is one +/// of its operands. +fn call_result_source_spans( + node: &Node, + behavior: Option<&dyn NormalizedLanguageBehavior>, +) -> Option> { + if let Some(span) = direct_call_result_span(node) { + return Some(vec![span]); + } + let operands = behavior?.value_preserving_call_result_operands(node)?; + let mut spans = BTreeSet::new(); + for operand in operands { + let operand_spans = call_result_source_spans(operand, behavior)?; + spans.extend(operand_spans); + } + (!spans.is_empty()).then(|| spans.into_iter().collect()) +} + fn direct_call_result_node(node: &Node) -> Option<&Node> { if matches!( node.r#type.as_str(), @@ -553,6 +843,13 @@ fn direct_call_result_node(node: &Node) -> Option<&Node> { ) { return Some(node); } + if node.r#type == "ITER" { + return node + .children + .first() + .and_then(ast::node) + .and_then(direct_call_result_node); + } if matches!(node.r#type.as_str(), "LASGN" | "DASGN") { return node .children @@ -639,6 +936,22 @@ fn find_by_span(node: &Node, span: Span, prefer_innermost: bool) -> Option<&Node .filter_map(ast::node) .find_map(|child| find_by_span(child, span, true)) { + // Argument containers may be assigned the same range and source + // text as their enclosing normalized expression. They are not a + // complete effect boundary: selecting one drops the receiver and + // callee. Prefer the enclosing semantic node in that exact case, + // while still unwrapping ordinary transparent expression groups. + if ["LIST", "ARGS"].contains(&match_.r#type.as_str()) + && CALL_TYPES.contains(&node.r#type.as_str()) + && [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ] == span + { + return Some(node); + } return Some(match_); } } @@ -673,6 +986,18 @@ fn find_syntax_node<'a>(node: &'a Node, span: Span, role: &str) -> Option<&'a No }; preferred_kind .and_then(|kind| find_by_span_and_kind(node, span, kind)) + // Some normalized parsers assign the same span to an assignment and + // its value expression. A linear CFG node represents the whole + // statement, so retain the normalized write node instead of selecting + // the innermost call and silently dropping the definition edge. + .or_else(|| { + (role == "linear_statement").then(|| { + WRITE_TYPES + .iter() + .chain(std::iter::once(&"MASGN")) + .find_map(|kind| find_by_span_and_kind(node, span, kind)) + })? + }) .or_else(|| find_by_span(node, span, role == "linear_statement")) } @@ -722,12 +1047,12 @@ mod tests { text: "callback()".to_string(), }; let mut enabled = RawEffect::default(); - collect(&call, &mut enabled, true); + collect(&call, &mut enabled, true, None); assert!(enabled.reads.contains("callback")); assert!(enabled.unknown_call); let mut disabled = RawEffect::default(); - collect(&call, &mut disabled, false); + collect(&call, &mut disabled, false, None); assert!(disabled.reads.is_empty()); assert!(disabled.unknown_call); } diff --git a/gems/fact-mine/src/syntax/cfg/facts.rs b/gems/fact-mine/src/syntax/cfg/facts.rs index 848a6d107..848e07be0 100644 --- a/gems/fact-mine/src/syntax/cfg/facts.rs +++ b/gems/fact-mine/src/syntax/cfg/facts.rs @@ -45,6 +45,7 @@ pub struct ControlFlowFacts { pub def_use: Vec, pub liveness: Vec, pub flow_types: Vec, + pub callback_bindings: Vec, } #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -58,6 +59,21 @@ pub struct Place { pub declaration_span: Span, } +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct CallbackBindingFact { + pub node_id: String, + pub file: String, + pub function: String, + pub owner: String, + pub place_id: String, + pub position: usize, + /// Exact callback/iterator region that owns this positional binding. + /// Several nested callbacks can be normalized into one compound CFG node; + /// the region keeps their independent position-zero parameters distinct. + #[serde(default)] + pub span: Span, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeEffect { pub node_id: String, @@ -80,6 +96,16 @@ pub struct NodeEffect { /// receiver text or guessing its return type in the CFG layer. #[serde(default)] pub write_call_sources: BTreeMap, + /// A sound producer set for a value-preserving expression such as a + /// language-owned short-circuit selection. Every span may produce the + /// assigned value; consumers must join all of them rather than choose one. + #[serde(default)] + pub write_call_source_sets: BTreeMap>, + /// Positional projection for a normalized destructuring write. The call + /// source identifies the producer; this index identifies which sequence + /// value reaches the written place. + #[serde(default)] + pub write_sequence_projections: BTreeMap, /// Reviewed exact call-result contracts keyed by their target place. /// This is not inferred from identifiers downstream of the adapter. #[serde(default)] diff --git a/gems/fact-mine/src/syntax/cfg/mod.rs b/gems/fact-mine/src/syntax/cfg/mod.rs index dc0439100..e18764744 100644 --- a/gems/fact-mine/src/syntax/cfg/mod.rs +++ b/gems/fact-mine/src/syntax/cfg/mod.rs @@ -19,8 +19,8 @@ pub(crate) mod worklist; pub(crate) use cursor::MethodCursor; pub(crate) use facts::ControlFlowProfile; pub use facts::{ - ControlFlowEdge, ControlFlowFacts, ControlFlowMetric, ControlFlowNode, DefUseFact, - DominatorFact, FlowTypeFact, LivenessFact, NodeEffect, Place, ReachabilityFact, + CallbackBindingFact, ControlFlowEdge, ControlFlowFacts, ControlFlowMetric, ControlFlowNode, + DefUseFact, DominatorFact, FlowTypeFact, LivenessFact, NodeEffect, Place, ReachabilityFact, ReachingDefinitionFact, }; diff --git a/gems/fact-mine/src/syntax/complexity_facts.rs b/gems/fact-mine/src/syntax/complexity_facts.rs index fa79ed290..26c31a4fa 100644 --- a/gems/fact-mine/src/syntax/complexity_facts.rs +++ b/gems/fact-mine/src/syntax/complexity_facts.rs @@ -97,6 +97,8 @@ pub struct SymbolicComplexityFact { pub factors: Vec, #[serde(default)] pub logarithmic: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logarithmic_domain_id: Option, #[serde(default = "default_true")] pub complete: bool, } @@ -209,6 +211,8 @@ pub struct RecursionFacts { pub shrinking_calls: usize, pub halving_calls: usize, #[serde(default)] + pub structural_calls: usize, + #[serde(default)] pub visited_guarded_calls: usize, pub loop_contained_shrinking_calls: usize, pub unknown_progress_calls: usize, @@ -222,6 +226,7 @@ struct Assignment { cardinality_dependencies: BTreeSet, shrinking: bool, halving: bool, + structural_descent: bool, empty_collection: bool, fixed_collection: bool, } @@ -307,6 +312,24 @@ impl DomainRegistry { span, }) } + + fn allocation_output(&mut self, names: &BTreeSet, span: [usize; 4]) -> String { + let name = if names.is_empty() { + format!("materialization at line {}", span[0]) + } else { + format!( + "materialized {}", + names.iter().cloned().collect::>().join(" + ") + ) + }; + self.insert(SizeDomainFact { + id: format!("allocation:{}:{}:{}", self.path, span[0], span[1]), + name, + source_kind: "output".to_string(), + path: self.path.clone(), + span, + }) + } } #[derive(Clone, Debug)] @@ -460,9 +483,6 @@ fn preliminary_block_summaries( .iter() .cloned() .collect::>(); - if callback_params.is_empty() { - return None; - } let mut summary = BlockSummary::default(); collect_block_invocations( &method.node, @@ -472,7 +492,8 @@ fn preliminary_block_summaries( &mut summary, behavior, ); - Some(((definition.owner.clone(), method.name.clone()), summary)) + (summary.invocations > 0) + .then(|| ((definition.owner.clone(), method.name.clone()), summary)) }) .collect() } @@ -488,6 +509,11 @@ fn collect_block_invocations( if deferred_block(node, behavior) { return; } + if node.r#type == "YIELD" && behavior.yield_semantic_effect(node) { + summary.invocations += 1; + summary.power = summary.power.max(power); + summary.unknown |= unknown; + } if let Some(message) = direct_call_message(node) { if behavior.callback_invocation_message(message) { let receiver_names = call_receiver(node).map(local_names).unwrap_or_default(); @@ -501,9 +527,7 @@ fn collect_block_invocations( if loop_node(node, behavior) { let semantics = if node.r#type == "ITER" { - iterator_message(node) - .map(|message| behavior.block_call_semantics(message)) - .unwrap_or(BlockCallSemantics::Unknown) + iterator_block_semantics(node, behavior) } else { BlockCallSemantics::Iteration }; @@ -580,7 +604,55 @@ fn fact_for_method( let state_progress = collect_state_progress(node, &mut domain_registry, behavior); let state_cursor_domains = collect_state_cursor_domains(node, &mut domain_registry, behavior); let mut collection_growth = BTreeMap::new(); + let type_names = document + .owner_defs + .iter() + .map(|owner| owner.name.clone()) + .collect::>(); + // Global (owner -> field -> type) table for `base.field` receiver typing, + // built once from every type's declared fields. + let mut field_types: BTreeMap> = BTreeMap::new(); + for state in &document.state_declarations { + let Some(declared) = state.r#type.as_deref() else { + continue; + }; + let Some(owner_name) = type_owner_name(&TypeExpr::parse(&state.owner, language)) else { + continue; + }; + field_types.entry(owner_name).or_default().insert( + state.field.trim_start_matches('@').to_string(), + TypeExpr::parse(declared, language), + ); + } + // Bind explicit method receiver variables to the owner type and augment + // types only through language-owned, proven flow/declaration contracts. + let mut augmented_parameter_types = parameter_types.clone(); + if behavior.complexity_uses_invariant_flow_types() { + augmented_parameter_types.extend(invariant_flow_local_types( + document, owner, function, language, + )); + } + if behavior.complexity_uses_syntax_local_types() { + let type_key = method_parameter_type_key(owner, function, line); + augmented_parameter_types.extend( + document + .method_local_types + .get(&type_key) + .into_iter() + .flat_map(|types| types.iter()) + .map(|(name, declared)| (name.clone(), TypeExpr::parse(declared, language))), + ); + } + for (receiver_var, target) in behavior.receiver_aliases_for_function(node) { + if target == "self" { + augmented_parameter_types + .entry(receiver_var) + .or_insert_with(|| TypeExpr::parse(owner, language)); + } + } + let parameter_types = &augmented_parameter_types; visit_loops( + node, node, params, &assignments, @@ -597,6 +669,8 @@ fn fact_for_method( block_summaries, state_types, type_aliases, + &type_names, + &field_types, language, behavior, &mut domain_registry, @@ -604,17 +678,23 @@ fn fact_for_method( let mut recursion = RecursionFacts::default(); let visited_guards = visited_guard_parameters(node, params, behavior); collect_recursion( + node, node, function, false, + params, &assignments, + &BTreeSet::new(), &visited_guards, &mut recursion, behavior, bare_self_calls_are_recursive, ); recursion.unknown_progress_calls = recursion.calls.saturating_sub( - recursion.shrinking_calls + recursion.halving_calls + recursion.visited_guarded_calls, + recursion.shrinking_calls + + recursion.halving_calls + + recursion.structural_calls + + recursion.visited_guarded_calls, ); let mut allocations = Vec::new(); collect_allocations( @@ -624,6 +704,7 @@ fn fact_for_method( &collection_mutations, parameter_types, state_types, + &field_types, &mut allocations, behavior, ); @@ -672,6 +753,26 @@ fn fact_for_method( } } } + // A materialization has a sound output-sensitive space bound even when + // DFG cannot relate its cardinality to a particular input. Preserve that + // distinction instead of reporting the allocation as unknowable: O(M) + // here means linear in the actual materialized result size M, represented + // by an explicit output domain. + for allocation in &mut allocations { + if allocation.cardinality_relation != "unknown" { + continue; + } + let names = allocation + .domain_expression + .iter() + .cloned() + .collect::>(); + let domain = domain_registry.allocation_output(&names, allocation.span); + allocation.cardinality_relation = "output_size".to_string(); + allocation.bound_classification = "output".to_string(); + allocation.evidence_gap = None; + allocation.symbolic_size = Some(symbolic_complexity(&BTreeMap::from([(domain, 1)]), true)); + } allocations.sort_by_key(|fact| (fact.line, fact.span[1], fact.kind.clone())); allocations.dedup_by(|left, right| left.span == right.span && left.kind == right.kind); @@ -1011,6 +1112,7 @@ fn collect_allocations( collection_mutations: &BTreeMap>, parameter_types: &BTreeMap, state_types: &BTreeMap, + field_types: &BTreeMap>, output: &mut Vec, behavior: &dyn NormalizedLanguageBehavior, ) { @@ -1023,6 +1125,7 @@ fn collect_allocations( parameter_types, assignments, state_types, + field_types, (node.first_lineno, node.first_column), behavior, ) @@ -1101,6 +1204,7 @@ fn collect_allocations( collection_mutations, parameter_types, state_types, + field_types, output, behavior, ); @@ -1110,6 +1214,7 @@ fn collect_allocations( #[allow(clippy::too_many_arguments)] // Loop analysis requires the full immutable analysis context. fn visit_loops( node: &Node, + method: &Node, params: &BTreeSet, assignments: &BTreeMap>, collection_mutations: &BTreeMap>, @@ -1125,6 +1230,8 @@ fn visit_loops( block_summaries: &BTreeMap<(String, String), BlockSummary>, state_types: &BTreeMap, type_aliases: &BTreeMap, + type_names: &BTreeSet, + field_types: &BTreeMap>, language: &str, behavior: &dyn NormalizedLanguageBehavior, domain_registry: &mut DomainRegistry, @@ -1132,7 +1239,23 @@ fn visit_loops( let block_semantics = if node.r#type == "ITER" { iterator_message(node) .map(|message| { - let configured = behavior.block_call_semantics(message); + let configured = behavior.block_call_semantics_with_receiver( + iterator_receiver(node), + loop_control(node) + .and_then(|control| { + call_receiver_type( + control, + parameter_types, + assignments, + state_types, + field_types, + (node.first_lineno, node.first_column), + behavior, + ) + }) + .as_ref(), + message, + ); if configured != BlockCallSemantics::Unknown { configured } else { @@ -1158,6 +1281,7 @@ fn visit_loops( if let Some(control) = loop_control(node) { visit_loops( control, + method, params, assignments, collection_mutations, @@ -1173,6 +1297,8 @@ fn visit_loops( block_summaries, state_types, type_aliases, + type_names, + field_types, language, behavior, domain_registry, @@ -1184,6 +1310,7 @@ fn visit_loops( for child in child_nodes(node) { visit_loops( child, + method, params, assignments, collection_mutations, @@ -1199,6 +1326,8 @@ fn visit_loops( block_summaries, state_types, type_aliases, + type_names, + field_types, language, behavior, domain_registry, @@ -1250,8 +1379,13 @@ fn visit_loops( .max_by_key(|growth| growth.power) .cloned(); let growth_power = growth.as_ref().map(|growth| growth.power); - let unknown_iteration = - node.r#type == "ITER" && block_semantics == BlockCallSemantics::Unknown; + let inferred_project_block = node.r#type == "ITER" + && iterator_message(node).is_some_and(|message| { + block_summaries.contains_key(&(owner.to_string(), message.to_string())) + }); + let unknown_iteration = node.r#type == "ITER" + && block_semantics == BlockCallSemantics::Unknown + && !inferred_project_block; let fixed_locals = !locals.is_empty() && locals.iter().all(|local| { fixed_collection_local( @@ -1487,6 +1621,7 @@ fn visit_loops( if let Some(control) = control { visit_loops( control, + method, params, assignments, collection_mutations, @@ -1502,6 +1637,8 @@ fn visit_loops( block_summaries, state_types, type_aliases, + type_names, + field_types, language, behavior, domain_registry, @@ -1510,6 +1647,7 @@ fn visit_loops( if let Some(body) = loop_body(node) { visit_loops( body, + method, params, assignments, collection_mutations, @@ -1525,6 +1663,8 @@ fn visit_loops( block_summaries, state_types, type_aliases, + type_names, + field_types, language, behavior, domain_registry, @@ -1630,6 +1770,7 @@ fn visit_loops( parameter_types, assignments, state_types, + field_types, (node.first_lineno, node.first_column), behavior, ); @@ -1641,6 +1782,22 @@ fn visit_loops( call_receiver(node).map(|receiver| receiver.text.trim()), message, ) + }) + .or_else(|| { + // `T(x)` where T names a declared type is a conversion, not + // a call. The adapter owns whether that is constant-time. + (call_receiver(node).is_none() && type_names.contains(message)) + .then(|| behavior.type_name_conversion_complexity()) + .flatten() + }) + .or_else(|| { + // A paren-less member access is a constant-time field/ + // property read, not an unresolved method call. + behavior.complexity_member_read_complexity(node) + }) + .or_else(|| { + let operand_type = operator_operand_type(node, parameter_types); + behavior.scalar_operator_complexity(message, operand_type.as_ref()) }); let evidence_gap = known_call_complexity.is_none().then(|| { if behavior.callback_invocation_message(message) { @@ -1671,9 +1828,13 @@ fn visit_loops( parameter_arguments: local_names(node).intersection(params).cloned().collect(), argument_cardinality_relation: argument_cardinality_relation.to_string(), argument_progress: call_argument_progress( + method, node, + params, assignments, + &parent.partition_locals, (node.first_lineno, node.first_column), + behavior, ), argument_size_domains, receiver_size_domains, @@ -1691,6 +1852,7 @@ fn visit_loops( for child in child_nodes(node) { visit_loops( child, + method, params, assignments, collection_mutations, @@ -1706,6 +1868,8 @@ fn visit_loops( block_summaries, state_types, type_aliases, + type_names, + field_types, language, behavior, domain_registry, @@ -1719,6 +1883,7 @@ fn call_receiver_type( parameter_types: &BTreeMap, assignments: &BTreeMap>, state_types: &BTreeMap, + field_types: &BTreeMap>, before: (usize, usize), behavior: &dyn NormalizedLanguageBehavior, ) -> Option { @@ -1732,6 +1897,17 @@ fn call_receiver_type( return Some(state_type.clone()); } } + // `base.field` receiver: resolve the base's type, then read the field's + // declared type. Runs before the plain local-name fallback, which would + // otherwise yield the base's own type for `c.field.method()`. + if let Some(field_type) = field_access_type( + receiver.text.trim(), + parameter_types, + state_types, + field_types, + ) { + return Some(field_type); + } let receiver_names = local_names(receiver); for name in &receiver_names { if let Some(parameter_type) = parameter_types.get(name) { @@ -1745,6 +1921,60 @@ fn call_receiver_type( .find_map(|name| parameter_types.get(&name).cloned()) } +/// Resolve a `base.field` receiver to the field's declared type: resolve the +/// base expression, then read the field from the global (owner -> field -> type) +/// table. Recurses for nested access (`a.b.field`). Language-neutral. +fn field_access_type( + receiver: &str, + parameter_types: &BTreeMap, + state_types: &BTreeMap, + field_types: &BTreeMap>, +) -> Option { + let (base, field) = receiver.rsplit_once('.')?; + let base = base.trim(); + let field = field.trim(); + if base.is_empty() || field.is_empty() || field.contains(['(', '[', ']', ' ']) { + return None; + } + let base_type = resolve_expr_type(base, parameter_types, state_types, field_types)?; + let owner = type_owner_name(&base_type)?; + field_types + .get(&owner) + .and_then(|fields| fields.get(field)) + .cloned() +} + +/// Type of a base expression: a param/local, a self-field, or a nested access. +fn resolve_expr_type( + expr: &str, + parameter_types: &BTreeMap, + state_types: &BTreeMap, + field_types: &BTreeMap>, +) -> Option { + if let Some(parameter_type) = parameter_types.get(expr) { + return Some(parameter_type.clone()); + } + if let Some(state_type) = state_types.get(expr.trim_start_matches('@')) { + return Some(state_type.clone()); + } + field_access_type(expr, parameter_types, state_types, field_types) +} + +/// Bare nominal name of a type (strips pointer/ref, generics, and package/module +/// qualifiers) for keying the field table. +fn type_owner_name(type_expr: &TypeExpr) -> Option { + match type_expr { + TypeExpr::Primitive(name) => { + let bare = name.trim().trim_start_matches(['*', '&']).trim(); + let bare = bare.split(['<', '[']).next().unwrap_or(bare); + let bare = bare.rsplit(['.', ':']).next().unwrap_or(bare).trim(); + (!bare.is_empty()).then(|| bare.to_string()) + } + TypeExpr::Nilable(inner) => type_owner_name(inner), + _ => None, + } +} + fn record_collection_growth( node: &Node, context: &LoopContext, @@ -1857,6 +2087,8 @@ fn collect_assignments( halving: symbols .iter() .any(|symbol| matches!(symbol.as_str(), "/" | ">>")), + structural_descent: rhs + .is_some_and(|value| structural_projection_expression(value, behavior)), empty_collection: rhs .is_some_and(|value| empty_collection_expression(value, behavior)), fixed_collection: rhs.is_some_and(fixed_collection_expression), @@ -2151,12 +2383,69 @@ fn call_argument_nodes(node: &Node) -> Vec<&Node> { } fn call_receiver(node: &Node) -> Option<&Node> { - if node.r#type != "CALL" { + if !matches!(node.r#type.as_str(), "CALL" | "QCALL") { return None; } node.children.first().and_then(ast::node) } +fn operator_operand_type( + node: &Node, + local_types: &BTreeMap, +) -> Option { + let operand = node.children.iter().find_map(ast::node)?; + local_names(operand) + .into_iter() + .find_map(|name| local_types.get(&name).cloned()) +} + +/// Rust bindings have invariant types. A complete singleton DFG type for a +/// local therefore remains usable throughout that binding; shadowed bindings +/// with conflicting types are deliberately omitted. +fn invariant_flow_local_types( + document: &Document, + owner: &str, + function: &str, + language: &str, +) -> BTreeMap { + let places = document + .places + .iter() + .filter(|place| { + place.function == function + && (place.owner == owner || place.owner == "(top-level)" || owner == "(top-level)") + }) + .map(|place| (place.id.as_str(), place.name.as_str())) + .collect::>(); + let mut candidates = BTreeMap::>::new(); + for fact in document.flow_types.iter().filter(|fact| { + fact.function == function + && fact.complete + && (fact.owner == owner || fact.owner == "(top-level)" || owner == "(top-level)") + }) { + let Some(name) = places.get(fact.place_id.as_str()) else { + continue; + }; + let types = fact + .types + .iter() + .filter_map(|hint| TypeExpr::from_flow_hint(hint, language)) + .collect::>(); + if types.len() == 1 { + candidates + .entry((*name).to_string()) + .or_default() + .extend(types); + } + } + candidates + .into_iter() + .filter_map(|(name, mut types)| { + (types.len() == 1).then(|| (name, types.pop_first().expect("one type"))) + }) + .collect() +} + fn contains_index_access(node: &Node) -> bool { direct_call_message(node) == Some("[]") || child_nodes(node).into_iter().any(contains_index_access) @@ -2173,12 +2462,75 @@ fn call_has_arguments(node: &Node) -> bool { }) } +/// Whether some argument is a proper projection out of a parameter rather than +/// the parameter itself - `walk(node.left)`, `visit(entry.children)`, +/// `descend(items[index])`. +/// +/// This is the progress measure structural recursion actually uses. No operand +/// shrinks arithmetically, so the token-level test below reports `unknown` and +/// the caller is left with no bound at all; but each step moves strictly one +/// level down a finite structure, so the same argument cannot recur. Passing +/// the parameter back unchanged is deliberately excluded - that is the shape +/// that does not terminate. +fn structural_descent_argument( + node: &Node, + params: &BTreeSet, + assignments: &BTreeMap>, + before: (usize, usize), + behavior: &dyn NormalizedLanguageBehavior, +) -> bool { + call_argument_nodes(node).into_iter().any(|argument| { + let names = local_names(argument); + let rooted_in_parameter = names.iter().any(|name| { + params.contains(name) + || params + .iter() + .any(|parameter| derived_from(name, parameter, before, assignments)) + }); + if !rooted_in_parameter { + return false; + } + if matches!( + argument.r#type.as_str(), + "LVAR" | "DVAR" | "IVAR" | "GVAR" | "SELF" | "CONST" + ) { + return names.iter().any(|name| { + assignments.get(name).is_some_and(|rows| { + rows.iter() + .rev() + .find(|row| (row.line, row.column) < before) + .is_some_and(|row| row.structural_descent) + }) + }); + } + structural_projection_expression(argument, behavior) + }) +} + +fn structural_projection_expression( + node: &Node, + behavior: &dyn NormalizedLanguageBehavior, +) -> bool { + contains_index_access(node) + || behavior.complexity_member_read_complexity(node).is_some() + || (matches!(node.r#type.as_str(), "PREFIX_UNARY_EXPRESSION" | "UNARY") + && node.text.trim_start().starts_with('*')) +} + fn call_argument_progress( + method: &Node, node: &Node, + params: &BTreeSet, assignments: &BTreeMap>, + structural_bindings: &BTreeSet, before: (usize, usize), + behavior: &dyn NormalizedLanguageBehavior, ) -> String { let arguments = call_argument_nodes(node); + let argument_names = arguments + .iter() + .flat_map(|argument| local_names(argument)) + .collect::>(); let symbols = arguments .iter() .flat_map(|argument| descendant_symbols(argument)) @@ -2204,6 +2556,13 @@ fn call_argument_progress( "halving".to_string() } else if symbols.iter().any(|symbol| symbol == "-") || assigned_shape.0 { "shrinking".to_string() + } else if !argument_names.is_disjoint(structural_bindings) + || structural_descent_argument(node, params, assignments, before, behavior) + { + "structural".to_string() + } else if behavior.recursive_call_argument_progress(method, node, params) == Some("structural") + { + "structural".to_string() } else { "unknown".to_string() } @@ -2212,9 +2571,12 @@ fn call_argument_progress( #[allow(clippy::too_many_arguments)] // Recursion evidence is accumulated from independent control-flow inputs. fn collect_recursion( node: &Node, + method: &Node, function: &str, inside_loop: bool, + params: &BTreeSet, assignments: &BTreeMap>, + structural_bindings: &BTreeSet, visited_guards: &BTreeSet, out: &mut RecursionFacts, behavior: &dyn NormalizedLanguageBehavior, @@ -2224,6 +2586,24 @@ fn collect_recursion( return; } let now_inside = inside_loop || loop_node(node, behavior); + let mut nested_structural_bindings = structural_bindings.clone(); + if loop_node(node, behavior) { + let control_names = loop_control(node).map(local_names).unwrap_or_default(); + let parameter_rooted = control_names.iter().any(|name| { + params.contains(name) + || params.iter().any(|parameter| { + derived_from( + name, + parameter, + (node.first_lineno, node.first_column), + assignments, + ) + }) + }); + if parameter_rooted { + nested_structural_bindings.extend(loop_binding_names(node)); + } + } if recursive_self_call(node, function, bare_self_calls_are_recursive) { out.calls += 1; let guarded = !local_names(node).is_disjoint(visited_guards); @@ -2251,14 +2631,35 @@ fn collect_recursion( if now_inside { out.loop_contained_shrinking_calls += 1; } + } else if !call_argument_nodes(node) + .into_iter() + .flat_map(local_names) + .collect::>() + .is_disjoint(structural_bindings) + || structural_descent_argument( + node, + params, + assignments, + (node.first_lineno, node.first_column), + behavior, + ) + { + out.structural_calls += 1; + } else if behavior.recursive_call_argument_progress(method, node, params) + == Some("structural") + { + out.structural_calls += 1; } } for child in child_nodes(node) { collect_recursion( child, + method, function, now_inside, + params, assignments, + &nested_structural_bindings, visited_guards, out, behavior, @@ -2319,7 +2720,10 @@ fn recursive_self_call(node: &Node, function: &str, bare_self_calls_are_recursiv } fn direct_call_message(node: &Node) -> Option<&str> { - if !matches!(node.r#type.as_str(), "CALL" | "VCALL" | "FCALL" | "OPCALL") { + if !matches!( + node.r#type.as_str(), + "CALL" | "QCALL" | "VCALL" | "FCALL" | "OPCALL" + ) { return None; } node.children.iter().find_map(|child| match child { @@ -2343,19 +2747,15 @@ fn descendant_symbols(node: &Node) -> Vec { fn loop_node(node: &Node, behavior: &dyn NormalizedLanguageBehavior) -> bool { matches!(node.r#type.as_str(), "FOR" | "WHILE" | "UNTIL") || (node.r#type == "ITER" - && iterator_message(node).is_some_and(|message| { - matches!( - behavior.block_call_semantics(message), - BlockCallSemantics::Iteration | BlockCallSemantics::Unknown - ) - })) + && matches!( + iterator_block_semantics(node, behavior), + BlockCallSemantics::Iteration | BlockCallSemantics::Unknown + )) } fn deferred_block(node: &Node, behavior: &dyn NormalizedLanguageBehavior) -> bool { node.r#type == "ITER" - && iterator_message(node).is_some_and(|message| { - behavior.block_call_semantics(message) == BlockCallSemantics::Deferred - }) + && iterator_block_semantics(node, behavior) == BlockCallSemantics::Deferred } fn iterator_message(node: &Node) -> Option<&str> { @@ -2365,6 +2765,26 @@ fn iterator_message(node: &Node) -> Option<&str> { .and_then(direct_call_message) } +fn iterator_receiver(node: &Node) -> Option<&str> { + node.children + .first() + .and_then(ast::node) + .and_then(call_receiver) + .map(|receiver| receiver.text.trim()) +} + +fn iterator_block_semantics( + node: &Node, + behavior: &dyn NormalizedLanguageBehavior, +) -> BlockCallSemantics { + iterator_message(node) + .map(|message| { + // Syntax-only callers deliberately omit inferred flow types. + behavior.block_call_semantics_with_receiver(iterator_receiver(node), None, message) + }) + .unwrap_or(BlockCallSemantics::Unknown) +} + fn loop_control(node: &Node) -> Option<&Node> { let index = if node.r#type == "FOR" && node.children.len() >= 3 { 1 @@ -2556,6 +2976,7 @@ fn symbolic_complexity( }) .collect(), logarithmic: false, + logarithmic_domain_id: None, complete, } } @@ -2584,6 +3005,55 @@ mod tests { facts(&document) } + #[test] + fn rust_scalar_operators_require_declared_or_dfg_operand_types() { + let rows = language_facts( + r#" +fn scalar_parameter(left: usize, right: usize) -> bool { + left < right +} + +fn scalar_local(input: usize) -> bool { + let local: usize = input; + local == 4 +} + +fn overloaded(left: Vec, right: Vec) -> bool { + left == right +} +"#, + Language::Rust, + ".rs", + ); + let context = |function: &str, message: &str| { + rows.iter() + .find(|row| row.function == function) + .unwrap() + .call_contexts + .iter() + .find(|call| call.message == message) + .unwrap() + }; + + assert_eq!( + context("scalar_parameter", "<") + .known_time_complexity + .as_deref(), + Some("O(1)") + ); + assert_eq!( + context("scalar_local", "==") + .known_time_complexity + .as_deref(), + Some("O(1)") + ); + assert_eq!( + context("overloaded", "==").known_time_complexity, + None, + "Vec equality dispatches through PartialEq and is not scalar" + ); + } + #[test] fn normalized_state_replay_facts_are_cross_language_and_reject_incomplete_protocols() { let ruby = language_facts( @@ -2774,6 +3244,9 @@ class Example { { return Some("unknown".to_string()); } + if recursion.structural_calls > 0 { + return Some("O(N)".to_string()); + } if row.parameters.len() == 1 && recursion.loop_contained_shrinking_calls > 0 { return Some("O(N!)".to_string()); } @@ -2976,7 +3449,9 @@ end .iter() .find(|row| row.function == "unknown_materialize") .unwrap(); - assert_eq!(unknown.allocations[0].cardinality_relation, "unknown"); + assert_eq!(unknown.allocations[0].cardinality_relation, "output_size"); + assert_eq!(unknown.allocations[0].bound_classification, "output"); + assert!(unknown.allocations[0].symbolic_size.is_some()); let callback_result = rows .iter() .find(|row| row.function == "callback_result") @@ -2984,7 +3459,7 @@ end assert!(callback_result .allocations .iter() - .any(|fact| fact.cardinality_relation == "unknown")); + .any(|fact| fact.cardinality_relation == "output_size")); let looped = rows .iter() .find(|row| row.function == "looped_call") @@ -3022,8 +3497,40 @@ def permute(items) items.each { |item| permute(items - [item]) } end def tree(node, seen) - tree(node.left, seen) - tree(node.right, seen) + tree(node[0], seen) + tree(node[1], seen) +end +def tree_via_local(node) + child = node[0] + tree_via_local(child) +end +def opaque_wrapper(node) + opaque_wrapper(identity(node)) +end +def unwrap_type(type_str) + if type_str =~ /^T\.nilable\((.+)\)$/ + unwrap_type($1) + elsif type_str =~ /^T\.any\((.+)\)$/ + types = $1.split(/\s*,\s*/) + present = types.reject { |type| type == "nil" } + unwrap_type(present.first || types.first) + else + type_str + end +end +def unwrap_match_data(type) + if type =~ /\AT\.nilable\((.+)\)\z/ + unwrap_match_data(Regexp.last_match(1)) + else + type + end +end +def unsafe_capture(value) + if value =~ /^(.*)$/ + unsafe_capture($1) + else + value + end end def guarded_tree(node, seen) return if seen.include?(node) @@ -3070,7 +3577,35 @@ end assert_eq!(complexity(&rows, "split"), Some("O(N)".into())); assert_eq!(complexity(&rows, "fib"), Some("O(2^N)".into())); assert_eq!(complexity(&rows, "permute"), Some("O(N!)".into())); - assert_eq!(complexity(&rows, "tree"), Some("unknown".into())); + assert_eq!(complexity(&rows, "tree"), Some("O(N)".into())); + assert_eq!(complexity(&rows, "tree_via_local"), Some("O(N)".into())); + assert_eq!(complexity(&rows, "opaque_wrapper"), Some("unknown".into())); + for function in ["unwrap_type", "unwrap_match_data"] { + let row = rows.iter().find(|row| row.function == function).unwrap(); + assert!(row.recursion.structural_calls > 0, "{function}"); + assert_eq!(row.recursion.unknown_progress_calls, 0, "{function}"); + assert!(row + .call_contexts + .iter() + .filter(|context| context.message == function) + .all(|context| context.argument_progress == "structural")); + assert_eq!(complexity(&rows, function), Some("O(N)".into())); + } + let unsafe_capture = rows + .iter() + .find(|row| row.function == "unsafe_capture") + .unwrap(); + assert_eq!(unsafe_capture.recursion.structural_calls, 0); + assert_eq!(unsafe_capture.recursion.unknown_progress_calls, 1); + assert_eq!(complexity(&rows, "unsafe_capture"), Some("unknown".into())); + assert_eq!( + rows.iter() + .find(|row| row.function == "tree_via_local") + .unwrap() + .recursion + .structural_calls, + 1 + ); let guarded = rows .iter() .find(|row| row.function == "guarded_tree") @@ -3109,7 +3644,15 @@ class Parser end def opaque_step(node) - even_step(node.child) + even_step(node[0]) + end + + def passthrough_step(node) + even_step(node) + end + + def opaque_source_step(node) + even_step(fetch_next) end end "#, @@ -3132,11 +3675,91 @@ end .argument_progress, "halving" ); + // Descending into an indexed projection of the parameter is progress + // even though nothing shrinks arithmetically: `node[0]` cannot be + // `node`, so the argument cannot recur. let opaque = rows .iter() .find(|row| row.function == "opaque_step") .unwrap(); - assert_eq!(opaque.call_contexts[0].argument_progress, "unknown"); + assert_eq!(opaque.call_contexts[0].argument_progress, "structural"); + // Handing the parameter straight back is the shape that does not + // terminate, so it must stay unproven. + let passthrough = rows + .iter() + .find(|row| row.function == "passthrough_step") + .unwrap(); + assert_eq!(passthrough.call_contexts[0].argument_progress, "unknown"); + // An argument with no parameter root proves nothing about progress. + let opaque_source = rows + .iter() + .find(|row| row.function == "opaque_source_step") + .unwrap(); + assert_eq!( + opaque_source + .call_contexts + .iter() + .find(|call| call.message == "even_step") + .unwrap() + .argument_progress, + "unknown" + ); + + let java = java_facts( + r#" +class Node { + Node left; + void walk(Node node) { + walk(node.left); + } +} +"#, + ); + let walk = java.iter().find(|row| row.function == "walk").unwrap(); + assert_eq!(walk.recursion.structural_calls, 1); + } + + #[test] + fn rust_recursive_calls_on_parameter_derived_loop_bindings_are_structural() { + let rows = language_facts( + r#" +struct Node { + children: Vec, +} + +fn walk(node: &Node) { + for child in node.children.iter() { + walk(child); + } +} + +fn no_progress(node: &Node) { + for _child in node.children.iter() { + no_progress(node); + } +} +"#, + Language::Rust, + ".rs", + ); + let walk = rows.iter().find(|row| row.function == "walk").unwrap(); + assert_eq!(walk.recursion.structural_calls, 1); + assert_eq!(walk.recursion.unknown_progress_calls, 0); + assert_eq!( + walk.call_contexts + .iter() + .find(|call| call.message == "walk") + .unwrap() + .argument_progress, + "structural" + ); + + let no_progress = rows + .iter() + .find(|row| row.function == "no_progress") + .unwrap(); + assert_eq!(no_progress.recursion.structural_calls, 0); + assert_eq!(no_progress.recursion.unknown_progress_calls, 1); } #[test] @@ -3157,6 +3780,7 @@ class Index [].sort {}.keys "value".split + %w[if unless].include?("if") end end "#, @@ -3195,6 +3819,36 @@ end !matches!(call.message.as_str(), "sort" | "keys" | "split") || call.known_time_complexity.is_some() })); + assert!(literals.call_contexts.iter().any(|call| { + call.message == "include?" + && call.known_time_complexity.as_deref() == Some("O(N)") + && call.known_space_complexity.as_deref() == Some("O(1)") + })); + } + + #[test] + fn declarative_owner_block_methods_receive_normalized_complexity_facts() { + let source = r#" +module CoverageData +Dataset = Struct.new(:files) do + def empty? + files.empty? + end +end +end +"#; + let mut file = tempfile::Builder::new().suffix(".rb").tempfile().unwrap(); + file.write_all(source.as_bytes()).unwrap(); + let document = syntax::parse_file(file.path().to_path_buf(), Language::Ruby).unwrap(); + let rows = facts(&document); + let row = rows + .iter() + .find(|row| row.function == "empty?") + .expect("method declared in a Struct block"); + assert!(row + .call_contexts + .iter() + .any(|call| call.message == "empty?")); } #[test] @@ -3327,6 +3981,38 @@ end assert_eq!(allocation.bound_classification, "input"); } + #[test] + fn ruby_resource_scope_blocks_execute_once_instead_of_iterating() { + let rows = ruby_facts( + r#" +class Scanner + def files(repo, command) + paths = Dir.chdir(repo) { Dir["**/*"] } + text = IO.popen(command) { |io| io.read } + [paths, text] + end +end +"#, + ); + let row = rows.iter().find(|row| row.function == "files").unwrap(); + + for message in ["chdir", "popen"] { + assert!( + row.iterations + .iter() + .all(|iteration| iteration.message.as_deref() != Some(message)), + "{message} is an exactly-once resource callback, not an input-sized iteration" + ); + assert_eq!( + row.call_contexts + .iter() + .find(|call| call.message == message) + .map(|call| call.execution_multiplicity.as_str()), + Some("O(1)") + ); + } + } + #[test] fn normalized_worklists_collapse_only_with_visited_set_evidence() { let rows = ruby_facts( @@ -3376,6 +4062,10 @@ class Driver items.each { |item| blk.call(item) } end + def each_yielded_item(items) + items.each { |item| yield item } + end + def wrapped(items) with_scope { items.each { |item| consume(item) } } end @@ -3383,12 +4073,17 @@ class Driver def traversed(items) each_item(items) { |item| consume(item) } end + + def yielded(items) + each_yielded_item(items) { |item| consume(item) } + end end "#, ); assert_eq!(complexity(&rows, "wrapped"), Some("O(N)".into())); assert_eq!(complexity(&rows, "traversed"), Some("O(N)".into())); + assert_eq!(complexity(&rows, "yielded"), Some("O(N)".into())); let wrapper = rows .iter() .find(|row| row.function == "with_scope") @@ -3480,6 +4175,7 @@ end cardinality_dependencies: BTreeSet::from(["b".into()]), shrinking: false, halving: false, + structural_descent: false, empty_collection: false, fixed_collection: false, }], @@ -3493,6 +4189,7 @@ end cardinality_dependencies: BTreeSet::from(["a".into()]), shrinking: false, halving: false, + structural_descent: false, empty_collection: false, fixed_collection: false, }], diff --git a/gems/fact-mine/src/syntax/cpp.rs b/gems/fact-mine/src/syntax/cpp.rs index f76265e60..43b65e8d1 100644 --- a/gems/fact-mine/src/syntax/cpp.rs +++ b/gems/fact-mine/src/syntax/cpp.rs @@ -5,34 +5,727 @@ use super::cfg::ControlFlowProfile; use super::effects::{effect_from_call_with_lexicon, EffectLexicon}; use super::normalized_behavior::{ balanced_selector_name, configured_collection_operation, configured_intrinsic_call_complexity, - configured_non_call_construct, configured_semantic_symbol_call_complexity, - configured_semantic_symbol_kind, configured_semantic_symbol_parametric_cost, - eliminable_guard_from_call, exact_direct_call_name, native_pointer_nullability_contract, - nil_guard_from_predicates, scip_descriptor_owner, scip_global_parts, - type_before_parameter_name, NormalizedCallParts, NormalizedCallProjection, - NormalizedLanguageBehavior, NormalizedNilGuardFact, NormalizedNullableOperation, - NormalizedSemanticEffect, NormalizedStateRead, + configured_modeled_runtime_bound, configured_non_call_construct, + configured_semantic_symbol_call_complexity, configured_semantic_symbol_kind, + configured_semantic_symbol_parametric_cost, configured_stdlib_type, eliminable_guard_from_call, + exact_direct_call_name, native_pointer_nullability_contract, nil_guard_from_predicates, + scip_descriptor_owner, scip_global_parts, split_top_level_commas, type_before_parameter_name, + NormalizedCallParts, NormalizedCallProjection, NormalizedLanguageBehavior, + NormalizedNilGuardFact, NormalizedNullableOperation, NormalizedSemanticEffect, + NormalizedStateRead, SyntaxMetadata, }; -use super::{CallSite, ExternalCallComplexity, ExternalSymbolMetadata}; +use super::{CallSite, ExternalCallComplexity, ExternalSymbolMetadata, FunctionDef}; use crate::ast::{Child, Node, Span}; use crate::type_inference::languages::nominal::{self, NominalTypeSyntax}; use crate::type_inference::TypeExpr; +use std::collections::{BTreeMap, BTreeSet}; const CPP_NOMINAL_TYPE_SYNTAX: NominalTypeSyntax = NominalTypeSyntax { strip_prefixes: &["const "], trim_prefix_chars: &[], trim_suffix_chars: &['&', '*'], - array_names: &["vector", "array", "span"], - hash_names: &["unordered_map"], - set_names: &["unordered_set"], - string_names: &["string", "basic_string"], + array_names: &["vector", "array", "deque", "forward_list", "list", "span"], + hash_names: &["map", "unordered_map"], + set_names: &["set", "unordered_set"], + string_names: &[ + "string", + "wstring", + "basic_string", + "string_view", + "wstring_view", + ], bare_array_names: &[], suffix_array: false, bracket_array: false, }; +const CPP_PRIMITIVE_OPERATORS: &[&str] = &[ + "==", "!=", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "&", "|", "^", "<<", ">>", "~", + "&&", "||", "!", +]; pub(crate) fn parse_declared_type(source: &str) -> TypeExpr { - nominal::parse(source, &CPP_NOMINAL_TYPE_SYNTAX) + let parsed = nominal::parse(source, &CPP_NOMINAL_TYPE_SYNTAX); + let terminal = source + .split('<') + .next() + .unwrap_or(source) + .trim() + .trim_end_matches(['&', '*']) + .rsplit("::") + .next() + .unwrap_or_default() + .trim(); + let normalized = source.trim().trim_start_matches("const ").trim(); + if normalized.starts_with("std::") + && matches!( + terminal, + "fstream" | "ifstream" | "ofstream" | "wfstream" | "wifstream" | "wofstream" + ) + { + return TypeExpr::Primitive("FileStream".to_string()); + } + if normalized.starts_with("nlohmann::") && matches!(terminal, "json" | "basic_json") { + return TypeExpr::Primitive("Json".to_string()); + } + if normalized.starts_with("std::atomic") + || matches!( + terminal, + "atomic_bool" + | "atomic_char" + | "atomic_int" + | "atomic_long" + | "atomic_llong" + | "atomic_uint" + | "atomic_ulong" + | "atomic_ullong" + | "atomic_size_t" + ) + { + return TypeExpr::Primitive("StdAtomic".to_string()); + } + match terminal { + "ostringstream" | "wostringstream" | "istringstream" | "wistringstream" + | "stringstream" | "wstringstream" => TypeExpr::Primitive("StringStream".to_string()), + "ostream" | "wostream" => TypeExpr::Primitive("OutputStream".to_string()), + _ => parsed, + } +} + +fn cpp_type_aliases( + source: &str, +) -> ( + BTreeMap, + BTreeMap, + BTreeSet, +) { + let mut candidates = BTreeMap::>::new(); + let mut statement = String::new(); + let mut statement_line = 1usize; + for (line_index, line) in source.lines().enumerate() { + let code = line.split("//").next().unwrap_or_default(); + if statement.trim().is_empty() { + statement_line = line_index + 1; + } + statement.push(' '); + statement.push_str(code); + while let Some(end) = statement.find(';') { + let current = statement[..end].trim().to_string(); + statement = statement[end + 1..].to_string(); + let using = current + .rfind("using ") + .and_then(|start| current.get(start + "using ".len()..)) + .and_then(|declaration| declaration.split_once('=')) + .and_then(|(name, target)| { + let name = name.trim(); + let valid = !name.is_empty() + && name + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + && name.chars().next().is_some_and(|character| { + character == '_' || character.is_ascii_alphabetic() + }); + valid.then(|| { + ( + name.to_string(), + target.split_whitespace().collect::>().join(" "), + ) + }) + }); + let typedef = current + .rfind("typedef ") + .and_then(|start| current.get(start + "typedef ".len()..)) + .and_then(|declaration| { + let split = declaration + .trim() + .rfind(char::is_whitespace) + .filter(|index| *index > 0)?; + let target = declaration[..split].trim(); + let name = declaration[split..].trim(); + (!target.is_empty() + && !name.is_empty() + && name + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric())) + .then(|| { + ( + name.to_string(), + target.split_whitespace().collect::>().join(" "), + ) + }) + }); + if let Some((name, target)) = using.or(typedef).filter(|(_, target)| !target.is_empty()) + { + candidates + .entry(name) + .or_default() + .insert((target, statement_line)); + } + statement_line = line_index + 1; + } + } + + let mut aliases = BTreeMap::new(); + let mut lines = BTreeMap::new(); + let mut dependent_aliases = BTreeSet::new(); + for (name, definitions) in candidates { + if definitions + .iter() + .all(|(target, _)| cpp_identifier_tokens(target).any(|token| token == "typename")) + { + // `typename X::Y` is a compiler-level declaration that Y depends + // on a template type. Preserve that proof even when an + // unqualified intermediate alias such as `super` has different + // meanings in multiple classes and is correctly omitted below. + dependent_aliases.insert(name.clone()); + } + let targets = definitions + .iter() + .map(|(target, _)| target) + .collect::>(); + // An unqualified alias is usable only when this translation unit gives + // it one meaning. Repeated `super`/`Type` aliases in unrelated owners + // intentionally remain unresolved rather than cross-contaminating. + let normalized_targets = targets + .iter() + .map(|target| parse_declared_type(target)) + .collect::>(); + let converged = normalized_targets + .first() + .is_some_and(|first| normalized_targets.iter().all(|target| target == first)); + if targets.len() != 1 && !converged { + continue; + } + let (target, line) = definitions.into_iter().next().expect("one alias target"); + aliases.insert(name.clone(), target); + lines.insert(name, line); + } + (aliases, lines, dependent_aliases) +} + +fn cpp_identifier_tokens(source: &str) -> impl DoubleEndedIterator { + source + .split(|character: char| character != '_' && !character.is_ascii_alphanumeric()) + .filter(|token| { + !token.is_empty() + && token + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + }) +} + +fn cpp_function_imports_symbol(source: &str, qualified: &str) -> bool { + let declaration = format!("using {qualified};"); + source.lines().any(|line| { + let code = line.split_once("//").map_or(line, |(code, _)| code).trim(); + code == declaration + || code.strip_suffix(&declaration).is_some_and(|prefix| { + prefix + .chars() + .all(|character| character.is_ascii_whitespace() || character == '{') + }) + }) +} + +fn cpp_function_is_friend(node: &Node, lines: &[String]) -> bool { + let normalized_node_has_friend = + node.text + .trim_start() + .strip_prefix("friend") + .is_some_and(|rest| { + rest.chars() + .next() + .is_some_and(|character| character.is_ascii_whitespace()) + }); + if normalized_node_has_friend || node.first_lineno == 0 { + return normalized_node_has_friend; + } + lines + .get(node.first_lineno - 1) + .and_then(|line| line.get(..node.first_column.min(line.len()))) + .is_some_and(|prefix| { + prefix + .split_whitespace() + .next_back() + .is_some_and(|modifier| modifier == "friend") + }) +} + +fn symbol_without_template_arguments(name: &str) -> String { + let mut output = String::with_capacity(name.len()); + let mut depth = 0usize; + for character in name.chars() { + match character { + '<' => depth += 1, + '>' if depth > 0 => depth -= 1, + _ if depth == 0 => output.push(character), + _ => {} + } + } + if depth == 0 { + output + } else { + name.to_string() + } +} + +fn owner_identity_name(value: &str) -> String { + let value = value + .trim() + .strip_prefix("const ") + .unwrap_or(value.trim()) + .trim_start_matches('*') + .trim_end_matches(|character: char| { + character.is_whitespace() || matches!(character, '&' | '*') + }); + // Erase template arguments without discarding a following nested owner. + // Splitting at the first `<` made `Base::Nested` indistinguishable from + // `Base`, which introduced a false specialization ambiguity during + // inherited lookup. + let without_templates = symbol_without_template_arguments(value); + let value = without_templates + .split('[') + .next() + .unwrap_or(&without_templates) + .trim(); + value + .rsplit([':', '.']) + .find(|part| !part.is_empty()) + .unwrap_or(value) + .to_string() +} + +fn cpp_declared_template_type_names(declaration: &str) -> BTreeSet { + let body = declaration + .split_once('<') + .and_then(|(_, rest)| rest.rsplit_once('>')) + .map(|(body, _)| body) + .unwrap_or(declaration); + body.split(',') + .filter_map(|parameter| { + let tokens = cpp_identifier_tokens(parameter).collect::>(); + tokens + .iter() + .position(|token| matches!(*token, "typename" | "class")) + .and_then(|position| tokens.get(position + 1)) + // C++20 constrained parameters spell the concept instead of + // `typename` (`template `). The final identifier is + // still the compiler-declared parameter. Non-type parameters + // are harmless here: only a declared receiver type containing + // that exact identifier can consume the symbolic contract. + .or_else(|| tokens.last()) + .map(|name| (*name).to_string()) + }) + .collect() +} + +fn cpp_owner_template_type_names(owner: &str) -> BTreeSet { + let Some((_, arguments)) = owner.split_once('<') else { + return BTreeSet::new(); + }; + let arguments = arguments + .rsplit_once('>') + .map(|(body, _)| body) + .unwrap_or(arguments); + cpp_identifier_tokens(arguments) + .filter(|token| { + !matches!( + *token, + "const" | "false" | "std" | "template" | "true" | "type" | "typename" | "void" + ) + }) + .map(str::to_string) + .collect() +} + +fn cpp_declared_owner_template_types( + source: &str, + functions: &[FunctionDef], +) -> BTreeMap> { + let lines = source.lines().collect::>(); + let function_owners = functions + .iter() + .filter_map(|function| { + let owner = function + .owner + .split(['<', '@']) + .next() + .unwrap_or(&function.owner) + .trim(); + (!owner.is_empty()).then_some(owner) + }) + .collect::>(); + let mut owners = BTreeMap::new(); + for (index, line) in lines.iter().enumerate() { + let Some(template_offset) = line.find("template") else { + continue; + }; + let declaration = lines[index..lines.len().min(index + 20)].join(" "); + let declaration = &declaration[template_offset..]; + let Some(open) = declaration.find('<') else { + continue; + }; + let mut depth = 0usize; + let mut close = None; + for (offset, character) in declaration[open..].char_indices() { + match character { + '<' => depth += 1, + '>' => { + depth = depth.saturating_sub(1); + if depth == 0 { + close = Some(open + offset); + break; + } + } + _ => {} + } + } + let Some(close) = close else { + continue; + }; + let parameters = cpp_declared_template_type_names(&declaration[..=close]); + if parameters.is_empty() { + continue; + } + let suffix = declaration[close + 1..].trim_start(); + let owner = ["class ", "struct "].into_iter().find_map(|keyword| { + let rest = suffix.strip_prefix(keyword)?; + let header = rest.split(['{', ':', ';']).next().unwrap_or(rest); + let candidates = cpp_identifier_tokens(header) + .filter(|token| function_owners.contains(token)) + .collect::>(); + (candidates.len() == 1) + .then(|| candidates.into_iter().next().map(str::to_string)) + .flatten() + }); + if let Some(owner) = owner { + owners.insert(owner, parameters); + } + } + owners +} + +fn cpp_method_template_types( + source: &str, + functions: &[FunctionDef], + dependent_aliases: &BTreeSet, + method_local_types: &BTreeMap>, +) -> BTreeMap> { + let lines = source.lines().collect::>(); + let declared_owners = cpp_declared_owner_template_types(source, functions); + functions + .iter() + .filter_map(|function| { + let mut parameters = cpp_owner_template_type_names(&function.owner); + let owner_base = function + .owner + .split('<') + .next() + .unwrap_or(&function.owner) + .trim(); + parameters.extend( + declared_owners + .get(owner_base) + .into_iter() + .flat_map(|parameters| parameters.iter().cloned()), + ); + let start = function.line.saturating_sub(1); + let lower = start.saturating_sub(16); + let prefix = lines + .get(lower..start) + .unwrap_or_default() + .iter() + .rev() + .take_while(|line| { + let trimmed = line.trim(); + !trimmed.ends_with([';', '}']) + }) + .copied() + .collect::>(); + let prefix = prefix.into_iter().rev().collect::>().join(" "); + if let Some(template_start) = prefix.rfind("template") { + parameters.extend(cpp_declared_template_type_names(&prefix[template_start..])); + } + if !parameters.is_empty() { + parameters.extend(dependent_aliases.iter().cloned()); + let key = format!("{}\0{}\0{}", function.owner, function.name, function.line); + let dependent_locals = method_local_types + .get(&key) + .into_iter() + .flat_map(|locals| locals.iter()) + .filter(|(_, declared_type)| { + cpp_identifier_tokens(declared_type).any(|token| parameters.contains(token)) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + parameters.extend(dependent_locals); + let body = lines + .get(function.line.saturating_sub(1)..lines.len().min(function.span[2])) + .unwrap_or_default() + .join(" "); + let statements = body.split(';').collect::>(); + loop { + let inferred = statements + .iter() + .filter_map(|statement| cpp_dependent_auto_binding(statement, ¶meters)) + .filter(|name| !parameters.contains(name)) + .collect::>(); + if inferred.is_empty() { + break; + } + parameters.extend(inferred); + } + } + (!parameters.is_empty()).then(|| { + ( + format!("{}\0{}\0{}", function.owner, function.name, function.line), + parameters, + ) + }) + }) + .collect() +} + +fn cpp_dependent_auto_binding(statement: &str, dependencies: &BTreeSet) -> Option { + for (auto, _) in statement.match_indices("auto") { + let boundary_before = statement[..auto] + .chars() + .next_back() + .is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_'); + let Some(after_auto) = statement.get(auto + "auto".len()..) else { + continue; + }; + let boundary_after = after_auto + .chars() + .next() + .is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_'); + if !boundary_before || !boundary_after { + continue; + } + let after_auto = after_auto + .trim_start() + .trim_start_matches(['&', '*']) + .trim_start(); + let Some(name) = cpp_identifier_tokens(after_auto).next() else { + continue; + }; + let Some(after_name) = after_auto.get(after_auto.find(name)? + name.len()..) else { + continue; + }; + let after_name = after_name.trim_start(); + let Some(initializer) = after_name + .strip_prefix('=') + .or_else(|| after_name.strip_prefix(':')) + .map(str::trim) + else { + continue; + }; + let Some(dependency) = + cpp_identifier_tokens(initializer).find(|token| dependencies.contains(*token)) + else { + continue; + }; + let type_dependent_syntax = initializer.contains("typename ") + || initializer.contains(".template ") + || initializer.contains("::template ") + || initializer.contains(&format!("{dependency}.")) + || initializer.contains(&format!("{dependency}->")) + || initializer + .strip_prefix(dependency) + .is_some_and(|rest| rest.trim_start().starts_with(['(', '{'])); + if type_dependent_syntax { + return Some(name.to_string()); + } + } + None +} + +fn cpp_method_local_types( + source: &str, + functions: &[FunctionDef], +) -> BTreeMap> { + let lines = source.lines().collect::>(); + functions + .iter() + .filter_map(|function| { + let start = function.span[0].saturating_sub(1); + let end = function.span[2].min(lines.len()); + let body = lines.get(start..end)?.join("\n"); + let body = body.split_once('{').map(|(_, body)| body)?; + let mut candidates = BTreeMap::>::new(); + for (name, declared_type) in cpp_lambda_parameter_types(body) { + candidates.entry(name).or_default().insert(declared_type); + } + for statement in body.split(';') { + let declaration = statement + .rsplit(['{', '}']) + .next() + .unwrap_or(statement) + .trim(); + if declaration.is_empty() || declaration.starts_with('#') { + continue; + } + let before_assignment = declaration.split('=').next().unwrap_or(declaration).trim(); + // Constructor-style locals (`std::string out(n, 0)`) put the + // initializer beside the binding rather than after `=`. + // Keep the declaration prefix. A plain call (`out.resize(n)`) + // then has a member-access suffix and is rejected below. + let left = before_assignment + .split('(') + .next() + .unwrap_or(before_assignment) + .trim(); + let Some(name) = cpp_identifier_tokens(left).next_back() else { + continue; + }; + let Some(name_start) = left.rfind(name) else { + continue; + }; + let declared_type = left[..name_start].trim(); + let first = cpp_identifier_tokens(declared_type) + .next() + .unwrap_or_default(); + if declared_type.is_empty() + || declared_type.ends_with(['.', ':']) + || declared_type.contains("->") + || matches!( + first, + "break" + | "case" + | "continue" + | "delete" + | "else" + | "goto" + | "if" + | "new" + | "return" + | "switch" + | "throw" + | "while" + ) + || matches!(declared_type, "auto" | "const auto" | "decltype(auto)") + { + continue; + } + candidates + .entry(name.to_string()) + .or_default() + .insert(declared_type.to_string()); + } + let locals = candidates + .into_iter() + .filter_map(|(name, types)| { + (types.len() == 1) + .then(|| (name, types.into_iter().next().expect("one local type"))) + }) + .collect::>(); + (!locals.is_empty()).then(|| { + ( + format!("{}\0{}\0{}", function.owner, function.name, function.line), + locals, + ) + }) + }) + .collect() +} + +fn cpp_lambda_parameter_types(source: &str) -> Vec<(String, String)> { + let mut parameters = Vec::new(); + for (capture_end, _) in source.match_indices(']') { + let Some(capture_start) = source[..capture_end].rfind('[') else { + continue; + }; + if source[capture_start..capture_end].contains([';', '{', '}']) { + continue; + } + let after_capture = source[capture_end + 1..].trim_start(); + let Some(parameter_text) = after_capture.strip_prefix('(') else { + continue; + }; + let mut depth = 1usize; + let mut parameter_end = None; + for (index, character) in parameter_text.char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + parameter_end = Some(index); + break; + } + } + _ => {} + } + } + let Some(parameter_end) = parameter_end else { + continue; + }; + // Keep this proof boundary deliberately narrow. A braced body after + // the parameter list distinguishes a lambda from subscripted callable + // expressions such as `handlers[index](value)`. + if !parameter_text[parameter_end + 1..] + .trim_start() + .starts_with('{') + { + continue; + } + for parameter in split_top_level_commas(¶meter_text[..parameter_end]) { + let Some(name) = cpp_identifier_tokens(¶meter).next_back() else { + continue; + }; + let Some(declared_type) = type_before_parameter_name(¶meter) else { + continue; + }; + parameters.push((name.to_string(), declared_type)); + } + } + parameters +} + +fn cpp_scalar_primitive(name: &str) -> bool { + let bare = name + .trim() + .trim_start_matches("const ") + .trim_start_matches("volatile ") + .trim_end_matches(['&', '*']) + .trim(); + let bare = bare.strip_prefix("std::").unwrap_or(bare); + let words = bare.split_whitespace().collect::>(); + matches!( + bare, + "bool" + | "char" + | "char8_t" + | "char16_t" + | "char32_t" + | "wchar_t" + | "float" + | "double" + | "size_t" + | "ptrdiff_t" + | "nullptr_t" + | "int8_t" + | "int16_t" + | "int32_t" + | "int64_t" + | "uint8_t" + | "uint16_t" + | "uint32_t" + | "uint64_t" + | "intmax_t" + | "uintmax_t" + | "intptr_t" + | "uintptr_t" + ) || (!words.is_empty() + && words.iter().all(|word| { + matches!( + *word, + "signed" | "unsigned" | "short" | "int" | "long" | "double" + ) + }) + && words + .iter() + .any(|word| matches!(*word, "short" | "int" | "long"))) } fn scip_clang_parts(symbol: &str) -> Option<(&str, &str)> { @@ -45,8 +738,19 @@ fn cpp_std_descriptor(descriptor: &str) -> bool { } fn cpp_std_owner_type(owner: &str) -> TypeExpr { - match owner.trim_matches('`') { - "array" | "span" | "vector" => TypeExpr::Array(Box::new(TypeExpr::Untyped)), + // libstdc++ exposes ABI namespaces in SCIP descriptors (for example + // `std/__cxx11/list#empty`). They are implementation details, not + // different complexity contracts, so classify the terminal owner. + let owner = owner + .trim_matches('`') + .rsplit('/') + .next() + .unwrap_or(owner) + .trim_matches('`'); + match owner { + "array" | "deque" | "forward_list" | "list" | "span" | "vector" => { + TypeExpr::Array(Box::new(TypeExpr::Untyped)) + } "map" | "unordered_map" => TypeExpr::Hash { key: Box::new(TypeExpr::Untyped), value: Box::new(TypeExpr::Untyped), @@ -118,6 +822,20 @@ pub(crate) fn external_symbol_metadata(symbol: &str) -> ExternalSymbolMetadata { } } +fn modeled_runtime_call_complexity(message: &str) -> Option { + let complexity = configured_modeled_runtime_bound("cpp", message)?; + Some(ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "cpp_source_runtime_registry", + bound_quality: "upper_bound_modeled_world", + candidates: vec![message.to_string()], + assumption: Some(format!( + "`{message}` follows the reviewed C/C++ platform runtime contract in this preprocessor configuration" + )), + }) +} + pub(crate) fn external_symbol_owner(symbol: &str) -> Option { let (_package, descriptor) = scip_clang_parts(symbol)?; scip_descriptor_owner(descriptor) @@ -183,6 +901,55 @@ const CPP_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct CppNormalizedBehavior; +fn cpp_receiver_local_binding(receiver: &str) -> Option { + let mut expression = receiver.trim(); + while expression.starts_with('(') && expression.ends_with(')') { + let inner = expression[1..expression.len() - 1].trim(); + if inner.is_empty() { + return None; + } + expression = inner; + } + expression = expression.strip_prefix('*')?.trim(); + while expression.starts_with('(') && expression.ends_with(')') { + let inner = expression[1..expression.len() - 1].trim(); + if inner.is_empty() { + return None; + } + expression = inner; + } + (!expression.is_empty() + && expression + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + && expression + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())) + .then(|| expression.to_string()) +} + +fn cpp_c_style_receiver_type(receiver: &str) -> Option { + let receiver = receiver.trim(); + let cast = receiver.strip_prefix('(')?; + let close = cast.find(')')?; + let declared = cast[..close].trim(); + let expression = cast[close + 1..].trim(); + if declared.is_empty() + || expression.is_empty() + || !declared.contains(['*', '&']) + || declared.contains(['(', ')', '=', '!', '?', ';']) + || !declared.chars().all(|character| { + character.is_ascii_alphanumeric() + || character.is_ascii_whitespace() + || matches!(character, '_' | ':' | '<' | '>' | ',' | '*' | '&') + }) + { + return None; + } + Some(declared.to_string()) +} + /// C++ normalizes `static_cast(call())` as an FCALL. The cast only changes /// the static view of the result, so a reviewed nullable result contract still /// belongs to the exact inner call. @@ -218,7 +985,467 @@ fn nullable_contract_call(node: &Node) -> &Node { node } +fn cpp_collection_element_binding(source: &str, local: &str) -> Option { + let compact = source.split_whitespace().collect::>().join(" "); + for (index, _) in compact.match_indices(local) { + let before = compact[..index].chars().next_back(); + let after = compact[index + local.len()..].chars().next(); + let boundary = |character: Option| { + character.is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_') + }; + if !boundary(before) || !boundary(after) { + continue; + } + + let prefix = compact[..index] + .rsplit([';', '{', '}']) + .next() + .unwrap_or_default() + .trim(); + let suffix = compact[index + local.len()..].trim_start(); + + if prefix.contains("for") + && prefix + .rsplit("for") + .next() + .is_some_and(|header| header.contains("auto") && header.contains('(')) + { + let collection = suffix + .strip_prefix(':')? + .trim_start() + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + .unwrap_or_default(); + if !collection.is_empty() { + return Some(collection.to_string()); + } + } + + let declares_auto = prefix + .split_whitespace() + .any(|token| token.trim_matches(['&', '*']) == "auto"); + if !declares_auto { + continue; + } + let initializer = suffix.strip_prefix('=')?.trim_start(); + let collection = initializer + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + .unwrap_or_default(); + if collection.is_empty() { + continue; + } + let remainder = &initializer[collection.len()..]; + if remainder.starts_with('[') + || remainder.starts_with(".begin(") + || remainder.starts_with(".cbegin(") + { + return Some(collection.to_string()); + } + } + None +} + +fn cpp_indexed_receiver_collection_binding(receiver: &str) -> Option { + let receiver = receiver.trim(); + let bracket = receiver.find('[')?; + let collection = receiver[..bracket].trim(); + let index = receiver[bracket..].trim(); + let balanced_index = index.starts_with("[[") && index.ends_with("]]") + || index.starts_with('[') && index.ends_with(']'); + (balanced_index + && !collection.is_empty() + && collection + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric())) + .then(|| collection.to_string()) +} + +fn cpp_indexed_collection_result_type(declared_type: &str) -> Option { + let declared_type = declared_type.trim().strip_prefix("typename ")?.trim(); + let select_map = declared_type.strip_prefix("SelectMap")?.trim_start(); + if !select_map.starts_with('<') || !select_map.ends_with("::Type") { + return None; + } + let close = select_map.rfind('>')?; + let arguments = split_top_level_commas(&select_map[1..close]); + arguments + .get(1) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn cpp_pointer_member_receiver_type( + source: &str, + receiver: &str, + message: &str, + declared_type: &str, +) -> Option { + let tight_source = source + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + let tight_message = message + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + if !tight_source.contains(&format!("{receiver}->{tight_message}")) { + return None; + } + + let declared_type = declared_type.trim().trim_end_matches(['&', '*']).trim(); + for pointer in [ + "std::shared_ptr", + "shared_ptr", + "std::unique_ptr", + "unique_ptr", + ] { + let Some(arguments) = declared_type.strip_prefix(pointer) else { + continue; + }; + let arguments = arguments.trim(); + if !arguments.starts_with('<') || !arguments.ends_with('>') { + continue; + } + let pointee = arguments[1..arguments.len() - 1].trim(); + if !pointee.is_empty() { + return Some(pointee.to_string()); + } + } + None +} + +fn cpp_owner_has_direct_pure_virtual(source: &str) -> bool { + let mut brace_depth = 0usize; + let mut statement = String::new(); + for character in source.chars() { + match character { + '{' => { + brace_depth += 1; + statement.clear(); + } + '}' => { + brace_depth = brace_depth.saturating_sub(1); + statement.clear(); + } + ';' => { + if brace_depth == 1 && statement.contains("virtual") && statement.contains("= 0") { + return true; + } + statement.clear(); + } + _ if brace_depth == 1 => statement.push(character), + _ => {} + } + } + false +} + impl NormalizedLanguageBehavior for CppNormalizedBehavior { + fn source_body_implicit_work_is_modeled( + &self, + source: &str, + template_types: &BTreeSet, + ) -> bool { + if template_types.is_empty() { + return true; + } + + let body = source.split_once('{').map(|(_, body)| body).unwrap_or(""); + let has_dependent_value_local = body.lines().any(|line| { + let line = line.trim(); + template_types.iter().any(|template_type| { + let Some(suffix) = line.strip_prefix(template_type) else { + return false; + }; + let suffix = suffix.trim_start(); + !suffix.starts_with(['*', '&']) + && suffix.chars().next().is_some_and(|character| { + character == '_' || character.is_ascii_alphabetic() + }) + && (suffix.contains('=') || suffix.contains('(') || suffix.contains('{')) + }) + }); + if has_dependent_value_local { + return false; + } + + let dependent_parameters = source + .split_once('{') + .map(|(header, _)| header) + .unwrap_or(source) + .split(',') + .filter_map(|parameter| { + template_types + .iter() + .find(|template_type| parameter.contains(template_type.as_str()))?; + let name = parameter + .split(|character: char| character != '_' && !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .next_back()?; + Some(name.to_string()) + }) + .collect::>(); + + !dependent_parameters.iter().any(|parameter| { + body.lines().any(|line| { + let line = line.trim_start(); + line.strip_prefix(parameter).is_some_and(|suffix| { + let suffix = suffix.trim_start(); + suffix.starts_with('=') && !suffix.starts_with("==") + }) + }) + }) + } + + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + fn complexity_uses_invariant_flow_types(&self) -> bool { + true + } + + fn complexity_uses_syntax_local_types(&self) -> bool { + true + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn relative_lexical_candidates(&self, symbol: &str, namespace: &str) -> Vec { + if !symbol.contains("::") || symbol.starts_with("std::") { + return Vec::new(); + } + let symbol = symbol.trim_start_matches("::"); + let mut scopes = namespace + .split("::") + .filter(|part| !part.is_empty()) + .collect::>(); + let mut candidates = Vec::new(); + loop { + candidates.push(if scopes.is_empty() { + symbol.to_string() + } else { + format!("{}::{symbol}", scopes.join("::")) + }); + if scopes.pop().is_none() { + break; + } + } + candidates + } + + fn fallback_lexical_candidates( + &self, + message: &str, + namespace: &str, + implicit_receiver: bool, + ) -> Vec { + if !implicit_receiver || message.contains("::") || namespace.is_empty() { + return Vec::new(); + } + let mut scopes = namespace + .split("::") + .filter(|part| !part.is_empty()) + .collect::>(); + let mut candidates = Vec::new(); + loop { + candidates.push(if scopes.is_empty() { + message.to_string() + } else { + format!("{}::{message}", scopes.join("::")) + }); + if scopes.pop().is_none() { + break; + } + } + candidates + } + + fn resolves_inherited_project_calls(&self) -> bool { + true + } + + fn inherited_lookup_uses_source_owner(&self, implicit_receiver: bool) -> bool { + implicit_receiver + } + + fn inherited_owner_identity_matches( + &self, + identity: &str, + owner_name: &str, + owner_symbol: Option<&str>, + ) -> bool { + let nominal = owner_identity_name(identity); + owner_identity_name(owner_name) == nominal + || owner_symbol.is_some_and(|symbol| owner_identity_name(symbol) == nominal) + } + + fn inherited_identity_prefers_specialization(&self, identity: &str) -> bool { + identity.contains('<') + } + + fn inherited_call_receiver_type(&self, supertypes: &[String], message: &str) -> Option { + let [supertype] = supertypes else { + return None; + }; + let receiver = parse_declared_type(supertype); + (configured_stdlib_type("cpp", &receiver) + && (self.call_complexity(&receiver, message).is_some() + || self.parametric_call_cost(&receiver, message).is_some())) + .then(|| supertype.clone()) + } + + fn preserve_supertype_identity(&self, supertype: &str) -> bool { + supertype.contains('<') + } + + fn complete_declaration_header( + &self, + lines: &[String], + start_line_1indexed: usize, + ) -> Option { + let start_index = start_line_1indexed.saturating_sub(1); + if start_index >= lines.len() { + return Some(String::new()); + } + let mut header = String::new(); + let mut paren_depth = 0i32; + let mut bracket_depth = 0i32; + let mut saw_parameters = false; + for line in lines + .iter() + .take(std::cmp::min(lines.len(), start_index + 20)) + .skip(start_index) + { + header.push_str(line); + header.push('\n'); + for character in line.chars() { + match character { + '(' => { + paren_depth += 1; + saw_parameters = true; + } + ')' => paren_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + '{' | ';' if saw_parameters && paren_depth <= 0 && bracket_depth <= 0 => { + return Some(header); + } + _ => {} + } + } + } + Some(header) + } + + fn call_result_parametric_cost(&self, type_expr: &TypeExpr) -> Option { + type_expr + .to_string() + .split(|character: char| character != '_' && !character.is_ascii_alphanumeric()) + .any(|token| token == "typename") + .then(|| "reflective_once".to_string()) + } + + fn receiver_denotes_current_owner(&self, receiver_type: &str, owner: &str) -> bool { + owner_identity_name(receiver_type) == owner_identity_name(owner) + } + + fn explicit_lexical_call_symbol( + &self, + message: &str, + namespace: Option<&str>, + top_level: bool, + ) -> Option { + let symbol = symbol_without_template_arguments(message); + if symbol.contains("::") { + Some(symbol) + } else if top_level { + namespace.map(|namespace| format!("{namespace}::{symbol}")) + } else { + None + } + } + + fn function_local_lexical_call_symbol( + &self, + function: &FunctionDef, + message: &str, + ) -> Option { + let qualified = format!("std::{message}"); + cpp_function_imports_symbol(&function.body.text, &qualified).then_some(qualified) + } + + fn merged_alias_call_name( + &self, + message: &str, + receiver_type: Option<&str>, + implicit_receiver: bool, + target_missing: bool, + ) -> Option<(String, bool)> { + let constructor = implicit_receiver && target_missing; + if constructor { + return symbol_without_template_arguments(message) + .rsplit("::") + .next() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| (name.to_string(), true)); + } + receiver_type + .map(owner_identity_name) + .filter(|name| !name.is_empty()) + .map(|name| (name, false)) + } + + fn function_dispatch_kind_from_source( + &self, + _name: &str, + node: &Node, + owner: &str, + lines: &[String], + ) -> String { + if cpp_function_is_friend(node, lines) { + "top".to_string() + } else { + self.function_dispatch_kind("", owner) + } + } + + fn owner_kind(&self, node: &Node, default_kind: &str) -> String { + let abstract_class = matches!(default_kind, "class" | "struct") + && cpp_owner_has_direct_pure_virtual(&node.text); + if abstract_class { + "abstract_class".to_string() + } else { + default_kind.to_string() + } + } + + fn type_kind_is_abstract_dispatch(&self, kind: &str) -> bool { + kind == "abstract_class" + } + + // C-family indexers render a local as `Type name` - the type leads. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_end_matches(';').trim(); + let (declared, _name) = text.rsplit_once(char::is_whitespace)?; + let declared = declared.trim(); + (!declared.is_empty() && !declared.contains('=')).then(|| declared.to_string()) + } + + // C++ declares `Ret name(T a)`, not `name(a: T) -> Ret`. + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + super::normalized_behavior::parse_prefix_return_declarator(signature) + } + fn nullable_operation(&self, node: &Node) -> Option { if node.r#type == "VCALL" { return local_call_subject(node).map(|subject| NormalizedNullableOperation { @@ -287,6 +1514,10 @@ impl NormalizedLanguageBehavior for CppNormalizedBehavior { external_symbol_call_complexity(symbol, message) } + fn modeled_runtime_call_complexity(&self, message: &str) -> Option { + modeled_runtime_call_complexity(message) + } + fn external_symbol_metadata(&self, symbol: &str) -> ExternalSymbolMetadata { external_symbol_metadata(symbol) } @@ -295,6 +1526,17 @@ impl NormalizedLanguageBehavior for CppNormalizedBehavior { external_symbol_owner(symbol) } + fn preprocessor_definition_call_complexity( + &self, + definition: &str, + ) -> Option { + super::c::preprocessor_definition_call_complexity(definition) + } + + fn preprocessor_definition_location(&self, symbol: &str) -> Option<(String, usize)> { + super::c::preprocessor_definition_location(symbol) + } + fn owner_supertypes(&self, node: &Node) -> Vec { let header = node.text.split('{').next().unwrap_or(&node.text); header @@ -304,13 +1546,87 @@ impl NormalizedLanguageBehavior for CppNormalizedBehavior { } fn declared_local_type(&self, source: &str, name: &str) -> Option { - super::normalized_behavior::type_before_local_name(source, name) + let declared = super::normalized_behavior::type_before_local_name(source, name)?; + // CFG asks about every read as well as every write. A use inside + // `if (!value)` or `value.method()` is not a declaration; the shared + // type-before-name fallback would otherwise turn punctuation such as + // `if(`, `(!`, or `auto item =` into a complete C++ type. + let function_pointer = + source.contains(&format!("(*{name}")) || source.contains(&format!("(&{name}")); + // Taking a local's address establishes a possible indirect write. + // Keeping this conservative complete hint makes the nullable lattice + // forget an earlier value proof until pointer-alias mutation is + // represented explicitly. + let address_alias = declared.contains('=') && declared.trim_end().ends_with('&'); + ((function_pointer || address_alias || !declared.contains(['(', ')', '!', '=', '?'])) + && !declared.contains("->") + && !declared.ends_with('.') + && !declared.ends_with(',') + && declared + .chars() + .any(|character| character.is_ascii_alphanumeric()) + && !matches!( + declared.split_whitespace().next().unwrap_or_default(), + "if" | "while" | "switch" | "return" + )) + .then_some(declared) + } + + fn collection_element_binding(&self, source: &str, local: &str) -> Option { + cpp_collection_element_binding(source, local) + } + + fn indexed_receiver_collection_binding(&self, receiver: &str) -> Option { + cpp_indexed_receiver_collection_binding(receiver) + } + + fn indexed_collection_result_type(&self, declared_type: &str) -> Option { + cpp_indexed_collection_result_type(declared_type) + } + + fn pointer_member_receiver_type( + &self, + source: &str, + receiver: &str, + message: &str, + declared_type: &str, + ) -> Option { + cpp_pointer_member_receiver_type(source, receiver, message, declared_type) + } + + fn receiver_local_binding(&self, receiver: &str) -> Option { + cpp_receiver_local_binding(receiver) + } + + fn explicit_receiver_type(&self, receiver: &str) -> Option { + cpp_c_style_receiver_type(receiver) + } + + fn template_dependent_call_type(&self, message: &str) -> Option { + let message = message.trim(); + (!message.is_empty()).then(|| message.to_string()) } fn stdlib_language(&self) -> Option<&'static str> { Some("cpp") } + fn scalar_operator_complexity( + &self, + message: &str, + operand_type: Option<&TypeExpr>, + ) -> Option { + let operator = message.strip_suffix('@').unwrap_or(message); + if !CPP_PRIMITIVE_OPERATORS.contains(&operator) { + return None; + } + matches!(operand_type, Some(TypeExpr::Primitive(name)) if cpp_scalar_primitive(name)) + .then_some(super::normalized_behavior::NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) + } + // CFG-SPECIFIC START: expose the C++ CFG profile. fn cfg_profile(&self) -> &'static ControlFlowProfile { &CPP_CFG_PROFILE @@ -355,6 +1671,37 @@ impl NormalizedLanguageBehavior for CppNormalizedBehavior { type_before_parameter_name(parameter) } + fn declared_callable_cost(&self, declared_type: &str) -> Option { + let normalized = declared_type + .split_whitespace() + .collect::>() + .join(" "); + (normalized.contains("(*)") + || (normalized.contains("(*") && normalized.contains(")(")) + || normalized.contains("std::function<")) + .then(|| "callback_once".to_string()) + } + + fn syntax_metadata(&self, source: &str, functions: &[FunctionDef]) -> SyntaxMetadata { + let (type_aliases, type_alias_lines, dependent_aliases) = cpp_type_aliases(source); + let method_local_types = cpp_method_local_types(source, functions); + SyntaxMetadata { + type_aliases, + type_alias_lines, + method_param_types: super::normalized_behavior::method_param_types_from_signatures( + self, source, functions, + ), + method_local_types: method_local_types.clone(), + method_template_types: cpp_method_template_types( + source, + functions, + &dependent_aliases, + &method_local_types, + ), + ..SyntaxMetadata::default() + } + } + fn property_read_call(&self, node: &Node, parts: &NormalizedCallParts) -> bool { cpp_member_selector_is_invoked(&node.text, &parts.message) == Some(false) } @@ -685,6 +2032,195 @@ mod tests { } } + #[test] + fn libstdcxx_abi_collection_owners_use_standard_contracts() { + let empty = external_symbol_call_complexity( + "cxx . . $ std/__cxx11/list#empty(3482b152b9333168).", + "empty", + ) + .expect("libstdc++ list is a reviewed standard collection"); + assert_eq!(empty.time, "O(1)"); + + let splice = external_symbol_call_complexity( + "cxx . . $ std/__cxx11/list#splice(40b6f1fd15459e25).", + "splice", + ) + .expect("list splice has a conservative common upper bound"); + assert_eq!(splice.time, "O(N)"); + } + + #[test] + fn exact_cpp_stdlib_descriptors_preserve_parametric_work() { + let string = external_symbol_call_complexity( + "cxx . . $ std/__cxx11/basic_ostringstream#str(d33e1a6fd36255f7).", + "str", + ) + .expect("stream materialization descriptor is reviewed"); + assert_eq!(string.time, "O(N)"); + + let swap = external_symbol_metadata("cxx . . $ std/swap(c75b8fd57d7c2e06)."); + assert_eq!(swap.scope, "stdlib"); + assert_eq!(swap.parametric_cost.as_deref(), Some("reflective_once")); + assert!( + external_symbol_call_complexity("cxx . . $ std/swap(c75b8fd57d7c2e06).", "swap") + .is_none() + ); + } + + #[test] + fn dependent_value_operations_fail_closed_for_source_export() { + let template_types = BTreeSet::from(["_Tp".to_string()]); + assert!(!CppNormalizedBehavior.source_body_implicit_work_is_modeled( + "void swap(_Tp& left, _Tp& right) {\n\ + _Tp temporary = std::move(left);\n\ + left = std::move(right);\n\ + right = std::move(temporary);\n\ + }", + &template_types, + )); + assert!(!CppNormalizedBehavior.source_body_implicit_work_is_modeled( + "void replace(_Tp& left, _Tp& right) {\n\ + left = std::move(right);\n\ + }", + &template_types, + )); + assert!(CppNormalizedBehavior.source_body_implicit_work_is_modeled( + "bool empty(const _Tp& value) { return value.size() == 0; }", + &template_types, + )); + } + + #[test] + fn friend_functions_and_function_local_using_declarations_keep_free_dispatch() { + assert!(cpp_function_is_friend( + &node( + "FUNCTION", + "friend void swap(Item & left, Item & right) { left.swap(right); }" + ), + &[] + )); + assert!(!cpp_function_is_friend( + &node( + "FUNCTION", + "void swap(Item & other) { using std::swap; swap(value, other.value); }" + ), + &[] + )); + + assert!(cpp_function_imports_symbol( + "void swap(Item & other) {\n using std::swap;\n swap(value, other.value);\n}", + "std::swap" + )); + assert!(!cpp_function_imports_symbol( + "void swap(Item & other) {\n // using std::swap;\n swap(value, other.value);\n}", + "std::swap" + )); + } + + #[test] + fn indexed_dependent_maps_project_their_declared_value_type() { + assert_eq!( + cpp_indexed_receiver_collection_binding("eventCallbackListMap[[event]]"), + Some("eventCallbackListMap".to_string()) + ); + assert_eq!( + cpp_indexed_receiver_collection_binding("items[index]"), + Some("items".to_string()) + ); + assert_eq!( + cpp_indexed_receiver_collection_binding("factory().items[index]"), + None + ); + assert_eq!( + cpp_indexed_collection_result_type( + "typename SelectMap< Event, CallbackList_, Policies, Enabled >::Type" + ), + Some("CallbackList_".to_string()) + ); + assert_eq!( + cpp_indexed_collection_result_type("std::map"), + None + ); + } + + #[test] + fn reserved_std_qualified_calls_survive_inactive_scip_branches() { + let strrchr = CppNormalizedBehavior + .intrinsic_call_complexity(None, "std::strrchr") + .expect("the reserved std namespace proves runtime identity"); + assert_eq!((strrchr.time, strrchr.space), ("O(N)", "O(1)")); + + assert!(CppNormalizedBehavior + .intrinsic_call_complexity(None, "vendor::strrchr") + .is_none()); + } + + #[test] + fn inactive_platform_runtime_models_are_explicit_assumptions() { + let write = + modeled_runtime_call_complexity("::write").expect("reviewed inactive POSIX branch"); + assert_eq!((write.time, write.space), ("O(N)", "O(1)")); + assert_eq!(write.bound_quality, "upper_bound_modeled_world"); + assert!(write.assumption.is_some()); + assert!(modeled_runtime_call_complexity("project::write").is_none()); + } + + #[test] + fn cpp_consumes_scip_indexed_macro_definitions() { + let literal_wrapper = CppNormalizedBehavior + .preprocessor_definition_call_complexity("#define PLOG_NSTR(x) x") + .expect("an exact compile-time wrapper is constant"); + assert_eq!( + (literal_wrapper.time, literal_wrapper.space), + ("O(1)", "O(1)") + ); + assert_eq!( + CppNormalizedBehavior + .preprocessor_definition_location("cxx . . $ `include/plog/Util.h:91:12`!"), + Some(("include/plog/Util.h".to_string(), 91)) + ); + } + + #[test] + fn cpp_aliases_keep_unambiguous_or_semantically_converged_bindings() { + let (aliases, lines, _) = cpp_type_aliases( + r#" +using Items = std::list< + Widget +>; +typedef std::wstring WideName; +typedef std::string NativeName; +typedef std::wstring NativeName; +typedef std::ostringstream NativeStream; +typedef std::wostringstream NativeStream; +struct First { using super = BaseOne; }; +struct Second { using super = BaseTwo; }; +"#, + ); + assert_eq!( + aliases.get("Items").map(String::as_str), + Some("std::list< Widget >") + ); + assert_eq!( + aliases.get("WideName").map(String::as_str), + Some("std::wstring") + ); + assert_eq!( + parse_declared_type(aliases.get("NativeName").expect("converged string alias")), + TypeExpr::Primitive("String".to_string()) + ); + assert_eq!( + parse_declared_type( + aliases + .get("NativeStream") + .expect("converged string-stream alias") + ), + TypeExpr::Primitive("StringStream".to_string()) + ); + assert_eq!(lines.get("Items"), Some(&2)); + assert!(!aliases.contains_key("super")); + } + #[test] fn function_pointer_operations_require_a_symbol_callee() { let callback = Node { @@ -779,6 +2315,54 @@ mod tests { behavior.declared_local_type("gsl::not_null value = load_widget()", "value"), Some("gsl::not_null".to_string()) ); + assert_eq!( + behavior.declared_local_type("if(! value.empty())", "value"), + None + ); + assert_eq!( + behavior.declared_local_type("value.resize(2)", "value"), + None + ); + assert_eq!( + behavior.declared_local_type( + "auto data = make_data(Data { condition, callbackList, listener });", + "callbackList" + ), + None + ); + } + + #[test] + fn declared_smart_pointer_reset_keeps_destructor_cost_parametric() { + let behavior = CppNormalizedBehavior; + for declared in ["std::shared_ptr", "std::unique_ptr"] { + let parsed = parse_declared_type(declared); + assert_eq!( + behavior.parametric_call_cost(&parsed, "reset"), + Some("reflective_once".to_string()), + "{declared}: {parsed:?}" + ); + } + } + + #[test] + fn recovers_only_proven_receiver_bindings_and_c_style_types() { + let behavior = CppNormalizedBehavior; + assert_eq!( + behavior.receiver_local_binding("*callableList"), + Some("callableList".to_string()) + ); + assert_eq!( + behavior.receiver_local_binding("(*callableList)"), + Some("callableList".to_string()) + ); + assert_eq!(behavior.receiver_local_binding("*items[0]"), None); + assert_eq!( + behavior.explicit_receiver_type("(const LargeData *)buffer.data()"), + Some("const LargeData *".to_string()) + ); + assert_eq!(behavior.explicit_receiver_type("(left + right)"), None); + assert_eq!(behavior.explicit_receiver_type("(Widget)value"), None); } #[test] @@ -888,6 +2472,7 @@ mod tests { // 12. terminating_call_message assert!(b.terminating_call_message("throw")); + assert!(configured_non_call_construct("cpp", "defined")); // 13. semantic_effect_for_call assert!(b diff --git a/gems/fact-mine/src/syntax/csharp.rs b/gems/fact-mine/src/syntax/csharp.rs index 4850576a5..25066517e 100644 --- a/gems/fact-mine/src/syntax/csharp.rs +++ b/gems/fact-mine/src/syntax/csharp.rs @@ -4,11 +4,11 @@ use super::cfg::ControlFlowProfile; use super::effects::{effect_from_call_with_lexicon, EffectLexicon}; use super::normalized_behavior::{ - configured_collection_operation, configured_intrinsic_call_complexity, - configured_semantic_symbol_call_complexity, configured_semantic_symbol_kind, - configured_semantic_symbol_parametric_cost, eliminable_guard_from_call, - nil_guard_from_predicates, scip_descriptor_owner, scip_global_parts, - type_before_parameter_name, NormalizedCallParts, NormalizedCallProjection, + configured_collection_operation, configured_external_latency_bound, + configured_intrinsic_call_complexity, configured_semantic_symbol_call_complexity, + configured_semantic_symbol_kind, configured_semantic_symbol_parametric_cost, + eliminable_guard_from_call, nil_guard_from_predicates, scip_descriptor_owner, + scip_global_parts, type_before_parameter_name, NormalizedCallParts, NormalizedCallProjection, NormalizedLanguageBehavior, NormalizedNilGuardFact, NormalizedNullableOperation, NormalizedSemanticEffect, }; @@ -20,7 +20,9 @@ use crate::type_inference::TypeExpr; const CSHARP_NOMINAL_TYPE_SYNTAX: NominalTypeSyntax = NominalTypeSyntax { strip_prefixes: &["readonly "], trim_prefix_chars: &[], - trim_suffix_chars: &[], + // Nullable reference/value annotations do not change the nominal receiver + // that owns a member. Nullability is tracked separately by CFG facts. + trim_suffix_chars: &['?'], array_names: &["List", "ArrayList", "Vector"], hash_names: &["Dictionary", "HashMap"], set_names: &["HashSet"], @@ -90,7 +92,7 @@ pub(crate) fn external_symbol_call_complexity( return None; } let owner = descriptor_owner(descriptor); - let complexity = configured_semantic_symbol_call_complexity("csharp", descriptor) + if let Some(complexity) = configured_semantic_symbol_call_complexity("csharp", descriptor) .or_else(|| { owner.as_deref().and_then(|owner| { configured_intrinsic_call_complexity("csharp", Some(owner), message) @@ -101,14 +103,28 @@ pub(crate) fn external_symbol_call_complexity( CSharpNormalizedBehavior .call_complexity(&TypeExpr::Primitive(owner.to_string()), message) }) - })?; + }) + { + return Some(ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "csharp_scip_symbol_registry", + bound_quality: "upper_bound_exact_target", + candidates: Vec::new(), + assumption: None, + }); + } + let complexity = configured_external_latency_bound("csharp", owner.as_deref()?, message)?; Some(ExternalCallComplexity { time: complexity.time, space: complexity.space, - provenance: "csharp_scip_symbol_registry", - bound_quality: "upper_bound_exact_target", + provenance: "csharp_external_effect_registry", + bound_quality: "upper_bound_external_latency_excluded", candidates: Vec::new(), - assumption: None, + assumption: Some( + "computational Big-O only; assembly loading, scheduling, and wait latency is excluded" + .to_string(), + ), }) } @@ -206,6 +222,64 @@ const CSHARP_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct CSharpNormalizedBehavior; impl NormalizedLanguageBehavior for CSharpNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + let source = node.text.trim_end(); + source.ends_with('}') || source.contains("=>") + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "csharp-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn resolves_inherited_project_calls(&self) -> bool { + true + } + + fn nested_function_is_local_callable(&self, _function: &Node) -> bool { + true + } + + fn declared_callable_cost(&self, declared_type: &str) -> Option { + let nominal = declared_type + .trim() + .trim_start_matches("readonly ") + .trim_start_matches("static ") + .trim_end_matches('?') + .rsplit('.') + .next() + .unwrap_or(declared_type); + (nominal == "Action" || nominal.starts_with("Action<") || nominal.starts_with("Func<")) + .then(|| "callback_once".to_string()) + .or_else(|| { + super::normalized_behavior::configured_callable_type_cost("csharp", declared_type) + }) + } + + // C-family indexers render a local as `Type name` - the type leads. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_end_matches(';').trim(); + let (declared, _name) = text.rsplit_once(char::is_whitespace)?; + let declared = declared.trim(); + (!declared.is_empty() && !declared.contains('=')).then(|| declared.to_string()) + } + + // C# declares `Ret name(T a)`, not `name(a: T) -> Ret`. + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + super::normalized_behavior::parse_prefix_return_declarator(signature) + } + fn nullable_operation(&self, node: &Node) -> Option { (node.r#type == "CALL") .then(|| node.children.first().and_then(crate::ast::node)) @@ -250,8 +324,62 @@ impl NormalizedLanguageBehavior for CSharpNormalizedBehavior { .unwrap_or_default() } + fn owner_kind(&self, node: &Node, default_kind: &str) -> String { + if node.text.contains("interface ") { + "interface".to_string() + } else if node.text.contains("abstract class ") { + "abstract_class".to_string() + } else if node.text.contains("enum ") { + "enum".to_string() + } else if node.text.contains("record ") { + "record".to_string() + } else if node.text.contains("struct ") { + "struct".to_string() + } else { + default_kind.to_string() + } + } + + fn type_kind_is_abstract_dispatch(&self, kind: &str) -> bool { + matches!(kind, "interface" | "abstract_class") + } + + fn constructor_dispatch_name( + &self, + receiver: &str, + message: &str, + owner: &str, + ) -> Option { + (receiver == "self" && message == "this") + .then(|| owner.rsplit("::").next().unwrap_or(owner).to_string()) + } + + fn constructor_delegation_excludes_self(&self) -> bool { + true + } + + fn fallback_owner_kind(&self, owner: &str, source: &str) -> Option { + let owner = owner.rsplit("::").next().unwrap_or(owner); + source.lines().find_map(|line| { + let tokens = line + .split(|character: char| !(character == '_' || character.is_ascii_alphanumeric())) + .filter(|token| !token.is_empty()) + .collect::>(); + tokens + .windows(2) + .any(|pair| pair == ["interface", owner]) + .then(|| "interface".to_string()) + .or_else(|| { + tokens + .windows(3) + .any(|triple| triple == ["abstract", "class", owner]) + .then(|| "abstract_class".to_string()) + }) + }) + } + fn declared_local_type(&self, source: &str, name: &str) -> Option { - super::normalized_behavior::type_before_local_name(source, name) + csharp_declared_local_type(source, name) } fn stdlib_language(&self) -> Option<&'static str> { @@ -356,7 +484,90 @@ impl NormalizedLanguageBehavior for CSharpNormalizedBehavior { receiver_type: &crate::type_inference::TypeExpr, message: &str, ) -> Option { - configured_collection_operation("csharp", receiver_type, message) + configured_collection_operation("csharp", receiver_type, message).or_else(|| { + let crate::type_inference::TypeExpr::Primitive(name) = receiver_type.strip_nilable() + else { + return None; + }; + let runtime_name = match name.as_str() { + "bool" => "Boolean", + "byte" => "Byte", + "sbyte" => "SByte", + "short" => "Int16", + "ushort" => "UInt16", + "int" => "Int32", + "uint" => "UInt32", + "long" => "Int64", + "ulong" => "UInt64", + "float" => "Single", + "double" => "Double", + "decimal" => "Decimal", + "char" => "Char", + "string" => "String", + "object" => "Object", + _ => return None, + }; + configured_collection_operation( + "csharp", + &crate::type_inference::TypeExpr::Primitive(runtime_name.to_string()), + message, + ) + }) + } + + fn intrinsic_call_complexity( + &self, + receiver: Option<&str>, + message: &str, + ) -> Option { + let runtime_receiver = match receiver { + Some("bool") => Some("Boolean"), + Some("byte") => Some("Byte"), + Some("sbyte") => Some("SByte"), + Some("short") => Some("Int16"), + Some("ushort") => Some("UInt16"), + Some("int") => Some("Int32"), + Some("uint") => Some("UInt32"), + Some("long") => Some("Int64"), + Some("ulong") => Some("UInt64"), + Some("float") => Some("Single"), + Some("double") => Some("Double"), + Some("decimal") => Some("Decimal"), + Some("char") => Some("Char"), + Some("string") => Some("String"), + Some("object") => Some("Object"), + receiver => receiver, + }; + configured_intrinsic_call_complexity("csharp", runtime_receiver, message) + } + + fn propagated_collection_return_type( + &self, + message: &str, + receiver_type: Option<&str>, + ) -> Option { + matches!(message, "Take" | "Skip" | "Where") + .then(|| receiver_type.map(str::to_string)) + .flatten() + } + + fn collection_callback_parameter(&self, message: &str) -> bool { + matches!( + message, + "Aggregate" | "All" | "Any" | "Count" | "First" | "FirstOrDefault" | "Select" | "Where" + ) + } + + fn super_constructor_call_complexity( + &self, + supertype: &str, + ) -> Option { + let owner = supertype + .trim() + .trim_start_matches("global::") + .rsplit(['.', ':']) + .find(|part| !part.is_empty())?; + configured_intrinsic_call_complexity("csharp", Some(owner), "ctor") } fn mutating_receiver_message(&self, message: &str) -> bool { @@ -612,6 +823,39 @@ pub(crate) fn behavior() -> &'static dyn NormalizedLanguageBehavior { &BEHAVIOR } +fn csharp_declared_local_type(source: &str, name: &str) -> Option { + let name_start = source.match_indices(name).find_map(|(index, _)| { + let before = source[..index].chars().next_back(); + let after = source[index + name.len()..].chars().next(); + let boundary = |character: Option| { + character.is_none_or(|character| !character.is_alphanumeric() && character != '_') + }; + (boundary(before) && boundary(after)).then_some(index) + })?; + let prefix = source[..name_start].trim(); + // C# type/declaration patterns bind the local after `is T` or `is not T`. + // Extract only the native type token; feeding the whole predicate into the + // shared leading-type parser produced fictional types such as + // `value is not byte[]`. + for marker in [" is not ", " is "] { + if let Some(pattern_type) = prefix.rsplit_once(marker).map(|(_, tail)| tail.trim()) { + if !pattern_type.is_empty() + && !pattern_type.contains(char::is_whitespace) + && !matches!(pattern_type, "var" | "dynamic") + { + return Some(pattern_type.to_string()); + } + } + } + let declared = super::normalized_behavior::type_before_local_name(source, name)?; + (!declared.contains(['(', ')', '=', '!']) + && !declared.contains(" is ") + && !declared.contains(" in") + && !declared.starts_with("var ") + && !declared.ends_with(" var")) + .then_some(declared) +} + #[cfg(test)] mod tests { use super::*; @@ -632,6 +876,35 @@ mod tests { fn test_csharp_behavior_comprehensive() { let b = CSharpNormalizedBehavior; assert_eq!(b.self_member_receiver("Foo"), "Foo"); + assert_eq!( + b.owner_kind(&node("CLASS", "public interface ILogger"), "owner"), + "interface" + ); + assert_eq!( + b.owner_kind(&node("CLASS", "public abstract class Sink"), "owner"), + "abstract_class" + ); + assert!(b.type_kind_is_abstract_dispatch("interface")); + assert!(b.type_kind_is_abstract_dispatch("abstract_class")); + assert!(!b.type_kind_is_abstract_dispatch("class")); + assert_eq!( + b.fallback_owner_kind( + "ILogger", + "namespace Demo;\npublic interface ILogger\n{\n}\n" + ), + Some("interface".to_string()) + ); + assert_eq!( + b.declared_local_type("if (value is not byte[] bytes)", "bytes"), + Some("byte[]".to_string()) + ); + assert_eq!( + b.declared_local_type( + "if (!properties.TryGetValue(key, out var propertyValue))", + "propertyValue" + ), + None + ); assert_eq!( b.explicit_self_state_ref(&node("LVAR", "x"), "Foo"), "this.Foo" @@ -825,5 +1098,120 @@ mod tests { assert_eq!(b.untyped_type(), "object"); assert_eq!(b.untyped_array_type(), "List"); assert_eq!(b.untyped_hash_type(), "Dictionary"); + assert_eq!( + b.call_complexity( + &crate::type_inference::TypeExpr::Primitive("uint".to_string()), + "ToString", + ) + .map(|cost| cost.time), + Some("O(N)") + ); + assert_eq!( + b.call_complexity(&parse_declared_type("byte[]"), "Take") + .map(|cost| cost.time), + Some("O(1)") + ); + assert_eq!( + b.parametric_call_cost(&parse_declared_type("byte[]"), "Select"), + Some("callback_linear".to_string()) + ); + assert_eq!( + b.intrinsic_call_complexity(Some("string"), "Concat") + .map(|cost| cost.time), + Some("O(N)") + ); + assert_eq!( + b.propagated_collection_return_type("Take", Some("byte[]")), + Some("byte[]".to_string()) + ); + assert_eq!( + b.propagated_collection_return_type("Select", Some("byte[]")), + None + ); + assert_eq!( + b.super_constructor_call_complexity("System.IO.StringWriter") + .map(|cost| cost.time), + Some("O(1)") + ); + assert_eq!( + b.super_constructor_call_complexity("UserDefinedParent"), + None + ); + } + + #[test] + fn scip_dotnet_symbols_use_reviewed_exact_and_parametric_costs() { + let symbol = + |descriptor: &str| format!("scip-dotnet nuget System.Runtime 10.0.0.0 {descriptor}"); + for (descriptor, message, expected) in [ + ("IO/TextWriter#Write(+1).", "Write", "O(1)"), + ("IO/TextWriter#Write(+11).", "Write", "O(N)"), + ("System/Span#Slice(+1).", "Slice", "O(1)"), + ( + "Reflection/MethodBase#GetParameters().", + "GetParameters", + "O(N)", + ), + ("Collections/Hashtable#Clear().", "Clear", "O(N)"), + ("System/Array#GetLength().", "GetLength", "O(1)"), + ("System/Array#GetValue(+3).", "GetValue", "O(1)"), + ("Linq/Enumerable#Any().", "Any", "O(N)"), + ("System/AppContext#TryGetSwitch().", "TryGetSwitch", "O(N)"), + ("System/Exception#ToString().", "ToString", "O(N)"), + ("RegularExpressions/Regex#IsMatch(+5).", "IsMatch", "O(N)"), + ("System/ReadOnlySpan#Slice(+1).", "Slice", "O(1)"), + ( + "InteropServices/MemoryMarshal#CreateSpan().", + "CreateSpan", + "O(1)", + ), + ] { + let cost = external_symbol_call_complexity(&symbol(descriptor), message) + .unwrap_or_else(|| panic!("missing exact cost for {descriptor}")); + assert_eq!(cost.time, expected, "descriptor={descriptor}"); + assert_eq!(cost.provenance, "csharp_scip_symbol_registry"); + } + + let callback = "scip-dotnet nuget System.Linq 10.0.0.0 Linq/Enumerable#Where()."; + assert!(external_symbol_call_complexity(callback, "Where").is_none()); + let metadata = external_symbol_metadata(callback); + assert_eq!(metadata.scope, "stdlib"); + assert_eq!(metadata.parametric_cost.as_deref(), Some("callback_linear")); + + let action = external_symbol_metadata( + "scip-dotnet nuget System.Runtime 10.0.0.0 System/Action#Invoke().", + ); + assert_eq!(action.parametric_cost.as_deref(), Some("callback_once")); + let virtual_object = external_symbol_metadata( + "scip-dotnet nuget System.Runtime 10.0.0.0 System/Object#Equals(+1).", + ); + assert_eq!( + virtual_object.parametric_cost.as_deref(), + Some("callback_once") + ); + let string_create = external_symbol_metadata( + "scip-dotnet nuget System.Runtime 10.0.0.0 System/String#Create().", + ); + assert_eq!( + string_create.parametric_cost.as_deref(), + Some("callback_linear") + ); + assert_eq!( + CSharpNormalizedBehavior + .intrinsic_call_complexity(None, "nameof") + .map(|cost| cost.time), + Some("O(1)") + ); + for (descriptor, message, expected) in [ + ("Reflection/Assembly#Load(+2).", "Load", "O(N)"), + ("Tasks/Task#Delay(+2).", "Delay", "O(1)"), + ("Tasks/Task#Wait().", "Wait", "O(1)"), + ] { + let cost = external_symbol_call_complexity(&symbol(descriptor), message) + .unwrap_or_else(|| panic!("missing external-latency cost for {descriptor}")); + assert_eq!(cost.time, expected); + assert_eq!(cost.bound_quality, "upper_bound_external_latency_excluded"); + assert!(cost.assumption.is_some()); + } } } diff --git a/gems/fact-mine/src/syntax/effects.rs b/gems/fact-mine/src/syntax/effects.rs index 1d663915d..a48762995 100644 --- a/gems/fact-mine/src/syntax/effects.rs +++ b/gems/fact-mine/src/syntax/effects.rs @@ -2,7 +2,7 @@ use super::{ normalized_behavior::{NormalizedLanguageBehavior, NormalizedSemanticEffect}, CallSite, FunctionDef, SemanticEffectSite, }; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; #[derive(Clone, Copy)] pub(crate) struct EffectLexicon { @@ -58,18 +58,29 @@ pub(crate) fn semantic_effect_sites_from_calls( } pub(crate) fn dedup_semantic_effect_sites(sites: &mut Vec) { - let mut seen = HashSet::new(); - sites.retain(|site| { - seen.insert(( + let mut by_effect: HashMap<(String, String, String, String, usize, [usize; 4]), usize> = + HashMap::new(); + let mut deduplicated: Vec = Vec::with_capacity(sites.len()); + for site in sites.drain(..) { + let key = ( site.kind.clone(), site.detail.clone(), - site.receiver_scope.clone(), site.file.clone(), site.function.clone(), site.line, site.span, - )) - }); + ); + if let Some(index) = by_effect.get(&key).copied() { + let existing = &mut deduplicated[index]; + if existing.receiver_scope == "unknown" && site.receiver_scope != "unknown" { + existing.receiver_scope = site.receiver_scope; + } + } else { + by_effect.insert(key, deduplicated.len()); + deduplicated.push(site); + } + } + *sites = deduplicated; } fn local_self_call_to_known_function( @@ -338,4 +349,23 @@ mod tests { assert_eq!(effect.kind, "hidden_mutation"); assert_eq!(effect.detail, "update!"); } + + #[test] + fn duplicate_effects_keep_the_more_precise_receiver_scope() { + let effect = |receiver_scope: &str| SemanticEffectSite { + kind: "hidden_mutation".to_string(), + detail: "<<".to_string(), + receiver_scope: receiver_scope.to_string(), + file: "sample.rb".to_string(), + function: "append".to_string(), + line: 3, + span: [3, 2, 3, 15], + }; + let mut sites = vec![effect("unknown"), effect("local")]; + + dedup_semantic_effect_sites(&mut sites); + + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].receiver_scope, "local"); + } } diff --git a/gems/fact-mine/src/syntax/go.rs b/gems/fact-mine/src/syntax/go.rs index 6cc3da877..535fd0123 100644 --- a/gems/fact-mine/src/syntax/go.rs +++ b/gems/fact-mine/src/syntax/go.rs @@ -83,10 +83,19 @@ fn raw_presence_correlations( function: &str, rows: &mut Vec, ) { + // A `func_literal` closure is normalized to a first-class lambda whose + // synthetic name is `` of its start. + // Attribute presence correlations declared inside it to that same name + // so the normalized seed and this raw span reconcile by function. + let lambda_name; let function = if node.kind() == "function_declaration" { node.child_by_field_name("name") .and_then(|name| text(name, source)) .unwrap_or(function) + } else if node.kind() == "func_literal" { + let start = node.start_position(); + lambda_name = crate::syntax::lambda_function_name(start.row + 1, start.column); + lambda_name.as_str() } else { function }; @@ -294,6 +303,14 @@ const GO_EFFECT_LEXICON: EffectLexicon = EffectLexicon { ..EffectLexicon::empty() }; +// Go builtin operators, emitted as call messages by the normalizer. They carry +// no overload in Go, so each is constant-time on primitive operands. +const GO_BUILTIN_OPERATORS: &[&str] = &[ + "==", "!=", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "&", "|", "^", "<<", ">>", "&^", + "&&", "||", "!", +]; +// Fixed-arg predeclared builtins that reduce to a constant-time comparison. +const GO_BUILTIN_FUNCTIONS: &[&str] = &["max", "min"]; const GO_NIL_PREDICATES: &[&str] = &["isNull", "is_null", "nil"]; const GO_NON_NIL_PREDICATES: &[&str] = &["isSome", "is_some", "present"]; const GO_GUARD_MIDS: &[&str] = &["isNull", "is_null"]; @@ -308,6 +325,57 @@ const GO_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct GoNormalizedBehavior; impl NormalizedLanguageBehavior for GoNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "go-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn canonical_project_namespace(&self, file: &std::path::Path, namespace: &str) -> String { + if namespace.is_empty() { + return String::new(); + } + let directory = file + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .to_string_lossy(); + format!("{directory}::{namespace}") + } + + fn project_function_reconciliation_key(&self, symbol: &str) -> Option<(String, String)> { + let mut parts = symbol.rsplit("::"); + let (name, package) = (parts.next()?, parts.next()?); + let package_leaf = package.rsplit('/').next().unwrap_or(package); + (!name.is_empty() && !package_leaf.is_empty()) + .then(|| (package_leaf.to_string(), name.to_string())) + } + + fn resolves_inherited_project_calls(&self) -> bool { + true + } + + // The Go indexer renders a local as `var name Type` - the type trails. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_start_matches("var ").trim(); + let (_name, declared) = text.split_once(char::is_whitespace)?; + let declared = declared.trim(); + (!declared.is_empty() && !declared.contains('=')).then(|| declared.to_string()) + } + fn nullable_operation(&self, node: &Node) -> Option { let (subject, operation_kind, nil_behavior) = match node.r#type.as_str() { "UNARY_EXPRESSION" if node.text.trim_start().starts_with('*') => ( @@ -384,6 +452,43 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { true } + /// Parameters whose type is a Go function type (`f func(...) ...`) are + /// callbacks: the function's cost is parametric in them, so a caller can + /// substitute the passed callable's cost. Parsed from the signature's + /// parameter list (Go params come from the signature, not `LASGN` nodes), + /// splitting on top-level commas so a `func(a, b) c` type is not torn apart. + fn callback_parameter_names(&self, function: &Node) -> Vec { + let params_source = self.parameter_list_source(&function.text); + if params_source.is_empty() { + return Vec::new(); + } + let mut depth = 0i32; + let mut start = 0usize; + let mut parts: Vec<&str> = Vec::new(); + for (index, byte) in params_source.bytes().enumerate() { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b',' if depth == 0 => { + parts.push(¶ms_source[start..index]); + start = index + 1; + } + _ => {} + } + } + parts.push(¶ms_source[start..]); + parts + .into_iter() + .filter_map(|part| { + let (name, ty) = part.trim().split_once(char::is_whitespace)?; + let ty = ty.trim_start(); + (ty.starts_with("func(") || ty.starts_with("func ")) + .then(|| name.trim().to_string()) + .filter(|name| !name.is_empty()) + }) + .collect() + } + fn external_symbol_call_complexity( &self, symbol: &str, @@ -433,7 +538,7 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { } fn declared_local_type(&self, source: &str, name: &str) -> Option { - super::normalized_behavior::type_after_go_local_name(source, name) + type_after_go_local_name(source, name) } fn stdlib_language(&self) -> Option<&'static str> { @@ -593,6 +698,14 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { .unwrap_or_else(|| current_owner.to_string()) } + fn function_defines_receiver(&self, node: &Node) -> bool { + node.text + .trim_start() + .strip_prefix("func") + .and_then(receiver_owner_from_go_function) + .is_some() + } + fn receiver_aliases_for_function( &self, node: &Node, @@ -714,6 +827,19 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { receiver: Option<&str>, message: &str, ) -> Option { + // Unary operators carry a trailing `@` (e.g. `-@`); strip it so the + // same table matches unary and binary forms. + let operator = message.strip_suffix('@').unwrap_or(message); + if GO_BUILTIN_OPERATORS.contains(&operator) || GO_BUILTIN_FUNCTIONS.contains(&message) { + // Go has no operator overloading: arithmetic, comparison, bitwise + // and logical operators - and the fixed-arg builtins max/min - are + // constant-time on primitives. Without this they are recorded as + // unresolved call targets, wrongly marking O(1) functions incomplete. + return Some(super::normalized_behavior::NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }); + } configured_intrinsic_call_complexity("go", receiver, message) } @@ -721,6 +847,26 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { false } + fn type_name_conversion_complexity( + &self, + ) -> Option { + Some(super::normalized_behavior::NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) + } + + fn type_kind_is_abstract_dispatch(&self, kind: &str) -> bool { + kind == "interface" + } + + fn abstract_type_requirements(&self, node: &Node) -> Vec { + if !is_interface_declaration(&node.text) { + return Vec::new(); + } + interface_method_names(&node.text) + } + fn split_case_source(&self, source: &str) -> Vec { vec![source.to_string()] } @@ -752,9 +898,23 @@ impl NormalizedLanguageBehavior for GoNormalizedBehavior { fn suppress_state_read_for_call( &self, call: &NormalizedCallProjection, - _span_source: &str, + span_source: &str, ) -> bool { - call.receiver == "self" && matches!(call.message.as_str(), "callback" | "println") + if call.receiver != "self" { + return false; + } + // A bare builtin call (`make(...)`, `len(x)`, `string(x)`, `float64(x)`) + // has no receiver, so the normalizer attributes it to `self`. It is not + // a struct-field read; treating it as one fabricates state like + // `read:make`. + if is_go_builtin(call.message.as_str()) { + return true; + } + // `self.method(...)` (the message immediately followed by `(`) is a + // method invocation, not a field read; only bare `self.field` is state. + // It is already recorded as a call edge, so recording it as state too + // would double-count and fabricate `read:method`. + span_source.contains(&format!("{}(", call.message)) } fn wrap_branch_predicate(&self, _branch: &Node) -> bool { @@ -1033,6 +1193,15 @@ fn go_method_local_types( Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*make\s*\(\s*chan\s+([^\s,)]+)") .expect("valid Go channel construction regex") }); + // `b := strings.Builder{}` / `b := &bytes.Buffer{}` - a composite literal + // names the variable's type directly. The `&?` is skipped so a pointer + // literal still yields the base type, which is how the stdlib registry + // keys its methods (`bytes.Buffer.WriteByte`, `sync.Mutex.Lock`). + static COMPOSITE: OnceLock = OnceLock::new(); + let composite = COMPOSITE.get_or_init(|| { + Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*&?\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\{") + .expect("valid Go composite literal regex") + }); let receive = RECEIVE.get_or_init(|| { Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*<-\s*([A-Za-z_][A-Za-z0-9_]*)") .expect("valid Go channel receive regex") @@ -1095,6 +1264,13 @@ fn go_method_local_types( .insert(capture[2].to_string()); known.insert(capture[1].to_string(), capture[2].to_string()); } + for capture in composite.captures_iter(&body) { + candidates + .entry(capture[1].to_string()) + .or_default() + .insert(capture[2].to_string()); + known.insert(capture[1].to_string(), capture[2].to_string()); + } let mut channel_elements = BTreeMap::new(); for capture in make_chan.captures_iter(&body) { let name = capture[1].to_string(); @@ -1217,6 +1393,28 @@ fn type_name(text: &str) -> Option { .map(str::to_string) } +/// The method names declared by a Go `interface { ... }` body. Embedded +/// interfaces (a bare type name, no parameter list) are conformance edges, not +/// methods, and are handled through `supertypes`. +fn interface_method_names(text: &str) -> Vec { + let Some(open) = text + .find("interface") + .and_then(|i| text[i..].find('{').map(|j| i + j + 1)) + else { + return Vec::new(); + }; + let body = &text[open..]; + let body = body.rfind('}').map(|close| &body[..close]).unwrap_or(body); + body.split([';', '\n']) + .filter_map(|spec| { + let spec = spec.trim(); + let paren = spec.find('(')?; + let name = spec[..paren].trim(); + simple_identifier(name).then(|| name.to_string()) + }) + .collect() +} + fn receiver_owner_from_go_function(source: &str) -> Option { let source = source.trim_start(); let receiver = source.strip_prefix('(')?.split_once(')')?.0; @@ -1235,6 +1433,39 @@ fn receiver_owner_from_go_function(source: &str) -> Option { .filter(|value| !value.is_empty()) } +/// Parse a Go `var name Type` declaration for the local `name`'s declared type. +/// Short declarations (`:=`) are inferred values and intentionally excluded. +fn type_after_go_local_name(source: &str, name: &str) -> Option { + let body = source.trim().strip_prefix("var ")?.trim(); + // A grouped declaration `var ( a T1; b T2 )` holds one spec per line; a + // single declaration `var a T1` is the lone spec. Scan each spec for the + // target name so grouped blocks are not dropped. + let body = body + .strip_prefix('(') + .map(|inner| inner.trim_end_matches(')')) + .unwrap_or(body); + for spec in body.split([';', '\n']) { + let spec = spec.trim(); + let Some(suffix) = spec.strip_prefix(name) else { + continue; + }; + let boundary = spec[name.len()..].chars().next(); + if boundary.is_some_and(|character| character.is_alphanumeric() || character == '_') { + continue; + } + let type_name = suffix + .trim_start() + .split(['=', ';']) + .next() + .unwrap_or_default() + .trim(); + if let Some(resolved) = super::normalized_behavior::usable_declared_local_type(type_name) { + return Some(resolved); + } + } + None +} + fn receiver_name_from_go_function(source: &str) -> Option { let source = source.trim_start(); let receiver = source.strip_prefix('(')?.split_once(')')?.0; @@ -1334,11 +1565,60 @@ fn simple_identifier(name: &str) -> bool { && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } +/// Go's predeclared builtin functions and builtin type-conversion names. A bare +/// call to one of these is not a struct-field access. +fn is_go_builtin(message: &str) -> bool { + matches!( + message, + // builtin functions + "append" | "cap" | "clear" | "close" | "complex" | "copy" | "delete" + | "imag" | "len" | "make" | "max" | "min" | "new" | "panic" + | "print" | "println" | "real" | "recover" + // builtin type conversions (predeclared types) + | "bool" | "byte" | "complex64" | "complex128" | "error" | "float32" + | "float64" | "int" | "int8" | "int16" | "int32" | "int64" | "rune" + | "string" | "uint" | "uint8" | "uint16" | "uint32" | "uint64" + | "uintptr" | "any" + // legacy suppression retained + | "callback" + ) +} + #[cfg(test)] mod tests { use super::*; use crate::syntax::Child; + #[test] + fn self_builtin_calls_are_not_state_reads() { + let profile = GoNormalizedBehavior; + let call = |message: &str, args: Vec| NormalizedCallProjection { + receiver: "self".to_string(), + message: message.to_string(), + arguments: args, + access_span: [0, 0, 0, 0], + span: [0, 0, 0, 0], + }; + // Bare builtins attributed to `self` must be suppressed so they do not + // fabricate state like `read:make` / `read:string` / `read:float64`. + for builtin in ["make", "len", "string", "float64", "append", "new", "cap"] { + assert!( + profile.suppress_state_read_for_call(&call(builtin, vec!["x".into()]), "make(x)"), + "expected {builtin} to be suppressed" + ); + } + // A `self.method()` invocation is a call, not a field read (span shows + // the invoking paren); it is suppressed as state. + assert!(profile.suppress_state_read_for_call( + &call("populateFileSizes", vec![]), + "g.populateFileSizes()" + )); + // A real struct-field read on self (no invoking paren) is kept. + assert!(!profile.suppress_state_read_for_call(&call("count", vec![]), "c.count")); + assert!(is_go_builtin("make") && is_go_builtin("string")); + assert!(!is_go_builtin("count") && !is_go_builtin("populateFileSizes")); + } + fn node(kind: &str, text: &str) -> Node { Node { r#type: kind.to_string(), @@ -1391,6 +1671,11 @@ mod tests { fn test_go_behavior_uncovered_methods() { let behavior = GoNormalizedBehavior; + assert!( + behavior.function_has_executable_body(&node("DEFN", "func size() int { return 0 }")) + ); + assert!(!behavior.function_has_executable_body(&node("DEFN", "Size() int"))); + // format_array_type etc assert_eq!(behavior.format_array_type("int"), "[]int"); assert_eq!( @@ -1543,6 +1828,121 @@ mod tests { .is_none()); } + /// The harness, path, filesystem and buffer surface is what actually + /// blocks Go completeness on real corpora, and each family resolves + /// through a different table: an embedded-struct owner (`testing.common`), + /// a package intrinsic (`filepath`), external latency (`os`), and a + /// parametric contract (`t.Run`, `t.Errorf`). Pin one of each against the + /// exact symbol scip-go emits, because a key that misses resolves to + /// nothing and silently leaves the caller unknown. + #[test] + fn scip_go_harness_and_filesystem_surface_is_priced() { + let go = |descriptor: &str| { + format!("scip-go gomod github.com/golang/go/src go1.22 {descriptor}") + }; + let time_of = |descriptor: &str, message: &str| { + external_symbol_call_complexity(&go(descriptor), message) + .map(|complexity| complexity.time) + }; + + // Owner table reached through the embedded `common` base of *testing.T. + assert_eq!(time_of("testing/common#Helper().", "Helper"), Some("O(1)")); + // Package intrinsic. + assert_eq!( + time_of("`path/filepath`/Join().", "Join"), + Some("O(N)"), + "filepath.Join builds a new path from its arguments" + ); + assert_eq!(time_of("`path/filepath`/Ext().", "Ext"), Some("O(N)")); + // Owner table on a pointer receiver. + assert_eq!(time_of("bytes/Buffer#Len().", "Len"), Some("O(1)")); + assert_eq!(time_of("bytes/Buffer#String().", "String"), Some("O(N)")); + assert_eq!( + time_of("`io/fs`/FileInfo#ModTime().", "ModTime"), + Some("O(1)") + ); + assert_eq!(time_of("flag/FlagSet#Bool().", "Bool"), Some("O(1)")); + assert_eq!(time_of("bufio/Scanner#Text().", "Text"), Some("O(N)")); + assert_eq!(time_of("time/Time#Format().", "Format"), Some("O(N)")); + assert_eq!(time_of("time/Duration#Hours().", "Hours"), Some("O(1)")); + assert_eq!(time_of("bytes/TrimSpace().", "TrimSpace"), Some("O(N)")); + assert_eq!(time_of("strings/IndexByte().", "IndexByte"), Some("O(N)")); + assert_eq!( + time_of("strings/NewReplacer().", "NewReplacer"), + Some("O(N)") + ); + assert_eq!( + time_of("regexp/Regexp#MatchString().", "MatchString"), + Some("O(N)") + ); + assert_eq!(time_of("strconv/Atoi().", "Atoi"), Some("O(N)")); + assert_eq!(time_of("math/Round().", "Round"), Some("O(1)")); + assert_eq!(time_of("flag/String().", "String"), Some("O(1)")); + assert_eq!(time_of("flag/NewFlagSet().", "NewFlagSet"), Some("O(1)")); + assert_eq!( + GoNormalizedBehavior + .call_complexity(&TypeExpr::Primitive("os.FileInfo".to_string()), "Sys") + .map(|complexity| complexity.time), + Some("O(1)"), + "os.FileInfo is an alias of io/fs.FileInfo even without a platform SCIP document" + ); + + // External latency: priced, and flagged as excluding device time. + let write = external_symbol_call_complexity(&go("os/WriteFile()."), "WriteFile").unwrap(); + assert_eq!(write.time, "O(N)"); + assert_eq!(write.bound_quality, "upper_bound_external_latency_excluded"); + assert!(write.assumption.is_some()); + assert_eq!(time_of("os/Stat().", "Stat"), Some("O(1)")); + assert_eq!(time_of("os/Getuid().", "Getuid"), Some("O(1)")); + assert_eq!(time_of("syscall/Flock().", "Flock"), Some("O(1)")); + assert_eq!( + time_of("bufio/Reader#ReadString().", "ReadString"), + Some("O(N)") + ); + assert_eq!(time_of("`os/exec`/Cmd#Output().", "Output"), Some("O(N)")); + assert_eq!(time_of("`os/exec`/Command().", "Command"), Some("O(N)")); + assert_eq!( + time_of("`os/exec`/Cmd#StdoutPipe().", "StdoutPipe"), + Some("O(1)") + ); + + // Parametric contracts must NOT return a closed cost here - they are + // reported through metadata so espalier keeps the open parameter. + for (descriptor, message, kind) in [ + ("testing/T#Run().", "Run", "callback_once"), + ("testing/common#Errorf().", "Errorf", "reflective_once"), + ("`path/filepath`/WalkDir().", "WalkDir", "callback_linear"), + ("sort/Slice().", "Slice", "callback_sort"), + ("flag/FlagSet#Visit().", "Visit", "callback_linear"), + ( + "`encoding/json`/Encoder#Encode().", + "Encode", + "reflective_once", + ), + ( + "`encoding/xml`/Unmarshal().", + "Unmarshal", + "reflective_once", + ), + ("fmt/Print().", "Print", "reflective_once"), + ] { + assert_eq!(time_of(descriptor, message), None, "{descriptor}"); + assert_eq!( + external_symbol_metadata(&go(descriptor)) + .parametric_cost + .as_deref(), + Some(kind), + "{descriptor}" + ); + } + + // Anything still unmodeled must keep reporting itself as unmodeled. + assert_eq!( + external_symbol_metadata(&go("testing/common#Setenv().")).missing_cost_kind, + "stdlib_cost_model_missing" + ); + } + #[test] fn scip_go_symbols_use_proven_stdlib_identity() { let value_type = "scip-go gomod github.com/golang/go/src go1.22 reflect/Value#Type()."; @@ -1600,6 +2000,38 @@ mod tests { } } + #[test] + fn go_local_type_reads_single_and_grouped_var_blocks() { + assert_eq!( + type_after_go_local_name("var b Value", "b"), + Some("Value".to_string()) + ); + let grouped = "var (\n\tb Value\n\tpos int\n)"; + assert_eq!( + type_after_go_local_name(grouped, "b"), + Some("Value".to_string()) + ); + } + + #[test] + fn go_builtin_operators_are_constant_time_intrinsics() { + let behavior = GoNormalizedBehavior; + for op in [ + "<", "==", "+", "*", "&", "<<", "!=", "-@", "+@", "max", "min", + ] { + let complexity = behavior.intrinsic_call_complexity(Some("x"), op); + assert_eq!( + complexity.map(|c| c.time), + Some("O(1)"), + "operator {op} should be O(1)" + ); + } + // A real method name is not an operator and stays unmodeled here. + assert!(behavior + .intrinsic_call_complexity(Some("x"), "Frobnicate") + .is_none()); + } + #[test] fn go_suppresses_synthetic_wrappers_and_selector_projections() { let behavior = GoNormalizedBehavior; diff --git a/gems/fact-mine/src/syntax/java.rs b/gems/fact-mine/src/syntax/java.rs index ac90b080f..ee596a05e 100644 --- a/gems/fact-mine/src/syntax/java.rs +++ b/gems/fact-mine/src/syntax/java.rs @@ -48,7 +48,7 @@ pub(crate) fn external_symbol_call_complexity( symbol: &str, message: &str, ) -> Option { - if !symbol.starts_with("scip-java maven jdk ") { + if !is_jdk_symbol(symbol) { return None; } @@ -128,7 +128,7 @@ pub(crate) fn external_symbol_metadata(symbol: &str) -> super::ExternalSymbolMet parametric_cost: None, }; }; - if symbol.starts_with("scip-java maven jdk ") { + if is_jdk_symbol(symbol) { super::ExternalSymbolMetadata { scope: "stdlib", missing_cost_kind: configured_semantic_symbol_kind("java", descriptor) @@ -144,6 +144,16 @@ pub(crate) fn external_symbol_metadata(symbol: &str) -> super::ExternalSymbolMet } } +fn is_jdk_symbol(symbol: &str) -> bool { + // scip-java 0.12.x writes SemanticDB-compatible symbols using the + // `semanticdb` scheme. Older fixtures and indexes used `scip-java`. + // Package manager/name/version still prove that the declaration is JDK + // owned; accept both producer spellings without weakening that check. + ["semanticdb maven jdk ", "scip-java maven jdk "] + .iter() + .any(|prefix| symbol.starts_with(prefix)) +} + const JAVA_CONTEXT_PAIRS: &[(&str, &[&str])] = &[ ( "System", @@ -214,6 +224,76 @@ const JAVA_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct JavaNormalizedBehavior; impl NormalizedLanguageBehavior for JavaNormalizedBehavior { + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "java-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn resolves_inherited_project_calls(&self) -> bool { + true + } + + fn project_call_candidate_compatible( + &self, + argument_count: usize, + parameter_count: usize, + ) -> bool { + argument_count == parameter_count + } + + fn unbound_receiver_may_name_project_type(&self, receiver: &str) -> bool { + !receiver.is_empty() && !receiver.contains(['.', ':', '(', ')', '[', ']']) + } + + fn declared_flow_type_fallback(&self, declared_type: &str) -> bool { + !declared_type.is_empty() + && declared_type + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + && !declared_type.contains(['=', '(', ')', ';', '\n']) + && !declared_type.contains("//") + && !declared_type.contains("&&") + } + + // C-family indexers render a local as `Type name` - the type leads. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_end_matches(';').trim(); + let (declared, _name) = text.rsplit_once(char::is_whitespace)?; + let declared = declared.trim(); + (!declared.is_empty() && !declared.contains('=')).then(|| declared.to_string()) + } + + // java declares `Ret name(T a)`, not `name(a: T) -> Ret`. + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + super::normalized_behavior::parse_prefix_return_declarator(signature) + } + + // The Java indexer emits several overlapping occurrences per call site; the + // first semantic one is the callee. + fn scip_prefers_first_semantic_occurrence(&self) -> bool { + true + } + + fn type_kind_is_abstract_dispatch(&self, kind: &str) -> bool { + kind == "interface" + } + + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + fn nullable_operation(&self, node: &Node) -> Option { (node.r#type == "CALL") .then(|| node.children.first().and_then(crate::ast::node)) @@ -794,6 +874,9 @@ mod tests { fn test_java_behavior_comprehensive() { let b = JavaNormalizedBehavior; + assert!(b.function_has_executable_body(&node("DEFN", "default int size() { return 0; }"))); + assert!(!b.function_has_executable_body(&node("DEFN", "int size();"))); + assert_eq!( b.collection_operation(&TypeExpr::Primitive("Set".to_string()), "add"), None diff --git a/gems/fact-mine/src/syntax/javascript.rs b/gems/fact-mine/src/syntax/javascript.rs index a0e15377e..ec5130df3 100644 --- a/gems/fact-mine/src/syntax/javascript.rs +++ b/gems/fact-mine/src/syntax/javascript.rs @@ -235,6 +235,37 @@ const JAVASCRIPT_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct JavaScriptNormalizedBehavior; impl NormalizedLanguageBehavior for JavaScriptNormalizedBehavior { + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + super::typescript::parse_profile_signature(signature) + } + + fn source_profile_signature( + &self, + lines: &[String], + function: &super::FunctionDef, + ) -> Option { + lines + .get(function.line.saturating_sub(1)) + .map(|line| line.trim().to_string()) + .or_else(|| Some(String::new())) + } + + fn profile_type_system(&self) -> &'static str { + "typescript" + } + + fn native_profile_literal_type(&self, value: &str) -> Option { + if matches!(value, "true" | "false") { + Some("boolean".to_string()) + } else if matches!(value, "null" | "undefined") { + Some("null".to_string()) + } else if value.parse::().is_ok() { + Some("number".to_string()) + } else { + None + } + } + fn nested_function_is_local_callable(&self, _function: &Node) -> bool { true } diff --git a/gems/fact-mine/src/syntax/kotlin.rs b/gems/fact-mine/src/syntax/kotlin.rs index 6ad396151..44cd6ae0c 100644 --- a/gems/fact-mine/src/syntax/kotlin.rs +++ b/gems/fact-mine/src/syntax/kotlin.rs @@ -189,6 +189,26 @@ const KOTLIN_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { struct KotlinNormalizedBehavior; impl NormalizedLanguageBehavior for KotlinNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + let source = node.text.trim_end(); + source.ends_with('}') + || source + .rfind(')') + .is_some_and(|parameters_end| source[parameters_end + 1..].contains('=')) + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "kotlin-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + fn external_symbol_call_complexity( &self, symbol: &str, @@ -486,6 +506,55 @@ mod tests { assert!(external_symbol_call_complexity(dependency, "readByte").is_none()); } + #[test] + fn current_kotlin_compiler_overloads_have_reviewed_costs() { + let exact = [ + ("kotlin/checkNotNull(+1).", "checkNotNull", "O(1)"), + ( + "kotlin/collections/contentHashCode().", + "contentHashCode", + "O(N)", + ), + ( + "kotlin/collections/contentToString(+3).", + "contentToString", + "O(N)", + ), + ("kotlin/collections/MutableList#add().", "add", "O(N)"), + ("kotlin/collections/toList(+10).", "toList", "O(N)"), + ("kotlin/text/trimMargin().", "trimMargin", "O(N)"), + ]; + for (descriptor, message, expected) in exact { + let symbol = format!("scip-java maven . . {descriptor}"); + let cost = external_symbol_call_complexity(&symbol, message) + .unwrap_or_else(|| panic!("missing exact Kotlin cost for {descriptor}")); + assert_eq!(cost.time, expected, "descriptor={descriptor}"); + assert_eq!(cost.bound_quality, "upper_bound_exact_target"); + } + + let parametric = [ + ("kotlin/collections/count(+1).", "callback_linear"), + ("kotlin/collections/filter(+9).", "callback_linear"), + ("kotlin/collections/map().", "callback_linear"), + ("kotlin/collections/sortedBy(+9).", "callback_sort"), + ("kotlin/collections/sumOf(+66).", "callback_linear"), + ]; + for (descriptor, expected) in parametric { + let symbol = format!("scip-java maven . . {descriptor}"); + let metadata = external_symbol_metadata(&symbol); + assert_eq!(metadata.scope, "stdlib", "descriptor={descriptor}"); + assert_eq!( + metadata.parametric_cost.as_deref(), + Some(expected), + "descriptor={descriptor}" + ); + assert!( + external_symbol_call_complexity(&symbol, "ignored").is_none(), + "parametric contract must not flatten callback cost: {descriptor}" + ); + } + } + fn node(kind: &str, text: &str) -> Node { Node { r#type: kind.to_string(), diff --git a/gems/fact-mine/src/syntax/local_flow.rs b/gems/fact-mine/src/syntax/local_flow.rs index 8228cdb0b..97c1568cf 100644 --- a/gems/fact-mine/src/syntax/local_flow.rs +++ b/gems/fact-mine/src/syntax/local_flow.rs @@ -54,7 +54,7 @@ pub struct Boundary { } const OWNER_TYPES: &[&str] = &["CLASS", "MODULE"]; -const METHOD_TYPES: &[&str] = &["DEFN", "DEFS"]; +const METHOD_TYPES: &[&str] = &["DEFN", "DEFS", "DEF", "LAMBDA"]; const SKIP_NESTED_TYPES: &[&str] = &["CLASS", "MODULE", "DEFN", "DEFS", "LAMBDA"]; const LOCAL_READ_TYPES: &[&str] = &["LVAR", "DVAR", "IVAR", "CVAR"]; const LOCAL_WRITE_TYPES: &[&str] = &["LASGN", "DASGN", "IASGN", "CVASGN"]; @@ -66,7 +66,6 @@ const STATEMENT_CONTAINER_TYPES: &[&str] = &[ "HASH", "STATEMENTS", ]; - fn empty_node() -> Node { Node { r#type: "ROOT".to_string(), @@ -120,6 +119,23 @@ pub(crate) fn local_methods_from_normalized( behavior, ); let mut methods = detector.scan(root); + // Normalized extraction already establishes every executable declaration + // and its lexical owner. Structural traversal above intentionally avoids + // treating arbitrary nested definitions as owner members, but declarative + // owners (for example a language macro that opens a record body) can + // validly contain an extracted method beneath a non-owner node. Reconcile + // the traversal with that declaration inventory by exact normalized span. + // This is language-neutral: adapters establish the function definition; + // local flow only recovers its CFG/DFG summary. + for function in functions { + if methods.iter().any(|method| method.span == function.span) { + continue; + } + let Some(node) = method_node_for_span(root, function.span) else { + continue; + }; + methods.push(detector.method_summary(node, None)); + } sort_method_summaries(&mut methods); methods } @@ -155,14 +171,25 @@ pub fn local_contract_assignments(method: &MethodSummary) -> BTreeMap Option { +pub(crate) fn raw_local_assignment_source(name: &str, source: &str) -> Option { let pattern = format!( r"(?s)\b{}\b\s*(?::=|=)\s*(.+?)\s*;?\s*$", regex::escape(name) ); let assignment = Regex::new(&pattern).ok()?; - let rhs = assignment.captures(source)?.get(1)?.as_str().trim(); - (!rhs.contains('?') && !rhs.contains(':')).then(|| rhs.to_string()) + Some( + assignment + .captures(source)? + .get(1)? + .as_str() + .trim() + .to_string(), + ) +} + +fn local_contract_source(name: &str, source: &str) -> Option { + let rhs = raw_local_assignment_source(name, source)?; + (!rhs.contains('?') && !rhs.contains(':')).then_some(rhs) } fn local_contract_conditional_statement(root: &Node, span: Span) -> bool { @@ -185,6 +212,26 @@ fn node_for_span(node: &Node, span: Span) -> Option<&Node> { .find_map(|child| node_for_span(child, span)) } +/// Several normalized container nodes intentionally retain the exact source +/// span of the declaration they wrap. For executable-method recovery, prefer +/// the method node itself over an enclosing `SCOPE` with the same span. +fn method_node_for_span(node: &Node, span: Span) -> Option<&Node> { + if [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ] == span + && METHOD_TYPES.contains(&node.r#type.as_str()) + { + return Some(node); + } + node.children + .iter() + .filter_map(ast::node) + .find_map(|child| method_node_for_span(child, span)) +} + fn contains_contract_condition(node: &Node) -> bool { matches!( node.r#type.as_str(), @@ -253,6 +300,13 @@ impl<'a> LocalFlow<'a> { for child in node.children.iter().filter_map(ast::node) { self.collect_methods(child, owners, out); } + } else { + // Even where the language does not treat nested named functions + // as local callables, a lambda body is its own function and must + // receive complexity facts so a caller can substitute its cost. + for child in node.children.iter().filter_map(ast::node) { + self.collect_lambdas(child, out); + } } } else { for child in node.children.iter().filter_map(ast::node) { @@ -261,6 +315,23 @@ impl<'a> LocalFlow<'a> { } } + fn collect_lambdas(&self, node: &Node, out: &mut Vec) { + if node.r#type == "LAMBDA" { + let span = [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ]; + if !out.iter().any(|method| method.span == span) { + out.push(self.method_summary(node, None)); + } + } + for child in node.children.iter().filter_map(ast::node) { + self.collect_lambdas(child, out); + } + } + fn collect_nested_owners(&self, node: &Node, owners: &[String], out: &mut Vec) { for child in node.children.iter().filter_map(ast::node) { if OWNER_TYPES.contains(&child.r#type.as_str()) { @@ -276,7 +347,8 @@ impl<'a> LocalFlow<'a> { // constructor or initializer. Preserve only declarations the // language adapter positively identifies as owner methods; // ordinary nested/inline declarations stay out of this pass. - if (self.behavior.nested_function_is_owner_method(child) + if (child.r#type == "LAMBDA" + || self.behavior.nested_function_is_owner_method(child) || (self.behavior.nested_function_is_local_callable(child) && self.methods_by_span.contains_key(&span))) && !out.iter().any(|method| method.span == span) @@ -454,25 +526,39 @@ impl<'a> LocalFlow<'a> { let Some(body) = self.owner_body(owner_node) else { return Vec::new(); }; - let stmts = if statement_container(body) { - body.children - .iter() - .filter_map(ast::node) - .collect::>() + let mut methods = Vec::new(); + if statement_container(body) { + for child in body.children.iter().filter_map(ast::node) { + self.collect_owner_method(child, &mut methods); + } } else { - vec![body] - }; + self.collect_owner_method(body, &mut methods); + } + methods + } - stmts - .into_iter() - .flat_map(|stmt| { - if METHOD_TYPES.contains(&stmt.r#type.as_str()) { - vec![stmt] - } else { - Vec::new() - } - }) - .collect() + fn collect_owner_method<'node>(&self, node: &'node Node, methods: &mut Vec<&'node Node>) { + if METHOD_TYPES.contains(&node.r#type.as_str()) { + let span = [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ]; + if self.methods_by_span.contains_key(&span) { + methods.push(node); + } + return; + } + // A C++ template declaration structurally wraps its inline method. + // It is a declaration envelope, unlike an arbitrary call or method + // body; descending only through this normalized wrapper avoids + // promoting lambdas/local functions into owner methods. + if node.r#type == "TEMPLATE_DECLARATION" { + for child in node.children.iter().filter_map(ast::node) { + self.collect_owner_method(child, methods); + } + } } fn owner_body<'node>(&self, owner_node: &'node Node) -> Option<&'node Node> { diff --git a/gems/fact-mine/src/syntax/lua.rs b/gems/fact-mine/src/syntax/lua.rs index c3904ca71..d06066311 100644 --- a/gems/fact-mine/src/syntax/lua.rs +++ b/gems/fact-mine/src/syntax/lua.rs @@ -143,6 +143,10 @@ const LUA_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct LuaNormalizedBehavior; impl NormalizedLanguageBehavior for LuaNormalizedBehavior { + fn native_profile_literal_type(&self, value: &str) -> Option { + value.parse::().is_ok().then(|| "number".to_string()) + } + fn external_symbol_call_complexity( &self, symbol: &str, diff --git a/gems/fact-mine/src/syntax/normalized_behavior.rs b/gems/fact-mine/src/syntax/normalized_behavior.rs index 59b25a603..81269da35 100644 --- a/gems/fact-mine/src/syntax/normalized_behavior.rs +++ b/gems/fact-mine/src/syntax/normalized_behavior.rs @@ -5,7 +5,8 @@ use super::{ use crate::ast::{Child, Node, Span}; use crate::syntax::cfg::ControlFlowProfile; use crate::type_inference::TypeExpr; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; use std::sync::OnceLock; #[derive(Clone, Debug, Default)] @@ -16,6 +17,7 @@ pub(crate) struct SyntaxMetadata { pub(crate) type_alias_lines: BTreeMap, pub(crate) method_param_types: BTreeMap>, pub(crate) method_local_types: BTreeMap>, + pub(crate) method_template_types: BTreeMap>, } #[derive(Clone, Debug)] @@ -34,6 +36,14 @@ pub(crate) struct NormalizedCallProjection { pub(crate) span: Span, } +#[derive(Clone, Debug)] +pub(crate) struct NormalizedRuntimeSemanticTarget { + pub(crate) symbol: String, + pub(crate) owner: String, + pub(crate) kind: String, + pub(crate) receiver_type: String, +} + #[derive(Clone, Debug)] pub(crate) struct NormalizedOwner { pub(crate) name: String, @@ -69,12 +79,42 @@ pub(crate) struct NormalizedVisibilityEvent { pub(crate) target_names: Vec, } +/// A language-owned declaration macro that creates a fixed-cost accessor. +/// The shared pass handles visibility, source export, and call-target joining; +/// adapters merely recognize their native declaration syntax and provide the +/// declaration witness consumed by `generated_callable_complexity`. +#[derive(Clone, Debug)] +pub(crate) struct NormalizedGeneratedAccessor { + pub(crate) name: String, + pub(crate) params: Vec, + pub(crate) declaration_source: String, +} + #[derive(Clone, Debug)] pub(crate) struct NormalizedNilGuardFact { pub(crate) local: String, pub(crate) non_nil_when_true: bool, } +/// A language-owned predicate that proves whether a receiver supports one +/// selector on either outgoing branch. The shared profile and runtime overlay +/// own CFG placement, evidence joining, and domain filtering; adapters only +/// recognize their native predicate syntax. +#[derive(Clone, Debug)] +pub(crate) struct NormalizedRuntimeCapabilityGuard { + pub(crate) subject: String, + pub(crate) member: String, +} + +/// A language-owned recognition of a bare value used as a branch condition. +/// The adapter owns the source spelling and truthiness semantics; FactMine +/// applies the resulting branch-local runtime-domain refinement through CFG +/// reaching definitions. +#[derive(Clone, Debug)] +pub(crate) struct NormalizedRuntimeTruthinessGuard { + pub(crate) subject: String, +} + /// A language-owned interpretation of one already-normalized nullable /// operation. The shared extractor owns traversal and CFG joining; adapters /// only classify syntax whose nil behavior differs by language. @@ -95,6 +135,7 @@ pub(crate) struct NormalizedPresenceCorrelation { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum BlockCallSemantics { Iteration, + LogarithmicIteration, Once, Deferred, Unknown, @@ -187,6 +228,24 @@ pub(crate) enum NormalizedCollectionOperation { Exponential, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RuntimeValueProjection { + Element, + Key, + Value, + Entry { collection_type: &'static str }, + Index { type_name: &'static str }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RuntimeCallResultProjection { + Receiver, + Element, + Value, + Keys { collection_type: &'static str }, + Values { collection_type: &'static str }, +} + impl NormalizedCollectionOperation { pub(crate) fn complexity(self) -> NormalizedCallComplexity { match self { @@ -228,6 +287,161 @@ impl NormalizedCollectionOperation { type StdlibOperationMap = BTreeMap>; +/// A declaration signature reduced to language-neutral facts: the declared +/// return type and the declared parameter types, in order. Produced by each +/// adapter's `parse_signature` from that language's own signature grammar, so +/// every downstream consumer sees one shape regardless of source language. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct NormalizedSignature { + pub return_type: Option, + /// (parameter name, declared type). Either may be empty when the language's + /// signature omits it (e.g. a C-family declarator with unnamed parameters). + pub params: Vec<(String, String)>, +} + +impl NormalizedSignature { + pub(crate) fn is_empty(&self) -> bool { + self.return_type.is_none() && self.params.is_empty() + } +} + +/// Default signature grammar: `name(a: T, b: U) -> Ret` or `... : Ret`, which +/// covers Rust/Go/Swift/Kotlin/TypeScript-shaped declarations. Adapters whose +/// grammar differs override `parse_signature`. +pub(crate) fn parse_arrow_or_colon_signature(signature: &str) -> NormalizedSignature { + let signature = signature.trim(); + let (Some(open), Some(close)) = (signature.find('('), signature.rfind(')')) else { + return NormalizedSignature::default(); + }; + if close < open { + return NormalizedSignature::default(); + } + let tail = signature[close + 1..].trim(); + let return_type = tail + .strip_prefix("->") + .or_else(|| tail.strip_prefix(':')) + .map(|rest| rest.trim().trim_end_matches(['{', ';']).trim().to_string()) + .filter(|value| !value.is_empty()); + let params = split_top_level_commas(&signature[open + 1..close]) + .into_iter() + .filter_map(|part| { + let part = part.trim(); + if part.is_empty() { + return None; + } + match part.split_once(':') { + Some((name, declared)) => { + Some((name.trim().to_string(), declared.trim().to_string())) + } + // `func f(a int)` style: trailing token is the type. + None => part + .rsplit_once(char::is_whitespace) + .map(|(name, declared)| (name.trim().to_string(), declared.trim().to_string())), + } + }) + .collect(); + NormalizedSignature { + return_type, + params, + } +} + +/// Prefix-return declarator grammar: `[modifiers] Ret name(T a, U b)`, where +/// the return type precedes the name and each parameter is `Type name`. +/// Adapters opt into this reusable grammar primitive explicitly. +pub(crate) fn parse_prefix_return_declarator(signature: &str) -> NormalizedSignature { + let signature = signature.trim(); + let Some(open) = signature.find('(') else { + return NormalizedSignature::default(); + }; + let mut depth = 0usize; + let mut close = None; + for (offset, character) in signature[open..].char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + close = Some(open + offset); + break; + } + } + _ => {} + } + } + let Some(close) = close else { + return NormalizedSignature::default(); + }; + // Everything before the name is the return type; the name is the last token + // before `(`. Modifiers (`public`, `static`, …) are dropped with it. + let head = signature[..open].trim(); + let leading_return_type = head + .rsplit_once(char::is_whitespace) + .map(|(before, _name)| before.trim()) + .and_then(|before| { + before.rsplit_once(char::is_whitespace).map_or( + (!before.is_empty()).then_some(before), + |(_modifiers, last)| (!last.is_empty()).then_some(last), + ) + }) + .map(str::to_string) + .filter(|value| !value.is_empty() && value != "return"); + let trailing_return_type = signature[close + 1..] + .split_once("->") + .map(|(_, declared)| declared.trim().trim_end_matches(['{', ';']).trim()) + .filter(|declared| !declared.is_empty()) + .map(str::to_string); + let return_type = trailing_return_type.or(leading_return_type); + let params = split_top_level_commas(&signature[open + 1..close]) + .into_iter() + .filter_map(|part| { + let part = part.trim(); + if part.is_empty() || part == "void" { + return None; + } + // `Type name` — the trailing token is the parameter name. + part.rsplit_once(char::is_whitespace) + .map(|(declared, name)| { + ( + name.trim().trim_start_matches('*').to_string(), + declared.trim().to_string(), + ) + }) + .or_else(|| Some((String::new(), part.to_string()))) + }) + .collect(); + NormalizedSignature { + return_type, + params, + } +} + +/// Split on commas that are not nested inside brackets or angle brackets, so a +/// generic parameter type (`Map`) stays one parameter. +pub(crate) fn split_top_level_commas(source: &str) -> Vec { + let mut depth = 0i32; + let mut angle = 0i32; + let mut start = 0usize; + let mut previous = b' '; + let mut parts = Vec::new(); + for (index, byte) in source.bytes().enumerate() { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'<' => angle += 1, + b'>' if previous != b'-' && angle > 0 => angle -= 1, + b',' if depth == 0 && angle == 0 => { + parts.push(source[start..index].to_string()); + start = index + 1; + } + _ => {} + } + previous = byte; + } + parts.push(source[start..].to_string()); + parts +} + const RUBY_STDLIB_OPERATIONS: &str = include_str!("../../config/stdlib_complexity/ruby.yml"); const PYTHON_STDLIB_OPERATIONS: &str = include_str!("../../config/stdlib_complexity/python.yml"); const TYPESCRIPT_STDLIB_OPERATIONS: &str = @@ -299,6 +513,24 @@ fn stdlib_operations(language: &str) -> Option<&'static StdlibOperationMap> { } } +fn configured_nominal_names(name: &str) -> Vec { + let unqualified = |value: &str| { + value + .rsplit([':', '.']) + .find(|part| !part.is_empty()) + .unwrap_or(value) + .to_string() + }; + let mut names = vec![name.to_string(), unqualified(name)]; + if let Some(base) = name.split('<').next().map(str::trim) { + if base != name { + names.push(base.to_string()); + names.push(unqualified(base)); + } + } + names +} + /// Whether a declared receiver spelling is owned by the reviewed standard- /// library registry for this language. This deliberately proves identity only; /// the absence of a method model remains distinct from an unknown receiver. @@ -308,13 +540,7 @@ pub(crate) fn configured_stdlib_type(language: &str, receiver_type: &TypeExpr) - TypeExpr::Array(_) => vec!["Array".to_string()], TypeExpr::Hash { .. } => vec!["Hash".to_string()], TypeExpr::Set(_) => vec!["Set".to_string()], - TypeExpr::Primitive(name) => { - let unqualified = name - .rsplit([':', '.']) - .find(|part| !part.is_empty()) - .unwrap_or(name); - vec![name.clone(), unqualified.to_string()] - } + TypeExpr::Primitive(name) => configured_nominal_names(name), _ => return false, }; stdlib_operations(language) @@ -550,6 +776,20 @@ pub(crate) fn configured_intrinsic_call_complexity( .map(NormalizedCollectionOperation::complexity) } +pub(crate) fn configured_intrinsic_parametric_call_cost( + language: &str, + receiver: Option<&str>, + message: &str, +) -> Option { + let operations = stdlib_operations(language)?; + let intrinsics = operations.get("IntrinsicParametricCall")?; + let key = receiver + .filter(|receiver| !receiver.trim().is_empty()) + .map(|receiver| format!("{}.{}", receiver.trim(), message)) + .unwrap_or_else(|| message.to_string()); + intrinsics.get(&key).cloned() +} + /// Resolve an opaque compiler symbol discriminator when the registry has been /// reviewed against that exact semantic scheme. This is preferable to /// guessing an overload from argument text, and deliberately has no fallback @@ -594,16 +834,17 @@ pub(crate) fn configured_parametric_call_cost( receiver_type: &TypeExpr, message: &str, ) -> Option { - let TypeExpr::Primitive(name) = receiver_type.strip_nilable() else { - return None; + let receiver = receiver_type.strip_nilable(); + let names = match &receiver { + TypeExpr::Array(_) => vec!["Array".to_string()], + TypeExpr::Hash { .. } => vec!["Hash".to_string()], + TypeExpr::Set(_) => vec!["Set".to_string()], + TypeExpr::Primitive(name) => configured_nominal_names(name), + _ => return None, }; - let unqualified = name - .rsplit([':', '.']) - .find(|part| !part.is_empty()) - .unwrap_or(&name); let operations = stdlib_operations(language)?; let contracts = operations.get("ParametricCall")?; - let result = [name.as_str(), unqualified] + let result = names .into_iter() .find_map(|owner| contracts.get(&format!("{owner}.{message}")).cloned()); result @@ -702,6 +943,38 @@ pub(crate) fn configured_external_latency_bound( .map(NormalizedCollectionOperation::complexity) } +/// Resolve a parameterized external-effect contract. This is the companion +/// to `ExternalLatency`: the bytes/path work is bounded while device latency +/// remains excluded, but a dynamic-language API may first invoke a coercion +/// hook such as Ruby's `to_path`. Keeping the parameter here preserves both +/// facts without putting a Ruby special case in the shared SCIP importer. +pub(crate) fn configured_external_latency_parametric_cost( + language: &str, + owner: &str, + message: &str, +) -> Option { + let operations = stdlib_operations(language)?; + operations + .get("ExternalLatencyParametric")? + .get(&format!("{}.{}", owner.trim(), message)) + .cloned() +} + +/// Computational model for a reviewed runtime spelling when the selected +/// compiler configuration has no SCIP occurrence (typically an inactive +/// preprocessor branch). The adapter must expose the modeled-world assumption +/// on the emitted call; this helper supplies only the operation algebra. +pub(crate) fn configured_modeled_runtime_bound( + language: &str, + message: &str, +) -> Option { + stdlib_operations(language)? + .get("ModeledRuntime")? + .get(message.trim()) + .and_then(|value| operation_from_config(value)) + .map(NormalizedCollectionOperation::complexity) +} + /// Resolve a language-owned collection spelling through Fact-Mine's YAML /// configuration. Adapters provide the language identity and normalize native /// declaration grammar; Espalier never loads or interprets a language-specific @@ -752,6 +1025,293 @@ pub(crate) fn native_pointer_nullability_contract(type_name: &str) -> Option<&'s } pub(crate) trait NormalizedLanguageBehavior: Sync { + /// Resolve a compile-time condition literal using native truthiness rules. + /// The shared extractor uses this only to omit a syntactically present but + /// provably unreachable branch; unknown expressions retain both arms. + fn constant_condition_truth(&self, _node: &Node) -> Option { + None + } + + /// Whether language-owned implicit runtime work in an otherwise executable + /// body is fully represented by the normalized calls and complexity facts. + /// + /// The shared exporter cannot infer constructor, assignment, destruction, + /// or other implicit semantics from source spelling. Adapters must fail + /// closed when their runtime can perform unmodeled work whose cost depends + /// on a generic type. + fn source_body_implicit_work_is_modeled( + &self, + _source: &str, + _template_types: &BTreeSet, + ) -> bool { + true + } + + /// Whether declarations in this language carry a canonical namespace that + /// can safely participate in corpus-wide lexical resolution. + fn canonical_symbol_scope(&self) -> bool { + false + } + + /// Project a parser-owned namespace into its corpus identity. Adapters may + /// incorporate the source path when the native package/module identity + /// requires it. + fn canonical_project_namespace(&self, _file: &Path, namespace: &str) -> String { + namespace.to_string() + } + + /// Project one parser-owned import target into the same corpus identity as + /// a declaration namespace. + fn canonical_project_import(&self, _file: &Path, _namespace: &str, target: &str) -> String { + target.to_string() + } + + /// A language-owned fallback key for reconciling a declaration lexical + /// symbol with a call lexical symbol when their canonical namespaces use + /// different external coordinates. The shared resolver binds only a + /// unique `(scope, callable)` key. + fn project_function_reconciliation_key(&self, _symbol: &str) -> Option<(String, String)> { + None + } + + /// Alternative lexical symbols searched before the exact call symbol. + /// This models native relative qualified-name lookup without teaching the + /// shared project resolver a namespace grammar. + fn relative_lexical_candidates(&self, _symbol: &str, _namespace: &str) -> Vec { + Vec::new() + } + + /// Candidate declaration owners for an explicit type/module receiver whose + /// source language permits lexical constant lookup. The adapter owns the + /// namespace syntax and lookup order; the shared resolver only joins an + /// exact, unique project declaration. + fn relative_type_receiver_candidates(&self, _receiver: &str, _owner: &str) -> Vec { + Vec::new() + } + + /// Interpret a native capability predicate (for example Ruby + /// `value.respond_to?(:member)`) on an already-normalized condition node. + /// Returning `None` keeps both runtime alternatives intact. + fn runtime_capability_guard( + &self, + _condition: &Node, + ) -> Option { + None + } + + fn runtime_truthiness_guard( + &self, + _condition: &Node, + ) -> Option { + None + } + + /// Lexical symbols searched after implicit owner and inheritance lookup + /// failed. This models languages where unqualified lookup continues into + /// enclosing lexical scopes. + fn fallback_lexical_candidates( + &self, + _message: &str, + _namespace: &str, + _implicit_receiver: bool, + ) -> Vec { + Vec::new() + } + + /// Whether the project resolver may traverse normalized supertype edges. + fn resolves_inherited_project_calls(&self) -> bool { + false + } + + /// Some native specialization models attach the authoritative inheritance + /// clause to the source declaration rather than its compiler-erased owner. + fn inherited_lookup_uses_source_owner(&self, _implicit_receiver: bool) -> bool { + false + } + + /// Match an inherited owner identity after exact symbol/name lookup fails. + /// The adapter owns any template, package, or descriptor normalization. + fn inherited_owner_identity_matches( + &self, + _identity: &str, + _owner_name: &str, + _owner_symbol: Option<&str>, + ) -> bool { + false + } + + /// Whether a specialized declaration should win over its unspecialized + /// sibling when an inherited identity explicitly carries specialization. + fn inherited_identity_prefers_specialization(&self, _identity: &str) -> bool { + false + } + + /// Select a declared library supertype as the static receiver for an + /// inherited call. Adapters opt in only when native lookup and the + /// reviewed stdlib registry prove the operation on one exact base type. + fn inherited_call_receiver_type( + &self, + _supertypes: &[String], + _message: &str, + ) -> Option { + None + } + + /// Whether overload compatibility can be proven from argument count alone. + /// The conservative default accepts all declarations and therefore leaves + /// a multi-candidate set unresolved. + fn project_call_candidate_compatible( + &self, + _argument_count: usize, + _parameter_count: usize, + ) -> bool { + true + } + + /// Whether an unbound receiver token may name a type declared in the + /// current canonical namespace. + fn unbound_receiver_may_name_project_type(&self, _receiver: &str) -> bool { + false + } + + /// Whether a declared flow type remains authoritative when the exact CFG + /// join at a call site has no single type. + fn declared_flow_type_fallback(&self, _declared_type: &str) -> bool { + false + } + + /// Whether complete invariant CFG types should augment declared parameter + /// types during local complexity analysis. + fn complexity_uses_invariant_flow_types(&self) -> bool { + false + } + + /// Whether adapter-extracted method-local declarations should augment + /// parameter types during local complexity analysis. + fn complexity_uses_syntax_local_types(&self) -> bool { + false + } + + /// Preserve a native supertype spelling instead of replacing it with an + /// index-erased canonical declaration identity. + fn preserve_supertype_identity(&self, _supertype: &str) -> bool { + false + } + + /// Collect a declaration header whose native grammar extends beyond the + /// balanced parameter list. Returning `None` uses the shared ordinary + /// declaration collector. + fn complete_declaration_header( + &self, + _lines: &[String], + _start_line_1indexed: usize, + ) -> Option { + None + } + + /// Whether an empty normalized function signature should be recovered from + /// the ordinary balanced source declaration header. + fn uses_source_declaration_header(&self) -> bool { + false + } + + /// Recover an empty profile signature from native source context. + fn source_profile_signature( + &self, + _lines: &[String], + _function: &FunctionDef, + ) -> Option { + None + } + + /// Parameters whose native declaration shape cannot be traced safely by + /// the profile's ordinary value-flow contract. + fn untraceable_profile_parameters( + &self, + _signature: &str, + _parameters: &[String], + ) -> Vec { + Vec::new() + } + + /// Stable public name of the native type system represented by profile + /// signatures. + fn profile_type_system(&self) -> &'static str { + "native" + } + + /// Whether this signature is a standalone type annotation rather than the + /// function declaration itself. + fn profile_signature_is_annotation(&self, _signature: &str) -> bool { + false + } + + /// Whether undeclared state writes must be constrained to an explicitly + /// declared owner in this language. + fn state_writes_require_declared_owner(&self) -> bool { + false + } + + /// Classify native literal spellings whose normalized type differs from + /// the shared string/boolean/number/collection fallback. + fn native_profile_literal_type(&self, _value: &str) -> Option { + None + } + + /// Prove the type written by one normalized, unconditional local + /// assignment. The shared CFG owns reaching-definition propagation while + /// adapters own source literal and constructor grammar. + fn local_assignment_type_hint(&self, _value: &str) -> Option { + None + } + + /// A parametric cost implied by a declared call-result type. The shared + /// resolver owns propagation; adapters own dependent-type grammar. + fn call_result_parametric_cost(&self, _type_expr: &TypeExpr) -> Option { + None + } + + /// Prefer the current declaration's canonical owner for a receiver type + /// that denotes its native specialization/injected owner. + fn receiver_denotes_current_owner(&self, _receiver_type: &str, _owner: &str) -> bool { + false + } + + /// Normalize a native qualified call spelling before constructing a + /// project lexical symbol. Returning `None` keeps ordinary package lookup. + fn explicit_lexical_call_symbol( + &self, + _message: &str, + _namespace: Option<&str>, + _top_level: bool, + ) -> Option { + None + } + + /// Resolve a call through a lexical import declared inside its enclosing + /// function. The adapter owns the source-language import grammar; the + /// shared profile only consumes the resulting canonical callee identity. + fn function_local_lexical_call_symbol( + &self, + _function: &FunctionDef, + _message: &str, + ) -> Option { + None + } + + /// Recover the alias named by a call so merged project type aliases can be + /// priced through the ordinary normalized call-cost interface. The boolean + /// marks a constructor-shaped alias call. + fn merged_alias_call_name( + &self, + _message: &str, + _receiver_type: Option<&str>, + _implicit_receiver: bool, + _target_missing: bool, + ) -> Option<(String, bool)> { + None + } + fn nullable_operation(&self, _node: &Node) -> Option { None } @@ -774,6 +1334,14 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Return operands of a native short-circuit expression whose value is + /// exactly one of those operands. The generic CFG records the resulting + /// producer set and joins it through reaching definitions; adapters own + /// whether their source operator has that value-preserving meaning. + fn value_preserving_call_result_operands<'a>(&self, _node: &'a Node) -> Option> { + None + } + /// A reviewed declaration-level nullability contract. This stays at the /// language boundary: the CFG sees the resulting contract but never has /// to interpret native annotation spellings. @@ -787,6 +1355,16 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Price an operator only when the language adapter recognizes both the + /// operator and a compiler/DFG-proven scalar operand type. + fn scalar_operator_complexity( + &self, + _message: &str, + _operand_type: Option<&TypeExpr>, + ) -> Option { + None + } + /// Interpret a compiler symbol only at the owning language boundary. The /// shared SCIP importer asks through this normalized interface and never /// contains a language-specific symbol grammar. @@ -798,6 +1376,32 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Price a reviewed source spelling when no compiler occurrence exists in + /// the selected preprocessor configuration. Implementations must return a + /// modeled-world quality and an explicit assumption. + fn modeled_runtime_call_complexity( + &self, + _message: &str, + ) -> Option { + None + } + + /// Price a compiler-indexed preprocessor definition. Adapters must reject + /// bodies with calls or control flow they cannot bound; the shared SCIP + /// importer supplies exact source text but does not interpret it. + fn preprocessor_definition_call_complexity( + &self, + _definition: &str, + ) -> Option { + None + } + + /// Recover an indexer-encoded macro definition location. Only adapters + /// whose compiler symbol grammar carries such a location implement this. + fn preprocessor_definition_location(&self, _symbol: &str) -> Option<(String, usize)> { + None + } + fn external_symbol_metadata(&self, _symbol: &str) -> super::ExternalSymbolMetadata { super::ExternalSymbolMetadata { scope: "external", @@ -813,6 +1417,31 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Price a source declaration that the language runtime turns into a + /// generated callable (for example an attribute reader). The adapter owns + /// the syntax decision; the shared project join owns identity and + /// uniqueness. + fn generated_callable_complexity( + &self, + _source: &str, + _name: &str, + ) -> Option { + None + } + + /// Prove progress for a recursive call whose argument transformation is + /// expressed through language-owned syntax. Shared recursion analysis + /// handles arithmetic and normalized structural projections; adapters may + /// add only native, strictly decreasing transformations. + fn recursive_call_argument_progress( + &self, + _method: &Node, + _call: &Node, + _parameters: &BTreeSet, + ) -> Option<&'static str> { + None + } + /// Whether a non-call SCIP occurrence can execute source-language code. /// Most field/property-shaped symbols are data access; adapters opt in /// only when their language gives that syntax callable semantics. @@ -820,6 +1449,15 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { false } + fn scip_occurrence_matches_call( + &self, + _symbol: &str, + source_text: &str, + message: &str, + ) -> bool { + source_text == message + } + fn cfg_profile(&self) -> &'static ControlFlowProfile { ControlFlowProfile::neutral_ref() } @@ -836,6 +1474,61 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Recover the collection binding that supplies an inferred element local + /// (`for (auto item : items)`, `auto item = items[i]`, or an iterator from + /// `items.begin()`). The adapter proves only the native syntax relation; + /// shared profile logic resolves the collection and element types. + fn collection_element_binding(&self, _source: &str, _local: &str) -> Option { + None + } + + /// Recover the collection binding from a native indexed receiver + /// expression. The shared profile resolves the binding's declared type; + /// adapters own the source/projection spelling. + fn indexed_receiver_collection_binding(&self, _receiver: &str) -> Option { + None + } + + /// Project a native dependent collection type to the value produced by + /// indexing it. Ordinary arrays/maps are handled through `TypeExpr`; this + /// hook is for language-specific wrapper/metafunction grammar. + fn indexed_collection_result_type(&self, _declared_type: &str) -> Option { + None + } + + /// Project a pointer-like declared type through native `receiver->member` + /// syntax. Adapters return the pointee type only when both the access + /// operator and a recognized pointer representation are proven. + fn pointer_member_receiver_type( + &self, + _source: &str, + _receiver: &str, + _message: &str, + _declared_type: &str, + ) -> Option { + None + } + + /// Recover the local binding named by a receiver expression when native + /// syntax proves that the expression is only a dereference/parenthesized + /// view of that binding. + fn receiver_local_binding(&self, _receiver: &str) -> Option { + None + } + + /// Recover a receiver type explicitly written in native cast syntax. + /// Adapters must accept only casts whose grammar proves a type. + fn explicit_receiver_type(&self, _receiver: &str) -> Option { + None + } + + /// Recover the template parameter that owns or names a call. C++ uses + /// this for `Formatter::format()` and callable non-type parameters such as + /// `Compare(...)`; other languages keep the conservative default. + fn template_dependent_call_type(&self, _message: &str) -> Option { + None + } + fn collection_allocation_semantics(&self, _message: &str) -> CollectionAllocationSemantics { CollectionAllocationSemantics::None } @@ -844,6 +1537,30 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { BlockCallSemantics::Unknown } + /// Refine block execution semantics when the normalized receiver spelling + /// is itself significant. The default remains language-neutral and falls + /// back to the message-only contract. + fn block_call_semantics_with_receiver( + &self, + _receiver: Option<&str>, + _receiver_type: Option<&TypeExpr>, + message: &str, + ) -> BlockCallSemantics { + self.block_call_semantics(message) + } + + /// Refine block execution only after a semantic index proves the exact + /// callable identity. This is deliberately separate from receiver-text + /// heuristics so a shared SCIP join can consume language-owned runtime + /// semantics without recognizing language-specific symbols. + fn semantic_symbol_block_call_semantics( + &self, + _symbol: &str, + _message: &str, + ) -> BlockCallSemantics { + BlockCallSemantics::Unknown + } + fn cardinality_call_semantics(&self, _message: &str) -> CardinalityCallSemantics { CardinalityCallSemantics::Unknown } @@ -968,6 +1685,16 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { .and_then(|language| configured_intrinsic_call_complexity(language, receiver, message)) } + fn intrinsic_parametric_call_cost( + &self, + receiver: Option<&str>, + message: &str, + ) -> Option { + self.stdlib_language().and_then(|language| { + configured_intrinsic_parametric_call_cost(language, receiver, message) + }) + } + /// Whether an unqualified call inside an instance method may dispatch to /// another method on the implicit current receiver. Languages such as Go /// require an explicit receiver (`x.f()`), while Ruby/Java-style method @@ -1045,6 +1772,26 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { Vec::new() } + /// Calls imposed by function-exit semantics rather than an explicit call + /// expression in the source body (for example, destruction of an owned + /// parameter). The extractor attributes these after entering the function + /// scope so they participate in the same CFG/DFG completeness proof. + fn implicit_function_exit_calls( + &self, + _node: &Node, + _function_name: &str, + _params: &[String], + ) -> Vec { + Vec::new() + } + + /// Whether a normalized declaration contains an executable source body. + /// Adapters override this when abstract signatures share a function node + /// with concrete methods. + fn function_has_executable_body(&self, _node: &Node) -> bool { + true + } + fn suppress_call_site(&self, _node: &Node, _call: &NormalizedCallProjection) -> bool { false } @@ -1077,6 +1824,16 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Project a call result whose type is determined by an argument rather + /// than its receiver (for example an accumulator-returning iterator). + fn static_argument_dependent_return_type( + &self, + _message: &str, + _arguments: &[String], + ) -> Option { + None + } + fn static_call_return_type( &self, _node: &Node, @@ -1098,6 +1855,122 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { None } + /// Reconstruct the adapter's native type spelling from a runtime value + /// domain. The shared runtime overlay owns propagation; an adapter only + /// translates its collection nominal/generic grammar. + fn runtime_value_domain_type( + &self, + owners: &[String], + _elements: &[String], + _keys: &[String], + _values: &[String], + ) -> Option { + (owners.len() == 1).then(|| owners[0].clone()) + } + + /// Decode a canonical runtime type SCIP symbol into the adapter's native + /// normalized type identity. The shared overlay never parses descriptor + /// grammars or embeds language type names. + fn runtime_value_type_from_symbol(&self, _symbol: &str) -> Option { + None + } + + /// Decode a canonical singleton/module/class value SCIP symbol into the + /// adapter's native normalized singleton identity. + fn runtime_value_singleton_from_symbol(&self, _symbol: &str) -> Option { + None + } + + /// Whether a runtime receiver type can dispatch an implementation owned + /// by a normalized library interface or mixin. Concrete language adapters + /// own these relationships; the shared overlay never embeds native type + /// names. + fn runtime_dispatch_owner_matches(&self, _owner: &str, _receiver_type: &str) -> bool { + false + } + + /// Convert a runtime-proven receiver identity into the language's + /// canonical stdlib symbol form. Adapters must return a target only when + /// that exact symbol has a reviewed cost or parametric contract. + fn runtime_value_semantic_target( + &self, + _receiver_type: &str, + _receiver_singleton: Option<&str>, + _message: &str, + _environment: &BTreeMap, + ) -> Option { + None + } + + /// Runtime nominal identities for normalized container/type shapes. These + /// spellings belong to the language adapter; the shared evidence overlay + /// must not assume that an array is named `Array`, `list`, or anything + /// else in the consumer language. + fn runtime_nil_type_name(&self) -> Option<&'static str> { + None + } + + fn runtime_array_type_name(&self) -> Option<&'static str> { + None + } + + fn runtime_hash_type_name(&self) -> Option<&'static str> { + None + } + + fn runtime_set_type_name(&self) -> Option<&'static str> { + None + } + + /// Map normalized callback parameter positions to collection value + /// projections. Yield/destructuring conventions are language semantics, + /// while applying these projections to runtime domains remains shared. + fn runtime_collection_callback_projections( + &self, + _receiver_type: Option<&str>, + _message: &str, + parameter_count: usize, + ) -> Vec { + (parameter_count > 0) + .then_some(vec![RuntimeValueProjection::Element]) + .unwrap_or_default() + } + + /// Prove the static type of a callback parameter supplied by one call + /// argument. The shared profile owns callback-region/DFG joins; adapters + /// own native yield order and literal/constructor grammar. + fn callback_argument_parameter_type( + &self, + _receiver: &str, + _receiver_type: Option<&str>, + _message: &str, + _position: usize, + _parameter_count: usize, + _arguments: &[String], + ) -> Option { + None + } + + fn runtime_call_result_projection( + &self, + _receiver_type: Option<&str>, + _message: &str, + _arguments: &[String], + ) -> Option { + None + } + + fn collection_callback_parameter(&self, _message: &str) -> bool { + false + } + + fn super_constructor_call_complexity( + &self, + _supertype: &str, + ) -> Option { + None + } + fn is_noreturn_method(&self, _message: &str) -> bool { false } @@ -1114,6 +1987,12 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { false } + /// Whether attribute/index assignment dispatches an executable setter + /// call in this language, rather than being only a storage mutation. + fn attribute_assignment_dispatches(&self) -> bool { + false + } + fn local_assignment_writes( &self, _field: Option<&str>, @@ -1274,6 +2153,15 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { default_kind.to_string() } + /// Classify an implicit owner that the parser attached functions to but + /// did not emit as a complete owner node. Recovery-heavy preprocessor + /// layouts can produce this shape; concrete languages may consult their + /// own declaration grammar, while the shared profile remains vocabulary + /// free. + fn fallback_owner_kind(&self, _owner: &str, _source: &str) -> Option { + None + } + /// Direct native base/interface spellings owned by this language's /// declaration grammar. Shared consumers canonicalize and traverse them. fn owner_supertypes(&self, _node: &Node) -> Vec { @@ -1335,14 +2223,69 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { self.function_dispatch_kind(name, owner) } + /// Project dispatch when a native modifier falls outside the normalized + /// declaration node. Adapters may consult the original source lines while + /// the shared extractor remains unaware of modifier spellings. + fn function_dispatch_kind_from_source( + &self, + name: &str, + node: &Node, + owner: &str, + _lines: &[String], + ) -> String { + self.function_dispatch_kind_from_node(name, node, owner) + } + + /// Whether a function declaration binds an explicit instance receiver (a + /// method). Languages with top-level method syntax (Go, Rust) override this + /// so a method whose receiver type happens to match its file name is not + /// mistaken for a free function stored under the synthetic file owner. + fn function_defines_receiver(&self, _node: &Node) -> bool { + false + } + + /// The cost of a bare call whose callee names a declared type. In languages + /// where `T(x)` is a representation conversion (Go) this is constant; a + /// language whose type-name call is a constructor returns None so it is + /// resolved as an ordinary call instead. + fn type_name_conversion_complexity(&self) -> Option { + None + } + + /// Whether an owner declared with this kind dispatches at runtime to an + /// implementation chosen elsewhere - an interface, trait, protocol, or + /// abstract class. A call to such a type's method has no single body, so it + /// is priced as a callback of unknown per-call cost (see the interface + /// dispatch design). Adapters name their abstract kinds. + fn type_kind_is_abstract_dispatch(&self, _kind: &str) -> bool { + false + } + + /// The method names an abstract type requires, for structural satisfaction + /// (a concrete type implements it if its method set is a superset). Only + /// structurally-typed languages (Go) populate this; nominal languages express + /// conformance through `owner_supertypes`. + fn abstract_type_requirements(&self, _node: &Node) -> Vec { + Vec::new() + } + fn receiver_is_type_reference(&self, _receiver: &str) -> bool { false } - fn constructor_dispatch_name(&self, _receiver: &str, _message: &str) -> Option { + fn constructor_dispatch_name( + &self, + _receiver: &str, + _message: &str, + _owner: &str, + ) -> Option { None } + fn constructor_delegation_excludes_self(&self) -> bool { + false + } + fn declarative_owner_constant_operations(&self, _node: &Node) -> Vec { Vec::new() } @@ -1403,6 +2346,49 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { false } + /// Parse a declaration signature into normalized parts. The signature TEXT is + /// language-specific - a Sorbet `sig {}`, a Python annotation, a C-family + /// declarator, or a SCIP `signature_documentation` - so each adapter owns its + /// grammar; the returned shape is language-neutral. + /// + /// This is the single seam through which any signature, source-derived or + /// SCIP-supplied, becomes normalized type facts. The default handles the + /// `name(params) -> Ret` / `name(params): Ret` family. + fn parse_signature(&self, signature: &str) -> NormalizedSignature { + parse_arrow_or_colon_signature(signature) + } + + /// Extract the declared type from a variable declaration as the indexer + /// renders it (SCIP emits one per local, e.g. Rust `let out: Output`, Go + /// `var uc *unleashCmd`, Java `Foo x`). Grammar is language-specific; the + /// returned type name is not. The default handles the `let x: T` / `var x: T` + /// colon form. + fn parse_variable_declaration(&self, text: &str) -> Option { + let text = text.trim().trim_end_matches(';').trim(); + let (_binding, declared) = text.split_once(':')?; + let declared = declared.trim(); + (!declared.is_empty()).then(|| declared.to_string()) + } + + /// Whether SCIP occurrence selection should prefer the first *semantic* + /// occurrence at a call site. Languages whose indexer emits several + /// overlapping occurrences per call (Java) need this; most do not. + fn scip_prefers_first_semantic_occurrence(&self) -> bool { + false + } + + /// Cost of a paren-less member access (`obj.field`) in the complexity path: + /// a constant-time field/property read, not a method call. Returns None when + /// the node is not such a read, or (Ruby) where `obj.foo` is itself a call. + /// Without this, property reads are recorded as unresolved typed operations + /// and wrongly block an otherwise-complete function. + fn complexity_member_read_complexity(&self, node: &Node) -> Option { + (node.r#type == "CALL" && !node.text.contains('(')).then_some(NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) + } + fn case_pattern_values(&self, pattern_values: Vec) -> Vec { pattern_values } @@ -1505,6 +2491,18 @@ pub(crate) trait NormalizedLanguageBehavior: Sync { &[] } + /// Generated accessors whose declaration cannot be expressed by the + /// generic `attr_reader`/`attr_writer` shape. This keeps macro syntax in + /// the language adapter while reusing the common generated-declaration + /// interface used by Ruby readers, Java/Kotlin properties, and similar + /// constructs. + fn generated_accessor_declarations( + &self, + _call: &CallSite, + ) -> Vec { + Vec::new() + } + fn protocol_read_label_from_state(&self, receiver: &str, field: &str) -> Option { if receiver.trim().is_empty() || receiver == "self" { Some(field.to_string()) @@ -1695,6 +2693,10 @@ pub(crate) fn behavior(language: Language) -> &'static dyn NormalizedLanguageBeh } } +pub(crate) fn behavior_for_name(language: &str) -> Option<&'static dyn NormalizedLanguageBehavior> { + Language::parse(language).ok().map(behavior) +} + pub(crate) fn matching_paren_index(source: &str, open_index: usize) -> Option { let mut depth = 0usize; for (index, ch) in source @@ -1720,6 +2722,12 @@ pub(crate) fn method_param_types_from_signatures BTreeMap> { functions .iter() + // A lambda's source line is its enclosing expression, not a callable + // declaration. Parsing the first parenthesized expression as a + // signature fabricates parameter types from surrounding syntax. + // Explicit/contextual lambda types are recovered from normalized + // parameters and their compiler/source-proven callback context. + .filter(|function| function.dispatch_kind != "lambda") .filter_map(|function| { let parse = |declaration: &str| { let params = behavior.parameter_list_source(declaration); @@ -1832,7 +2840,7 @@ pub(crate) fn type_after_parameter_colon(parameter: &str) -> Option { (!type_name.is_empty()).then(|| type_name.to_string()) } -fn usable_declared_local_type(type_name: &str) -> Option { +pub(crate) fn usable_declared_local_type(type_name: &str) -> Option { let type_name = type_name.trim(); let lower = type_name.to_ascii_lowercase(); (!type_name.is_empty() @@ -1905,19 +2913,6 @@ pub(crate) fn type_after_local_colon(source: &str, name: &str) -> Option usable_declared_local_type(type_name) } -/// Shared parser for Go `var name Type` declarations. Short declarations are -/// inferred values and intentionally remain outside the declared-type fact. -pub(crate) fn type_after_go_local_name(source: &str, name: &str) -> Option { - let declaration = source.trim().strip_prefix("var ")?.trim(); - let suffix = declaration.strip_prefix(name)?.trim_start(); - let boundary = declaration[name.len()..].chars().next(); - if boundary.is_some_and(|character| character.is_alphanumeric() || character == '_') { - return None; - } - let type_name = suffix.split(['=', ';']).next().unwrap_or_default().trim(); - usable_declared_local_type(type_name) -} - #[cfg(test)] mod tests { use super::*; diff --git a/gems/fact-mine/src/syntax/normalized_extractor.rs b/gems/fact-mine/src/syntax/normalized_extractor.rs index 3398d4b94..c264f06b3 100644 --- a/gems/fact-mine/src/syntax/normalized_extractor.rs +++ b/gems/fact-mine/src/syntax/normalized_extractor.rs @@ -21,7 +21,9 @@ pub(crate) struct NormalizedFacts { /// parser origin is joined later by the tree-sitter adapter; this pass /// never guesses a raw parser identity from a normalized span. pub(crate) call_node_projections: Vec, + pub(crate) call_selector_projections: Vec, pub(crate) call_receiver_projections: Vec, + pub(crate) call_execution_projections: Vec, pub(crate) state_declarations: Vec, pub(crate) state_reads: Vec, pub(crate) state_writes: Vec, @@ -134,6 +136,14 @@ impl<'a> Extractor<'a> { ) }); self.facts.call_node_projections.dedup(); + self.facts + .call_execution_projections + .sort_by_key(|projection| (projection.call_span, projection.execution_span)); + self.facts.call_execution_projections.dedup(); + self.facts + .call_selector_projections + .sort_by_key(|projection| (projection.call_span, projection.selector_span)); + self.facts.call_selector_projections.dedup(); self.facts.semantic_effect_sites.sort_by_key(effect_key); self.facts } @@ -159,6 +169,7 @@ impl<'a> Extractor<'a> { "AND" | "OR" => self.scan_boolean(node), "CALL" | "QCALL" | "FCALL" | "VCALL" => self.record_call_node(node, false), "ITER" => self.scan_iter(node), + "LAMBDA" => self.scan_lambda(node), "YIELD" => self.scan_yield(node), "XSTR" => self.scan_command_string(node), "SCLASS" => self.scan_singleton_class(node), @@ -283,6 +294,7 @@ impl<'a> Extractor<'a> { kind: kind.to_string(), reopenable: self.behavior.reopenable_owner(node), supertypes: self.behavior.owner_supertypes(node), + requirements: self.behavior.abstract_type_requirements(node), line: owner_span[0], span: owner_span, } @@ -304,21 +316,29 @@ impl<'a> Extractor<'a> { self.record_semantic_effect(node, &effect.kind, &effect.detail); } let params = function_params(node, self.behavior); + let implicit_exit_calls = self + .behavior + .implicit_function_exit_calls(node, &name, ¶ms); let visibility = self.behavior.function_visibility(&name, node, &self.lines); self.facts.function_defs.push(FunctionDef { file: self.file.clone(), name: name.clone(), owner: owner.clone(), - dispatch_kind: if self.owners.is_empty() && owner == self.file_owner { + dispatch_kind: if self.owners.is_empty() + && owner == self.file_owner + && !self.behavior.function_defines_receiver(node) + { // The extractor creates a stable file owner for lexical // declarations. That storage identity is not an instance // dispatch fact. A real declaration may legitimately have // the same name as its file, so owner-stack context is the - // proof that distinguishes the two. + // proof that distinguishes the two -- unless the declaration + // binds an explicit receiver (a Go method on a type named + // after its file), which is an instance method regardless. "top".to_string() } else { self.behavior - .function_dispatch_kind_from_node(&name, node, &owner) + .function_dispatch_kind_from_source(&name, node, &owner, &self.lines) }, line: node.first_lineno, span: span(node), @@ -326,6 +346,7 @@ impl<'a> Extractor<'a> { visibility: Some(visibility), params: params.clone(), callback_params: self.behavior.callback_parameter_names(node), + source_export_eligible: self.behavior.function_has_executable_body(node), signature: String::new(), }); if let Some(mut declaration) = self.behavior.state_declaration_from_function(node, &owner) { @@ -355,6 +376,9 @@ impl<'a> Extractor<'a> { self.record_initializer_field_reads(node, &owner_name, &function_name); self.receiver_aliases .push(self.behavior.receiver_aliases_for_function(node)); + for call in implicit_exit_calls { + self.append_call_site(call, node, false, false); + } let body_context = self.current_owner(); let body_owner = self.behavior.body_owner_for_function( self.current_function().as_str(), @@ -393,9 +417,66 @@ impl<'a> Extractor<'a> { } } + /// A lambda / function literal is analyzed as a first-class function so its + /// Big-O is computed with the same pipeline as a named function. It is owned + /// by the enclosing owner and given a synthetic, span-stable name; its body + /// is scanned as that function (its calls/loops attribute to the lambda, not + /// the enclosing function), which is what lets a passed callback carry its + /// own cost. + fn scan_lambda(&mut self, node: &Node) { + let owner = self.current_owner(); + let lambda_span = span(node); + let name = crate::syntax::lambda_function_name(lambda_span[0], lambda_span[1]); + let params = function_params(node, self.behavior); + self.facts.function_defs.push(FunctionDef { + file: self.file.clone(), + name: name.clone(), + owner: owner.clone(), + dispatch_kind: "lambda".to_string(), + line: node.first_lineno, + span: lambda_span, + body: raw_from_normalized(node), + visibility: Some("private".to_string()), + params: params.clone(), + callback_params: self.behavior.callback_parameter_names(node), + source_export_eligible: true, + signature: String::new(), + }); + self.functions.push(name); + self.function_params.push(params); + self.local_owned_values.push(BTreeSet::new()); + self.receiver_aliases + .push(self.behavior.receiver_aliases_for_function(node)); + if let Some(scope) = function_scope(node) { + if let Some(args) = scope_args(scope) { + self.scan(args); + } + if let Some(body) = scope_body(scope) { + self.scan(body); + } + } else { + self.scan_children(node); + } + self.receiver_aliases.pop(); + self.local_owned_values.pop(); + self.function_params.pop(); + self.functions.pop(); + } + fn scan_iter(&mut self, node: &Node) { if let Some(call) = child_node(node, 0) { + // ITER is the complete executable expression for a call with an + // attached callback body. Preserve that range here so runtime + // collectors never need to parse language syntax to find it. self.record_call_node(call, true); + if let Some(call_span) = self.direct_emitted_receiver_call_span(call) { + self.facts + .call_execution_projections + .push(super::CallExecutionProjection { + call_span, + execution_span: span(node), + }); + } } if let Some(scope) = child_node(node, 1) { if let Some(body) = scope_body(scope) { @@ -457,6 +538,15 @@ impl<'a> Extractor<'a> { self.decision_spans.push(span(node)); self.with_control("conditional", |this| this.scan(condition)); self.decision_spans.pop(); + if let Some(mut truth) = self.behavior.constant_condition_truth(condition) { + if node.r#type == "UNLESS" { + truth = !truth; + } + if let Some(reachable) = child_node(node, if truth { 1 } else { 2 }) { + self.scan(reachable); + } + return; + } self.record_branch_decision(node, condition); self.record_if_arms(node, condition); @@ -603,11 +693,15 @@ impl<'a> Extractor<'a> { &receiver_scope, ); } - if let Some(receiver) = child_node(node, 0) { - self.scan(receiver); - } - if let Some(args) = child_node(node, 2) { - self.scan(args); + if self.behavior.attribute_assignment_dispatches() { + self.record_call_node(node, false); + } else { + if let Some(receiver) = child_node(node, 0) { + self.scan(receiver); + } + if let Some(args) = child_node(node, 2) { + self.scan(args); + } } } @@ -740,10 +834,15 @@ impl<'a> Extractor<'a> { self.scan_children(node); return; }; - let receiver_call_span = parts.receiver_node.and_then(direct_receiver_call_span); if let Some(receiver) = parts.receiver_node { self.scan(receiver); } + // The normalized IR also represents non-call member projections as + // CALL nodes. Only link an outer receiver to a nested node that was + // actually emitted as an executable call while scanning the receiver. + let receiver_call_span = parts + .receiver_node + .and_then(|receiver| self.direct_emitted_receiver_call_span(receiver)); if let Some(args) = parts.args_node { self.scan(args); } @@ -808,6 +907,14 @@ impl<'a> Extractor<'a> { ); if self.seen_calls.insert(key) { self.record_call_node_projection(node, call.span); + if projected.access_span != call.span { + self.facts + .call_selector_projections + .push(super::CallSelectorProjection { + call_span: call.span, + selector_span: projected.access_span, + }); + } if let Some(receiver_call_span) = receiver_call_span { self.facts .call_receiver_projections @@ -849,6 +956,7 @@ impl<'a> Extractor<'a> { conditional: bool, block: bool, ) { + let selector_span = projected.access_span; let call = CallSite { receiver: self.behavior.clean_receiver(&projected.receiver), message: self.behavior.clean_identifier(&projected.message), @@ -872,6 +980,14 @@ impl<'a> Extractor<'a> { ); if self.seen_calls.insert(key) { self.record_call_node_projection(node, call.span); + if selector_span != call.span { + self.facts + .call_selector_projections + .push(super::CallSelectorProjection { + call_span: call.span, + selector_span, + }); + } self.record_call_receiver_projection(node, call.span); self.facts.call_sites.push(call); } @@ -890,7 +1006,8 @@ impl<'a> Extractor<'a> { } fn record_call_receiver_projection(&mut self, node: &Node, outer_span: Span) { - let Some(receiver_call_span) = child_node(node, 0).and_then(direct_receiver_call_span) + let Some(receiver_call_span) = child_node(node, 0) + .and_then(|receiver| self.direct_emitted_receiver_call_span(receiver)) else { return; }; @@ -902,6 +1019,36 @@ impl<'a> Extractor<'a> { }); } + fn direct_emitted_receiver_call_span(&self, node: &Node) -> Option { + let normalized_span = span(node); + if matches!(node.r#type.as_str(), "CALL" | "FCALL" | "QCALL" | "VCALL") { + if let Some(projection) = self + .facts + .call_node_projections + .iter() + .rev() + .find(|projection| projection.normalized_node_span == normalized_span) + { + return Some(projection.emitted_call_span); + } + } + // `ITER` is a normalized wrapper around the call that owns its + // callback region. It is transparent for receiver-result flow: + // in `source.select { ... }.map { ... }`, the outer receiver is the + // result of the inner `select`, not the callback body. This is a + // property of the normalized IR, rather than a language-specific + // source rule. + if node.r#type == "ITER" { + return child_node(node, 0) + .and_then(|call| self.direct_emitted_receiver_call_span(call)); + } + let children = child_nodes(node); + if children.len() != 1 { + return None; + } + self.direct_emitted_receiver_call_span(children[0]) + } + fn record_state_write(&mut self, node: &Node) { let field = first_string_or_symbol(node).unwrap_or_else(|| normalized_text(node)); let field = self.behavior.clean_identifier(&field); @@ -1747,7 +1894,7 @@ impl<'a> Extractor<'a> { args_node, }) } - "CALL" | "QCALL" => { + "CALL" | "QCALL" | "ATTRASGN" => { let receiver_node = child_node(node, 0); let args_node = child_node(node, 2); Some(CallParts { @@ -2114,7 +2261,14 @@ fn function_name_with_behavior( } fn function_scope(node: &Node) -> Option<&Node> { - child_node(node, if node.r#type == "DEFS" { 2 } else { 1 }) + child_node( + node, + match node.r#type.as_str() { + "LAMBDA" => 0, + "DEFS" => 2, + _ => 1, + }, + ) } fn scope_child(node: &Node) -> Option<&Node> { @@ -2384,17 +2538,6 @@ fn state_receiver_field(receiver: &str) -> Option { None } -fn direct_receiver_call_span(node: &Node) -> Option { - if matches!(node.r#type.as_str(), "CALL" | "FCALL" | "QCALL" | "VCALL") { - return Some(span(node)); - } - let children = child_nodes(node); - if children.len() != 1 { - return None; - } - direct_receiver_call_span(children[0]) -} - fn target_name_span(name: &str, node: &Node) -> Span { if node.first_lineno == node.last_lineno { if let Some(index) = node.text.find(name) { @@ -2756,7 +2899,18 @@ fn extract_type_from_field_node(node: &Node, field_name: &str) -> Option } } let text = node.text.trim().trim_end_matches(';').trim(); - if let Some(idx) = text.find(field_name) { + let field_offset = text + .match_indices(field_name) + .filter(|(index, _)| { + let before = text[..*index].chars().next_back(); + let after = text[*index + field_name.len()..].chars().next(); + before.is_none_or(|character| character != '_' && !character.is_ascii_alphanumeric()) + && after + .is_none_or(|character| character != '_' && !character.is_ascii_alphanumeric()) + }) + .map(|(index, _)| index) + .last(); + if let Some(idx) = field_offset { let after_name = text[idx + field_name.len()..].trim_start(); let after_name = after_name .strip_prefix(':') @@ -2769,6 +2923,26 @@ fn extract_type_from_field_node(node: &Node, field_name: &str) -> Option } } let before_name = text[..idx].trim_end(); + let declarator_parts = super::normalized_behavior::split_top_level_commas(before_name); + if declarator_parts.len() > 1 { + let first_declarator = declarator_parts + .first() + .map(String::as_str) + .unwrap_or(before_name); + let declaration = first_declarator + .split('=') + .next() + .unwrap_or(first_declarator) + .trim(); + if let Some(name_start) = declaration + .rfind(|character: char| !character.is_ascii_alphanumeric() && character != '_') + { + let shared_type = declaration[..=name_start].trim(); + if is_valid_type_text(shared_type) { + return Some(shared_type.to_string()); + } + } + } if let Some(last_part) = before_name.split(['=', ':']).next_back() { let last_part = last_part.trim(); let mut parts = last_part.split_whitespace().collect::>(); @@ -2852,9 +3026,14 @@ mod tests { state_read_uses_access_span_impl: Option bool>, case_predicate_text_impl: Option String>, suppress_call_site_impl: Option bool>, + constant_condition_truth_impl: Option Option>, } impl NormalizedLanguageBehavior for CustomBehavior { + fn constant_condition_truth(&self, node: &Node) -> Option { + self.constant_condition_truth_impl.and_then(|f| f(node)) + } + fn mutating_receiver_message(&self, message: &str) -> bool { self.mutating_receiver_message_impl .map(|f| f(message)) @@ -2914,6 +3093,40 @@ mod tests { } } + #[test] + fn compile_time_false_branch_does_not_emit_calls() { + let mut behavior = CustomBehavior::default(); + behavior.constant_condition_truth_impl = + Some(|node| (node.text.trim() == "0").then_some(false)); + let branch = mock_node( + "IF", + vec![ + Child::Node(Box::new(mock_node("LIT", vec![], "0"))), + Child::Node(Box::new(mock_node( + "VCALL", + vec![Child::Symbol("dead".to_string())], + "dead", + ))), + Child::Node(Box::new(mock_node( + "VCALL", + vec![Child::Symbol("live".to_string())], + "live", + ))), + ], + "if (0) dead(); else live();", + ); + let facts = extract(Path::new("test.c"), &[], &branch, &behavior); + assert_eq!( + facts + .call_sites + .iter() + .map(|call| call.message.as_str()) + .collect::>(), + ["live"] + ); + assert!(!facts.call_sites[0].conditional); + } + #[test] fn test_extractor_iter_edge_cases() { let behavior = CustomBehavior::default(); diff --git a/gems/fact-mine/src/syntax/nullable.rs b/gems/fact-mine/src/syntax/nullable.rs index 61bbd5d9c..533474c94 100644 --- a/gems/fact-mine/src/syntax/nullable.rs +++ b/gems/fact-mine/src/syntax/nullable.rs @@ -307,7 +307,16 @@ pub(crate) fn apply_refinements( // A node reachable from any unselected successor is a control-flow // join for this proof. Do not apply the selected-edge state at or // beyond it; the base state already joins both incoming paths. - let joins = reachable_nodes(&unselected_successors, &outgoing); + // Re-entering the same condition through a loop backedge starts a new + // evaluation of the guard. It is a boundary for this edge-local proof, + // not a route by which the unselected edge can retroactively reach the + // selected branch from the current evaluation. + let mut joins = reachable_nodes_until( + &unselected_successors, + &refinement.condition_node_id, + &outgoing, + ); + joins.insert(refinement.condition_node_id.clone()); let mut pending = selected_successors.into_iter().collect::>(); let mut visited = BTreeSet::new(); while let Some(node_id) = pending.pop() { @@ -346,13 +355,17 @@ pub(crate) fn apply_refinements( output.into_values().collect() } -fn reachable_nodes( +fn reachable_nodes_until( starts: &BTreeSet, + boundary: &str, outgoing: &BTreeMap<&str, Vec<&ControlFlowEdge>>, ) -> BTreeSet { let mut reachable = BTreeSet::new(); let mut pending = starts.iter().cloned().collect::>(); while let Some(node_id) = pending.pop() { + if node_id == boundary { + continue; + } if !reachable.insert(node_id.clone()) { continue; } diff --git a/gems/fact-mine/src/syntax/passes.rs b/gems/fact-mine/src/syntax/passes.rs index 2cd2971d2..15a2a2b7d 100644 --- a/gems/fact-mine/src/syntax/passes.rs +++ b/gems/fact-mine/src/syntax/passes.rs @@ -205,7 +205,7 @@ fn synthesize_accessor_functions( return; } - let existing = facts + let mut existing = facts .function_defs .iter() .map(|function| (function.owner.clone(), function.name.clone())) @@ -213,42 +213,53 @@ fn synthesize_accessor_functions( let mut synthesized = Vec::new(); for call in &facts.call_sites { - let Some((_, reader, writer)) = declarations + let mut generated = Vec::new(); + if let Some((_, reader, writer)) = declarations .iter() .find(|(message, _, _)| *message == call.message) - else { - continue; - }; - if !(call.receiver.is_empty() || call.receiver == "self") { - continue; - } - for argument in &call.arguments { - let name = argument - .trim() - .trim_start_matches(':') - .trim_matches(|c| c == '"' || c == '\''); - if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { - continue; - } - let mut emit = |method_name: String, params: Vec| { - if existing.contains(&(call.owner.clone(), method_name.clone())) { - return; + { + if call.receiver.is_empty() || call.receiver == "self" { + for argument in &call.arguments { + let name = argument + .trim() + .trim_start_matches(':') + .trim_matches(|c| c == '"' || c == '\''); + if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { + continue; + } + let declaration_source = format!("{} :{name}", call.message); + if *reader { + generated.push(super::normalized_behavior::NormalizedGeneratedAccessor { + name: name.to_string(), + params: Vec::new(), + declaration_source: declaration_source.clone(), + }); + } + if *writer { + generated.push(super::normalized_behavior::NormalizedGeneratedAccessor { + name: format!("{name}="), + params: vec!["value".to_string()], + declaration_source, + }); + } } - synthesized.push(super::FunctionDef::synthetic_accessor( - call.file.clone(), - method_name, - call.owner.clone(), - call.line, - call.span, - params.clone(), - )); - }; - if *reader { - emit(name.to_string(), Vec::new()); } - if *writer { - emit(format!("{name}="), vec!["value".to_string()]); + } + generated.extend(behavior.generated_accessor_declarations(call)); + for generated_accessor in generated { + let key = (call.owner.clone(), generated_accessor.name.clone()); + if !existing.insert(key) { + continue; } + synthesized.push(super::FunctionDef::synthetic_accessor( + call.file.clone(), + generated_accessor.name, + call.owner.clone(), + call.line, + call.span, + generated_accessor.params, + generated_accessor.declaration_source, + )); } } facts.function_defs.extend(synthesized); diff --git a/gems/fact-mine/src/syntax/php.rs b/gems/fact-mine/src/syntax/php.rs index c96e1b06b..ea2fd1bfd 100644 --- a/gems/fact-mine/src/syntax/php.rs +++ b/gems/fact-mine/src/syntax/php.rs @@ -166,6 +166,14 @@ const PHP_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct PhpNormalizedBehavior; impl NormalizedLanguageBehavior for PhpNormalizedBehavior { + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + fn external_symbol_call_complexity( &self, symbol: &str, diff --git a/gems/fact-mine/src/syntax/protocols.rs b/gems/fact-mine/src/syntax/protocols.rs index 88e580acf..3e8012bb8 100644 --- a/gems/fact-mine/src/syntax/protocols.rs +++ b/gems/fact-mine/src/syntax/protocols.rs @@ -124,6 +124,11 @@ fn protocol_call_variants(calls: Vec<(ProtocolCall, bool)>) -> Vec String { + // A synthetic lambda name is already unqualified and its `:` separates row + // from column; splitting it would report `18>` as the method name. + if crate::syntax::is_lambda_function_name(name) { + return name.to_string(); + } name.split(['.', ':']) .next_back() .unwrap_or(name) diff --git a/gems/fact-mine/src/syntax/python.rs b/gems/fact-mine/src/syntax/python.rs index b0af84d06..772c56d4e 100644 --- a/gems/fact-mine/src/syntax/python.rs +++ b/gems/fact-mine/src/syntax/python.rs @@ -77,6 +77,117 @@ const PYTHON_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct PythonNormalizedBehavior; impl NormalizedLanguageBehavior for PythonNormalizedBehavior { + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + let signature = signature.trim(); + let (Some(open), Some(close)) = (signature.find('('), signature.rfind(')')) else { + return super::normalized_behavior::NormalizedSignature::default(); + }; + let return_type = signature[close + 1..] + .trim() + .strip_prefix("->") + .map(|declared| { + declared + .trim() + .trim_end_matches(": ...") + .trim_end_matches(':') + .trim() + .to_string() + }); + let params = signature[open + 1..close] + .split(',') + .filter_map(|entry| { + let entry = entry.trim(); + if entry.is_empty() || matches!(entry, "self" | "cls") { + return None; + } + let (name, declared) = entry.split_once(':')?; + let name = name.trim().trim_end_matches('='); + let declared = declared.trim(); + (!declared.is_empty()).then(|| (name.to_string(), declared.to_string())) + }) + .collect(); + super::normalized_behavior::NormalizedSignature { + return_type, + params, + } + } + + fn source_profile_signature( + &self, + lines: &[String], + function: &super::FunctionDef, + ) -> Option { + lines + .get(function.line.saturating_sub(1)) + .map(|line| line.trim().to_string()) + .or_else(|| Some(String::new())) + } + + fn profile_type_system(&self) -> &'static str { + "python-typing" + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn canonical_project_namespace(&self, file: &std::path::Path, _namespace: &str) -> String { + let mut package = Vec::new(); + let mut directory = file.parent(); + while let Some(current) = directory { + if !current.join("__init__.py").is_file() { + break; + } + let Some(name) = current.file_name().and_then(|name| name.to_str()) else { + break; + }; + package.push(name.to_string()); + directory = current.parent(); + } + package.reverse(); + let stem = file + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if stem != "__init__" && !stem.is_empty() { + package.push(stem.to_string()); + } + package.join(".") + } + + fn canonical_project_import( + &self, + file: &std::path::Path, + namespace: &str, + target: &str, + ) -> String { + let dots = target + .chars() + .take_while(|character| *character == '.') + .count(); + if dots == 0 { + return target.to_string(); + } + let mut package = namespace.split('.').map(str::to_string).collect::>(); + if file.file_stem().and_then(|stem| stem.to_str()) != Some("__init__") { + package.pop(); + } + for _ in 1..dots { + package.pop(); + } + package.extend( + target[dots..] + .split('.') + .filter(|part| !part.is_empty()) + .map(str::to_string), + ); + package.join(".") + } + + fn resolves_inherited_project_calls(&self) -> bool { + true + } + fn owner_supertypes(&self, node: &Node) -> Vec { let header = node.text.lines().next().unwrap_or(&node.text); let Some(open) = header.find('(') else { diff --git a/gems/fact-mine/src/syntax/redundant_nil_guard.rs b/gems/fact-mine/src/syntax/redundant_nil_guard.rs index 6d182c670..ee3468359 100644 --- a/gems/fact-mine/src/syntax/redundant_nil_guard.rs +++ b/gems/fact-mine/src/syntax/redundant_nil_guard.rs @@ -408,6 +408,15 @@ impl<'a> RedundantNilGuard<'a> { } fn inspect_node(&mut self, node: &Node, defstack: &[String], known: &BTreeSet) { + // Control-flow regions can be nested inside expressions in the + // normalized tree (Ruby iterator bodies are the common example). + // Walk those regions with branch semantics so their edge-local + // refinements are not lost merely because the enclosing iterator is + // represented as one statement. + if matches!(node.r#type.as_str(), "IF" | "UNLESS") { + self.process_branch(node, defstack, known); + return; + } let recorded = self.record_redundant(node, defstack, known); if matches!(node.r#type.as_str(), "DEFN" | "DEFS") { return; diff --git a/gems/fact-mine/src/syntax/ruby.rs b/gems/fact-mine/src/syntax/ruby.rs index 6bd23afcb..b6c734320 100644 --- a/gems/fact-mine/src/syntax/ruby.rs +++ b/gems/fact-mine/src/syntax/ruby.rs @@ -24,13 +24,16 @@ use super::effects::{effect_from_call_with_lexicon, EffectLexicon}; use super::normalized_behavior::{ configured_collection_operation, configured_external_latency_bound, - configured_intrinsic_call_complexity, configured_semantic_symbol_call_complexity, - configured_semantic_symbol_kind, configured_semantic_symbol_parametric_cost, - configured_stdlib_call_identity, eliminable_guard_from_call, matching_paren_index, - method_parameter_type_key, BlockCallSemantics, CardinalityCallSemantics, - CollectionAllocationSemantics, NormalizedCallComplexity, NormalizedCallParts, - NormalizedCallProjection, NormalizedCollectionOperation, NormalizedLanguageBehavior, - NormalizedNilGuardFact, NormalizedSemanticEffect, NormalizedVisibilityEvent, SyntaxMetadata, + configured_external_latency_parametric_cost, configured_intrinsic_call_complexity, + configured_semantic_symbol_call_complexity, configured_semantic_symbol_kind, + configured_semantic_symbol_parametric_cost, configured_stdlib_call_identity, + eliminable_guard_from_call, matching_paren_index, method_parameter_type_key, + BlockCallSemantics, CardinalityCallSemantics, CollectionAllocationSemantics, + NormalizedCallComplexity, NormalizedCallParts, NormalizedCallProjection, + NormalizedCollectionOperation, NormalizedGeneratedAccessor, NormalizedLanguageBehavior, + NormalizedNilGuardFact, NormalizedRuntimeCapabilityGuard, NormalizedRuntimeSemanticTarget, + NormalizedRuntimeTruthinessGuard, NormalizedSemanticEffect, NormalizedVisibilityEvent, + RuntimeCallResultProjection, RuntimeValueProjection, SyntaxMetadata, }; use super::{CallSite, ExternalCallComplexity, FunctionDef, StateDeclaration}; use crate::ast::{self, Node, Span}; @@ -38,15 +41,127 @@ use crate::type_inference::TypeExpr; use std::collections::{BTreeMap, BTreeSet}; fn scip_ruby_descriptor(symbol: &str) -> Option<&str> { - let rest = symbol.strip_prefix("scip-ruby gem ")?; - let mut fields = rest.splitn(3, ' '); - fields.next()?; // gem name - fields.next()?; // gem version - fields.next() + if let Some(rest) = symbol.strip_prefix("scip-ruby gem ") { + let mut fields = rest.splitn(3, ' '); + fields.next()?; // gem name + fields.next()?; // gem version + return fields.next(); + } + + runtime_ruby_core_descriptor(symbol) +} + +// NilKill uses this identity only for code that Ruby itself owns: native core +// methods and standard-library source that does not belong to a loaded gem or +// workspace. Unlike scip-ruby's project-scoped package identity, it is a +// provenance guarantee, so an as-yet-unmodelled descriptor is still a stdlib +// cost gap rather than a missing declaration in the consumer project. +fn runtime_ruby_core_descriptor(symbol: &str) -> Option<&str> { + let rest = symbol.strip_prefix("nil-kill-runtime ")?; + let mut fields = rest.splitn(4, ' '); + let manager = fields.next()?; + let package = fields.next()?; + fields.next()?; // runtime version + let descriptor = fields.next()?; + // Runtime core frames are deliberately distinct from project and gem + // frames. Only the former may consume the Ruby stdlib registry. + // Ruby's standard library is partly distributed as default gems. NilKill + // retains the component package (for example `stringio`) while the trusted + // `ruby` manager distinguishes it from an identically named third-party + // Rubygem. + (manager == "ruby" && !package.is_empty()).then_some(descriptor) +} + +fn runtime_ruby_dependency_descriptor(symbol: &str) -> Option<&str> { + let rest = symbol.strip_prefix("nil-kill-runtime ")?; + let mut fields = rest.splitn(4, ' '); + let manager = fields.next()?; + fields.next()?; // package + fields.next()?; // runtime version + let descriptor = fields.next()?; + (manager != "ruby").then_some(descriptor) +} + +fn runtime_descriptor_name(value: &str) -> String { + if value.chars().enumerate().all(|(index, character)| { + character == '_' + || character.is_ascii_alphanumeric() + || (index > 0 && matches!(character, '!' | '?' | '=')) + }) && value + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + { + value.to_string() + } else { + format!("`{}`", value.replace('`', "``")) + } +} + +fn runtime_descriptor_owner(value: &str) -> String { + value + .split("::") + .filter(|part| !part.is_empty()) + .map(runtime_descriptor_name) + .collect::>() + .join("/") +} + +fn runtime_value_identity_from_symbol(symbol: &str, suffix: char) -> Option { + let (_package, _version, descriptor) = + super::normalized_behavior::scip_global_parts(symbol, "nil-kill-runtime", "ruby")?; + let descriptor = descriptor.strip_suffix(suffix)?; + if descriptor.is_empty() { + return None; + } + Some( + descriptor + .split('/') + .map(|part| { + part.strip_prefix('`') + .and_then(|part| part.strip_suffix('`')) + .unwrap_or(part) + .replace("``", "`") + }) + .collect::>() + .join("::"), + ) +} + +fn ruby_module_function_mode(node: &Node, lines: &[String]) -> bool { + if node.first_lineno == 0 { + return false; + } + let declaration_index = node.first_lineno.saturating_sub(1); + let declaration_indent = lines + .get(declaration_index) + .map(|line| line.len().saturating_sub(line.trim_start().len())) + .unwrap_or(node.first_column); + + for line in lines.iter().take(declaration_index).rev() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let indent = line.len().saturating_sub(line.trim_start().len()); + if indent < declaration_indent { + break; + } + if indent != declaration_indent { + continue; + } + if trimmed == "module_function" { + return true; + } + if matches!(trimmed, "public" | "private" | "protected") { + return false; + } + } + false } fn ruby_descriptor_owner(descriptor: &str) -> Option { - let owner = descriptor.split_once('#')?.0.trim_matches('`'); + let owner = ruby_descriptor_parts(descriptor)?.0.trim_matches('`'); let owner = owner .strip_prefix("')) @@ -62,25 +177,206 @@ fn ruby_descriptor_owner(descriptor: &str) -> Option { } } +fn ruby_descriptor_parts(descriptor: &str) -> Option<(&str, &str)> { + let callable = descriptor.strip_suffix("().")?; + let separator = callable.rfind(['#', '.'])?; + Some((&callable[..separator], &callable[separator + 1..])) +} + +/// NilKill gives generated Ruby records without an analyzed declaration a +/// structural owner. Anonymous records use `AnonymousStruct(file,line)`; +/// named records outside the source corpus use +/// `GeneratedStruct(Nominal/Owner;file,line)`. That is raw runtime identity, +/// not a cost claim: this adapter validates the exact requested member before +/// applying Ruby's generated-record contract. Named records inside the corpus +/// continue to join against FactMine's parsed source declarations. +fn ruby_generated_record_operation( + descriptor: &str, + message: &str, +) -> Option { + let (owner, member) = ruby_descriptor_parts(descriptor)?; + let owner = owner.trim_matches('`'); + let (family, payload) = [ + ("AnonymousStruct(", "Struct"), + ("AnonymousData(", "Data"), + ("GeneratedStruct(", "Struct"), + ("GeneratedData(", "Data"), + ] + .iter() + .find_map(|(prefix, family)| { + owner + .strip_prefix(prefix) + .and_then(|rest| rest.strip_suffix(')')) + .map(|payload| (*family, payload)) + })?; + let field_payload = if owner.starts_with("Generated") { + let (nominal_owner, fields) = payload.split_once(';')?; + if nominal_owner.is_empty() + || nominal_owner.split('/').any(|component| { + component.is_empty() + || !component + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + || component + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()) + }) + { + return None; + } + fields + } else { + payload + }; + let fields = field_payload + .split(',') + .map(str::trim) + .filter(|field| !field.is_empty()) + .collect::>(); + if fields.is_empty() + || fields.iter().any(|field| { + !field + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + || field + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()) + }) + { + return None; + } + let member = member.trim_matches('`'); + if member != message + || (member != "new" + && !fields.iter().any(|field| { + *field == member + || (family == "Struct" + && member + .strip_suffix('=') + .is_some_and(|setter| setter == *field)) + })) + { + return None; + } + Some(NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) +} + fn ruby_stdlib_descriptor(descriptor: &str, message: &str) -> bool { - ruby_descriptor_owner(descriptor) - .is_some_and(|owner| configured_stdlib_call_identity("ruby", Some(&owner), None, message)) + ruby_descriptor_owner(descriptor).is_some_and(|owner| { + let namespace_owner = owner.replace('/', "::"); + let plain = format!("{owner}#{message}()."); + let quoted = format!("{owner}#`{message}`()."); + RubyNormalizedBehavior + .call_complexity(&TypeExpr::Primitive(namespace_owner.clone()), message) + .is_some() + || configured_stdlib_call_identity("ruby", Some(&namespace_owner), None, message) + || configured_external_latency_bound("ruby", &namespace_owner, message).is_some() + || configured_external_latency_parametric_cost("ruby", &namespace_owner, message) + .is_some() + || configured_semantic_symbol_parametric_cost("ruby", &plain).is_some() + || configured_semantic_symbol_parametric_cost("ruby", "ed).is_some() + || ruby_stdlib_fallback_owners(&owner).iter().any(|fallback| { + let descriptor = format!("{fallback}#{message}()."); + let quoted = format!("{fallback}#`{message}`()."); + RubyNormalizedBehavior + .call_complexity(&TypeExpr::Primitive((*fallback).to_string()), message) + .is_some() + || configured_semantic_symbol_parametric_cost("ruby", &descriptor).is_some() + || configured_semantic_symbol_parametric_cost("ruby", "ed).is_some() + }) + }) +} + +fn ruby_stdlib_fallback_owners(owner: &str) -> &'static [&'static str] { + match owner { + "Array" | "Hash" | "Set" | "Enumerator" | "Range" => &["Enumerable", "Kernel"], + "Integer" | "Float" | "Numeric" => &["Numeric", "Kernel"], + _ => &["Kernel"], + } +} + +fn ruby_family_parametric_cost(owner: &str, message: &str) -> Option { + ruby_stdlib_fallback_owners(owner) + .iter() + .find_map(|fallback| { + let plain = format!("{fallback}#{message}()."); + let quoted = format!("{fallback}#`{message}`()."); + configured_semantic_symbol_parametric_cost("ruby", &plain) + .or_else(|| configured_semantic_symbol_parametric_cost("ruby", "ed)) + }) } pub(crate) fn external_symbol_call_complexity( symbol: &str, message: &str, ) -> Option { + if let Some(complexity) = runtime_ruby_dependency_descriptor(symbol) + .or_else(|| runtime_ruby_core_descriptor(symbol)) + .and_then(|descriptor| ruby_generated_record_operation(descriptor, message)) + { + return Some(ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "ruby_generated_record_runtime_contract", + bound_quality: "upper_bound_normalized_runtime_record_contract", + candidates: Vec::new(), + assumption: None, + }); + } + + // Runtime SCIP retains exact gem/package identity. A reviewed dependency + // contract may therefore be keyed by the exact callable descriptor without + // allowing an arbitrary gem method to consume the native Ruby registry. + if let Some(descriptor) = runtime_ruby_dependency_descriptor(symbol) { + if let Some(complexity) = configured_semantic_symbol_call_complexity("ruby", descriptor) { + return Some(ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "ruby_reviewed_dependency_registry", + bound_quality: "upper_bound_exact_target", + candidates: Vec::new(), + assumption: None, + }); + } + } + let descriptor = scip_ruby_descriptor(symbol)?; + let owner = ruby_descriptor_owner(descriptor)?; + // An exact reviewed semantic-symbol contract is already the strongest + // available identity. It must not depend on also registering the owner in + // the family-level fallback table (default-gem module functions such as + // JSON.parse are the common counterexample). + if let Some(complexity) = configured_semantic_symbol_call_complexity("ruby", descriptor) { + return Some(ExternalCallComplexity { + time: complexity.time, + space: complexity.space, + provenance: "ruby_stdlib_registry", + bound_quality: "upper_bound_exact_target", + candidates: Vec::new(), + assumption: None, + }); + } if !ruby_stdlib_descriptor(descriptor, message) || configured_semantic_symbol_parametric_cost("ruby", descriptor).is_some() + || ruby_family_parametric_cost(&owner, message).is_some() { return None; } - let owner = ruby_descriptor_owner(descriptor)?; let behavior = RubyNormalizedBehavior; let complexity = configured_semantic_symbol_call_complexity("ruby", descriptor) .or_else(|| behavior.call_complexity(&TypeExpr::Primitive(owner.clone()), message)) + .or_else(|| { + ruby_stdlib_fallback_owners(&owner) + .iter() + .find_map(|fallback| { + behavior.call_complexity(&TypeExpr::Primitive((*fallback).to_string()), message) + }) + }) .or_else(|| behavior.intrinsic_call_complexity(Some(&owner), message)); if let Some(complexity) = complexity { return Some(ExternalCallComplexity { @@ -92,6 +388,19 @@ pub(crate) fn external_symbol_call_complexity( assumption: None, }); } + if let Some(kind) = configured_external_latency_parametric_cost("ruby", &owner, message) { + let (time, space) = super::parametric_call_complexity(&kind)?; + return Some(ExternalCallComplexity { + time, + space, + provenance: "ruby_external_effect_parametric_registry", + bound_quality: "upper_bound_external_latency_excluded_parametric", + candidates: Vec::new(), + assumption: Some(format!( + "computational Big-O only; filesystem, process, stream, or terminal latency is excluded; `{kind}` remains symbolic" + )), + }); + } let complexity = configured_external_latency_bound("ruby", &owner, message)?; Some(ExternalCallComplexity { time: complexity.time, @@ -108,26 +417,44 @@ pub(crate) fn external_symbol_call_complexity( pub(crate) fn external_symbol_metadata(symbol: &str) -> super::ExternalSymbolMetadata { let Some(descriptor) = scip_ruby_descriptor(symbol) else { + let runtime_manager = symbol + .strip_prefix("nil-kill-runtime ") + .and_then(|rest| rest.split_whitespace().next()); + let (scope, missing_cost_kind) = if runtime_manager == Some("workspace") { + ( + "project_declaration", + "project_declaration_body_or_generated_member_missing", + ) + } else if symbol.contains(" Proc#call().") + || symbol.contains(" Method#call().") + || symbol.contains(" UnboundMethod#call().") + { + ("dynamic", "callback_or_function_value_origin_unknown") + } else { + ("dependency", "dependency_cost_model_missing") + }; return super::ExternalSymbolMetadata { - scope: "dynamic", - missing_cost_kind: "callback_or_function_value_origin_unknown".to_string(), + scope, + missing_cost_kind: missing_cost_kind.to_string(), parametric_cost: None, }; }; - let message = descriptor - .rsplit_once('#') + let message = ruby_descriptor_parts(descriptor) .map(|(_, member)| member) .unwrap_or_default() .trim_matches('`') - .split('(') - .next() - .unwrap_or_default(); - if ruby_stdlib_descriptor(descriptor, message) { + .to_string(); + if runtime_ruby_core_descriptor(symbol).is_some() + || ruby_stdlib_descriptor(descriptor, &message) + { + let owner = ruby_descriptor_owner(descriptor).unwrap_or_default(); super::ExternalSymbolMetadata { scope: "stdlib", missing_cost_kind: configured_semantic_symbol_kind("ruby", descriptor) .unwrap_or_else(|| "stdlib_cost_model_missing".to_string()), - parametric_cost: configured_semantic_symbol_parametric_cost("ruby", descriptor), + parametric_cost: configured_semantic_symbol_parametric_cost("ruby", descriptor) + .or_else(|| ruby_family_parametric_cost(&owner, &message)) + .or_else(|| configured_external_latency_parametric_cost("ruby", &owner, &message)), } } else { super::ExternalSymbolMetadata { @@ -371,41 +698,391 @@ const RUBY_EFFECT_LEXICON: EffectLexicon = EffectLexicon { // CFG-SPECIFIC START: Ruby control-flow vocabulary. const RUBY_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { - iterator_messages: &[ - "all", - "any", - "collect", - "detect", - "downto", - "each", - "each_cons", - "each_entry", - "each_key", - "each_pair", - "each_slice", - "each_value", - "filter_map", - "find", - "find_all", - "flat_map", - "inject", - "loop", - "map", - "none", - "reduce", - "reject", - "select", - "step", - "times", - "upto", - ], + // CFG and complexity projection must agree on which blocks can execute + // once per collection element. A second hand-maintained subset silently + // modeled methods such as each_line/each_with_object/sort_by as one-shot + // callbacks and broke loop-carried reaching definitions. + iterator_messages: RUBY_ITERATION_METHODS, ignored_callback_body_sources: &["do end", "{}"], }; // CFG-SPECIFIC END +fn ruby_generated_reader_names(source: &str) -> BTreeSet { + let source = source.trim(); + for declaration in ["attr_reader", "attr_accessor", "const", "prop"] { + let Some(rest) = source.strip_prefix(declaration) else { + continue; + }; + if rest + .chars() + .next() + .is_some_and(|character| character.is_alphanumeric() || character == '_') + { + continue; + } + let rest = rest + .trim() + .strip_prefix('(') + .unwrap_or(rest.trim()) + .trim_end_matches(')') + .trim(); + let mut names = BTreeSet::new(); + for argument in split_top_level_params_local(rest) { + let name = argument + .trim() + .trim_start_matches(':') + .trim_matches(['\'', '"']) + .to_string(); + if name.is_empty() + || !name + .chars() + .all(|character| character.is_alphanumeric() || character == '_') + { + continue; + } + names.insert(name.clone()); + if declaration == "attr_accessor" { + names.insert(format!("{name}=")); + } + if matches!(declaration, "const" | "prop") { + break; + } + } + return names; + } + BTreeSet::new() +} + +fn ruby_generated_record_accessor_names(source: &str) -> BTreeSet { + let source = source.trim(); + let declaration = ["Struct.new(", "Data.define("] + .iter() + .find_map(|prefix| source.strip_prefix(prefix).map(|rest| (*prefix, rest))); + let Some((prefix, rest)) = declaration else { + return BTreeSet::new(); + }; + let Some(arguments) = rest.strip_suffix(')') else { + return BTreeSet::new(); + }; + let mutable = prefix == "Struct.new("; + split_top_level_params_local(arguments).into_iter().fold( + BTreeSet::new(), + |mut names, argument| { + let name = argument + .trim() + .trim_start_matches(':') + .trim_matches(['\'', '"']); + if !name.is_empty() + && name + .chars() + .all(|character| character.is_alphanumeric() || character == '_') + { + names.insert(name.to_string()); + if mutable { + names.insert(format!("{name}=")); + } + } + names + }, + ) +} + +fn ruby_identifier_in(source: &str, identifier: &str) -> bool { + source.match_indices(identifier).any(|(start, _)| { + let before = source[..start].chars().next_back(); + let after = source[start + identifier.len()..].chars().next(); + let identifier_character = + |character: char| character.is_alphanumeric() || character == '_'; + before.is_none_or(|character| !identifier_character(character)) + && after.is_none_or(|character| !identifier_character(character)) + }) +} + +fn ruby_strict_capture_guard(line: &str, roots: &BTreeSet) -> bool { + let Some((condition, pattern)) = line.split_once("=~") else { + return false; + }; + if !roots.iter().any(|root| ruby_identifier_in(condition, root)) { + return false; + } + let pattern = pattern.trim(); + let anchored_start = pattern.starts_with("/^") || pattern.starts_with(r"/\A"); + let anchored_end = pattern.contains("$/") || pattern.contains(r"\z/"); + let Some(capture) = pattern.find("(.+)") else { + return false; + }; + if !anchored_start || !anchored_end { + return false; + } + let prefix = pattern[..capture] + .trim_start_matches('/') + .trim_start_matches('^') + .trim_start_matches(r"\A"); + let suffix = pattern[capture + 4..] + .trim_end_matches('/') + .trim_end_matches('$') + .trim_end_matches(r"\z"); + // Requiring a wrapper on both sides proves the capture is a proper + // substring, rather than merely no larger than the original string. + !prefix.is_empty() && !suffix.is_empty() +} + +fn ruby_assignment(line: &str) -> Option<(&str, &str)> { + let (left, right) = line.trim().split_once('=')?; + if right.starts_with(['=', '>', '~']) || left.ends_with(['!', '<', '>', '=']) { + return None; + } + let left = left.trim(); + (!left.is_empty() + && left + .chars() + .all(|character| character.is_alphanumeric() || character == '_')) + .then_some((left, right.trim())) +} + +fn ruby_strict_capture_recursion( + method: &Node, + call: &Node, + parameters: &BTreeSet, +) -> bool { + let line_count = call + .first_lineno + .saturating_sub(method.first_lineno) + .saturating_add(1); + let prior = method.text.lines().take(line_count).collect::>(); + let mut nonexpanding_roots = parameters.clone(); + for line in &prior { + let Some((name, value)) = ruby_assignment(line) else { + continue; + }; + let Some(root) = nonexpanding_roots + .iter() + .find(|root| value.starts_with(root.as_str())) + else { + continue; + }; + let suffix = &value[root.len()..]; + if !suffix.is_empty() + && suffix + .split('.') + .filter(|part| !part.is_empty()) + .all(|operation| matches!(operation, "to_s" | "strip" | "lstrip" | "rstrip")) + { + nonexpanding_roots.insert(name.to_string()); + } + } + let capture_guards = prior + .iter() + .filter(|line| line.contains("=~") && line.contains("(.+)")) + .collect::>(); + if capture_guards.is_empty() + || capture_guards + .iter() + .any(|line| !ruby_strict_capture_guard(line, &nonexpanding_roots)) + { + return false; + } + + let direct_capture = + |source: &str| source.contains("$1") || source.contains("Regexp.last_match(1)"); + if direct_capture(&call.text) { + return true; + } + + let mut smaller_values = BTreeSet::new(); + let mut substring_collections = BTreeSet::new(); + for line in prior { + let Some((name, value)) = ruby_assignment(line) else { + continue; + }; + if direct_capture(value) { + if value.contains(".split") { + substring_collections.insert(name.to_string()); + } else { + smaller_values.insert(name.to_string()); + } + continue; + } + let collection_source = substring_collections + .iter() + .find(|candidate| ruby_identifier_in(value, candidate)); + if collection_source.is_some() { + if [".reject", ".select", ".filter", ".compact"] + .iter() + .any(|operation| value.contains(operation)) + { + substring_collections.insert(name.to_string()); + } else if [".first", ".last", "["] + .iter() + .any(|operation| value.contains(operation)) + { + smaller_values.insert(name.to_string()); + } + } + } + + smaller_values + .iter() + .any(|name| ruby_identifier_in(&call.text, name)) + || substring_collections.iter().any(|name| { + [".first", ".last", "["] + .iter() + .any(|projection| call.text.contains(&format!("{name}{projection}"))) + }) +} + pub(crate) struct RubyNormalizedBehavior; impl NormalizedLanguageBehavior for RubyNormalizedBehavior { + fn complexity_uses_invariant_flow_types(&self) -> bool { + true + } + + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + let signature = signature.trim(); + if !signature.starts_with("sig") { + return super::normalized_behavior::NormalizedSignature::default(); + } + let return_type = signature_component(signature, ".returns(") + .or_else(|| signature_component(signature, "returns(")); + let params = signature_component(signature, ".params(") + .or_else(|| signature_component(signature, "params(")) + .map(|params| { + split_top_level_params_local(¶ms) + .into_iter() + .filter_map(|entry| { + let (name, declared) = entry.split_once(':')?; + Some((name.trim().to_string(), declared.trim().to_string())) + }) + .collect() + }) + .unwrap_or_default(); + super::normalized_behavior::NormalizedSignature { + return_type, + params, + } + } + + fn source_profile_signature(&self, lines: &[String], function: &FunctionDef) -> Option { + let mut cursor = function.line.saturating_sub(2); + if cursor >= lines.len() { + return Some(String::new()); + } + while cursor > 0 && lines[cursor].trim().is_empty() { + cursor = cursor.saturating_sub(1); + } + let mut start = cursor; + loop { + let text = lines[start].trim(); + if text.starts_with("sig ") { + return Some( + lines[start..=cursor] + .iter() + .map(|line| line.trim()) + .collect::>() + .join(" ") + .split_whitespace() + .collect::>() + .join(" "), + ); + } + if text.starts_with("def ") + || text.starts_with("class ") + || text.starts_with("module ") + || start == 0 + { + return Some(String::new()); + } + start -= 1; + } + } + + fn profile_type_system(&self) -> &'static str { + "sorbet" + } + + fn profile_signature_is_annotation(&self, signature: &str) -> bool { + signature.starts_with("sig ") + } + + fn native_profile_literal_type(&self, value: &str) -> Option { + if value.starts_with(':') || value.starts_with("%s") { + Some("Symbol".to_string()) + } else if value.starts_with("%q") || value.starts_with("%Q") { + Some("String".to_string()) + } else if value.starts_with("%i") + || value.starts_with("%I") + || value.starts_with("%w") + || value.starts_with("%W") + { + Some(self.untyped_array_type()) + } else { + None + } + } + + fn local_assignment_type_hint(&self, value: &str) -> Option { + let value = value.trim(); + if value.starts_with('[') || value.starts_with("%w") || value.starts_with("%W") { + return Some(self.untyped_array_type()); + } + if value.starts_with('{') { + return Some(self.untyped_hash_type()); + } + if value.starts_with('"') || value.starts_with('\'') { + return Some("String".to_string()); + } + if value.parse::().is_ok() { + return Some("Integer".to_string()); + } + if value.parse::().is_ok() { + return Some("Float".to_string()); + } + let constructor = value.split_once(".new").map(|(owner, _)| owner.trim())?; + (!constructor.is_empty() + && constructor + .split("::") + .all(|segment| segment.chars().next().is_some_and(char::is_uppercase))) + .then(|| constructor.to_string()) + } + + // In Ruby `obj.foo` (no parens) is a real method call, not a field read, so + // it must not be assumed constant-time. + fn complexity_member_read_complexity( + &self, + _node: &Node, + ) -> Option { + None + } + + fn explicit_receiver_type(&self, receiver: &str) -> Option { + let receiver = receiver.trim(); + if receiver == "ENV" { + return Some("Hash".to_string()); + } + if ruby_word_array_literal(receiver) { + return Some("T::Array[String]".to_string()); + } + if receiver.starts_with('[') { + return Some("T::Array[T.untyped]".to_string()); + } + if receiver.starts_with('{') { + return Some("T::Hash[T.untyped, T.untyped]".to_string()); + } + if (receiver.starts_with('"') && receiver.ends_with('"')) + || (receiver.starts_with('\'') && receiver.ends_with('\'')) + { + return Some("String".to_string()); + } + if receiver.parse::().is_ok() { + return Some("Integer".to_string()); + } + if receiver.parse::().is_ok() { + return Some("Float".to_string()); + } + None + } + fn external_symbol_call_complexity( &self, symbol: &str, @@ -418,6 +1095,75 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { external_symbol_metadata(symbol) } + fn external_symbol_owner(&self, symbol: &str) -> Option { + scip_ruby_descriptor(symbol) + .or_else(|| runtime_ruby_dependency_descriptor(symbol)) + .and_then(ruby_descriptor_parts) + .map(|(owner, _)| owner.trim_matches('`').replace('/', "::")) + } + + fn generated_callable_complexity( + &self, + source: &str, + name: &str, + ) -> Option { + let generated_reader = ruby_generated_reader_names(source).contains(name) + || ruby_generated_record_accessor_names(source).contains(name); + generated_reader.then_some(NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) + } + + fn recursive_call_argument_progress( + &self, + method: &Node, + call: &Node, + parameters: &BTreeSet, + ) -> Option<&'static str> { + ruby_strict_capture_recursion(method, call, parameters).then_some("structural") + } + + fn scip_occurrence_matches_call(&self, symbol: &str, source_text: &str, message: &str) -> bool { + if source_text == message || source_text == format!("{message}=") { + return true; + } + if message + .strip_suffix('=') + .is_some_and(|setter| source_text == setter) + { + // Ruby's writer syntax has no `=` in the selector token, and a + // runtime producer can legitimately report both the generated + // reader and writer at that range. The symbol, rather than the + // shared source token, must disambiguate the callable identity. + return scip_ruby_descriptor(symbol) + .or_else(|| runtime_ruby_dependency_descriptor(symbol)) + .and_then(ruby_descriptor_parts) + .is_some_and(|(_, member)| member.trim_matches('`') == message); + } + if !matches!(message, "[]" | "[]=") { + return false; + } + // An index occurrence for Ruby's bracket selector commonly spans the + // opening bracket. The normalized call already distinguishes reader + // from writer; the token is sufficient even when a project/runtime + // symbol intentionally uses an opaque stable declaration ID. + if source_text == "[" { + return true; + } + scip_ruby_descriptor(symbol) + .or_else(|| runtime_ruby_dependency_descriptor(symbol)) + .and_then(ruby_descriptor_parts) + .map(|(_, member)| { + member + .trim_matches('`') + .split('(') + .next() + .unwrap_or_default() + }) + == Some(message) + } + fn owner_supertypes(&self, node: &Node) -> Vec { let header = node.text.lines().next().unwrap_or(&node.text); header @@ -453,8 +1199,25 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { .to_string() } + fn function_dispatch_kind_from_source( + &self, + name: &str, + node: &Node, + owner: &str, + lines: &[String], + ) -> String { + if !name.starts_with("self.") && ruby_module_function_mode(node, lines) { + "class".to_string() + } else { + self.function_dispatch_kind(name, owner) + } + } + fn receiver_is_type_reference(&self, receiver: &str) -> bool { let receiver = receiver.strip_prefix("::").unwrap_or(receiver); + if receiver == "ENV" { + return false; + } !receiver.is_empty() && receiver.split("::").all(|segment| { segment @@ -467,7 +1230,225 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { }) } - fn constructor_dispatch_name(&self, receiver: &str, message: &str) -> Option { + fn relative_type_receiver_candidates(&self, receiver: &str, owner: &str) -> Vec { + let receiver = receiver.trim().trim_start_matches("::"); + if receiver.is_empty() || !self.receiver_is_type_reference(receiver) { + return Vec::new(); + } + + let mut candidates = Vec::new(); + let scopes = owner + .split("::") + .filter(|part| !part.is_empty()) + .collect::>(); + for length in (0..=scopes.len()).rev() { + let candidate = if length == 0 { + receiver.to_string() + } else { + format!("{}::{receiver}", scopes[..length].join("::")) + }; + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + candidates + } + + fn runtime_capability_guard( + &self, + condition: &Node, + ) -> Option { + if !matches!(condition.r#type.as_str(), "CALL" | "QCALL") { + return None; + } + let receiver = condition.children.first().and_then(ast::node)?; + let message = match condition.children.get(1)? { + ast::Child::String(value) | ast::Child::Symbol(value) => value, + _ => return None, + }; + if message != "respond_to?" { + return None; + } + let arguments = condition.children.get(2).and_then(ast::node)?; + let argument = arguments.children.iter().find_map(ast::node)?; + let member = ast::normalize_text(&argument.text) + .trim() + .trim_start_matches(':') + .trim_matches(['\'', '"']) + .to_string(); + let subject = ast::normalize_text(&receiver.text).trim().to_string(); + (!subject.is_empty() && !member.is_empty()) + .then_some(NormalizedRuntimeCapabilityGuard { subject, member }) + } + + fn runtime_truthiness_guard( + &self, + condition: &Node, + ) -> Option { + // FactMine subsequently requires a matching CFG local place and + // reaching definition, so a bare method spelling cannot gain a + // refinement from this syntactic recognition alone. That lets this + // adapter accept Ripper's normalized local-reference node without + // teaching shared CFG code Ruby's node vocabulary. + let subject = ast::normalize_text(&condition.text).trim().to_string(); + (!subject.is_empty() + && subject + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric())) + .then_some(NormalizedRuntimeTruthinessGuard { subject }) + } + + fn node_call_projections(&self, node: &Node) -> Vec { + // Ripper represents `receiver << value` as OPCALL rather than CALL. + // It is nevertheless ordinary Ruby dispatch (Array, Set, String, or a + // user implementation), so it needs the same runtime-evidence request + // and cost join as an explicitly spelled method call. + if node.r#type != "OPCALL" { + return Vec::new(); + } + let Some(receiver) = node.children.first().and_then(ast::node) else { + return Vec::new(); + }; + let Some(message) = node.children.get(1).and_then(|child| match child { + ast::Child::String(value) | ast::Child::Symbol(value) => Some(value.as_str()), + _ => None, + }) else { + return Vec::new(); + }; + if message != "<<" { + return Vec::new(); + } + let full_span = [ + node.first_lineno, + node.first_column, + node.last_lineno, + node.last_column, + ]; + let arguments = node + .children + .get(2) + .and_then(ast::node) + .map(|arguments| { + arguments + .children + .iter() + .filter_map(ast::node) + .map(|argument| ast::normalize_text(&argument.text).trim().to_string()) + .collect() + }) + .unwrap_or_default(); + vec![NormalizedCallProjection { + receiver: ast::normalize_text(&receiver.text).trim().to_string(), + message: message.to_string(), + arguments, + access_span: self.call_access_span(node, None, full_span), + span: full_span, + }] + } + + fn call_access_span(&self, node: &Node, computed_span: Option, full_span: Span) -> Span { + // Ruby normalizes an explicit receiver call as + // `CALL(receiver, message, arguments)`, but a bare function call as + // `FCALL(message, arguments)` (and a no-argument bare call as + // `VCALL(message)`). The selector is therefore not at one fixed + // child position. Returning the source span of the selector keeps + // runtime SCIP occurrences distinct from their nested argument calls. + let message_child = match node.r#type.as_str() { + "CALL" | "QCALL" | "OPCALL" => node.children.get(1), + "FCALL" | "VCALL" => node.children.first(), + "ATTRASGN" => node.children.get(1), + _ => None, + }; + let message = match message_child { + Some(ast::Child::String(value) | ast::Child::Symbol(value)) => value.as_str(), + _ => return computed_span.unwrap_or(full_span), + }; + let source_selector = if matches!(node.r#type.as_str(), "CALL" | "QCALL") && message == "[]" + { + "[" + } else if node.r#type == "ATTRASGN" && message == "[]=" { + "[" + } else if node.r#type == "ATTRASGN" { + message.strip_suffix('=').unwrap_or(message) + } else { + message + }; + let search_start = match node.r#type.as_str() { + "CALL" | "QCALL" | "OPCALL" | "ATTRASGN" => node + .children + .first() + .and_then(|child| match child { + ast::Child::Node(receiver) => Some(receiver.as_ref()), + _ => None, + }) + .and_then(|receiver| { + node.text + .find(&receiver.text) + .map(|offset| offset + receiver.text.len()) + }) + .unwrap_or(0), + _ => 0, + }; + let Some(offset) = node.text[search_start..] + .find(source_selector) + .map(|offset| search_start + offset) + else { + return computed_span.unwrap_or(full_span); + }; + let prefix = &node.text[..offset]; + let line_offset = prefix.bytes().filter(|byte| *byte == b'\n').count(); + let column = prefix + .rsplit_once('\n') + .map(|(_, line)| line.len()) + .unwrap_or_else(|| full_span[1] + prefix.len()); + let line = full_span[0] + line_offset; + [line, column, line, column + source_selector.len()] + } + + fn value_preserving_call_result_operands<'a>(&self, node: &'a Node) -> Option> { + if matches!(node.r#type.as_str(), "OR" | "AND") { + return Some( + node.children + .iter() + .filter_map(ast::node) + .collect::>(), + ) + .filter(|operands| operands.len() >= 2); + } + if matches!(node.r#type.as_str(), "IF" | "UNLESS") { + let operands = node + .children + .iter() + .skip(1) + .filter_map(ast::node) + .filter_map(ruby_branch_result) + .collect::>(); + return (operands.len() == 2).then_some(operands); + } + None + } + + fn nullable_call_result_contract(&self, node: &Node) -> Option<&'static str> { + let ("CALL" | "QCALL", Some(receiver), Some(message)) = ( + node.r#type.as_str(), + node.children.first().and_then(ast::node), + node.children.get(1).and_then(|child| match child { + ast::Child::String(value) | ast::Child::Symbol(value) => Some(value.as_str()), + _ => None, + }), + ) else { + return None; + }; + (message == "new" && self.receiver_is_type_reference(receiver.text.trim())) + .then_some("non_null_declared_type") + } + + fn constructor_dispatch_name( + &self, + receiver: &str, + message: &str, + _owner: &str, + ) -> Option { (message == "new" && self.receiver_is_type_reference(receiver)) .then(|| "initialize".to_string()) } @@ -507,10 +1488,11 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { // CFG-SPECIFIC END // TYPE-INFERENCE-SPECIFIC: Ruby's normalized LIST/ARRAY vocabulary is - // also used for call arguments. Only bracket-delimited nodes represent - // source array literals and are eligible for tuple-shape facts. + // also used for call arguments. Only bracket-delimited nodes and Ruby's + // word-array literals represent source array literals and are eligible + // for tuple-shape facts. fn array_literal_node(&self, node: &Node) -> bool { - node.text.trim_start().starts_with('[') + ruby_array_literal(node.text.trim_start()) } fn collection_allocation_semantics(&self, message: &str) -> CollectionAllocationSemantics { @@ -548,13 +1530,62 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { BlockCallSemantics::Iteration } else if RUBY_ONCE_BLOCK_METHODS.contains(&message) { BlockCallSemantics::Once - } else if ["lambda", "proc"].contains(&message) { + } else if ["lambda", "proc", "on"].contains(&message) { BlockCallSemantics::Deferred } else { BlockCallSemantics::Unknown } } + fn block_call_semantics_with_receiver( + &self, + receiver: Option<&str>, + receiver_type: Option<&TypeExpr>, + message: &str, + ) -> BlockCallSemantics { + match (receiver.map(str::trim), message) { + // Hash defaults are stored for later missing-key lookups; the + // constructor does not execute the block. + (Some("Hash"), "new") => BlockCallSemantics::Deferred, + // Core Hash/environment fallback blocks execute at most once. + (Some("Hash" | "ENV"), "fetch") => BlockCallSemantics::Once, + // OptionParser evaluates its configuration DSL once while + // constructing the parser. + (Some("OptionParser"), "new") => BlockCallSemantics::Once, + // Resource-scope callbacks receive one opened resource or run + // once under a temporary process-wide directory. They are not + // collection iterations, regardless of the size of the path or + // command arguments. + (Some("Dir"), "chdir") | (Some("IO"), "popen") => BlockCallSemantics::Once, + _ if message == "fetch" && matches!(receiver_type, Some(TypeExpr::Hash { .. })) => { + BlockCallSemantics::Once + } + _ => self.block_call_semantics(message), + } + } + + fn semantic_symbol_block_call_semantics( + &self, + symbol: &str, + message: &str, + ) -> BlockCallSemantics { + let Some(descriptor) = scip_ruby_descriptor(symbol) else { + return BlockCallSemantics::Unknown; + }; + let Some(owner) = ruby_descriptor_owner(descriptor) else { + return BlockCallSemantics::Unknown; + }; + match (owner.as_str(), message) { + ("Array", "bsearch" | "bsearch_index") => { + BlockCallSemantics::LogarithmicIteration + } + ("Hash" | "ENV", "fetch") => BlockCallSemantics::Once, + ("Hash", "new") => BlockCallSemantics::Deferred, + ("Dir", "chdir") | ("IO", "popen") => BlockCallSemantics::Once, + _ => BlockCallSemantics::Unknown, + } + } + fn cardinality_call_semantics(&self, message: &str) -> CardinalityCallSemantics { if ["length", "size", "count"].contains(&message) { CardinalityCallSemantics::MeasuresReceiver @@ -633,6 +1664,17 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { message == "new" } + fn suppress_call_site(&self, _node: &Node, call: &NormalizedCallProjection) -> bool { + // These are Ruby lexical pseudo-constants. Ripper represents them as + // VCALL nodes, but evaluating them performs no method dispatch; if we + // retained them as `self.__FILE__()` calls they would manufacture a + // semantic-identity gap in every enclosing function. + matches!( + call.message.as_str(), + "__FILE__" | "__LINE__" | "__ENCODING__" + ) + } + fn collection_parameter_type(&self, type_name: &str) -> bool { ["Array", "Hash", "Set", "Enumerable"] .iter() @@ -644,7 +1686,19 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { receiver_type: &TypeExpr, message: &str, ) -> Option { - configured_collection_operation("ruby", receiver_type, message) + configured_collection_operation("ruby", receiver_type, message).or_else(|| { + let TypeExpr::Primitive(name) = receiver_type.strip_nilable() else { + return None; + }; + let canonical = match name.as_str() { + "array" => "Array", + "hash" => "Hash", + "set" => "Set", + "string" => "String", + _ => return None, + }; + configured_collection_operation("ruby", &TypeExpr::parse(canonical, "ruby"), message) + }) } fn intrinsic_call_complexity( @@ -652,6 +1706,12 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { receiver: Option<&str>, message: &str, ) -> Option { + if matches!(message, "||" | "&&") { + return Some(NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }); + } let sorbet_type_operation = receiver == Some("T") && [ "any", @@ -679,6 +1739,11 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { } fn literal_receiver_type(&self, node: &Node) -> Option { + if ruby_word_array_literal(node.text.trim_start()) { + return Some(TypeExpr::Array(Box::new(TypeExpr::Primitive( + "String".to_string(), + )))); + } match node.r#type.as_str() { "ARRAY" | "LIST" | "ZLIST" => Some(TypeExpr::Array(Box::new(TypeExpr::Untyped))), "HASH" => Some(TypeExpr::Hash { @@ -694,7 +1759,7 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { &self, node: &Node, owner: &str, - _in_method: bool, + in_method: bool, ) -> Option { if node.r#type != "IASGN" { return None; @@ -704,26 +1769,35 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { _ => None, })?; let value = node.children.get(1).and_then(ast::node)?; - if !matches!(value.r#type.as_str(), "CALL" | "QCALL") { - return None; - } - let receiver = value.children.first().and_then(ast::node)?; - let message = value.children.get(1).and_then(|child| match child { - ast::Child::String(value) | ast::Child::Symbol(value) => Some(value.as_str()), - _ => None, - })?; - if receiver.text != "T" || message != "let" { - return None; - } - let arguments = value.children.get(2).and_then(ast::node)?; - let declared_type = arguments - .children - .iter() - .filter_map(ast::node) - .nth(1)? - .text - .trim() - .to_string(); + let declared_type = if matches!(value.r#type.as_str(), "CALL" | "QCALL") { + let receiver = value.children.first().and_then(ast::node)?; + let message = value.children.get(1).and_then(|child| match child { + ast::Child::String(value) | ast::Child::Symbol(value) => Some(value.as_str()), + _ => None, + })?; + if receiver.text != "T" || message != "let" { + return None; + } + let arguments = value.children.get(2).and_then(ast::node)?; + arguments + .children + .iter() + .filter_map(ast::node) + .nth(1)? + .text + .trim() + .to_string() + } else { + // A class/module-body ivar literal is a declaration just as much + // as a typed `T.let`: Ruby executes it once while defining the + // owner, before any method can observe the field. Do not infer + // from ordinary method assignments, where a later write can + // legitimately change the field's type. + if in_method { + return None; + } + self.literal_receiver_type(value)?.to_sorbet_string() + }; if declared_type.is_empty() || declared_type == "T.untyped" { return None; } @@ -854,6 +1928,7 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { type_alias_lines: metadata.type_alias_lines, method_param_types: metadata.method_param_types, method_local_types: BTreeMap::new(), + method_template_types: BTreeMap::new(), } } @@ -974,6 +2049,12 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { fn known_return_type(&self, name: &str) -> Option { match name { "puts" | "print" | "warn" => Some("NilClass".to_string()), + // Kernel#Array can invoke a user conversion hook, so its *cost* + // remains parametric. Ruby nevertheless guarantees that a + // successful conversion returns an Array, which is sufficient for + // FactMine's generic direct-call-result join to type a following + // receiver without reconstructing Ruby flow in a tracer. + "Array" => Some("T::Array[T.untyped]".to_string()), "to_s" | "to_str" | "inspect" => Some("String".to_string()), "to_i" | "size" | "length" | "count" | "hash" => Some("Integer".to_string()), "to_f" => Some("Float".to_string()), @@ -1084,6 +2165,9 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { if message == "class" { return Some("Class".to_string()); } + if r == "Regexp" && message == "last_match" { + return Some("T.nilable(MatchData)".to_string()); + } if r == "String" { if message == "upcase" || message == "downcase" @@ -1107,6 +2191,21 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { None } + fn static_argument_dependent_return_type( + &self, + message: &str, + arguments: &[String], + ) -> Option { + if !matches!(message, "each_with_object" | "inject" | "reduce") { + return None; + } + let mut argument = arguments.first()?.trim(); + while argument.starts_with('(') && argument.ends_with(')') { + argument = argument[1..argument.len() - 1].trim(); + } + self.local_assignment_type_hint(argument) + } + fn propagated_collection_return_type( &self, message: &str, @@ -1127,13 +2226,10 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { let inner = &r[9..r.len() - 1]; return Some(wrap_nilable(inner)); } - if message == "map" - || message == "select" - || message == "reject" - || message == "filter" - || message == "sort" - || message == "split" - { + if message == "select" || message == "reject" || message == "filter" || message == "sort" { + return receiver_type.map(str::to_string); + } + if message == "map" || message == "split" { return Some("T::Array[T.untyped]".to_string()); } if message == "compact" && r.starts_with("T::Array[") && r.ends_with(']') { @@ -1155,16 +2251,242 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { return Some(format!("T::Array[{}]", v)); } } - if message == "join" && (r.starts_with("T::Array[") || r == "Array") { - return Some("String".to_string()); + if message == "join" && (r.starts_with("T::Array[") || r == "Array") { + return Some("String".to_string()); + } + if message == "to_a" + && (r.starts_with("T::Array[") || r.starts_with("T::Hash[") || r.starts_with("T::Set[")) + { + return Some(r.to_string()); + } + if message == "to_h" && (r.starts_with("T::Hash[") || r.starts_with("T::Array[")) { + return Some(r.to_string()); + } + None + } + + fn runtime_value_domain_type( + &self, + owners: &[String], + elements: &[String], + keys: &[String], + values: &[String], + ) -> Option { + let owner = (owners.len() == 1).then(|| owners[0].as_str())?; + match owner { + "Array" if elements.len() == 1 => Some(self.format_array_type(&elements[0])), + "Hash" if keys.len() == 1 && values.len() == 1 => { + Some(self.format_hash_type(&keys[0], &values[0])) + } + "Set" if elements.len() == 1 => Some(self.format_set_type(&elements[0])), + _ => Some(owner.to_string()), + } + } + + fn runtime_value_type_from_symbol(&self, symbol: &str) -> Option { + runtime_value_identity_from_symbol(symbol, '#') + } + + fn runtime_value_singleton_from_symbol(&self, symbol: &str) -> Option { + runtime_value_identity_from_symbol(symbol, '.') + } + + fn runtime_dispatch_owner_matches(&self, owner: &str, receiver_type: &str) -> bool { + match owner.rsplit("::").next().unwrap_or(owner) { + "Enumerable" => matches!( + receiver_type.rsplit("::").next().unwrap_or(receiver_type), + "Array" | "Hash" | "Set" | "Range" | "Enumerator" + ), + "Kernel" => !matches!( + receiver_type.rsplit("::").next().unwrap_or(receiver_type), + "BasicObject" | "Class" | "Module" + ), + _ => false, + } + } + + fn runtime_value_semantic_target( + &self, + receiver_type: &str, + receiver_singleton: Option<&str>, + message: &str, + environment: &BTreeMap, + ) -> Option { + let version = environment + .get("runtime.version") + .map(String::as_str) + .unwrap_or("workspace"); + let build = |owner: &str, kind: &str, receiver_type: &str| { + let separator = if kind == "class" { "." } else { "#" }; + let symbol = format!( + "nil-kill-runtime ruby ruby {} {}{}{}().", + version, + runtime_descriptor_owner(owner), + separator, + runtime_descriptor_name(message) + ); + let metadata = external_symbol_metadata(&symbol); + (external_symbol_call_complexity(&symbol, message).is_some() + || metadata.parametric_cost.is_some()) + .then_some(NormalizedRuntimeSemanticTarget { + symbol, + owner: owner.to_string(), + kind: kind.to_string(), + receiver_type: receiver_type.to_string(), + }) + }; + + if let Some(singleton) = receiver_singleton { + return build(singleton, "class", receiver_type); + } + build(receiver_type, "instance", receiver_type) + // Bare Ruby calls dispatch through Kernel when the concrete owner + // has no reviewed stdlib contract. Project declarations have + // already been selected before this modeled target is requested. + .or_else(|| build("Kernel", "instance", receiver_type)) + } + + fn runtime_nil_type_name(&self) -> Option<&'static str> { + Some("NilClass") + } + + fn runtime_array_type_name(&self) -> Option<&'static str> { + Some("Array") + } + + fn runtime_hash_type_name(&self) -> Option<&'static str> { + Some("Hash") + } + + fn runtime_set_type_name(&self) -> Option<&'static str> { + Some("Set") + } + + fn runtime_collection_callback_projections( + &self, + receiver_type: Option<&str>, + message: &str, + parameter_count: usize, + ) -> Vec { + let hash = receiver_type.is_some_and(|value| { + value == "Hash" || value.starts_with("T::Hash[") || value.starts_with("Hash[") + }); + if hash && matches!(message, "each" | "each_pair" | "map") { + return if parameter_count > 1 { + vec![RuntimeValueProjection::Key, RuntimeValueProjection::Value] + } else { + vec![RuntimeValueProjection::Entry { + collection_type: "Array", + }] + }; + } + if hash && message == "each_key" { + return vec![RuntimeValueProjection::Key]; + } + if hash && message == "each_value" { + return vec![RuntimeValueProjection::Value]; + } + if message == "each_with_index" { + return vec![ + RuntimeValueProjection::Element, + RuntimeValueProjection::Index { + type_name: "Integer", + }, + ]; + } + (parameter_count > 0) + .then_some(vec![RuntimeValueProjection::Element]) + .unwrap_or_default() + } + + fn callback_argument_parameter_type( + &self, + receiver: &str, + receiver_type: Option<&str>, + message: &str, + position: usize, + parameter_count: usize, + arguments: &[String], + ) -> Option { + let hash_constructor = message == "new" + && position == 0 + && (receiver == "Hash" + || receiver_type.is_some_and(|receiver| { + receiver == "Hash" + || receiver.starts_with("T::Hash[") + || receiver.starts_with("Hash[") + })); + if hash_constructor { + return Some(self.untyped_hash_type()); + } + let argument = match message { + "each_with_object" if position + 1 == parameter_count => arguments.first(), + "inject" | "reduce" if position == 0 => arguments.first(), + _ => None, + }?; + let mut argument = argument.trim(); + while argument.starts_with('(') && argument.ends_with(')') { + argument = argument[1..argument.len() - 1].trim(); + } + self.local_assignment_type_hint(argument) + } + + fn runtime_call_result_projection( + &self, + receiver_type: Option<&str>, + message: &str, + arguments: &[String], + ) -> Option { + let receiver = receiver_type.unwrap_or_default(); + let hash = + receiver == "Hash" || receiver.starts_with("T::Hash[") || receiver.starts_with("Hash["); + let sequence = receiver == "Array" + || receiver == "Set" + || receiver == "Range" + || receiver.starts_with("T::Array[") + || receiver.starts_with("Array[") + || receiver.starts_with("T::Set[") + || receiver.starts_with("Set["); + if hash && matches!(message, "[]" | "fetch") { + return Some(RuntimeCallResultProjection::Value); } - if message == "to_a" - && (r.starts_with("T::Array[") || r.starts_with("T::Hash[") || r.starts_with("T::Set[")) + if sequence && matches!(message, "[]" | "fetch") { + // A range index returns a collection. Preserve uncertainty rather + // than treating source argument text in the shared overlay. + return (!arguments.iter().any(|argument| argument.contains(".."))) + .then_some(RuntimeCallResultProjection::Element); + } + if sequence + && matches!(message, "first" | "last" | "pop" | "shift" | "sample") + && arguments.is_empty() { - return Some(r.to_string()); + return Some(RuntimeCallResultProjection::Element); } - if message == "to_h" && (r.starts_with("T::Hash[") || r.starts_with("T::Array[")) { - return Some(r.to_string()); + if hash && message == "keys" { + return Some(RuntimeCallResultProjection::Keys { + collection_type: "Array", + }); + } + if hash && message == "values" { + return Some(RuntimeCallResultProjection::Values { + collection_type: "Array", + }); + } + if matches!( + message, + "select" + | "reject" + | "filter" + | "compact" + | "uniq" + | "sort" + | "sort_by" + | "reverse" + | "take" + | "drop" + | "merge" + ) { + return Some(RuntimeCallResultProjection::Receiver); } None } @@ -1190,29 +2512,16 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { field != Some("[]") } - fn preserve_constant_receiver_call(&self, call: &NormalizedCallProjection) -> bool { - let base = call - .receiver - .trim_start_matches("::") - .split("::") - .next() - .unwrap_or(""); - (call.receiver == "ENV") - || RUBY_EFFECT_LEXICON - .context_pairs - .iter() - .any(|(name, mids)| *name == base && mids.contains(&call.message.as_str())) - || RUBY_EFFECT_LEXICON.io_consts.contains(&base) - || RUBY_EFFECT_LEXICON - .io_pairs - .iter() - .any(|(name, mids)| *name == base && mids.contains(&call.message.as_str())) - || RUBY_EFFECT_LEXICON - .io_receiver_prefixes - .iter() - .any(|prefix| call.receiver.starts_with(prefix)) - || (call.receiver == "T" && call.message == "type_alias") - || RUBY_CORE_CONSTS.contains(&base) + fn attribute_assignment_dispatches(&self) -> bool { + true + } + + fn preserve_constant_receiver_call(&self, _call: &NormalizedCallProjection) -> bool { + // Ruby has no field/property read syntax after `.`: `Owner.member` + // always dispatches a method, including a zero-argument call without + // parentheses. Provenance or cost may remain unknown, but dropping the + // call would manufacture a false completeness claim. + true } fn branch_state_ref( @@ -1259,6 +2568,51 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { ] } + fn generated_accessor_declarations(&self, call: &CallSite) -> Vec { + if !matches!( + call.receiver.as_str(), + "Struct" | "::Struct" | "Data" | "::Data" + ) || !matches!(call.message.as_str(), "new" | "define") + { + return Vec::new(); + } + let constructor = if call.message == "new" { + "Struct.new" + } else { + "Data.define" + }; + let mutable = matches!(call.receiver.as_str(), "Struct" | "::Struct"); + call.arguments + .iter() + .flat_map(|argument| { + let name = argument + .trim() + .trim_start_matches(':') + .trim_matches(['\'', '"']); + let valid = !name.is_empty() + && name + .chars() + .all(|character| character.is_alphanumeric() || character == '_'); + if !valid { + return Vec::new(); + } + let mut accessors = vec![NormalizedGeneratedAccessor { + name: name.to_string(), + params: Vec::new(), + declaration_source: format!("{constructor}(:{name})"), + }]; + if mutable { + accessors.push(NormalizedGeneratedAccessor { + name: format!("{name}="), + params: vec!["value".to_string()], + declaration_source: format!("{constructor}(:{name})"), + }); + } + accessors + }) + .collect() + } + fn visibility_events_from_calls( &self, calls: &[super::CallSite], @@ -1419,6 +2773,49 @@ impl NormalizedLanguageBehavior for RubyNormalizedBehavior { } } +fn ruby_branch_result(node: &Node) -> Option<&Node> { + if matches!(node.r#type.as_str(), "BLOCK" | "BEGIN" | "EXPRESSION_LIST") { + return node + .children + .iter() + .filter_map(ast::node) + .next_back() + .and_then(ruby_branch_result); + } + Some(node) +} + +/// Ruby has two syntactically distinct array-literal families. Tree-sitter's +/// normalized node kind for `%w[...]` is not stable across parser versions, so +/// the Ruby adapter owns this source-syntax check instead of making shared +/// inference depend on a Ruby node kind. +fn ruby_array_literal(text: &str) -> bool { + text.starts_with('[') || ruby_word_array_literal(text) +} + +fn ruby_word_array_literal(text: &str) -> bool { + text.starts_with("%w[") || text.starts_with("%W[") +} + +fn signature_component(signature: &str, marker: &str) -> Option { + let start = signature.find(marker)?; + let inner = &signature[start + marker.len()..]; + let mut depth = 1u32; + for (index, character) in inner.char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(inner[..index].trim().to_string()); + } + } + _ => {} + } + } + None +} + static RUBY_BEHAVIOR: RubyNormalizedBehavior = RubyNormalizedBehavior; pub(crate) fn behavior() -> &'static dyn NormalizedLanguageBehavior { @@ -1488,8 +2885,14 @@ fn immutable_struct_reader_sets( ) -> BTreeMap> { let mut readers: BTreeMap> = BTreeMap::new(); let mut class_stack = Vec::new(); - let method_ranges: Vec<(usize, usize)> = - functions.iter().map(|f| (f.span[0], f.span[2])).collect(); + // Exclude lambdas: a `factory: -> { [] }` default on a `prop`/`const` line + // is an inline expression, not a method body, and must not mask the + // struct-field line from this line-based reader. + let method_ranges: Vec<(usize, usize)> = functions + .iter() + .filter(|f| f.dispatch_kind != "lambda") + .map(|f| (f.span[0], f.span[2])) + .collect(); for (idx, line) in source.lines().enumerate() { let line_num = idx + 1; if method_ranges @@ -1545,8 +2948,14 @@ fn immutable_struct_reader_types( ) -> BTreeMap> { let mut reader_types: BTreeMap> = BTreeMap::new(); let mut class_stack = Vec::new(); - let method_ranges: Vec<(usize, usize)> = - functions.iter().map(|f| (f.span[0], f.span[2])).collect(); + // Exclude lambdas: a `factory: -> { [] }` default on a `prop`/`const` line + // is an inline expression, not a method body, and must not mask the + // struct-field line from this line-based reader. + let method_ranges: Vec<(usize, usize)> = functions + .iter() + .filter(|f| f.dispatch_kind != "lambda") + .map(|f| (f.span[0], f.span[2])) + .collect(); for (idx, line) in source.lines().enumerate() { let line_num = idx + 1; if method_ranges @@ -1884,10 +3293,168 @@ mod tests { use super::*; use crate::syntax::normalized_behavior::NormalizedLanguageBehavior; + fn normalized_test_node(kind: &str, children: Vec) -> Node { + Node { + r#type: kind.to_string(), + children, + first_lineno: 1, + first_column: 0, + last_lineno: 1, + last_column: 1, + text: kind.to_string(), + } + } + + #[test] + fn conditional_expression_results_are_the_two_branch_values() { + let behavior = RubyNormalizedBehavior; + let condition = normalized_test_node("LVAR", vec![]); + let positive = normalized_test_node("CALL", vec![]); + let ignored = normalized_test_node("LIT", vec![]); + let negative = normalized_test_node( + "BEGIN", + vec![ + ast::Child::Node(Box::new(ignored)), + ast::Child::Node(Box::new(normalized_test_node("CALL", vec![]))), + ], + ); + let conditional = normalized_test_node( + "IF", + vec![ + ast::Child::Node(Box::new(condition)), + ast::Child::Node(Box::new(positive)), + ast::Child::Node(Box::new(negative)), + ], + ); + + let operands = behavior + .value_preserving_call_result_operands(&conditional) + .expect("Ruby IF expressions preserve one branch result"); + assert_eq!( + operands + .iter() + .map(|operand| operand.r#type.as_str()) + .collect::>(), + vec!["CALL", "CALL"] + ); + } + + #[test] + fn attribute_assignment_runtime_anchors_name_the_source_selector() { + let behavior = RubyNormalizedBehavior; + let index_write = Node { + r#type: "ATTRASGN".to_string(), + children: vec![ + ast::Child::Nil, + ast::Child::Symbol("[]=".to_string()), + ast::Child::Nil, + ], + first_lineno: 7, + first_column: 6, + last_lineno: 7, + last_column: 23, + text: "values[0] = value".to_string(), + }; + assert_eq!( + behavior.call_access_span(&index_write, None, [7, 6, 7, 23]), + [7, 12, 7, 13] + ); + + let writer = Node { + r#type: "ATTRASGN".to_string(), + children: vec![ + ast::Child::Nil, + ast::Child::Symbol("payload=".to_string()), + ast::Child::Nil, + ], + first_lineno: 9, + first_column: 4, + last_lineno: 9, + last_column: 31, + text: "record.payload = replacement".to_string(), + }; + assert_eq!( + behavior.call_access_span(&writer, None, [9, 4, 9, 31]), + [9, 11, 9, 18] + ); + let repeated_writer = Node { + r#type: "ATTRASGN".to_string(), + children: vec![ + ast::Child::Node(Box::new(Node { + r#type: "VAR_REF".to_string(), + children: vec![], + first_lineno: 10, + first_column: 4, + last_lineno: 10, + last_column: 10, + text: "target".to_string(), + })), + ast::Child::Symbol("format=".to_string()), + ast::Child::Nil, + ], + first_lineno: 10, + first_column: 4, + last_lineno: 10, + last_column: 48, + text: "target.format = target.format == source.format".to_string(), + }; + assert_eq!( + behavior.call_access_span(&repeated_writer, None, [10, 4, 10, 48]), + [10, 11, 10, 17] + ); + } + + #[test] + fn chained_index_runtime_anchor_names_the_bracket_after_its_receiver() { + let behavior = RubyNormalizedBehavior; + let receiver = Node { + r#type: "FCALL".to_string(), + children: vec![ast::Child::Symbol("Array".to_string()), ast::Child::Nil], + first_lineno: 12, + first_column: 6, + last_lineno: 12, + last_column: 17, + text: "Array(value)".to_string(), + }; + let index = Node { + r#type: "CALL".to_string(), + children: vec![ + ast::Child::Node(Box::new(receiver)), + ast::Child::Symbol("[]".to_string()), + ast::Child::Nil, + ], + first_lineno: 12, + first_column: 6, + last_lineno: 12, + last_column: 24, + text: "Array(value)[index]".to_string(), + }; + assert_eq!( + behavior.call_access_span(&index, None, [12, 6, 12, 24]), + [12, 18, 12, 19] + ); + } + #[test] fn ruby_behavior_edge_cases() { let behavior = RubyNormalizedBehavior; + assert!(behavior.scip_occurrence_matches_call( + "nil-kill-runtime workspace slopcop workspace SlopCop/CoverageData/Dataset#`[]`().", + "[", + "[]" + )); + assert!(behavior.scip_occurrence_matches_call( + "nil-kill-runtime workspace project abc Generated#`payload=`().", + "payload", + "payload=" + )); + assert!(!behavior.scip_occurrence_matches_call( + "nil-kill-runtime workspace project abc Generated#payload().", + "payload", + "payload=" + )); + assert_eq!( behavior.collection_allocation_semantics("map"), CollectionAllocationSemantics::PreservesReceiver @@ -2030,6 +3597,26 @@ mod tests { behavior.block_call_semantics("proc"), BlockCallSemantics::Deferred ); + assert_eq!( + behavior.block_call_semantics("on"), + BlockCallSemantics::Deferred + ); + assert_eq!( + behavior.block_call_semantics("new"), + BlockCallSemantics::Unknown + ); + assert_eq!( + behavior.block_call_semantics("fetch"), + BlockCallSemantics::Unknown + ); + assert_eq!( + behavior.block_call_semantics_with_receiver(Some("Hash"), None, "new"), + BlockCallSemantics::Deferred + ); + assert_eq!( + behavior.block_call_semantics_with_receiver(Some("ENV"), None, "fetch"), + BlockCallSemantics::Once + ); let array = TypeExpr::Array(Box::new(TypeExpr::Primitive("String".into()))); let hash = TypeExpr::Hash { key: Box::new(TypeExpr::Primitive("String".into())), @@ -2043,6 +3630,18 @@ mod tests { "Array##{message}" ); } + assert_eq!( + behavior + .call_complexity(&TypeExpr::Primitive("array".into()), "concat") + .map(|cost| cost.time), + Some("O(N)") + ); + assert_eq!( + behavior + .call_complexity(&TypeExpr::Primitive("Float".into()), "+") + .map(|cost| cost.time), + Some("O(1)") + ); for message in ["[]", "each_value", "keys", "sort"] { assert!( behavior.call_complexity(&hash, message).is_some(), @@ -2261,6 +3860,7 @@ mod tests { visibility: None, params: Vec::new(), callback_params: Vec::new(), + source_export_eligible: true, signature: String::new(), }; let reader_sets = immutable_struct_reader_sets("class Parent; end", &[mock_fn]); @@ -2278,6 +3878,36 @@ mod tests { } } + // A literal class-body ivar is a stable state declaration. Before + // this regression test it was discarded unless wrapped in `T.let`, + // leaving `@cache[key]` without a Hash receiver identity. + let cache_initializer = Node { + r#type: "IASGN".to_string(), + children: vec![ + Child::String("@cache".to_string()), + Child::Node(Box::new(node("HASH", "{}"))), + ], + first_lineno: 12, + first_column: 0, + last_lineno: 12, + last_column: 11, + text: "@cache = {}".to_string(), + }; + let cache_declaration = behavior + .state_declaration_from_node(&cache_initializer, "Demo", false) + .expect("class-body literal ivar declaration"); + assert_eq!(cache_declaration.field, "@cache"); + assert_eq!( + cache_declaration.r#type.as_deref(), + Some("T::Hash[T.untyped, T.untyped]") + ); + assert!( + behavior + .state_declaration_from_node(&cache_initializer, "Demo", true) + .is_none(), + "a method assignment is not a sound field type declaration" + ); + // static_return_type string chars and lines assert_eq!( behavior.static_return_type("chars", Some("String")), @@ -2674,6 +4304,7 @@ mod tests { visibility: None, params: Vec::new(), callback_params: Vec::new(), + source_export_eligible: true, signature: String::new(), }; @@ -2697,6 +4328,8 @@ mod tests { #[test] fn scip_ruby_symbols_use_proven_core_identity() { let length = "scip-ruby gem clear-compiler workspace Array#length()."; + let runtime_length = "nil-kill-runtime ruby ruby 3.2.3 Array#length()."; + let runtime_index = "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`()."; let file = "scip-ruby gem clear-compiler workspace ``#join()."; let generated = "scip-ruby gem clear-compiler workspace AST#BinaryOp#left()."; @@ -2704,15 +4337,170 @@ mod tests { external_symbol_call_complexity(length, "length").map(|complexity| complexity.time), Some("O(1)") ); + assert_eq!( + external_symbol_call_complexity(runtime_length, "length") + .map(|complexity| complexity.time), + Some("O(1)") + ); + assert_eq!( + external_symbol_call_complexity(runtime_index, "[]").map(|complexity| complexity.time), + Some("O(1)") + ); + for symbol in [ + "nil-kill-runtime ruby ruby 3.2.3 Array#`[]`().", + "nil-kill-runtime ruby ruby 3.2.3 Hash#`[]`().", + "nil-kill-runtime ruby ruby 3.2.3 Hash.`[]`().", + "nil-kill-runtime ruby ruby 3.2.3 MatchData#`[]`().", + "nil-kill-runtime ruby ruby 3.2.3 String#`[]`().", + ] { + assert!( + external_symbol_call_complexity(symbol, "[]").is_some(), + "missing runtime core cost for {symbol}" + ); + } + for symbol in [ + "nil-kill-runtime ruby ruby 3.2.3 Integer#to_i().", + "nil-kill-runtime ruby ruby 3.2.3 NilClass#to_i().", + "nil-kill-runtime ruby ruby 3.2.3 String#to_i().", + "nil-kill-runtime ruby ruby 3.2.3 Float#to_f().", + "nil-kill-runtime ruby ruby 3.2.3 Integer#to_f().", + "nil-kill-runtime ruby ruby 3.2.3 NilClass#to_f().", + ] { + assert!( + external_symbol_call_complexity( + symbol, + ruby_descriptor_parts(scip_ruby_descriptor(symbol).unwrap()) + .unwrap() + .1, + ) + .is_some(), + "missing runtime conversion cost for {symbol}" + ); + } + assert_eq!( + external_symbol_call_complexity( + "nil-kill-runtime ruby ruby 3.2.3 Zlib/GzipReader#read().", + "read", + ) + .map(|cost| cost.time), + Some("O(N)") + ); + for (symbol, message) in [ + ( + "nil-kill-runtime ruby ruby 3.2.3 Digest/Class#new().", + "new", + ), + ("nil-kill-runtime ruby ruby 3.2.3 Float#round().", "round"), + ( + "nil-kill-runtime ruby ruby 3.2.3 String#upcase().", + "upcase", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 Open3.capture2e().", + "capture2e", + ), + ] { + assert!( + external_symbol_call_complexity(symbol, message).is_some(), + "missing runtime stdlib cost for {symbol}" + ); + } + assert_eq!( + external_symbol_metadata("nil-kill-runtime ruby ruby 3.2.3 Method#call().") + .parametric_cost + .as_deref(), + Some("callback_once") + ); + assert_eq!( + external_symbol_metadata("nil-kill-runtime ruby ruby 3.2.3 Enumerator#with_index().") + .parametric_cost + .as_deref(), + Some("callback_linear") + ); assert_eq!( external_symbol_call_complexity(file, "join").map(|complexity| complexity.time), Some("O(N)") ); assert_eq!(external_symbol_metadata(length).scope, "stdlib"); + assert_eq!(external_symbol_metadata(runtime_length).scope, "stdlib"); + let unmodelled_runtime_core = + external_symbol_metadata("nil-kill-runtime ruby ruby 3.2.3 Math.exp()."); + assert_eq!(unmodelled_runtime_core.scope, "stdlib"); + assert_eq!( + unmodelled_runtime_core.missing_cost_kind, + "stdlib_cost_model_missing" + ); assert_eq!( external_symbol_metadata(generated).scope, "project_declaration" ); + let dependency = + external_symbol_metadata("nil-kill-runtime rubygems json 2.19.5 JSON.parse()."); + assert_eq!(dependency.scope, "dependency"); + assert_eq!( + dependency.missing_cost_kind, + "dependency_cost_model_missing" + ); + for (symbol, message) in [ + ( + "nil-kill-runtime rubygems json 2.19.5 JSON.parse().", + "parse", + ), + ("nil-kill-runtime ruby json 2.19.5 JSON.parse().", "parse"), + ( + "nil-kill-runtime rubygems json 2.19.5 JSON.generate().", + "generate", + ), + ( + "nil-kill-runtime ruby json 2.19.5 JSON.pretty_generate().", + "pretty_generate", + ), + ( + "nil-kill-runtime rubygems psych 5.4.0 Psych.safe_load().", + "safe_load", + ), + ] { + let cost = external_symbol_call_complexity(symbol, message) + .unwrap_or_else(|| panic!("missing reviewed dependency cost for {symbol}")); + assert_eq!(cost.time, "O(N)"); + assert_eq!(cost.space, "O(N)"); + } + } + + #[test] + fn generated_reader_contracts_match_exact_declared_names() { + let behavior = RubyNormalizedBehavior; + for (source, name) in [ + ("attr_reader :value, :other", "value"), + ("attr_accessor(:value, :other)", "value="), + ("const :value, String", "value"), + ("prop :value, String", "value"), + ("Struct.new(:value, :other)", "value"), + ("Struct.new(:value, :other)", "value="), + ("Data.define(:value, :other)", "other"), + ] { + assert!( + behavior + .generated_callable_complexity(source, name) + .is_some(), + "{source} should generate {name}" + ); + } + for (source, name) in [ + ("attr_reader :other_value", "value"), + ("attr_reader :value", "value="), + ("const :other_value, String", "value"), + ("property :value", "value"), + ("Struct.new(:other_value)", "value"), + ("Data.define(:value)", "value="), + ] { + assert!( + behavior + .generated_callable_complexity(source, name) + .is_none(), + "{source} must not generate {name}" + ); + } } #[test] @@ -2722,5 +4510,176 @@ mod tests { assert_eq!(metadata.parametric_cost.as_deref(), Some("callback_linear")); assert!(external_symbol_call_complexity(each, "each").is_none()); + + let comparable = + external_symbol_metadata("nil-kill-runtime ruby ruby 3.2.3 Comparable#between?()."); + assert_eq!(comparable.scope, "stdlib"); + assert_eq!( + comparable.parametric_cost.as_deref(), + Some("reflective_once") + ); + } + + #[test] + fn cruby_runtime_effect_and_callback_contracts_cover_the_native_surface() { + // NilKill emits core frames under its runtime identity. These calls + // must use the same reviewed contracts as SCIP-Ruby core symbols; + // otherwise the ExternalLatency section is dead data for Ruby. + let read = + external_symbol_call_complexity("nil-kill-runtime ruby ruby 3.2.3 IO#read().", "read") + .expect("IO.read should use the external-latency contract"); + assert_eq!((read.time, read.space), ("O(N+C)", "O(N+S)")); + assert_eq!( + read.bound_quality, + "upper_bound_external_latency_excluded_parametric" + ); + let stringio_read = external_symbol_call_complexity( + "nil-kill-runtime ruby stringio 3.2.0 StringIO#read().", + "read", + ) + .expect("StringIO#read must converge with a runtime IO#read candidate"); + assert_eq!( + (stringio_read.time, stringio_read.space), + (read.time, read.space) + ); + + for (symbol, message) in [ + ( + "nil-kill-runtime ruby ruby 3.2.3 File.realpath().", + "realpath", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 File.executable?().", + "executable?", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 File.absolute_path?().", + "absolute_path?", + ), + ("nil-kill-runtime ruby ruby 3.2.3 Dir.`[]`().", "[]"), + ("nil-kill-runtime ruby ruby 3.2.3 Dir.chdir().", "chdir"), + ] { + let cost = external_symbol_call_complexity(symbol, message) + .unwrap_or_else(|| panic!("missing external contract for {symbol}")); + assert_eq!( + cost.bound_quality, + "upper_bound_external_latency_excluded_parametric" + ); + } + + for (symbol, message, expected) in [ + ( + "nil-kill-runtime ruby bundler 2.7.2 Kernel#gem().", + "gem", + ("O(N)", "O(1)"), + ), + ( + "nil-kill-runtime ruby psych 5.4.0 Psych.load_file().", + "load_file", + ("O(N)", "O(N)"), + ), + ] { + let cost = external_symbol_call_complexity(symbol, message) + .unwrap_or_else(|| panic!("missing external contract for {symbol}")); + assert_eq!((cost.time, cost.space), expected); + assert_eq!(cost.bound_quality, "upper_bound_external_latency_excluded"); + } + + for (symbol, kind) in [ + ( + "nil-kill-runtime ruby ruby 3.2.3 Exception#message().", + "callback_once", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 Enumerable#each_slice().", + "callback_linear", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 Kernel#require().", + "loader_once", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 Math.exp().", + "callback_once", + ), + ( + "nil-kill-runtime ruby ruby 3.2.3 Regexp.escape().", + "coercive_linear_materialize", + ), + ] { + assert_eq!( + external_symbol_metadata(symbol).parametric_cost.as_deref(), + Some(kind), + "missing parametric contract for {symbol}" + ); + } + + let match_p = external_symbol_call_complexity( + "nil-kill-runtime ruby ruby 3.2.3 Regexp#`match?`().", + "match?", + ) + .expect("Regexp#match? should carry the regex-engine worst-case bound"); + assert_eq!((match_p.time, match_p.space), ("O(2^N)", "O(N)")); + } + + #[test] + fn struct_class_generation_has_a_reviewed_linear_fallback() { + let cost = external_symbol_call_complexity( + "nil-kill-runtime ruby ruby 3.2.3 Struct.new().", + "new", + ) + .expect("Struct.new must price its generated member surface"); + + assert_eq!((cost.time, cost.space), ("O(N)", "O(N)")); + assert_eq!(cost.bound_quality, "upper_bound_exact_target"); + } + + #[test] + fn anonymous_runtime_record_contract_is_exact_and_closed() { + for (descriptor, message) in [ + ("`AnonymousStruct(file,line)`#file().", "file"), + ("`AnonymousStruct(file,line)`#`line=`().", "line="), + ("`AnonymousStruct(file,line)`.new().", "new"), + ("`AnonymousData(file,line)`#line().", "line"), + ("`AnonymousData(file,line)`.new().", "new"), + ( + "`GeneratedStruct(WaitLoopCoverage/Loop;tag,file,line)`#file().", + "file", + ), + ( + "`GeneratedStruct(WaitLoopCoverage/Loop;tag,file,line)`#`line=`().", + "line=", + ), + ( + "`GeneratedData(Dependency/Record;file,line)`#line().", + "line", + ), + ] { + let symbol = format!("nil-kill-runtime workspace demo 1 {descriptor}"); + let cost = external_symbol_call_complexity(&symbol, message) + .unwrap_or_else(|| panic!("missing structural record cost for {symbol}")); + assert_eq!((cost.time, cost.space), ("O(1)", "O(1)")); + assert_eq!(cost.provenance, "ruby_generated_record_runtime_contract"); + } + + for (descriptor, message) in [ + ("`AnonymousStruct(file,line)`#missing().", "missing"), + ("`AnonymousData(file,line)`#`line=`().", "line="), + ("`AnonymousStruct(file,bad-field)`#file().", "file"), + ("`AnonymousStruct()`#file().", "file"), + ("`AnonymousRecord(file)`#file().", "file"), + ( + "`GeneratedStruct(WaitLoopCoverage/Loop;tag,file)`#missing().", + "missing", + ), + ("`GeneratedStruct(WaitLoopCoverage/Loop)`#file().", "file"), + ("`GeneratedStruct(;file,line)`#file().", "file"), + ] { + let symbol = format!("nil-kill-runtime workspace demo 1 {descriptor}"); + assert!( + external_symbol_call_complexity(&symbol, message).is_none(), + "malformed or undeclared record operation must stay open: {symbol}" + ); + } } } diff --git a/gems/fact-mine/src/syntax/rust.rs b/gems/fact-mine/src/syntax/rust.rs index 821cbb42e..fc85ea523 100644 --- a/gems/fact-mine/src/syntax/rust.rs +++ b/gems/fact-mine/src/syntax/rust.rs @@ -17,6 +17,11 @@ use crate::ast::{Node, Span}; use crate::type_inference::languages::nominal::{self, NominalTypeSyntax}; use crate::type_inference::TypeExpr; +const RUST_PRIMITIVE_OPERATORS: &[&str] = &[ + "==", "!=", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "&", "|", "^", "<<", ">>", "&&", + "||", "!", +]; + fn scip_rust_parts(symbol: &str) -> Option<(&str, &str)> { let rest = symbol.strip_prefix("rust-analyzer cargo ")?; let mut fields = rest.splitn(3, ' '); @@ -33,6 +38,25 @@ fn rust_semantic_constructor(descriptor: &str, message: &str) -> bool { !message.is_empty() && descriptor.ends_with(&format!("#{message}#")) } +fn rust_semantic_parametric_cost(descriptor: &str) -> Option<&'static str> { + match configured_semantic_symbol_parametric_cost("rust", descriptor).as_deref() { + Some("callback_once") => Some("callback_once"), + Some("callback_linear") => Some("callback_linear"), + Some("reflective_once") => Some("reflective_once"), + Some(_) => None, + None => None, + } + // rust-analyzer emits the selected derived/manual Clone impl as an + // exact crate-local symbol. The target is proven, but cloning the + // fields may be non-constant, so retain its cost as one parametric + // implementation invocation rather than pretending it is O(1). + .or_else(|| { + descriptor + .ends_with("[Clone]clone().") + .then_some("reflective_once") + }) +} + fn rust_descriptor_owner(descriptor: &str) -> Option { if descriptor.contains("][ToString]") { return Some("ToString".to_string()); @@ -111,7 +135,7 @@ pub(crate) fn external_symbol_call_complexity( assumption: None, }); } - if configured_semantic_symbol_parametric_cost("rust", descriptor).is_some() { + if rust_semantic_parametric_cost(descriptor).is_some() { return None; } let exact = configured_semantic_symbol_call_complexity("rust", descriptor); @@ -181,7 +205,7 @@ pub(crate) fn external_symbol_metadata(symbol: &str) -> super::ExternalSymbolMet } }, ), - parametric_cost: configured_semantic_symbol_parametric_cost("rust", descriptor), + parametric_cost: rust_semantic_parametric_cost(descriptor).map(str::to_string), } } @@ -202,6 +226,32 @@ pub(crate) fn parse_declared_type(source: &str) -> TypeExpr { nominal::parse(source, &RUST_NOMINAL_TYPE_SYNTAX) } +fn rust_scalar_primitive(name: &str) -> bool { + let bare = name + .trim() + .trim_start_matches("&mut ") + .trim_start_matches('&') + .trim(); + matches!( + bare, + "i8" | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "f32" + | "f64" + | "bool" + | "char" + ) +} + const RUST_CONTEXT_PAIRS: &[(&str, &[&str])] = &[("SystemTime", &["now"]), ("Instant", &["now"])]; const RUST_EFFECT_LEXICON: EffectLexicon = EffectLexicon { @@ -279,6 +329,54 @@ const RUST_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct RustNormalizedBehavior; impl NormalizedLanguageBehavior for RustNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "rust-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + + fn complexity_uses_invariant_flow_types(&self) -> bool { + true + } + + fn intrinsic_call_complexity( + &self, + receiver: Option<&str>, + message: &str, + ) -> Option { + // Enum-variant constructors (`Some`/`None`/`Ok`/`Err`) wrap a value in + // O(1); `transmute` is a reinterpret cast. These have no analyzable body + // in source-only mode, so without this they read as unresolved calls. + if matches!(message, "Some" | "None" | "Ok" | "Err" | "transmute") + // Rust's `pair.0`/`triple.2` syntax is a tuple-field projection, + // never method dispatch. The normalized call shape retains it so + // DFG can follow the receiver, but its operation cost is O(1). + || (receiver.is_some_and(|receiver| !receiver.is_empty()) + && !message.is_empty() + && message.bytes().all(|byte| byte.is_ascii_digit())) + { + return Some(super::normalized_behavior::NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }); + } + self.stdlib_language().and_then(|language| { + super::normalized_behavior::configured_intrinsic_call_complexity( + language, receiver, message, + ) + }) + } + fn external_symbol_call_complexity( &self, symbol: &str, @@ -299,6 +397,22 @@ impl NormalizedLanguageBehavior for RustNormalizedBehavior { Some("rust") } + fn scalar_operator_complexity( + &self, + message: &str, + operand_type: Option<&TypeExpr>, + ) -> Option { + let operator = message.strip_suffix('@').unwrap_or(message); + if !RUST_PRIMITIVE_OPERATORS.contains(&operator) { + return None; + } + matches!(operand_type, Some(TypeExpr::Primitive(name)) if rust_scalar_primitive(name)) + .then_some(super::normalized_behavior::NormalizedCallComplexity { + time: "O(1)", + space: "O(1)", + }) + } + // CFG-SPECIFIC START: expose the Rust CFG profile. fn cfg_profile(&self) -> &'static ControlFlowProfile { &RUST_CFG_PROFILE @@ -332,6 +446,35 @@ impl NormalizedLanguageBehavior for RustNormalizedBehavior { call } + fn implicit_function_exit_calls( + &self, + node: &Node, + function_name: &str, + params: &[String], + ) -> Vec { + if function_name != "drop" || !params.iter().any(|param| param == "_x") { + return Vec::new(); + } + + // `core::mem::drop(_x: T) {}` has no explicit call expression, but + // Rust drops the owned parameter before returning. Keep the exit call + // unresolved unless a concrete destructor is proven; an empty body is + // therefore not misreported as complete O(1). + let exit = [ + node.last_lineno, + node.last_column, + node.last_lineno, + node.last_column, + ]; + vec![NormalizedCallProjection { + receiver: "_x".to_string(), + message: "drop".to_string(), + arguments: Vec::new(), + access_span: exit, + span: exit, + }] + } + fn property_read_call(&self, node: &Node, parts: &NormalizedCallParts) -> bool { node.r#type != "VCALL" && parts.arguments.is_empty() && !node.text.contains('(') } @@ -672,6 +815,15 @@ mod tests { assert_eq!(projected.receiver, "callback"); assert_eq!(projected.message, "call"); + let drop_node = node("DEFN", "pub const fn drop(_x: T) {}"); + let exit_calls = + behavior.implicit_function_exit_calls(&drop_node, "drop", &["_x".to_string()]); + assert_eq!(exit_calls.len(), 1); + assert_eq!(exit_calls[0].receiver, "_x"); + assert_eq!(exit_calls[0].message, "drop"); + assert!(behavior + .implicit_function_exit_calls(&drop_node, "drop", &["value".to_string()]) + .is_empty()); assert!(behavior.terminating_call_message("panic")); assert!(!behavior.terminating_call_message("recover")); assert_eq!(owner_after_keyword("enum Widget {}", "struct"), None); @@ -779,6 +931,8 @@ mod tests { #[test] fn test_rust_behavior_uncovered_methods() { let behavior = RustNormalizedBehavior; + assert!(behavior.function_has_executable_body(&node("DEFN", "fn size() -> usize { 0 }"))); + assert!(!behavior.function_has_executable_body(&node("DEFN", "fn size() -> usize;"))); assert_eq!(behavior.format_array_type("i32"), "Vec"); assert_eq!( behavior.format_hash_type("String", "i32"), @@ -826,6 +980,19 @@ mod tests { assert!(external_symbol_call_complexity(unknown_dependency, "work").is_none()); } + #[test] + fn rust_tuple_field_projections_are_constant_time_intrinsics() { + let behavior = RustNormalizedBehavior; + let projection = behavior + .intrinsic_call_complexity(Some("value.split_once(':')?"), "0") + .unwrap(); + assert_eq!(projection.time, "O(1)"); + assert_eq!(projection.space, "O(1)"); + assert!(behavior + .intrinsic_call_complexity(Some("value"), "field") + .is_none()); + } + #[test] fn rust_analyzer_term_symbols_prove_constant_enum_construction() { let some = "rust-analyzer cargo core https://github.com/rust-lang/rust/library/core option/Option#Some#"; @@ -841,6 +1008,172 @@ mod tests { assert!(external_symbol_call_complexity(project, "Other").is_none()); } + #[test] + fn reviewed_external_cost_models_resolve_through_every_registry_path() { + let core = "rust-analyzer cargo core https://github.com/rust-lang/rust/library/core "; + let alloc = "rust-analyzer cargo alloc https://github.com/rust-lang/rust/library/alloc "; + let std = "rust-analyzer cargo std https://github.com/rust-lang/rust/library/std "; + + // Owner table: the descriptor's `impl#[Path]` owner selects the Path map. + for (symbol, message, time) in [ + ( + format!("{std}path/impl#[Path]file_stem()."), + "file_stem", + "O(N)", + ), + ( + format!("{std}ffi/os_str/impl#[OsStr]to_str()."), + "to_str", + "O(N)", + ), + (format!("{core}str/impl#[str]parse()."), "parse", "O(N)"), + (format!("{core}str/impl#[str]splitn()."), "splitn", "O(1)"), + ( + format!("{std}collections/hash/set/impl#[`HashSet`]new()."), + "new", + "O(1)", + ), + ( + format!("{alloc}vec/impl#[`Vec`]as_slice()."), + "as_slice", + "O(1)", + ), + ( + format!("{alloc}collections/btree/set/impl#[`BTreeSet`][`From<[T; N]>`]from()."), + "from", + "O(N log N)", + ), + ( + format!("{alloc}collections/btree/map/impl#[`BTreeMap`]get_mut()."), + "get_mut", + "O(log N)", + ), + ( + format!("{core}slice/impl#[`[T]`]iter_mut()."), + "iter_mut", + "O(1)", + ), + ( + format!("{alloc}collections/btree/map/impl#[`BTreeMap`]values_mut()."), + "values_mut", + "O(1)", + ), + ( + format!("{alloc}collections/btree/set/impl#[`BTreeSet`]is_subset()."), + "is_subset", + "O(N log N)", + ), + ( + format!( + "{core}convert/num/ptr_try_from_impls/impl#[usize][`TryFrom`]try_from()." + ), + "try_from", + "O(1)", + ), + (format!("{core}iter/sources/once/once()."), "once", "O(1)"), + ( + format!("{std}thread/builder/impl#[Builder]name()."), + "name", + "O(1)", + ), + ] { + let complexity = external_symbol_call_complexity(&symbol, message) + .unwrap_or_else(|| panic!("no cost model for {symbol}")); + assert_eq!(complexity.time, time, "{symbol}"); + assert_eq!(complexity.provenance, "rust_stdlib_registry", "{symbol}"); + } + + // Exact descriptors: both map families name their entry type `Entry`, so + // the owner table cannot separate the tree cost from the table cost. + let btree_entry = + format!("{alloc}collections/btree/map/entry/impl#[`Entry<'a, K, V, A>`]or_default()."); + let hash_entry = format!("{std}collections/hash/map/impl#[`Entry<'a, K, V>`]or_default()."); + assert_eq!( + external_symbol_call_complexity(&btree_entry, "or_default").map(|c| c.time), + Some("O(log N)") + ); + assert_eq!( + external_symbol_call_complexity(&hash_entry, "or_default").map(|c| c.time), + Some("O(1)") + ); + + // A dependency crate has no owner-table fallback: it is priced only by an + // exact reviewed descriptor. + let utf8_text = "rust-analyzer cargo tree-sitter 0.25.8 impl#[`Node<'tree>`]utf8_text()."; + let node_id = "rust-analyzer cargo tree-sitter 0.25.8 impl#[`Node<'tree>`]id()."; + assert_eq!( + external_symbol_call_complexity(utf8_text, "utf8_text").map(|c| c.time), + Some("O(N)") + ); + assert_eq!( + external_symbol_call_complexity(node_id, "id").map(|c| c.provenance), + Some("rust_dependency_registry") + ); + + // Blanket trait dispatch and closure-running APIs must stay parametric: + // the symbol proves identity but never names the selected impl. + for (symbol, cost) in [ + ( + format!("{core}convert/impl#[T][`Into`]into()."), + "reflective_once", + ), + (format!("{core}clone/Clone#clone()."), "reflective_once"), + (format!("{std}path/impl#[Path]new()."), "reflective_once"), + ( + format!("{std}sync/once_lock/impl#[`OnceLock`]get_or_init()."), + "callback_once", + ), + ( + format!("{core}iter/traits/iterator/Iterator#fold()."), + "callback_linear", + ), + ( + format!("{core}iter/traits/iterator/Iterator#partition()."), + "callback_linear", + ), + ( + format!("{core}iter/adapters/map/impl#[`Map`][Iterator]next()."), + "callback_once", + ), + (format!("{core}mem/drop()."), "reflective_once"), + ] { + assert_eq!( + external_symbol_metadata(&symbol).parametric_cost.as_deref(), + Some(cost), + "{symbol}" + ); + assert!( + external_symbol_call_complexity(&symbol, "into").is_none(), + "{symbol} must not carry a closed bound" + ); + } + let project_clone = + "rust-analyzer cargo fact-mine-rust 0.1.0 type_inference/impl#[TypeExpr][Clone]clone()."; + assert_eq!( + external_symbol_metadata(project_clone) + .parametric_cost + .as_deref(), + Some("reflective_once") + ); + assert!(external_symbol_call_complexity(project_clone, "clone").is_none()); + + // Filesystem entry points keep their excluded-latency assumption. + let read = format!("{std}fs/read()."); + let complexity = external_symbol_call_complexity(&read, "read").unwrap(); + assert_eq!( + complexity.bound_quality, + "upper_bound_external_latency_excluded" + ); + assert!(complexity.assumption.is_some()); + let create = format!("{std}fs/impl#[File]create()."); + let complexity = external_symbol_call_complexity(&create, "create").unwrap(); + assert_eq!( + complexity.bound_quality, + "upper_bound_external_latency_excluded" + ); + assert!(complexity.assumption.is_some()); + } + #[test] fn rust_callback_and_iterator_contracts_remain_parametric() { let collect = "rust-analyzer cargo core https://github.com/rust-lang/rust/library/core iter/traits/iterator/Iterator#collect()."; diff --git a/gems/fact-mine/src/syntax/swift.rs b/gems/fact-mine/src/syntax/swift.rs index 5ea8bd370..595b1627a 100644 --- a/gems/fact-mine/src/syntax/swift.rs +++ b/gems/fact-mine/src/syntax/swift.rs @@ -180,6 +180,22 @@ const SWIFT_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { struct SwiftNormalizedBehavior; impl NormalizedLanguageBehavior for SwiftNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn profile_type_system(&self) -> &'static str { + "swift-types" + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + fn external_symbol_call_complexity( &self, symbol: &str, diff --git a/gems/fact-mine/src/syntax/tree_sitter_adapter.rs b/gems/fact-mine/src/syntax/tree_sitter_adapter.rs index 58251a697..cc4d8d441 100644 --- a/gems/fact-mine/src/syntax/tree_sitter_adapter.rs +++ b/gems/fact-mine/src/syntax/tree_sitter_adapter.rs @@ -5,6 +5,7 @@ use super::{ use crate::ast::normalize_tree_with_call_origins; use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -19,7 +20,7 @@ pub(crate) fn parse_file_for_report(file: PathBuf, language: Language) -> Result } fn parse_file_with_options(file: PathBuf, language: Language) -> Result { - let profile = rust_profile_enabled(); + let profile = parser_profile_enabled(); let total_started = Instant::now(); let file_label = file.to_string_lossy().to_string(); let parsed_started = Instant::now(); @@ -53,19 +54,23 @@ fn parse_normalized_file( crate::ast::declaration_namespaces(parsed.tree.root_node(), &parsed.source, language); let preprocessor_callables = crate::ast::preprocessor_callable_names(parsed.tree.root_node(), &parsed.source, language); - if language == Language::Go && !namespace.is_empty() { - let directory = parsed - .file - .parent() - .unwrap_or_else(|| std::path::Path::new(".")) - .to_string_lossy(); - namespace = format!("{directory}::{namespace}"); - } - if language == Language::Python { - namespace = python_module_namespace(&parsed.file); - for (_, target) in &mut explicit_imports { - *target = canonical_python_import(&parsed.file, &namespace, target); - } + let preprocessor_definitions = crate::ast::preprocessor_callable_definitions( + parsed.tree.root_node(), + &parsed.source, + language, + ) + .into_iter() + .fold( + BTreeMap::>::new(), + |mut definitions, (name, definition)| { + definitions.entry(name).or_default().insert(definition); + definitions + }, + ); + let behavior = normalized_behavior::behavior(language); + namespace = behavior.canonical_project_namespace(&parsed.file, &namespace); + for (_, target) in &mut explicit_imports { + *target = behavior.canonical_project_import(&parsed.file, &namespace, target); } profile_parse_phase(profile, file_label, "normalized_root", started.elapsed()); @@ -74,23 +79,16 @@ fn parse_normalized_file( .lines() .map(ToString::to_string) .collect::>(); - let behavior = normalized_behavior::behavior(language); - let started = Instant::now(); let mut facts = passes::StatelessSyntaxPass::normalized(&parsed.file, &lines, &normalized_root, behavior) .run(); - if language == Language::Go { - // Go function literals can normalize to a synthetic wrapper with the - // span of `return func`, rather than the comma-ok declaration. Keep - // source ownership in the Go adapter, which has the unmodified parser - // tree and can provide an exact node span without text recovery. - super::go::attach_raw_presence_correlation_spans( - parsed.tree.root_node(), - &parsed.source, - &mut facts.presence_correlation_seeds, - ); - } + crate::ast::reconcile_presence_correlation_spans( + parsed.tree.root_node(), + &parsed.source, + language, + &mut facts.presence_correlation_seeds, + ); let normalization_call_origins = parser_call_origins .into_iter() .map( @@ -143,15 +141,13 @@ fn parse_normalized_file( parse_recovery_spans, raw_call_sites, symbol_scope: SymbolScope { - canonical: matches!( - language, - Language::Java | Language::Go | Language::CSharp | Language::Cpp | Language::Python - ), + canonical: behavior.canonical_symbol_scope(), unqualified_types_use_current_namespace: crate::ast::unqualified_types_use_current_namespace(language), namespace, explicit_imports: explicit_imports.into_iter().collect(), preprocessor_callables: preprocessor_callables.into_iter().collect(), + preprocessor_definitions, declaration_namespaces: declaration_namespaces.into_iter().collect(), }, function_defs: facts.function_defs, @@ -159,7 +155,9 @@ fn parse_normalized_file( call_sites: facts.call_sites, normalization_call_origins, call_raw_origin_projections, + call_selector_projections: facts.call_selector_projections, call_receiver_projections: facts.call_receiver_projections, + call_execution_projections: facts.call_execution_projections, state_declarations: facts.state_declarations, state_reads: facts.state_reads, state_writes: facts.state_writes, @@ -185,6 +183,7 @@ fn parse_normalized_file( def_use: metadata.control_flow.def_use, liveness: metadata.control_flow.liveness, flow_types: metadata.control_flow.flow_types, + callback_bindings: metadata.control_flow.callback_bindings, protocol_method_effects: metadata.protocol_method_effects, protocol_call_paths: metadata.protocol_call_paths, clone_candidates: metadata.clone_candidates, @@ -200,6 +199,7 @@ fn parse_normalized_file( type_alias_lines: metadata.syntax.type_alias_lines, method_param_types: metadata.syntax.method_param_types, method_local_types: metadata.syntax.method_local_types, + method_template_types: metadata.syntax.method_template_types, state_param_origins: Vec::new(), hazard_sites: facts.hazard_sites, imports: Vec::new(), @@ -249,55 +249,7 @@ fn parse_recovery_spans(root: tree_sitter::Node<'_>) -> Vec<[usize; 4]> { spans } -fn python_module_namespace(file: &std::path::Path) -> String { - let mut package = Vec::new(); - let mut directory = file.parent(); - while let Some(current) = directory { - if !current.join("__init__.py").is_file() { - break; - } - let Some(name) = current.file_name().and_then(|name| name.to_str()) else { - break; - }; - package.push(name.to_string()); - directory = current.parent(); - } - package.reverse(); - let stem = file - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or_default(); - if stem != "__init__" && !stem.is_empty() { - package.push(stem.to_string()); - } - package.join(".") -} - -fn canonical_python_import(file: &std::path::Path, namespace: &str, target: &str) -> String { - let dots = target - .chars() - .take_while(|character| *character == '.') - .count(); - if dots == 0 { - return target.to_string(); - } - let mut package = namespace.split('.').map(str::to_string).collect::>(); - if file.file_stem().and_then(|stem| stem.to_str()) != Some("__init__") { - package.pop(); - } - for _ in 1..dots { - package.pop(); - } - package.extend( - target[dots..] - .split('.') - .filter(|part| !part.is_empty()) - .map(str::to_string), - ); - package.join(".") -} - -fn rust_profile_enabled() -> bool { +fn parser_profile_enabled() -> bool { std::env::var_os("DECOMPLEX_RUST_PROFILE").is_some() } diff --git a/gems/fact-mine/src/syntax/typescript.rs b/gems/fact-mine/src/syntax/typescript.rs index 4b1c6d862..1be6df3c7 100644 --- a/gems/fact-mine/src/syntax/typescript.rs +++ b/gems/fact-mine/src/syntax/typescript.rs @@ -30,7 +30,148 @@ const TYPESCRIPT_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct TypeScriptNormalizedBehavior; +fn strip_module_extension(path: &str) -> &str { + for extension in [".d.ts", ".tsx", ".ts", ".jsx", ".mjs", ".cjs", ".js"] { + if let Some(stem) = path.strip_suffix(extension) { + return stem; + } + } + path +} + +fn normalize_module_path(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + if matches!(out.last(), Some(&last) if last != "..") { + out.pop(); + } else if !path.starts_with('/') { + out.push(".."); + } + } + other => out.push(other), + } + } + let joined = out.join("/"); + if path.starts_with('/') { + format!("/{joined}") + } else { + joined + } +} + +fn module_namespace(file: &std::path::Path) -> String { + strip_module_extension(&normalize_module_path(&file.to_string_lossy())).to_string() +} + +fn canonical_import(file: &std::path::Path, target: &str) -> String { + let (module, name) = match target.split_once('\u{0}') { + Some((module, name)) => (module, Some(name)), + None => (target, None), + }; + let resolved = if module.starts_with("./") || module.starts_with("../") { + let directory = file + .parent() + .map(|directory| directory.to_string_lossy().into_owned()) + .unwrap_or_default(); + strip_module_extension(&normalize_module_path(&format!("{directory}/{module}"))).to_string() + } else { + module.to_string() + }; + match name { + Some(name) => format!("{resolved}.{name}"), + None => resolved, + } +} + +pub(crate) fn parse_profile_signature( + signature: &str, +) -> super::normalized_behavior::NormalizedSignature { + let signature = signature.trim(); + let (Some(open), Some(close)) = (signature.find('('), signature.rfind(')')) else { + return super::normalized_behavior::NormalizedSignature::default(); + }; + let return_type = signature[close + 1..] + .trim() + .strip_prefix(':') + .map(|declared| { + declared + .trim() + .trim_end_matches(';') + .trim_end_matches('{') + .trim() + .to_string() + }); + let params = signature[open + 1..close] + .split(',') + .filter_map(|entry| { + let entry = entry.trim().trim_start_matches("..."); + if entry.is_empty() { + return None; + } + let (name, declared) = entry.split_once(':')?; + let declared = declared.trim(); + (!declared.is_empty()).then(|| { + ( + name.trim().trim_end_matches('?').to_string(), + declared.to_string(), + ) + }) + }) + .collect(); + super::normalized_behavior::NormalizedSignature { + return_type, + params, + } +} + impl NormalizedLanguageBehavior for TypeScriptNormalizedBehavior { + fn function_has_executable_body(&self, node: &Node) -> bool { + node.text.trim_end().ends_with('}') + } + + fn parse_signature(&self, signature: &str) -> super::normalized_behavior::NormalizedSignature { + parse_profile_signature(signature) + } + + fn source_profile_signature( + &self, + lines: &[String], + function: &super::FunctionDef, + ) -> Option { + lines + .get(function.line.saturating_sub(1)) + .map(|line| line.trim().to_string()) + .or_else(|| Some(String::new())) + } + + fn profile_type_system(&self) -> &'static str { + "typescript" + } + + fn native_profile_literal_type(&self, value: &str) -> Option { + super::javascript::behavior().native_profile_literal_type(value) + } + + fn canonical_symbol_scope(&self) -> bool { + true + } + + fn canonical_project_namespace(&self, file: &std::path::Path, _namespace: &str) -> String { + module_namespace(file) + } + + fn canonical_project_import( + &self, + file: &std::path::Path, + _namespace: &str, + target: &str, + ) -> String { + canonical_import(file, target) + } + fn nullable_operation(&self, node: &Node) -> Option { (node.r#type == "CALL") .then(|| node.children.first().and_then(crate::ast::node)) @@ -496,6 +637,32 @@ impl NormalizedLanguageBehavior for TypeScriptNormalizedBehavior { } } +#[cfg(test)] +mod module_tests { + use super::*; + use std::path::Path; + + #[test] + fn relative_import_resolves_to_the_target_module_namespace() { + let helper = Path::new("/proj/src/util/helper.ts"); + let main = Path::new("/proj/src/app/main.ts"); + assert_eq!(module_namespace(helper), "/proj/src/util/helper"); + assert_eq!( + canonical_import(main, "../util/helper\u{0}simple"), + "/proj/src/util/helper.simple" + ); + assert_eq!( + canonical_import(Path::new("/proj/a.ts"), "./b\u{0}f"), + "/proj/b.f" + ); + assert_eq!( + canonical_import(main, "../util/helper"), + "/proj/src/util/helper" + ); + assert_eq!(canonical_import(main, "lodash\u{0}map"), "lodash.map"); + } +} + static BEHAVIOR: TypeScriptNormalizedBehavior = TypeScriptNormalizedBehavior; pub(crate) fn behavior() -> &'static dyn NormalizedLanguageBehavior { diff --git a/gems/fact-mine/src/syntax/zig.rs b/gems/fact-mine/src/syntax/zig.rs index 3b4521e22..754a403d5 100644 --- a/gems/fact-mine/src/syntax/zig.rs +++ b/gems/fact-mine/src/syntax/zig.rs @@ -98,6 +98,14 @@ const ZIG_CFG_PROFILE: ControlFlowProfile = ControlFlowProfile { pub(crate) struct ZigNormalizedBehavior; impl NormalizedLanguageBehavior for ZigNormalizedBehavior { + fn uses_source_declaration_header(&self) -> bool { + true + } + + fn state_writes_require_declared_owner(&self) -> bool { + true + } + fn declared_local_type(&self, source: &str, name: &str) -> Option { super::normalized_behavior::type_after_local_colon(source, name) } diff --git a/gems/fact-mine/src/syntax_oracle.rs b/gems/fact-mine/src/syntax_oracle.rs index 733aee818..7bcbab880 100644 --- a/gems/fact-mine/src/syntax_oracle.rs +++ b/gems/fact-mine/src/syntax_oracle.rs @@ -1,3 +1,4 @@ +use crate::parallel; use crate::syntax::{self, Document, Language}; use anyhow::Result; use serde_json::{json, Value}; @@ -8,15 +9,41 @@ pub const FORMAT: &str = "decomplex.syntax-facts.v1"; pub const CFG_SCHEMA: &str = "fact-mine.cfg.v1"; pub fn project_files(files: &[PathBuf], language: Language) -> Result { + project_selected_files(files, language, None) +} + +/// `fields`, when given, keeps only those top-level document keys. +/// +/// A full projection of this repository is ~1.08 GB of JSON, and a consumer +/// like the architecture reports reads five keys of it - 8% - discarding the +/// dataflow bulk (`clone_candidates` alone is a third). Emitting all of it +/// costs serialization here and a matching `JSON.parse` in the caller, which +/// together were most of that stage's runtime. Selection happens after the +/// document is built, so the facts are identical to a full projection's; +/// only what crosses the pipe shrinks. +pub fn project_selected_files( + files: &[PathBuf], + language: Language, + fields: Option<&BTreeSet>, +) -> Result { let documents = syntax::parse_files(files, language)?; let metadata = SyntaxFactMetadata::from_documents(&documents); + // Projection, not parsing, is the bulk of the work here: it derives the + // per-function dataflow (liveness, dominators, reaching definitions, + // def-use, path conditions). Parsing was already parallel, so leaving this + // serial capped a whole-corpus run at ~1.5 cores no matter how many were + // available. `map_ordered` keeps document order, so output stays byte-identical. + let projected = parallel::map_ordered(&documents, |document| { + let mut value = project_document_with_metadata(document, &metadata); + if let (Some(fields), Some(object)) = (fields, value.as_object_mut()) { + object.retain(|key, _| fields.contains(key)); + } + Ok(value) + })?; Ok(json!({ "format": FORMAT, "cfg_schema": CFG_SCHEMA, - "documents": documents - .iter() - .map(|document| project_document_with_metadata(document, &metadata)) - .collect::>(), + "documents": projected, })) } @@ -487,7 +514,9 @@ mod tests { call_sites: Vec::new(), normalization_call_origins: Vec::new(), call_raw_origin_projections: Vec::new(), + call_selector_projections: Vec::new(), call_receiver_projections: Vec::new(), + call_execution_projections: Vec::new(), state_declarations: Vec::new(), state_reads: Vec::new(), state_writes: Vec::new(), @@ -513,6 +542,7 @@ mod tests { def_use: Vec::new(), liveness: Vec::new(), flow_types: Vec::new(), + callback_bindings: Vec::new(), protocol_method_effects: Vec::new(), protocol_call_paths: Vec::new(), clone_candidates: Vec::new(), @@ -528,6 +558,7 @@ mod tests { type_alias_lines: BTreeMap::new(), method_param_types: BTreeMap::new(), method_local_types: BTreeMap::new(), + method_template_types: BTreeMap::new(), state_param_origins: Vec::new(), hazard_sites: Vec::new(), imports: Vec::new(), @@ -551,7 +582,9 @@ mod tests { call_sites: Vec::new(), normalization_call_origins: Vec::new(), call_raw_origin_projections: Vec::new(), + call_selector_projections: Vec::new(), call_receiver_projections: Vec::new(), + call_execution_projections: Vec::new(), state_declarations: Vec::new(), state_reads: Vec::new(), state_writes: Vec::new(), @@ -577,6 +610,7 @@ mod tests { def_use: Vec::new(), liveness: Vec::new(), flow_types: Vec::new(), + callback_bindings: Vec::new(), protocol_method_effects: Vec::new(), protocol_call_paths: Vec::new(), clone_candidates: Vec::new(), @@ -615,6 +649,7 @@ mod tests { .into_iter() .collect(), method_local_types: BTreeMap::new(), + method_template_types: BTreeMap::new(), state_param_origins: Vec::new(), hazard_sites: Vec::new(), imports: Vec::new(), diff --git a/gems/fact-mine/src/trace_document.rs b/gems/fact-mine/src/trace_document.rs new file mode 100644 index 000000000..924c2b87b --- /dev/null +++ b/gems/fact-mine/src/trace_document.rs @@ -0,0 +1,995 @@ +//! The single artifact a trace run produces. +//! +//! Everything in it is an observation: what ran, what values were seen, where. +//! Nothing in it is a decision about which planned anchor an observation +//! satisfies -- that join is the consumer's, and doing it in two places is how +//! two implementations drift apart. +//! +//! Minting a protocol value from an observed one needs the language's own +//! type-symbol rules, so values travel already encoded and the consumer only +//! decides which anchor each belongs to. + +use anyhow::{Context, Result}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::Path; + +pub const VERSION: i64 = 1; +pub const PRODUCER: &str = "nil-kill"; +pub const PRODUCER_VERSION: &str = "1"; +const UNTYPED: &str = "T.untyped"; + +/// What the runtime was when it observed something. The orchestrator has to +/// repeat these claims from outside the traced program and a trace whose claims +/// disagree is rejected at merge, so they come from the VM that observed. +pub fn environment_claims(runtime: &Runtime, root: &Path) -> Vec<(String, String)> { + let mut claims = vec![ + ("runtime.language".to_string(), "ruby".to_string()), + ("runtime.version".to_string(), runtime.version.clone()), + ("runtime.engine".to_string(), runtime.engine.clone()), + ("runtime.engine_version".to_string(), runtime.engine_version.clone()), + ]; + let lockfile = root.join("Gemfile.lock"); + if let Ok(bytes) = std::fs::read(&lockfile) { + use sha2::{Digest, Sha256}; + claims.push(( + "runtime.lockfile.Gemfile.lock.sha256".to_string(), + format!("sha256:{:x}", Sha256::digest(&bytes)), + )); + } + // A claim with no value is not a claim. A shard assembled by hand may + // carry no collector document to have asked. + claims.retain(|(_, value)| !value.is_empty()); + claims +} + +/// The interpreter that did the observing, as it reported itself. +#[derive(Debug, Clone, Default)] +pub struct Runtime { + pub version: String, + pub engine: String, + pub engine_version: String, +} + +pub fn provenance(run_id: &str) -> Value { + json!({"provider": "ruby-tracepoint", "provider_version": "1", "run_id": run_id}) +} + +// ------------------------------------------------------------ value encoding + +/// A SCIP symbol word is never empty; `.` is the placeholder when a version is +/// unknown, which is what a shard assembled without a collector document has. +fn symbol_word(value: &str) -> &str { + if value.is_empty() { + "." + } else { + value + } +} + +fn type_symbol(runtime: &Runtime, name: &str) -> String { + format!( + "nil-kill-runtime ruby ruby {} {}#", + symbol_word(&runtime.version), + descriptor_owner(name) + ) +} + +fn singleton_symbol(runtime: &Runtime, name: &str) -> String { + format!( + "nil-kill-runtime ruby ruby {} {}.", + symbol_word(&runtime.version), + descriptor_owner(name) + ) +} + +/// A SCIP descriptor for a type name: `A::B` becomes `A/B`, empty segments +/// dropped, and a segment needing escaping backtick-quoted. +fn descriptor_owner(value: &str) -> String { + value + .split("::") + .filter(|part| !part.is_empty()) + .map(descriptor_name) + .collect::>() + .join("/") +} + +/// SCIP's canonical descriptor escaping exactly: question marks, bangs, +/// equality signs, slashes and most Ruby operators must be backtick-escaped; +/// only ASCII alphanumerics and these four punctuation characters are bare. +fn descriptor_name(value: &str) -> String { + let simple = !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-' || c == '$'); + if simple { + value.to_string() + } else { + format!("`{}`", value.replace('`', "``")) + } +} + +pub fn normalize_source_role(role: &str) -> &'static str { + match role.to_ascii_uppercase().as_str() { + "NONPRODUCTION" | "NON_PRODUCTION" => "NON_PRODUCTION", + "STDLIB" | "STANDARD_LIBRARY" => "STANDARD_LIBRARY", + "PRODUCTION" => "PRODUCTION", + "DEPENDENCY" => "DEPENDENCY", + "RUNTIME" => "RUNTIME", + _ => "UNKNOWN_SOURCE", + } +} + +fn simple_value(runtime: &Runtime, name: &str, source_role: &str) -> Value { + json!({ + "type_symbol": type_symbol(runtime, name), + "source_role": normalize_source_role(source_role), + }) +} + +/// The whole point of a shape: an alternative that is itself structured says +/// what it contains, so a declared type can name `Array` rather than +/// `Array`. +fn wire_shape(shape: &Value, runtime: &Runtime, source_role: &str) -> Option { + let kind = shape["kind"].as_str().unwrap_or_default(); + let children = |field: &str| -> Vec { + shape[field].as_array().cloned().unwrap_or_default() + }; + match kind { + "array" | "set" => { + let values = child_value_set(&children("elements"), runtime, source_role)?; + Some(json!({"sequence": {"elements": values}})) + } + "hash" => { + let keys = children("keys"); + let values = children("values"); + let mut entries = Vec::new(); + for key in &keys { + for child in &values { + let (Some(key), Some(child)) = ( + value_from_shape(key, runtime, source_role), + value_from_shape(child, runtime, source_role), + ) else { + continue; + }; + entries.push(json!({"key": key, "value": child, "count": 1})); + } + } + (!entries.is_empty()).then(|| json!({"mapping": {"entries": entries}})) + } + "record" => { + let mut members = shape["members"] + .as_object() + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + members.sort_by(|(left, _), (right, _)| left.cmp(right)); + let members = members + .into_iter() + .filter_map(|(name, child)| { + let values = child_value_set(&[child], runtime, source_role)?; + Some(json!({"name": name, "values": values})) + }) + .collect::>(); + Some(json!({"record": {"members": members}})) + } + "tuple" => { + let elements = children("elements") + .iter() + .filter_map(|child| child_value_set(&[child.clone()], runtime, source_role)) + .collect::>(); + Some(json!({"tuple": {"elements": elements}})) + } + _ => None, + } +} + +fn child_value_set(values: &[Value], runtime: &Runtime, source_role: &str) -> Option { + let mut alternatives: Vec = Vec::new(); + for value in values { + let Some(encoded) = value_from_shape(value, runtime, source_role) else { continue }; + let alternative = json!({"value": encoded, "count": 1}); + if !alternatives.contains(&alternative) { + alternatives.push(alternative); + } + } + (!alternatives.is_empty()).then(|| json!({"alternatives": alternatives})) +} + +fn value_from_shape(value: &Value, runtime: &Runtime, source_role: &str) -> Option { + let Some(shape) = value.as_object() else { + return Some(simple_value(runtime, value.as_str()?, source_role)); + }; + let kind = shape.get("kind").and_then(Value::as_str).unwrap_or_default(); + let name = shape.get("name").and_then(Value::as_str).unwrap_or_default(); + let type_name = if !name.is_empty() { + name + } else { + match kind { + "array" | "tuple" => "Array", + "set" => "Set", + "hash" => "Hash", + _ => return None, + } + }; + let mut encoded = simple_value(runtime, type_name, source_role); + if let Some(Value::Object(nested)) = wire_shape(value, runtime, source_role) { + if let Some(object) = encoded.as_object_mut() { + object.extend(nested); + } + } + Some(encoded) +} + +/// The alternatives a domain names. Support sets, not frequencies: one +/// alternative is an exact positive witness and the bucket's count carries the +/// observed execution count. +pub fn value_set( + domain: &Value, + runtime: &Runtime, + source_role: &str, +) -> Option { + let mut types = domain["types"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + types.sort(); + types.dedup(); + if types.is_empty() { + return None; + } + let roles = domain["source_roles"].as_object().cloned().unwrap_or_default(); + let singletons = domain["singletons"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|name| !name.is_empty()) + .collect::>(); + + let alternatives = types + .iter() + .map(|name| { + let role = roles + .get(name) + .and_then(Value::as_str) + .unwrap_or(source_role); + let mut value = simple_value(runtime, name, role); + if singletons.len() == 1 { + value["singleton_symbol"] = json!(singleton_symbol(runtime, singletons[0])); + } + if let Some(shape) = shape_for(domain, name) { + if let Some(Value::Object(nested)) = wire_shape(&shape, runtime, role) { + if let Some(object) = value.as_object_mut() { + object.extend(nested); + } + } + } + json!({"value": value, "count": 1}) + }) + .collect::>(); + Some(json!({"alternatives": alternatives})) +} + +/// The shape describing this alternative: the one that names it, else the +/// first, with the domain's own element and key classes filled in where the +/// shape does not carry them. +fn shape_for(domain: &Value, name: &str) -> Option { + let shapes = domain["shapes"].as_array()?; + let mut shape = shapes + .iter() + .find(|shape| shape["name"].as_str() == Some(name)) + .or_else(|| shapes.first()) + .cloned() + .unwrap_or_else(|| json!({})); + let object = shape.as_object_mut()?; + for field in ["elements", "keys", "values"] { + if !object.contains_key(field) { + if let Some(values) = domain.get(field) { + object.insert(field.to_string(), values.clone()); + } + } + } + Some(shape) +} + +pub fn target(row: &Value) -> Option { + let target = row.get("target")?; + Some(json!({ + "symbol": target["symbol"], + "source_role": normalize_source_role(target["source_role"].as_str().unwrap_or_default()), + "package_manager": target["package_manager"], + "package_name": target["package_name"], + "package_version": target["package_version"], + })) +} + +// -------------------------------------------------------------- observations + +/// A value the collector saw in a named slot, and where that slot was. +fn observation( + kind: &str, + scope: Value, + slot: &str, + domain: Value, + count: i64, + slot_kind: &str, +) -> Value { + json!({ + "kind": kind, "scope": scope, "slot": slot, + "slot_kind": slot_kind, "domain": domain, "count": count, + }) +} + +fn strings(values: Option<&Value>) -> Vec { + let mut out = values + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string) + .collect::>(); + out.sort(); + out.dedup(); + out +} + +fn normalize_shape(shape: &Value) -> Option { + if let Some(name) = shape.as_str() { + return Some(json!({"kind": "class", "name": name})); + } + let object = shape.as_object()?; + let kind = object.get("kind").and_then(Value::as_str).unwrap_or_default(); + if kind.is_empty() { + return Some(json!({"kind": "unknown"})); + } + let mut out = Map::new(); + out.insert("kind".to_string(), json!(kind)); + if let Some(name) = object.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) { + out.insert("name".to_string(), json!(name)); + } + for field in ["elements", "keys", "values"] { + let children = object + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(normalize_shape) + .collect::>(); + if !children.is_empty() { + out.insert(field.to_string(), json!(children)); + } + } + let members = object + .get("members") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(name, child)| normalize_shape(child).map(|child| (name.clone(), child))) + .collect::>(); + if !members.is_empty() { + out.insert("members".to_string(), Value::Object(members)); + } + Some(Value::Object(out)) +} + +/// `T.untyped` is an absence-of-identity marker, not a runtime alternative. +/// Where a shape supplies an exact record identity, it replaces the marker. +fn reconcile_record_identities(domain: &mut Map) { + let shapes = domain["shapes"].as_array().cloned().unwrap_or_default(); + let nested = |field: &str| -> Vec { + shapes + .iter() + .flat_map(|shape| shape[field].as_array().cloned().unwrap_or_default()) + .collect() + }; + let slots: [(&str, Vec); 4] = [ + ("types", shapes.clone()), + ("elements", nested("elements")), + ("keys", nested("keys")), + ("values", nested("values")), + ]; + for (slot, candidates) in slots { + let mut names = strings(domain.get(slot)); + if !names.iter().any(|name| name == UNTYPED) { + continue; + } + let records = candidates + .iter() + .filter(|shape| shape["kind"].as_str() == Some("record")) + .filter_map(|shape| shape["name"].as_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if records.is_empty() { + continue; + } + names.retain(|name| name != UNTYPED); + for record in records { + if !names.contains(&record) { + names.push(record); + } + } + names.sort(); + domain.insert(slot.to_string(), json!(names)); + } +} + +fn domain(fields: &[(&str, Option<&Value>)], shapes: Vec) -> Value { + let mut normalized_shapes: Vec = Vec::new(); + for shape in shapes.iter().filter_map(normalize_shape) { + if !normalized_shapes.contains(&shape) { + normalized_shapes.push(shape); + } + } + let mut out = Map::new(); + for field in ["types", "singletons", "elements", "keys", "values"] { + let values = fields.iter().find(|(name, _)| *name == field).and_then(|(_, v)| *v); + out.insert(field.to_string(), json!(strings(values))); + } + out.insert("shapes".to_string(), json!(normalized_shapes)); + reconcile_record_identities(&mut out); + Value::Object(out) +} + +/// The recorder stores raw shape samples per container edge: a +/// `param_elem_shapes` record describes an element of `items`, not `items`. +/// That ownership is preserved at the schema boundary. +fn container_shapes( + types: &[String], + kinds: &[String], + elements: &[Value], + keys: &[Value], + values: &[Value], +) -> Vec { + let mut seen: Vec = Vec::new(); + let mut shapes = Vec::new(); + for name in types.iter().chain(kinds) { + if seen.contains(name) { + continue; + } + seen.push(name.clone()); + match name.to_ascii_lowercase().as_str() { + "array" if !elements.is_empty() => { + shapes.push(json!({"kind": "array", "elements": elements})); + } + "set" if !elements.is_empty() => { + shapes.push(json!({"kind": "set", "elements": elements})); + } + "hash" if !(keys.is_empty() && values.is_empty()) => { + shapes.push(json!({"kind": "hash", "keys": keys, "values": values})); + } + _ => {} + } + } + shapes +} + +fn scope(language: &str, path: &str, owner: &str, function: &str, line: i64) -> Value { + json!({ + "language": language, "path": path, "owner": owner, + "function": function, "line": line, + }) +} + +fn relative(path: &str, root: &Path) -> String { + if path.is_empty() { + return String::new(); + } + let absolute = if path.starts_with('/') { + std::path::PathBuf::from(path) + } else { + root.join(path) + }; + absolute + .strip_prefix(root) + .map(|rest| rest.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string()) +} + +fn array_at(row: &Value, field: &str) -> Vec { + row[field].as_array().cloned().unwrap_or_default() +} + +fn pair_at(row: &Value, field: &str) -> (Vec, Vec) { + let values = array_at(row, field); + ( + values.first().and_then(Value::as_array).cloned().unwrap_or_default(), + values.get(1).and_then(Value::as_array).cloned().unwrap_or_default(), + ) +} + +/// Everything the collector observed about a named slot, from the row files it +/// wrote. Duplicates across files describing the same slot are merged. +pub fn observations(rows: &ShardRows, root: &Path) -> Vec { + let mut out = Vec::new(); + + for method in &rows.methods { + let at = scope( + "ruby", + &relative(method["path"].as_str().unwrap_or_default(), root), + method["class"].as_str().unwrap_or_default(), + method["method"].as_str().unwrap_or_default(), + method["line"].as_i64().unwrap_or_default(), + ); + for (name, types) in method["params_by_name"].as_object().into_iter().flatten() { + let field = |group: &str| method[group].get(name); + let (key_shapes, value_shapes) = { + let raw = method["param_kv_shapes"].get(name).cloned().unwrap_or(json!([])); + pair_at(&json!({"kv": raw}), "kv") + }; + let (keys, values) = { + let raw = method["param_kv"].get(name).cloned().unwrap_or(json!([])); + pair_at(&json!({"kv": raw}), "kv") + }; + let mut shapes = field("param_value_shapes") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + shapes.extend(container_shapes( + &strings(Some(types)), + &[], + &field("param_elem_shapes").and_then(Value::as_array).cloned().unwrap_or_default(), + &key_shapes, + &value_shapes, + )); + let keys = json!(keys); + let values = json!(values); + out.push(observation( + "parameter", + at.clone(), + name, + domain( + &[ + ("types", Some(types)), + ("singletons", field("param_singleton_types")), + ("elements", field("param_elem")), + ("keys", Some(&keys)), + ("values", Some(&values)), + ], + shapes, + ), + method["calls"].as_i64().unwrap_or_default(), + "", + )); + } + let (return_keys, return_values) = pair_at(method, "return_kv"); + let returns_empty = strings(method.get("returns")).is_empty() + && strings(method.get("return_elem")).is_empty() + && return_keys.is_empty() + && return_values.is_empty(); + if !returns_empty { + let (key_shapes, value_shapes) = pair_at(method, "return_kv_shapes"); + let mut shapes = array_at(method, "return_value_shapes"); + shapes.extend(container_shapes( + &strings(method.get("returns")), + &[], + &array_at(method, "return_elem_shapes"), + &key_shapes, + &value_shapes, + )); + let keys = json!(return_keys); + let values = json!(return_values); + out.push(observation( + "return", + at.clone(), + "", + domain( + &[ + ("types", method.get("returns")), + ("singletons", method.get("return_singleton_types")), + ("elements", method.get("return_elem")), + ("keys", Some(&keys)), + ("values", Some(&values)), + ], + shapes, + ), + method["ok_calls"].as_i64().unwrap_or_default(), + "", + )); + } + } + + for field in &rows.ivars { + out.push(observation( + "state", + scope("ruby", "", field["class"].as_str().unwrap_or_default(), "", 0), + field["name"].as_str().unwrap_or_default(), + domain(&[("types", field.get("classes"))], vec![]), + field["calls"].as_i64().unwrap_or_default(), + "", + )); + } + + for field in &rows.state_values { + out.push(observation( + "state", + scope( + "ruby", + &relative(field["path"].as_str().unwrap_or_default(), root), + field["class"].as_str().unwrap_or_default(), + "", + field["line"].as_i64().unwrap_or_default(), + ), + field["name"].as_str().unwrap_or_default(), + domain(&[("types", field.get("classes"))], vec![]), + field["calls"].as_i64().unwrap_or_default(), + "", + )); + } + + for field in &rows.structs { + out.push(observation( + "state", + scope( + "ruby", + &relative(field["path"].as_str().unwrap_or_default(), root), + field["class"].as_str().unwrap_or_default(), + "", + field["line"].as_i64().unwrap_or_default(), + ), + field["field"].as_str().unwrap_or_default(), + domain( + &[ + ("types", field.get("classes")), + ("elements", field.get("elem_classes")), + ("keys", field.get("key_classes")), + ("values", field.get("value_classes")), + ], + vec![], + ), + field["calls"].as_i64().unwrap_or_default(), + "", + )); + } + + for collection in &rows.collections { + let kind = collection["kind"].as_str().unwrap_or_default().to_string(); + let shapes = container_shapes( + &[], + &[kind], + &array_at(collection, "elem_shapes"), + &array_at(collection, "key_shapes"), + &array_at(collection, "value_shapes"), + ); + out.push(observation( + "collection", + scope( + "ruby", + &relative(collection["path"].as_str().unwrap_or_default(), root), + "", + "", + collection["line"].as_i64().unwrap_or_default(), + ), + collection["name"].as_str().unwrap_or_default(), + domain( + &[ + ("types", collection.get("classes")), + ("elements", collection.get("elem_classes")), + ("keys", collection.get("key_classes")), + ("values", collection.get("value_classes")), + ], + shapes, + ), + collection["calls"].as_i64().unwrap_or_default(), + collection["owner_kind"].as_str().unwrap_or_default(), + )); + } + + merge_observations(out) +} + +/// Two files can describe the same slot -- a struct field is both a record +/// member and a state write. One slot, one observation. +fn merge_observations(rows: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + let identity = |row: &Value| { + ( + row["kind"].clone(), + row["scope"].clone(), + row["slot"].clone(), + row["slot_kind"].clone(), + ) + }; + for row in rows { + match merged.iter_mut().find(|existing| identity(existing) == identity(&row)) { + Some(existing) => { + for field in ["types", "singletons", "elements", "keys", "values", "shapes"] { + let mut values = existing["domain"][field].as_array().cloned().unwrap_or_default(); + for value in row["domain"][field].as_array().into_iter().flatten() { + if !values.contains(value) { + values.push(value.clone()); + } + } + existing["domain"][field] = json!(values); + } + let total = existing["count"].as_i64().unwrap_or_default() + + row["count"].as_i64().unwrap_or_default(); + existing["count"] = json!(total); + } + None => merged.push(row), + } + } + merged.sort_by_cached_key(|row| { + let at = &row["scope"]; + ( + at["language"].as_str().unwrap_or_default().to_string(), + at["path"].as_str().unwrap_or_default().to_string(), + at["owner"].as_str().unwrap_or_default().to_string(), + at["function"].as_str().unwrap_or_default().to_string(), + at["line"].as_i64().unwrap_or_default(), + row["kind"].as_str().unwrap_or_default().to_string(), + row["slot"].as_str().unwrap_or_default().to_string(), + ) + }); + merged +} + +// ------------------------------------------------------------------ document + +/// The row files a shard directory holds. +#[derive(Debug, Default)] +pub struct ShardRows { + pub calls: Vec, + pub invalid_calls: usize, + pub methods: Vec, + pub ivars: Vec, + pub state_values: Vec, + pub structs: Vec, + pub collections: Vec, + pub executed_callsites: Vec, + pub exact_anchor_executions: Vec, + pub function_entries: Vec, + pub coverage: Vec, +} + +/// Rows are written plain and gzipped in place afterwards, so both forms occur +/// -- and within one shard directory, both can occur at once. +pub fn read_rows(runtime_dir: &Path, name: &str) -> Vec { + read_jsonl(runtime_dir, name) +} + +fn read_jsonl(runtime_dir: &Path, name: &str) -> Vec { + let mut paths = std::fs::read_dir(runtime_dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name().is_some_and(|file| { + let file = file.to_string_lossy(); + file.starts_with(&format!("{name}-")) + && (file.ends_with(".jsonl") || file.ends_with(".jsonl.gz")) + }) + }) + .collect::>(); + paths.sort(); + // A plain file and its own gzipped copy are the same rows twice. + paths.dedup_by_key(|path| path.to_string_lossy().trim_end_matches(".gz").to_string()); + paths + .iter() + .filter_map(|path| read_text(path)) + .flat_map(|text| { + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>() + }) + .collect() +} + +fn read_text(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + if path.extension().is_some_and(|extension| extension == "gz") { + use std::io::Read; + let mut text = String::new(); + flate2::read::GzDecoder::new(&bytes[..]).read_to_string(&mut text).ok()?; + return Some(text); + } + String::from_utf8(bytes).ok() +} + +/// A call event is only usable when it names where it happened and what it +/// reached; anything else is counted and dropped. +fn valid_call(event: &Value) -> bool { + event["event"].as_str() == Some("runtime_call") + && !event["language"].as_str().unwrap_or_default().is_empty() + && event["caller"].is_object() + && event["callee"].is_object() + && event["callsite"].is_object() + && !event["caller"]["path"].as_str().unwrap_or_default().is_empty() + && !event["callee"]["name"].as_str().unwrap_or_default().is_empty() + && !event["callsite"]["path"].as_str().unwrap_or_default().is_empty() + && event["callsite"]["line"].as_i64().unwrap_or_default() > 0 +} + +pub fn read_shard(runtime_dir: &Path) -> ShardRows { + let raw_calls = read_jsonl(runtime_dir, "runtime-calls"); + let total = raw_calls.len(); + let calls = raw_calls.into_iter().filter(valid_call).collect::>(); + ShardRows { + invalid_calls: total - calls.len(), + calls, + methods: read_jsonl(runtime_dir, "methods"), + ivars: read_jsonl(runtime_dir, "ivars"), + state_values: read_jsonl(runtime_dir, "state-values"), + structs: read_jsonl(runtime_dir, "structs"), + collections: read_jsonl(runtime_dir, "collections"), + executed_callsites: read_jsonl(runtime_dir, "executed-callsites"), + exact_anchor_executions: read_jsonl(runtime_dir, "exact-anchor-executions"), + function_entries: read_jsonl(runtime_dir, "function-entries"), + coverage: read_jsonl(runtime_dir, "coverage"), + } +} + +fn call_bucket(row: &Value, event: &Value, runtime: &Runtime) -> Option { + let count = row["count"].as_i64().unwrap_or(1).max(1); + let receiver = value_set( + &row["receiver_domain"], + runtime, + row["receiver_source_role"].as_str().unwrap_or("UNKNOWN_SOURCE"), + )?; + let mut bucket = Map::new(); + bucket.insert("count".to_string(), json!(count)); + bucket.insert("receiver".to_string(), receiver); + if let Some(target) = target(row) { + bucket.insert("target".to_string(), target); + } + // The declaration the collector observed. Which planned function it + // corresponds to is a question about the plan, so the locator travels raw. + bucket.insert( + "target_definition".to_string(), + row["target"].get("definition").cloned().unwrap_or(Value::Null), + ); + bucket.insert( + "provenance".to_string(), + provenance(event["run_id"].as_str().unwrap_or_default()), + ); + if let Some(result) = value_set(&row["result_domain"], runtime, "UNKNOWN_SOURCE") { + bucket.insert("result".to_string(), result); + } + // One observed truth is a fact about the call; two is a call that went both + // ways and says nothing about either. + let mut truths: Vec<&Value> = Vec::new(); + for truth in row["result_truths"].as_array().into_iter().flatten() { + if !truths.contains(&truth) { + truths.push(truth); + } + } + if truths.len() == 1 { + bucket.insert("boolean_result".to_string(), truths[0].clone()); + } + Some(Value::Object(bucket)) +} + +fn value_bucket(row: &Value, runtime: &Runtime) -> Option { + let count = row["count"].as_i64().unwrap_or(1).max(1); + let values = value_set(&row["domain"], runtime, "UNKNOWN_SOURCE")?; + Some(json!({"count": count, "value": values, "provenance": provenance("")})) +} + +pub fn build( + root: &Path, + runtime_dir: &Path, + plan_digest: &str, + runtime: &Runtime, + run_ids: &[String], +) -> Result { + let rows = read_shard(runtime_dir); + // The translation from what a VM saw into what SCIP names renames and + // regroups and infers nothing, so it happens with the rest of the join. + let decoded = rows + .calls + .iter() + .map(|event| crate::runtime_decode::call(event, root)) + .collect::>(); + let calls = decoded + .iter() + .zip(&rows.calls) + .map(|(row, event)| { + let mut entry = Map::new(); + entry.insert("row".to_string(), row.clone()); + if let Some(bucket) = call_bucket(row, event, runtime) { + entry.insert("bucket".to_string(), bucket); + } + Value::Object(entry) + }) + .collect::>(); + + let observations = observations(&rows, root) + .into_iter() + .map(|row| { + let mut row = row; + if let Some(bucket) = value_bucket(&row, runtime) { + row["bucket"] = bucket; + } + row + }) + .collect::>(); + + // A document's runs are exactly the runs its executions cite. Taking them + // from the traced program's own claim alone left a hand-assembled shard + // citing a run the document did not declare. + let mut run_ids = run_ids.iter().filter(|id| !id.is_empty()).cloned().collect::>(); + for event in &rows.calls { + let cited = event["run_id"].as_str().unwrap_or_default(); + if !cited.is_empty() && !run_ids.iter().any(|known| known == cited) { + run_ids.push(cited.to_string()); + } + } + run_ids.sort(); + run_ids.dedup(); + + let mut claims = environment_claims(runtime, root); + claims.sort(); + claims.dedup(); + + Ok(json!({ + "trace_version": VERSION, + "producer": {"name": PRODUCER, "version": PRODUCER_VERSION}, + "trace_plan_digest": plan_digest, + "languages": ["ruby"], + "environment": claims + .into_iter() + .map(|(key, value)| json!({"key": key, "value": value})) + .collect::>(), + "run_ids": run_ids, + "invalid_events": rows.invalid_calls, + "observations": observations, + "calls": calls, + "executed_callsites": rows.executed_callsites, + "exact_anchor_executions": rows.exact_anchor_executions, + "function_entries": rows.function_entries, + "coverage": rows.coverage, + })) +} + +pub fn write( + root: &Path, + runtime_dir: &Path, + plan_digest: &str, + runtime: &Runtime, + run_ids: &[String], +) -> Result<()> { + let document = build(root, runtime_dir, plan_digest, runtime, run_ids)?; + crate::runtime_trace::write_json( + &runtime_dir.join("runtime-trace.json.gz"), + &serde_json::to_string(&document)?, + ) + .with_context(|| format!("failed to write the trace document for {}", runtime_dir.display())) +} + +/// The runtime facts a shard's collector document reports about itself. +pub fn runtime_of(runtime_dir: &Path) -> Result<(Runtime, String)> { + let mut documents = std::fs::read_dir(runtime_dir) + .with_context(|| format!("unreadable shard {}", runtime_dir.display()))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name().is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("collector-raw-") && name.ends_with(".json.gz") + }) + }) + .collect::>(); + documents.sort(); + let Some(path) = documents.first() else { + return Ok((Runtime::default(), String::new())); + }; + let raw = crate::runtime_protocol::read_json(path)?; + let document: BTreeMap = serde_json::from_str(&raw)?; + let text = |field: &str| { + document.get(field).and_then(Value::as_str).unwrap_or_default().to_string() + }; + Ok(( + Runtime { + version: text("ruby_version"), + engine: text("ruby_engine"), + engine_version: text("ruby_engine_version"), + }, + text("run_id"), + )) +} diff --git a/gems/fact-mine/src/trace_plan.rs b/gems/fact-mine/src/trace_plan.rs new file mode 100644 index 000000000..d508a8be4 --- /dev/null +++ b/gems/fact-mine/src/trace_plan.rs @@ -0,0 +1,984 @@ +//! Turning static facts into the instrumentation plan the collector reads. +//! +//! FactMine already decides what the source says: which methods exist, what +//! their signatures promise, which call sites produce values worth watching. +//! This module answers the one remaining question -- what the runtime still has +//! to observe -- and writes it where the C extension can find it. +//! +//! The shape is a set of flat lookup tables keyed by NUL-joined tuples. That is +//! deliberate: the collector consults them from inside a TracePoint handler, +//! where the only affordable operation is a hash lookup on data the VM already +//! has (a path, a line, a class and method name). +//! +//! Ported from nil-kill's `trace_plan.rb`. Its output is the contract, so the +//! tests compare against what that file produces on the real corpora. + +use crate::sorbet_sig; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// Tables are keyed by NUL-joined tuples so the collector can build a lookup +/// key by concatenation, without parsing, inside a trace handler. +const SEP: char = '\0'; + +fn key(parts: &[&str]) -> String { + parts.join(&SEP.to_string()) +} + +fn text(value: Option<&Value>) -> String { + value.and_then(Value::as_str).unwrap_or("").to_string() +} + +fn integer(value: Option<&Value>) -> i64 { + value + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .unwrap_or(0) +} + +fn array(value: Option<&Value>) -> &[Value] { + value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) +} + +fn absolute(path: &str, root: &Path) -> String { + let candidate = Path::new(path); + if candidate.is_absolute() { + candidate.to_string_lossy().into_owned() + } else { + root.join(candidate).to_string_lossy().into_owned() + } +} + +/// A signature that promises nothing back has no return worth sampling. +fn void_signature(signature: &str) -> bool { + signature + .match_indices("void") + .any(|(idx, _)| { + let before = signature[..idx].chars().next_back(); + let after = signature[idx + 4..].chars().next(); + let boundary = |c: Option| !c.is_some_and(|c| c.is_alphanumeric() || c == '_'); + boundary(before) && boundary(after) + }) +} + +/// The narrow slice of static facts the collector is allowed to see. It +/// deliberately excludes CFG/DFG, protocol, shape, alias, call-graph and +/// pressure facts: the collector receives opaque source anchors that FactMine +/// selected, never the reasoning behind them. +const FACT_KEYS: [&str; 7] = [ + "tlet_sites", + "struct_declarations", + "state_type_records", + "type_definitions", + "runtime_call_sites", + "runtime_result_call_sites", + "runtime_collection_receiver_sites", +]; + +/// Reshape raw `profile trace-plan` output into what [`TracePlan::build`] reads. +/// +/// A struct declaration may name its class unqualified (`Point` for +/// `Geometry::Point`), while the runtime looks the class up by its qualified +/// name. Both spellings are kept: the unqualified one stays sampled, and the +/// qualified one carries the enforceable field types, so a lookup tries the +/// qualified class first and falls back to suffixes. +pub fn reshape_static_facts(raw: &Value, root: &Path) -> Value { + let mut facts = Map::new(); + for key in FACT_KEYS { + facts.insert(key.to_string(), raw.get(key).cloned().unwrap_or(Value::Null)); + } + let declarations = array(raw.get("struct_declarations")).to_vec(); + let mut resolved = declarations.clone(); + resolve_struct_declaration_classes( + &mut resolved, + array(raw.get("type_definitions")), + array(raw.get("methods")), + root, + ); + // The unqualified entry keeps no field types, so it stays conservative. + let mut both: Vec = declarations + .into_iter() + .map(|mut decl| { + if let Some(object) = decl.as_object_mut() { + object.insert("field_types".to_string(), json!({})); + } + decl + }) + .collect(); + both.extend(resolved); + facts.insert("struct_declarations".to_string(), Value::Array(both)); + + json!({ + "methods": raw.get("methods").cloned().unwrap_or_else(|| json!([])), + "fields": raw.get("fields").cloned().unwrap_or_else(|| json!([])), + "facts": Value::Object(facts), + }) +} + +/// Rewrite each unqualified declaration class to its fully-qualified name when +/// the file says unambiguously what that is. +fn resolve_struct_declaration_classes( + declarations: &mut [Value], + type_definitions: &[Value], + methods: &[Value], + root: &Path, +) { + let normalize = |path: &str| -> String { + if path.is_empty() { + String::new() + } else { + absolute(path, root) + } + }; + + let mut qualified: BTreeMap<(String, String), String> = BTreeMap::new(); + let mut by_path: BTreeMap> = BTreeMap::new(); + for definition in type_definitions { + let owner = text(definition.get("owner")); + if owner.is_empty() { + continue; + } + let path = normalize(&text(definition.get("path"))); + let kind = text(definition.get("kind")); + if kind == "state_field" || kind == "method_signature" { + let unqualified = owner.rsplit("::").next().unwrap_or(&owner).to_string(); + qualified.insert((path.clone(), unqualified), owner.clone()); + } + by_path.entry(path).or_default().insert(owner); + } + for method in methods { + let owner = text(method.get("owner")); + if owner.is_empty() { + continue; + } + by_path + .entry(normalize(&text(method.get("path")))) + .or_default() + .insert(owner); + } + + for declaration in declarations { + let name = text(declaration.get("class")); + if name.contains("::") { + continue; + } + let path = normalize(&text(declaration.get("path"))); + let resolved = qualified.get(&(path.clone(), name.clone())).cloned().or_else(|| { + // Nothing declared it here by name, so accept a suffix match only + // when exactly one owner in this file could be meant. + let suffix = format!("::{name}"); + let mut candidates = by_path + .get(&path) + .into_iter() + .flatten() + .filter(|owner| owner.ends_with(&suffix)); + let first = candidates.next()?; + candidates.next().is_none().then(|| first.clone()) + }); + if let Some(resolved) = resolved { + if let Some(object) = declaration.as_object_mut() { + object.insert("class".to_string(), Value::String(resolved)); + } + } + } +} + +/// What the runtime must watch, accumulated as facts arrive. +#[derive(Default)] +pub struct TracePlan { + methods: BTreeMap, + tlets: BTreeMap, + struct_fields: BTreeMap, + state_write_site_owners: BTreeMap, + runtime_call_sites: BTreeMap, + runtime_result_call_sites: BTreeMap, + runtime_collection_receiver_sites: BTreeMap, + runtime_native_activation_sites: BTreeMap, +} + +/// A site either wants every call on its line, or only named selectors. +/// "Everything" wins permanently once claimed -- a narrower later demand must +/// not shrink it. +#[derive(Debug, Clone, PartialEq, Eq)] +enum SiteDemand { + Everything, + Selectors(BTreeSet), +} + +impl SiteDemand { + fn to_value(&self) -> Value { + match self { + SiteDemand::Everything => Value::Bool(true), + SiteDemand::Selectors(names) => { + Value::Array(names.iter().map(|n| Value::String(n.clone())).collect()) + } + } + } +} + +fn demand(index: &mut BTreeMap, key: String, selector: &str) { + match index.get_mut(&key) { + Some(SiteDemand::Everything) => {} + Some(SiteDemand::Selectors(names)) => { + if selector.is_empty() { + index.insert(key, SiteDemand::Everything); + } else { + names.insert(selector.to_string()); + } + } + None => { + let value = if selector.is_empty() { + SiteDemand::Everything + } else { + SiteDemand::Selectors(BTreeSet::from([selector.to_string()])) + }; + index.insert(key, value); + } + } +} + +impl TracePlan { + pub fn new() -> Self { + Self::default() + } + + /// Build from `profile trace-plan` output plus the runtime evidence plan. + pub fn build(static_facts: &Value, root: &Path) -> Self { + let mut plan = Self::new(); + for method in array(static_facts.get("methods")) { + plan.add_static_method(method, root); + } + let facts = static_facts.get("facts").cloned().unwrap_or_else(|| json!({})); + + // A T.let at a line supplies the declared type for a field written + // there, so these are collected before the fields that consult them. + let mut tlet_types: BTreeMap<(String, i64), String> = BTreeMap::new(); + for site in array(facts.get("tlet_sites")) { + plan.add_tlet(site, root); + tlet_types.insert( + ( + absolute(&text(site.get("path")), root), + integer(site.get("line")), + ), + text(site.get("type")), + ); + } + for decl in array(facts.get("struct_declarations")) { + plan.add_struct_decl(decl); + } + for site in array(facts.get("runtime_call_sites")) { + plan.add_runtime_value_site(SiteKind::Call, site, root); + } + for site in array(facts.get("runtime_result_call_sites")) { + plan.add_runtime_value_site(SiteKind::Result, site, root); + } + for site in array(facts.get("runtime_collection_receiver_sites")) { + plan.add_runtime_value_site(SiteKind::CollectionReceiver, site, root); + } + for field in array(static_facts.get("fields")) { + plan.add_static_field(field, &tlet_types, root); + } + for field in array(facts.get("state_type_records")) { + plan.add_static_state_type(field); + } + // Flow-derived state records are conservative and may report T.untyped + // for a field whose declaration is already strong. The declaration is + // the enforceable contract, so it is applied last and suppresses the + // redundant sampling. + for definition in array(facts.get("type_definitions")) { + plan.add_static_type_definition(definition); + } + plan + } + + fn add_static_method(&mut self, method: &Value, root: &Path) { + let signature = text(method.get("signature")); + let param_types: BTreeMap = + sorbet_sig::param_entries(&signature).into_iter().collect(); + let untraceable: BTreeSet = array(method.get("untraceable_params")) + .iter() + .map(|v| text(Some(v))) + .collect(); + + let mut params = Map::new(); + for name in array(method.get("params")) { + let name = text(Some(name)); + if untraceable.contains(&name) { + continue; + } + let declared = param_types.get(&name).map(String::as_str).unwrap_or(""); + params.insert(name, Value::Bool(!sorbet_sig::strong_trace_type(declared))); + } + let return_type = sorbet_sig::return_type(&signature).unwrap_or(""); + let sample_return = + !void_signature(&signature) && !sorbet_sig::strong_trace_type(return_type); + let sample_method = params.values().any(|v| v == &Value::Bool(true)) || sample_return; + + let name = text(method.get("name")); + let name = name.strip_prefix("self.").unwrap_or(&name).to_string(); + let entry_key = key(&[ + &text(method.get("owner")), + &name, + &method_kind(method), + &absolute(&text(method.get("path")), root), + &integer(method.get("line")).to_string(), + ]); + self.methods.insert( + entry_key, + json!({ + "frame": sample_method, + "params": Value::Object(params), + "return": sample_return, + "sample": sample_method, + }), + ); + } + + fn add_tlet(&mut self, site: &Value, root: &Path) { + if !site.get("tlet").and_then(Value::as_bool).unwrap_or(false) { + return; + } + if sorbet_sig::strong_trace_type(&text(site.get("type"))) { + return; + } + let entry = key(&[ + &absolute(&text(site.get("path")), root), + &integer(site.get("line")).to_string(), + ]); + self.tlets.insert(entry, Value::Bool(true)); + } + + /// FactMine has already chosen these semantic source ranges. TracePoint + /// reports a line rather than an AST, so the span is expanded into opaque + /// per-line lookup keys. No syntax or flow interpretation happens here. + fn add_runtime_value_site(&mut self, kind: SiteKind, site: &Value, root: &Path) { + let path = text(site.get("path")); + let span = array(site.get("span")); + if path.is_empty() || span.len() != 4 { + return; + } + let activation = array(site.get("activation_span")); + let activation = if activation.len() == 4 { activation } else { span }; + let activation_line = integer(activation.first()) + .min(integer(activation.get(2))); + let selector = text(site.get("selector")); + let first = integer(span.first()).min(integer(span.get(2))); + let last = integer(span.first()).max(integer(span.get(2))); + let absolute_path = absolute(&path, root); + + // FactMine may select an enclosing line to arm a native call before a + // multiline expression begins; Ruby then emits later :line events + // inside that expression. Repeating the selector window on every + // capture-span line keeps those events from disarming the capture. + let mut activation_lines = vec![activation_line]; + activation_lines.extend(first..=last); + activation_lines.dedup(); + for line in activation_lines { + demand( + &mut self.runtime_native_activation_sites, + key(&[&absolute_path, &line.to_string()]), + &selector, + ); + } + + let index = match kind { + SiteKind::Call => &mut self.runtime_call_sites, + SiteKind::Result => &mut self.runtime_result_call_sites, + SiteKind::CollectionReceiver => &mut self.runtime_collection_receiver_sites, + }; + for line in first..=last { + demand(index, key(&[&absolute_path, &line.to_string()]), &selector); + } + } + + fn add_struct_decl(&mut self, decl: &Value) { + let field_types = decl.get("field_types").and_then(Value::as_object); + for field in array(decl.get("fields")) { + let field = text(Some(field)); + let declared = field_types + .and_then(|types| types.get(&field)) + .map(|v| text(Some(v))) + .unwrap_or_default(); + self.struct_fields.insert( + key(&[&text(decl.get("class")), &field]), + declared.is_empty() || !sorbet_sig::strong_trace_type(&declared), + ); + } + } + + fn add_static_state_type(&mut self, field: &Value) { + let owner = text(field.get("owner")); + let name = text(field.get("field")); + let name = name.strip_prefix('@').unwrap_or(&name).to_string(); + let declared = text(field.get("declared_type")); + if owner.is_empty() || name.is_empty() || declared.is_empty() { + return; + } + self.struct_fields.insert( + key(&[&owner, &name]), + !sorbet_sig::strong_trace_type(&declared), + ); + } + + fn add_static_type_definition(&mut self, definition: &Value) { + if text(definition.get("kind")) != "state_field" { + return; + } + let owner = text(definition.get("owner")); + let name = text(definition.get("name")); + let name = name.strip_prefix('@').unwrap_or(&name).to_string(); + let declared = text(definition.get("declared_type")); + if owner.is_empty() || name.is_empty() || declared.is_empty() { + return; + } + self.struct_fields.insert( + key(&[&owner, &name]), + !sorbet_sig::strong_trace_type(&declared), + ); + } + + fn add_static_field( + &mut self, + field: &Value, + tlet_types: &BTreeMap<(String, i64), String>, + root: &Path, + ) { + let owner = text(field.get("owner")); + let raw_name = if field.get("name").is_some() { + text(field.get("name")) + } else { + text(field.get("field")) + }; + let name = raw_name.strip_prefix('@').unwrap_or(&raw_name).to_string(); + let path = absolute(&text(field.get("path")), root); + let line = integer(field.get("line")); + if !owner.is_empty() && !name.is_empty() { + self.state_write_site_owners.insert( + key(&[&path, &line.to_string(), &name]), + key(&[&owner, &name]), + ); + } + let mut declared = text(field.get("declared_type")); + if declared.is_empty() { + declared = tlet_types + .get(&(path, line)) + .cloned() + .unwrap_or_default(); + } + if owner.is_empty() || name.is_empty() || declared.is_empty() { + return; + } + self.struct_fields.insert( + key(&[&owner, &name]), + !sorbet_sig::strong_trace_type(&declared), + ); + } + + /// Exact source sites let the collector skip a state slot whose final + /// enforceable contract is strong. A site with no known owner is absent + /// rather than false, and therefore stays sampled. + fn state_write_sites(&self) -> Map { + self.state_write_site_owners + .iter() + .map(|(site, owner)| { + ( + site.clone(), + Value::Bool(*self.struct_fields.get(owner).unwrap_or(&true)), + ) + }) + .collect() + } + + /// The document the collector reads. `generated_at` is supplied rather than + /// read from the clock so the output is reproducible and testable. + pub fn document( + &self, + generated_at: &str, + target_dirs: &[String], + target_exclude_dirs: &[String], + runtime_evidence: Value, + ) -> Value { + let sites = |index: &BTreeMap| -> Value { + Value::Object( + index + .iter() + .map(|(key, demand)| (key.clone(), demand.to_value())) + .collect(), + ) + }; + let mut dirs = target_dirs.to_vec(); + dirs.sort(); + let mut excludes = target_exclude_dirs.to_vec(); + excludes.sort(); + json!({ + "version": 1, + "generated_at": generated_at, + "target_dirs": dirs, + "target_exclude_dirs": excludes, + "methods": Value::Object(self.methods.clone().into_iter().collect()), + "tlets": Value::Object(self.tlets.clone().into_iter().collect()), + "struct_fields": Value::Object( + self.struct_fields + .iter() + .map(|(k, v)| (k.clone(), Value::Bool(*v))) + .collect() + ), + "state_write_sites": Value::Object(self.state_write_sites()), + "runtime_call_sites": sites(&self.runtime_call_sites), + "runtime_result_call_sites": sites(&self.runtime_result_call_sites), + "runtime_collection_receiver_sites": sites(&self.runtime_collection_receiver_sites), + "runtime_native_activation_sites": sites(&self.runtime_native_activation_sites), + // Public FactMine <-> collector contract. Everything else in this + // document is private instrumentation control. + "runtime_evidence": runtime_evidence, + }) + } +} + +#[derive(Clone, Copy)] +enum SiteKind { + Call, + Result, + CollectionReceiver, +} + +/// A name spelled `self.x` is a class method whatever the record claims, and a +/// callable with no owner is a bare function. +fn method_kind(method: &Value) -> String { + let raw = text(method.get("kind")); + let name = text(method.get("name")); + if name.starts_with("self.") || raw == "class" || raw == "class_method" { + return "class".to_string(); + } + if raw == "function" || text(method.get("owner")).is_empty() { + return "function".to_string(); + } + "instance".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> &'static Path { + Path::new("/repo") + } + + fn plan_from(facts: Value) -> Value { + TracePlan::build(&facts, root()).document("t", &[], &[], Value::Null) + } + + #[test] + fn a_parameter_the_signature_already_pins_is_not_sampled() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "Foo", "name": "bar", "kind": "instance", + "path": "lib/foo.rb", "line": 3, + "params": ["a", "b"], + "signature": "sig { params(a: String, b: T.untyped).returns(Integer) }" + }] + })); + let entry = &plan["methods"]["Foo\u{0}bar\u{0}instance\u{0}/repo/lib/foo.rb\u{0}3"]; + assert_eq!(entry["params"]["a"], json!(false), "String is enough"); + assert_eq!(entry["params"]["b"], json!(true), "T.untyped must be watched"); + assert_eq!(entry["return"], json!(false), "Integer is enough"); + assert_eq!(entry["sample"], json!(true), "one weak param is enough to sample"); + assert_eq!(entry["frame"], json!(true)); + } + + #[test] + fn a_method_that_promises_nothing_weak_is_not_sampled_at_all() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "Foo", "name": "bar", "kind": "instance", + "path": "lib/foo.rb", "line": 1, + "params": ["a"], + "signature": "sig { params(a: String).returns(Integer) }" + }] + })); + let entry = &plan["methods"]["Foo\u{0}bar\u{0}instance\u{0}/repo/lib/foo.rb\u{0}1"]; + assert_eq!(entry["sample"], json!(false)); + assert_eq!(entry["frame"], json!(false)); + } + + #[test] + fn a_void_signature_has_no_return_to_sample() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "Foo", "name": "bar", "kind": "instance", + "path": "lib/foo.rb", "line": 1, "params": [], + "signature": "sig { params(a: String).void }" + }] + })); + let entry = &plan["methods"]["Foo\u{0}bar\u{0}instance\u{0}/repo/lib/foo.rb\u{0}1"]; + assert_eq!(entry["return"], json!(false)); + assert_eq!(entry["sample"], json!(false)); + } + + #[test] + fn an_untraceable_parameter_is_dropped_rather_than_sampled() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "Foo", "name": "bar", "kind": "instance", + "path": "lib/foo.rb", "line": 1, + "params": ["blk", "a"], + "untraceable_params": ["blk"], + "signature": "sig { params(a: T.untyped).void }" + }] + })); + let entry = &plan["methods"]["Foo\u{0}bar\u{0}instance\u{0}/repo/lib/foo.rb\u{0}1"]; + assert!(entry["params"].get("blk").is_none()); + assert_eq!(entry["params"]["a"], json!(true)); + } + + #[test] + fn a_self_prefixed_name_is_a_class_method_and_loses_the_prefix() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "Foo", "name": "self.build", "kind": "instance", + "path": "lib/foo.rb", "line": 2, "params": [], "signature": "" + }] + })); + assert!(plan["methods"] + .get("Foo\u{0}build\u{0}class\u{0}/repo/lib/foo.rb\u{0}2") + .is_some()); + } + + #[test] + fn a_callable_without_an_owner_is_a_function() { + let plan = plan_from(json!({ + "methods": [{ + "owner": "", "name": "helper", "kind": "", + "path": "lib/foo.rb", "line": 5, "params": [], "signature": "" + }] + })); + assert!(plan["methods"] + .get("\u{0}helper\u{0}function\u{0}/repo/lib/foo.rb\u{0}5") + .is_some()); + } + + #[test] + fn a_span_arms_every_line_it_covers() { + let plan = plan_from(json!({ + "facts": { "runtime_call_sites": [ + { "path": "lib/a.rb", "span": [4, 0, 6, 9], "selector": "map" } + ]} + })); + for line in 4..=6 { + assert_eq!( + plan["runtime_call_sites"][format!("/repo/lib/a.rb\u{0}{line}")], + json!(["map"]), + "line {line}" + ); + } + assert!(plan["runtime_call_sites"] + .get("/repo/lib/a.rb\u{0}7") + .is_none()); + } + + #[test] + fn an_empty_selector_claims_the_whole_line_and_a_later_one_cannot_narrow_it() { + let plan = plan_from(json!({ + "facts": { "runtime_call_sites": [ + { "path": "lib/a.rb", "span": [4, 0, 4, 9], "selector": "" }, + { "path": "lib/a.rb", "span": [4, 0, 4, 9], "selector": "map" } + ]} + })); + assert_eq!(plan["runtime_call_sites"]["/repo/lib/a.rb\u{0}4"], json!(true)); + } + + #[test] + fn selectors_on_one_line_accumulate_and_are_sorted() { + let plan = plan_from(json!({ + "facts": { "runtime_call_sites": [ + { "path": "lib/a.rb", "span": [4, 0, 4, 9], "selector": "map" }, + { "path": "lib/a.rb", "span": [4, 0, 4, 9], "selector": "each" } + ]} + })); + assert_eq!( + plan["runtime_call_sites"]["/repo/lib/a.rb\u{0}4"], + json!(["each", "map"]) + ); + } + + #[test] + fn an_activation_span_arms_the_line_that_starts_the_expression() { + let plan = plan_from(json!({ + "facts": { "runtime_call_sites": [ + { + "path": "lib/a.rb", "span": [6, 0, 6, 9], + "activation_span": [4, 0, 4, 2], "selector": "map" + } + ]} + })); + assert_eq!( + plan["runtime_native_activation_sites"]["/repo/lib/a.rb\u{0}4"], + json!(["map"]), + "the enclosing line is armed" + ); + assert!( + plan["runtime_call_sites"].get("/repo/lib/a.rb\u{0}4").is_none(), + "but the capture itself stays on its own span" + ); + } + + #[test] + fn a_site_without_a_four_element_span_is_ignored() { + let plan = plan_from(json!({ + "facts": { "runtime_call_sites": [ + { "path": "lib/a.rb", "span": [4, 0], "selector": "map" }, + { "path": "", "span": [4, 0, 4, 9], "selector": "map" } + ]} + })); + assert_eq!(plan["runtime_call_sites"], json!({})); + } + + #[test] + fn the_three_site_kinds_stay_in_their_own_tables() { + let plan = plan_from(json!({ + "facts": { + "runtime_call_sites": [{ "path": "a.rb", "span": [1,0,1,1], "selector": "x" }], + "runtime_result_call_sites": [{ "path": "a.rb", "span": [2,0,2,1], "selector": "y" }], + "runtime_collection_receiver_sites": [{ "path": "a.rb", "span": [3,0,3,1], "selector": "z" }] + } + })); + assert_eq!(plan["runtime_call_sites"]["/repo/a.rb\u{0}1"], json!(["x"])); + assert_eq!(plan["runtime_result_call_sites"]["/repo/a.rb\u{0}2"], json!(["y"])); + assert_eq!( + plan["runtime_collection_receiver_sites"]["/repo/a.rb\u{0}3"], + json!(["z"]) + ); + } + + #[test] + fn a_struct_field_is_sampled_unless_its_declaration_is_strong() { + let plan = plan_from(json!({ + "facts": { "struct_declarations": [{ + "class": "Point", + "fields": ["x", "y", "z"], + "field_types": { "x": "Integer", "y": "T.untyped" } + }]} + })); + assert_eq!(plan["struct_fields"]["Point\u{0}x"], json!(false)); + assert_eq!(plan["struct_fields"]["Point\u{0}y"], json!(true)); + assert_eq!(plan["struct_fields"]["Point\u{0}z"], json!(true), "undeclared"); + } + + #[test] + fn a_field_takes_its_type_from_a_t_let_on_the_same_line() { + let plan = plan_from(json!({ + "fields": [{ "owner": "Foo", "name": "@bar", "path": "lib/foo.rb", "line": 7 }], + "facts": { "tlet_sites": [ + { "path": "lib/foo.rb", "line": 7, "type": "String", "tlet": true } + ]} + })); + assert_eq!(plan["struct_fields"]["Foo\u{0}bar"], json!(false)); + } + + #[test] + fn a_strong_t_let_is_not_recorded_as_needing_a_sample() { + let plan = plan_from(json!({ + "facts": { "tlet_sites": [ + { "path": "a.rb", "line": 1, "type": "String", "tlet": true }, + { "path": "a.rb", "line": 2, "type": "T.untyped", "tlet": true }, + { "path": "a.rb", "line": 3, "type": "T.untyped", "tlet": false } + ]} + })); + assert!(plan["tlets"].get("/repo/a.rb\u{0}1").is_none()); + assert_eq!(plan["tlets"]["/repo/a.rb\u{0}2"], json!(true)); + assert!(plan["tlets"].get("/repo/a.rb\u{0}3").is_none(), "not a T.let"); + } + + #[test] + fn a_declaration_applied_last_suppresses_a_conservative_flow_record() { + // state_type_records and type_definitions both name Foo#bar; the + // declaration is the enforceable contract and must win. + let plan = plan_from(json!({ + "facts": { + "state_type_records": [ + { "owner": "Foo", "field": "@bar", "declared_type": "T.untyped" } + ], + "type_definitions": [ + { "kind": "state_field", "owner": "Foo", "name": "@bar", "declared_type": "String" } + ] + } + })); + assert_eq!(plan["struct_fields"]["Foo\u{0}bar"], json!(false)); + } + + #[test] + fn a_type_definition_that_is_not_a_state_field_is_ignored() { + let plan = plan_from(json!({ + "facts": { "type_definitions": [ + { "kind": "method", "owner": "Foo", "name": "@bar", "declared_type": "String" } + ]} + })); + assert_eq!(plan["struct_fields"], json!({})); + } + + #[test] + fn a_write_site_reports_whether_its_owning_slot_is_sampled() { + let plan = plan_from(json!({ + "fields": [ + { "owner": "Foo", "name": "@strong", "path": "a.rb", "line": 1, + "declared_type": "String" }, + { "owner": "Foo", "name": "@weak", "path": "a.rb", "line": 2, + "declared_type": "T.untyped" } + ] + })); + assert_eq!(plan["state_write_sites"]["/repo/a.rb\u{0}1\u{0}strong"], json!(false)); + assert_eq!(plan["state_write_sites"]["/repo/a.rb\u{0}2\u{0}weak"], json!(true)); + } + + #[test] + fn a_write_site_whose_owner_is_unknown_stays_sampled() { + // No declared type anywhere, so struct_fields never learns the slot. + let plan = plan_from(json!({ + "fields": [{ "owner": "Foo", "name": "@mystery", "path": "a.rb", "line": 4 }] + })); + assert_eq!(plan["state_write_sites"]["/repo/a.rb\u{0}4\u{0}mystery"], json!(true)); + assert!(plan["struct_fields"].get("Foo\u{0}mystery").is_none()); + } + + #[test] + fn an_absolute_path_in_the_facts_is_left_alone() { + let plan = plan_from(json!({ + "facts": { "tlet_sites": [ + { "path": "/elsewhere/a.rb", "line": 1, "type": "T.untyped", "tlet": true } + ]} + })); + assert_eq!(plan["tlets"]["/elsewhere/a.rb\u{0}1"], json!(true)); + } + + #[test] + fn the_document_sorts_target_dirs_and_passes_the_evidence_plan_through() { + let plan = TracePlan::new().document( + "2026-01-01T00:00:00Z", + &["/b".to_string(), "/a".to_string()], + &["/z".to_string()], + json!({ "plan_digest": "abc" }), + ); + assert_eq!(plan["version"], json!(1)); + assert_eq!(plan["generated_at"], json!("2026-01-01T00:00:00Z")); + assert_eq!(plan["target_dirs"], json!(["/a", "/b"])); + assert_eq!(plan["target_exclude_dirs"], json!(["/z"])); + assert_eq!(plan["runtime_evidence"]["plan_digest"], json!("abc")); + } + + + // --- reshaping raw facts -------------------------------------------------- + + #[test] + fn an_unqualified_declaration_gains_the_name_its_file_declares() { + let raw = json!({ + "struct_declarations": [ + { "class": "Point", "path": "lib/geo.rb", "fields": ["x"], + "field_types": { "x": "Integer" } } + ], + "type_definitions": [ + { "kind": "state_field", "owner": "Geometry::Point", "path": "lib/geo.rb", + "name": "@x", "declared_type": "Integer" } + ] + }); + let reshaped = reshape_static_facts(&raw, root()); + let declarations = array(reshaped["facts"].get("struct_declarations")); + assert_eq!(declarations.len(), 2, "both spellings are kept"); + assert_eq!(declarations[0]["class"], json!("Point")); + assert_eq!( + declarations[0]["field_types"], + json!({}), + "the unqualified entry stays conservative" + ); + assert_eq!(declarations[1]["class"], json!("Geometry::Point")); + assert_eq!(declarations[1]["field_types"]["x"], json!("Integer")); + + // And the conservative entry must not undo the qualified one. + let plan = TracePlan::build(&reshaped, root()).document("t", &[], &[], Value::Null); + assert_eq!(plan["struct_fields"]["Point\u{0}x"], json!(true)); + assert_eq!(plan["struct_fields"]["Geometry::Point\u{0}x"], json!(false)); + } + + #[test] + fn an_ambiguous_suffix_is_left_unqualified() { + let raw = json!({ + "struct_declarations": [{ "class": "Point", "path": "lib/geo.rb", "fields": [] }], + "type_definitions": [ + { "kind": "method", "owner": "A::Point", "path": "lib/geo.rb" }, + { "kind": "method", "owner": "B::Point", "path": "lib/geo.rb" } + ] + }); + let reshaped = reshape_static_facts(&raw, root()); + let declarations = array(reshaped["facts"].get("struct_declarations")); + assert_eq!(declarations[1]["class"], json!("Point"), "two candidates, so neither"); + } + + #[test] + fn a_lone_suffix_match_resolves_even_without_a_declaring_kind() { + let raw = json!({ + "struct_declarations": [{ "class": "Point", "path": "lib/geo.rb", "fields": [] }], + "methods": [{ "owner": "Geometry::Point", "path": "lib/geo.rb" }] + }); + let reshaped = reshape_static_facts(&raw, root()); + assert_eq!( + array(reshaped["facts"].get("struct_declarations"))[1]["class"], + json!("Geometry::Point") + ); + } + + #[test] + fn an_already_qualified_declaration_is_left_alone() { + let raw = json!({ + "struct_declarations": [{ "class": "Other::Point", "path": "lib/geo.rb", "fields": [] }], + "type_definitions": [ + { "kind": "state_field", "owner": "Geometry::Point", "path": "lib/geo.rb" } + ] + }); + let reshaped = reshape_static_facts(&raw, root()); + assert_eq!( + array(reshaped["facts"].get("struct_declarations"))[1]["class"], + json!("Other::Point") + ); + } + + #[test] + fn a_declaration_in_another_file_does_not_qualify_this_one() { + let raw = json!({ + "struct_declarations": [{ "class": "Point", "path": "lib/a.rb", "fields": [] }], + "type_definitions": [ + { "kind": "state_field", "owner": "Geometry::Point", "path": "lib/b.rb" } + ] + }); + let reshaped = reshape_static_facts(&raw, root()); + assert_eq!( + array(reshaped["facts"].get("struct_declarations"))[1]["class"], + json!("Point") + ); + } + + #[test] + fn reshaping_forwards_only_the_facts_the_collector_may_see() { + let raw = json!({ + "methods": [{ "owner": "A" }], + "fields": [{ "owner": "A" }], + "tlet_sites": [{ "path": "a.rb" }], + "call_graph": [{ "secret": true }], + "pressure_facts": [{ "secret": true }] + }); + let reshaped = reshape_static_facts(&raw, root()); + let facts = reshaped["facts"].as_object().expect("facts"); + assert!(facts.contains_key("tlet_sites")); + assert!(!facts.contains_key("call_graph"), "CFG/DFG facts stay out"); + assert!(!facts.contains_key("pressure_facts")); + assert_eq!(facts.len(), FACT_KEYS.len()); + assert_eq!(reshaped["methods"], raw["methods"]); + assert_eq!(reshaped["fields"], raw["fields"]); + } + + #[test] + fn void_is_matched_as_a_word_not_a_substring() { + assert!(void_signature("sig { void }")); + assert!(void_signature("sig { params(a: X).void }")); + assert!(!void_signature("sig { returns(Avoidance) }")); + assert!(!void_signature("sig { returns(T::Array[Void_]) }")); + } +} diff --git a/gems/fact-mine/src/value_domain.rs b/gems/fact-mine/src/value_domain.rs new file mode 100644 index 000000000..0085b57af --- /dev/null +++ b/gems/fact-mine/src/value_domain.rs @@ -0,0 +1,666 @@ +//! What an observed value's type domain is. +//! +//! A tracer answers questions only an interpreter can answer -- name this +//! object's class, sample its container, list a record's fields, find the file +//! its class was declared in -- and stops. What those answers *mean* is this +//! module: what counts as a shape, when two collections are the same shape, +//! singleton versus type, which names are test-only and must not be exported. +//! +//! None of that is language-specific, which is why it lives here rather than +//! being rewritten in C for every language with a shim. +//! +//! Two behaviours look like optimisations and are not. The shape memo is +//! *lossy by design*: a collection's shape is remembered against the classes it +//! was carrying, so the second collection of the same element class reuses the +//! first one's shape rather than describing itself. And shape ordering is by +//! JSON text, because that text is also what identifies a record layout. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +const COLLECTION_DEPTH: i64 = 3; +const RECORD_DEPTH: i64 = 2; +const UNTYPED: &str = "T.untyped"; +const ANONYMOUS_RECORD_PREFIX: &str = "AnonymousStruct("; + +/// One node of what a tracer saw. `class_id` is the identity of the value's +/// class within the traced process: two anonymous classes share the name +/// `T.untyped`, and the memo buckets on identity rather than on spelling. +#[derive(Debug, Clone, Deserialize)] +pub struct RawObservation { + #[serde(rename = "type")] + pub type_name: String, + pub class_id: i64, + #[serde(default)] + pub singleton: Option, + #[serde(default)] + pub source: Option, + pub kind: String, + /// How many members the container really had, as against how many were + /// sampled. A fixed-length array is a tuple; a long one is not. + #[serde(default)] + pub length: Option, + #[serde(default)] + pub elements: Vec, + #[serde(default)] + pub pairs: Vec<(RawObservation, RawObservation)>, + #[serde(default)] + pub fields: Vec<(String, RawObservation)>, +} + +impl RawObservation { + fn is_collection(&self) -> bool { + matches!(self.kind.as_str(), "array" | "hash" | "set") + } + + fn is_record(&self) -> bool { + self.kind == "record" + } +} + +/// A shape, in the exact JSON shape the collector's C rules emit. `members` is +/// ordered by insertion, like the Ruby Hash it replaces, so its JSON text -- and +/// therefore the record layout it identifies -- is unchanged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Shape { + Class { kind: &'static str, name: String }, + Sequence { kind: &'static str, elements: Vec }, + Mapping { kind: &'static str, keys: Vec, values: Vec }, + Record { kind: &'static str, name: String, members: Vec<(String, Shape)> }, +} + +impl Shape { + fn untyped() -> Self { + Shape::Class { kind: "class", name: UNTYPED.to_string() } + } + + fn name(&self) -> Option<&str> { + match self { + Shape::Class { name, .. } | Shape::Record { name, .. } => Some(name), + _ => None, + } + } + + fn is_named_kind(&self) -> bool { + matches!(self, Shape::Class { .. } | Shape::Record { .. }) + } + + /// The JSON text a shape orders and identifies by. Written here rather than + /// through a serializer so member order is exactly insertion order. + fn json(&self) -> String { + let mut out = String::new(); + self.write_json(&mut out); + out + } + + fn write_json(&self, out: &mut String) { + match self { + Shape::Class { kind, name } => { + out.push_str("{\"kind\":"); + write_json_string(kind, out); + out.push_str(",\"name\":"); + write_json_string(name, out); + out.push('}'); + } + Shape::Sequence { kind, elements } => { + out.push_str("{\"kind\":"); + write_json_string(kind, out); + out.push_str(",\"elements\":"); + write_json_list(elements, out); + out.push('}'); + } + Shape::Mapping { kind, keys, values } => { + out.push_str("{\"kind\":"); + write_json_string(kind, out); + out.push_str(",\"keys\":"); + write_json_list(keys, out); + out.push_str(",\"values\":"); + write_json_list(values, out); + out.push('}'); + } + Shape::Record { kind, name, members } => { + out.push_str("{\"kind\":"); + write_json_string(kind, out); + out.push_str(",\"name\":"); + write_json_string(name, out); + out.push_str(",\"members\":{"); + for (at, (member, shape)) in members.iter().enumerate() { + if at > 0 { + out.push(','); + } + write_json_string(member, out); + out.push(':'); + shape.write_json(out); + } + out.push_str("}}"); + } + } + } +} + +impl Shape { + pub fn to_value(&self) -> Value { + match self { + Shape::Class { kind, name } => json!({"kind": kind, "name": name}), + Shape::Sequence { kind, elements } => { + json!({"kind": kind, "elements": values_of(elements)}) + } + Shape::Mapping { kind, keys, values } => { + json!({"kind": kind, "keys": values_of(keys), "values": values_of(values)}) + } + Shape::Record { kind, name, members } => { + let members = members + .iter() + .map(|(member, shape)| (member.clone(), shape.to_value())) + .collect::>(); + json!({"kind": kind, "name": name, "members": members}) + } + } + } +} + +impl ValueDomain { + pub fn to_value(&self) -> Value { + json!({ + "types": self.types, + "singletons": self.singletons, + "elements": self.elements, + "keys": self.keys, + "values": self.values, + "shapes": values_of(&self.shapes), + "nonproduction": self.nonproduction, + }) + } +} + +fn values_of(shapes: &[Shape]) -> Vec { + shapes.iter().map(Shape::to_value).collect() +} + +fn write_json_list(shapes: &[Shape], out: &mut String) { + out.push('['); + for (at, shape) in shapes.iter().enumerate() { + if at > 0 { + out.push(','); + } + shape.write_json(out); + } + out.push(']'); +} + +fn write_json_string(text: &str, out: &mut String) { + out.push('"'); + for byte in text.bytes() { + match byte { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + 0x08 => out.push_str("\\b"), + 0x0c => out.push_str("\\f"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + _ if byte < 0x20 => out.push_str(&format!("\\u{byte:04x}")), + _ => out.push(byte as char), + } + } + out.push('"'); +} + +/// What a value was observed to be. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValueDomain { + pub types: Vec, + pub singletons: Vec, + pub elements: Vec, + pub keys: Vec, + pub values: Vec, + pub shapes: Vec, + pub nonproduction: Option, +} + +/// The signature a collection's shape is remembered against: the one class every +/// sampled member shared, or nothing when they disagreed or any of them was +/// itself a collection. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Signature { + Empty, + Class(i64), +} + +/// The state that makes the memo lossy across a run: which layout was seen +/// first for a given carried class, and which shape each key names. +/// +/// It is deliberately long-lived. Two collections carrying the same class get +/// the same shape even when their contents differ, because that is what the +/// collector has always done and what its evidence describes. +#[derive(Debug, Default)] +pub struct DomainDeriver { + shapes: BTreeMap, + sequence_memo: BTreeMap<(i64, Signature), String>, + mapping_memo: BTreeMap<(Signature, Option), String>, + /// Files the collect was told hold non-production code. + nonproduction_paths: Vec, + /// Every type name seen, and the file its class was declared in, so a name + /// appearing inside a shape can be judged without resolving a constant. + sources: BTreeMap>, +} + +impl DomainDeriver { + pub fn new(nonproduction_paths: Vec) -> Self { + Self { nonproduction_paths, ..Self::default() } + } + + pub fn derive(&mut self, raw: &RawObservation) -> ValueDomain { + self.learn_sources(raw); + let mut domain = self.observed(raw); + domain.nonproduction = raw.source.as_ref().map(|path| self.is_nonproduction(path)); + domain + } + + /// A name inside a shape is judged by the file its class was declared in, + /// which the tracer reported next to every value it named. + fn learn_sources(&mut self, raw: &RawObservation) { + self.sources.entry(raw.type_name.clone()).or_insert_with(|| raw.source.clone()); + for element in &raw.elements { + self.learn_sources(element); + } + for (key, value) in &raw.pairs { + self.learn_sources(key); + self.learn_sources(value); + } + for (_, value) in &raw.fields { + self.learn_sources(value); + } + } + + fn is_nonproduction(&self, path: &str) -> bool { + self.nonproduction_paths.iter().any(|listed| listed == path) + } + + fn nonproduction_name(&self, name: &str) -> bool { + if name.is_empty() || name == UNTYPED || name.starts_with(ANONYMOUS_RECORD_PREFIX) { + return false; + } + self.sources + .get(name) + .and_then(|source| source.as_deref()) + .is_some_and(|path| self.is_nonproduction(path)) + } + + fn observed(&mut self, raw: &RawObservation) -> ValueDomain { + let mut types = vec![raw.type_name.clone()]; + let singletons = raw.singleton.iter().cloned().collect::>(); + let mut elements = Vec::new(); + let mut keys = Vec::new(); + let mut values = Vec::new(); + let mut shapes = Vec::new(); + + if let Some(key) = self.record_shape_key(raw, RECORD_DEPTH) { + let payload = self.payload(&key); + // A record whose class is anonymous is better described by its + // layout than by the absence of a name. + if let Some(name) = payload.name() { + if types.len() == 1 && types[0] == UNTYPED { + types = vec![name.to_string()]; + } + } + shapes.push(payload); + } + + match raw.kind.as_str() { + "array" | "set" => { + for element in &raw.elements { + push_unique(&mut elements, &element.type_name); + } + } + "hash" => { + for (key, value) in &raw.pairs { + push_unique(&mut keys, &key.type_name); + push_unique(&mut values, &value.type_name); + } + } + _ => {} + } + if raw.is_collection() { + let key = self.collection_shape_key(raw, COLLECTION_DEPTH); + let payload = self.payload(&key); + if let Some(shape) = self.production_shape(&payload) { + shapes.push(shape); + } + } + + elements.retain(|name| !self.nonproduction_name(name)); + keys.retain(|name| !self.nonproduction_name(name)); + values.retain(|name| !self.nonproduction_name(name)); + shapes.sort_by_cached_key(Shape::json); + + ValueDomain { + types: sorted(types), + singletons: sorted(singletons), + elements: sorted(elements), + keys: sorted(keys), + values: sorted(values), + shapes, + nonproduction: None, + } + } + + // ------------------------------------------------------------- shapes + + fn remember(&mut self, key: String, shape: Shape) -> String { + self.shapes.entry(key.clone()).or_insert(shape); + key + } + + fn payload(&self, key: &str) -> Shape { + self.shapes.get(key).cloned().unwrap_or_else(Shape::untyped) + } + + fn payloads(&self, keys: &[String]) -> Vec { + keys.iter().map(|key| self.payload(key)).collect() + } + + fn class_shape_key(&mut self, raw: &RawObservation) -> String { + let key = format!("class:{}", raw.type_name); + let shape = Shape::Class { kind: "class", name: raw.type_name.clone() }; + self.remember(key, shape) + } + + /// A record's own type name, or its field list when the class is anonymous. + fn record_type_name(&self, raw: &RawObservation) -> String { + if raw.type_name != UNTYPED { + return raw.type_name.clone(); + } + let fields = raw.fields.iter().map(|(name, _)| name.as_str()).collect::>(); + format!("{ANONYMOUS_RECORD_PREFIX}{})", fields.join(",")) + } + + fn record_member_shape(&mut self, raw: &RawObservation, depth: i64) -> Shape { + if depth <= 0 { + let key = self.class_shape_key(raw); + return self.payload(&key); + } + if let Some(key) = self.record_shape_key(raw, depth - 1) { + return self.payload(&key); + } + if raw.is_collection() { + let key = self.collection_shape_key(raw, depth - 1); + return self.payload(&key); + } + let key = self.class_shape_key(raw); + self.payload(&key) + } + + fn record_shape_key(&mut self, raw: &RawObservation, depth: i64) -> Option { + if !raw.is_record() { + return None; + } + let mut members: Vec<(String, Shape)> = Vec::new(); + let mut signature = Vec::new(); + for (name, value) in &raw.fields { + if name.is_empty() { + continue; + } + let shape = self.record_member_shape(value, depth); + signature.push(format!("{name}={}", shape.json())); + // Insertion order, and a repeated field name replaces in place -- + // exactly what assigning into a Ruby Hash did. + match members.iter_mut().find(|(existing, _)| existing == name) { + Some(slot) => slot.1 = shape, + None => members.push((name.clone(), shape)), + } + } + if members.is_empty() { + return None; + } + let name = self.record_type_name(raw); + let key = format!("record:{name}:{}", signature.join("\\0")); + let shape = Shape::Record { kind: "record", name, members }; + Some(self.remember(key, shape)) + } + + fn shape_key_full(&mut self, raw: &RawObservation, depth: i64) -> String { + // A sampled member may itself be a record; that layout belongs under + // the collection so a block binding still sees it. + if let Some(key) = self.record_shape_key(raw, RECORD_DEPTH) { + return key; + } + if depth <= 0 { + return self.class_shape_key(raw); + } + match raw.kind.as_str() { + kind @ ("array" | "set") => { + let mut keys = Vec::new(); + for element in &raw.elements { + let key = self.collection_shape_key(element, depth - 1); + keys.push(key); + } + let keys = unique_sorted(keys); + let shape = Shape::Sequence { + kind: if kind == "array" { "array" } else { "set" }, + elements: self.payloads(&keys), + }; + self.remember(format!("{kind}:[{}]", keys.join(";")), shape) + } + "hash" => { + let mut key_shapes = Vec::new(); + let mut value_shapes = Vec::new(); + for (key, value) in &raw.pairs { + let observed = self.collection_shape_key(key, depth - 1); + key_shapes.push(observed); + let observed = self.collection_shape_key(value, depth - 1); + value_shapes.push(observed); + } + let key_shapes = unique_sorted(key_shapes); + let value_shapes = unique_sorted(value_shapes); + let shape = Shape::Mapping { + kind: "hash", + keys: self.payloads(&key_shapes), + values: self.payloads(&value_shapes), + }; + self.remember( + format!("hash:{{{}}}:{{{}}}", key_shapes.join(";"), value_shapes.join(";")), + shape, + ) + } + _ => self.class_shape_key(raw), + } + } + + /// The memo is the behaviour, not an optimisation: a collection's shape is + /// remembered against the classes it was carrying, so a second collection + /// of the same element class reuses the first one's shape. + fn collection_shape_key(&mut self, raw: &RawObservation, depth: i64) -> String { + if depth > 0 { + match raw.kind.as_str() { + "array" | "set" => { + if let Some(signature) = homogeneous_element(raw) { + let bucket = (raw.class_id, signature); + if let Some(known) = self.sequence_memo.get(&bucket) { + return known.clone(); + } + let key = self.shape_key_full(raw, depth); + self.sequence_memo.insert(bucket, key.clone()); + return key; + } + } + "hash" => { + if let Some((keys, values)) = homogeneous_pair(raw) { + let bucket = (keys, values); + if let Some(known) = self.mapping_memo.get(&bucket) { + return known.clone(); + } + let key = self.shape_key_full(raw, depth); + self.mapping_memo.insert(bucket, key.clone()); + return key; + } + } + _ => {} + } + } + self.shape_key_full(raw, depth) + } + + /// A shape naming a test-only class must not be exported, and neither must a + /// member of one. The rest of the shape survives. + fn production_shape(&self, shape: &Shape) -> Option { + if shape.is_named_kind() && shape.name().is_some_and(|name| self.nonproduction_name(name)) { + return None; + } + Some(match shape { + Shape::Class { .. } => shape.clone(), + Shape::Sequence { kind, elements } => Shape::Sequence { + kind, + elements: elements.iter().filter_map(|shape| self.production_shape(shape)).collect(), + }, + Shape::Mapping { kind, keys, values } => Shape::Mapping { + kind, + keys: keys.iter().filter_map(|shape| self.production_shape(shape)).collect(), + values: values.iter().filter_map(|shape| self.production_shape(shape)).collect(), + }, + Shape::Record { kind, name, members } => Shape::Record { + kind, + name: name.clone(), + members: members + .iter() + .filter_map(|(member, shape)| { + self.production_shape(shape).map(|shape| (member.clone(), shape)) + }) + .collect(), + }, + }) + } +} + +/// The one class every sampled member shared. `None` when they disagreed or any +/// of them was itself a collection -- the memo must not conflate those. +fn homogeneous_element(raw: &RawObservation) -> Option { + if raw.elements.is_empty() { + return Some(Signature::Empty); + } + let first = raw.elements[0].class_id; + for element in &raw.elements { + if element.is_collection() || element.class_id != first { + return None; + } + } + Some(Signature::Class(first)) +} + +/// The same question for a mapping. The value class stays unset for an empty +/// mapping, matching the collector it replaces. +fn homogeneous_pair(raw: &RawObservation) -> Option<(Signature, Option)> { + let mut keys: Option = None; + let mut values: Option = None; + for (at, (key, value)) in raw.pairs.iter().enumerate() { + if key.is_collection() || value.is_collection() { + return None; + } + if at == 0 { + keys = Some(key.class_id); + values = Some(value.class_id); + } else if Some(key.class_id) != keys || Some(value.class_id) != values { + return None; + } + } + Some((keys.map_or(Signature::Empty, Signature::Class), values)) +} + +/// An array observed either in full or with its classes disagreeing. A declared +/// type then has to spell out each position rather than name one element type, +/// so this is a different claim from "an array of these element types". +/// +/// The one array that does not qualify is the long uniform one: its tail went +/// unobserved, and its head says nothing the element type does not already. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Tuple { + pub types: Vec, + pub size: String, + pub complete: bool, + pub mixed: bool, +} + +pub fn tuple_of(raw: &RawObservation, element_sample: usize) -> Option { + if raw.kind != "array" { + return None; + } + let length = raw.length?; + if length < 2 { + return None; + } + let types = raw.elements.iter().map(|element| element.type_name.clone()).collect::>(); + let mixed = types.iter().skip(1).any(|name| name != &types[0]); + let complete = types.len() == length; + if !complete && !mixed { + return None; + } + let size = if complete { length.to_string() } else { format!(">={element_sample}") }; + Some(Tuple { types, size, complete, mixed }) +} + +fn push_unique(names: &mut Vec, name: &str) { + if !names.iter().any(|existing| existing == name) { + names.push(name.to_string()); + } +} + +fn sorted(mut names: Vec) -> Vec { + names.sort(); + names +} + +fn unique_sorted(keys: Vec) -> Vec { + let mut unique = keys; + unique.sort(); + unique.dedup(); + unique +} + +// ------------------------------------------------------------------ documents + +/// Turn a collector document's raw observations into value domains. +/// +/// The traced program writes what it saw, in the order it saw it. Order is not +/// incidental: a collection's shape is remembered against the classes it was +/// carrying, so deriving out of order would answer differently. The table is +/// therefore walked exactly as it was filled, which is the order the collector +/// itself derived in. +pub fn derive_document(document: &mut Value, nonproduction_paths: Vec) -> usize { + let Some(observations) = document.get_mut("observations").map(Value::take) else { + return 0; + }; + let Value::Array(observations) = observations else { + return 0; + }; + let mut deriver = DomainDeriver::new(nonproduction_paths); + let domains = observations + .into_iter() + .map(|raw| match serde_json::from_value::(raw) { + Ok(raw) => deriver.derive(&raw).to_value(), + // A malformed observation describes nothing rather than failing the + // collect; the record referencing it keeps its slot. + Err(_) => ValueDomain::empty().to_value(), + }) + .collect::>(); + let derived = domains.len(); + if let Some(object) = document.as_object_mut() { + object.remove("observations"); + object.insert("domains".to_string(), Value::Array(domains)); + } + derived +} + +impl ValueDomain { + fn empty() -> Self { + Self { + types: Vec::new(), + singletons: Vec::new(), + elements: Vec::new(), + keys: Vec::new(), + values: Vec::new(), + shapes: Vec::new(), + nonproduction: None, + } + } +} diff --git a/gems/fact-mine/src/workload_plan.rs b/gems/fact-mine/src/workload_plan.rs new file mode 100644 index 000000000..65c8eac5e --- /dev/null +++ b/gems/fact-mine/src/workload_plan.rs @@ -0,0 +1,224 @@ +//! Splitting a workload into shards. +//! +//! One shard per test file, so an incremental collect can rerun the tests a +//! change actually touched instead of all of them. A command that names no +//! recognizable test runner gets one opaque shard per command and no such +//! selectivity -- which is correct, not a fallback: nothing about it says which +//! part of it a source change affects. +//! +//! Fingerprinting the test files is the language's own business (Ruby digests a +//! Ripper tree so a reformat is not an edit), so this reports which files need +//! one and the caller fills them in. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Shard { + pub id: String, + pub test_path: String, + pub command: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Plan { + pub mode: &'static str, + pub shards: Vec, + /// Files whose fingerprints the caller must supply: the tests that own a + /// shard, and the support files a change to which invalidates all of them. + pub test_paths: Vec, + pub support_paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Rspec, + Minitest, +} + +/// Which runner a command names. An rspec executable is explicit; minitest is +/// recognized by the test file the command already points at. +pub fn kind_of(command: &[String]) -> Option { + let names_rspec = command.iter().any(|part| { + Path::new(part).file_name().is_some_and(|name| { + name.to_string_lossy().starts_with("rspec") + }) + }); + if names_rspec { + return Some(Kind::Rspec); + } + let joined = command.join(" "); + (joined.contains("_test.rb") || joined.contains("Dir[") && joined.contains("test")) + .then_some(Kind::Minitest) +} + +fn shard_id(relative: &str) -> String { + use sha2::{Digest, Sha256}; + format!("test-{:x}", Sha256::digest(relative.as_bytes()))[..21].to_string() +} + +/// The command that runs exactly one file, built from the one that ran them all. +pub fn command_for(command: &[String], path: &str, kind: Kind) -> Vec { + let basename = |part: &String| { + Path::new(part).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default() + }; + if kind == Kind::Rspec { + if let Some(at) = command.iter().position(|part| basename(part).starts_with("rspec")) { + return command[..=at].iter().cloned().chain([path.to_string()]).collect(); + } + } + // `-e` means the command inlined a loader; the file replaces the whole of it. + if let Some(at) = command.iter().position(|part| part == "-e") { + return command[..at].iter().cloned().chain([path.to_string()]).collect(); + } + if let Some(at) = command.iter().position(|part| { + let name = basename(part); + name == "ruby" + || (name.starts_with("ruby") + && name[4..].chars().all(|c| c.is_ascii_digit() || c == '.')) + }) { + return command[..=at].iter().cloned().chain([path.to_string()]).collect(); + } + vec!["ruby".to_string(), path.to_string()] +} + +fn relative(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .map(|rest| rest.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()) +} + +fn ruby_files(directory: &Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(directory) else { return found }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + found.extend(ruby_files(&path)); + } else if path.extension().is_some_and(|extension| extension == "rb") { + found.push(path); + } + } + found +} + +/// The projects a set of targets belongs to: a `lib`, `src` or `app` directory +/// means the project is its parent, and anything else means the root. +fn projects(targets: &[PathBuf], root: &Path) -> Vec { + let mut found: Vec = Vec::new(); + for target in targets { + let absolute = if target.is_absolute() { target.clone() } else { root.join(target) }; + let directory = if absolute.is_dir() { absolute } else { + absolute.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf()) + }; + let named = directory.file_name().map(|n| n.to_string_lossy().to_string()); + let project = match named.as_deref() { + Some("lib") | Some("src") | Some("app") => { + directory.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf()) + } + _ => root.to_path_buf(), + }; + if !found.contains(&project) { + found.push(project); + } + } + found +} + +pub fn build(targets: &[PathBuf], command: &[String], root: &Path) -> Option { + let kind = kind_of(command)?; + let projects = projects(targets, root); + let suffix = if kind == Kind::Rspec { "_spec.rb" } else { "_test.rb" }; + let directory = if kind == Kind::Rspec { "spec" } else { "test" }; + + let mut entries = projects + .iter() + .flat_map(|project| ruby_files(&project.join(directory))) + .filter(|path| path.to_string_lossy().ends_with(suffix)) + .collect::>(); + entries.sort(); + entries.dedup(); + if entries.is_empty() { + return None; + } + + let mut all = projects + .iter() + .flat_map(|project| { + ["test", "spec"].iter().flat_map(|name| ruby_files(&project.join(name))).collect::>() + }) + .collect::>(); + all.sort(); + all.dedup(); + + let test_paths = entries.iter().map(|path| relative(path, root)).collect::>(); + let support_paths = all + .iter() + .filter(|path| !entries.contains(path)) + .map(|path| relative(path, root)) + .collect::>(); + let shards = entries + .iter() + .map(|path| { + let test_path = relative(path, root); + Shard { + id: shard_id(&test_path), + command: command_for(command, &path.to_string_lossy(), kind), + test_path, + } + }) + .collect(); + Some(Plan { mode: "test_files", shards, test_paths, support_paths }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_shard_id_is_the_relative_path_digest() { + // "test-" plus sixteen hex characters, which is what the manifest and + // every stored snapshot key on. + let id = shard_id("test/a_test.rb"); + assert_eq!(id.len(), 21); + assert!(id.starts_with("test-")); + assert_eq!(id, shard_id("test/a_test.rb")); + assert_ne!(id, shard_id("test/b_test.rb")); + } + + #[test] + fn a_command_is_rewritten_to_run_one_file() { + let ruby = ["bundle", "exec", "ruby", "-Ilib", "-Itest", "x_test.rb"] + .map(str::to_string) + .to_vec(); + assert_eq!( + command_for(&ruby, "test/a_test.rb", Kind::Minitest), + ["bundle", "exec", "ruby", "test/a_test.rb"].map(str::to_string).to_vec() + ); + let rspec = ["bundle", "exec", "rspec", "spec/"].map(str::to_string).to_vec(); + assert_eq!( + command_for(&rspec, "spec/a_spec.rb", Kind::Rspec), + ["bundle", "exec", "rspec", "spec/a_spec.rb"].map(str::to_string).to_vec() + ); + // `-e` inlined a loader; the file replaces the whole of it. + let inline = ["ruby", "-Ilib", "-e", "Dir['test/**'].each"].map(str::to_string).to_vec(); + assert_eq!( + command_for(&inline, "test/a_test.rb", Kind::Minitest), + ["ruby", "-Ilib", "test/a_test.rb"].map(str::to_string).to_vec() + ); + } + + #[test] + fn a_command_naming_no_runner_has_no_plan() { + assert_eq!(kind_of(&["make".to_string(), "check".to_string()]), None); + assert_eq!( + kind_of(&["bundle".to_string(), "exec".to_string(), "rspec".to_string()]), + Some(Kind::Rspec) + ); + assert_eq!( + kind_of(&["ruby".to_string(), "a_test.rb".to_string()]), + Some(Kind::Minitest) + ); + } +} diff --git a/gems/fact-mine/tests/architecture_extraction_multilang_test.rs b/gems/fact-mine/tests/architecture_extraction_multilang_test.rs index 44820eeb7..72a90e419 100644 --- a/gems/fact-mine/tests/architecture_extraction_multilang_test.rs +++ b/gems/fact-mine/tests/architecture_extraction_multilang_test.rs @@ -1,7 +1,7 @@ // Minimal, in-repo fixtures for Espalier-consumed architecture extraction // (owner/function/state facts) across languages, replacing the need to // clone large external OSS repos to validate this specific concern. See -// gems/lineage/docs/agents/lang-support-quality.md for the original +// gems/gigasail/docs/agents/lang-support-quality.md for the original // large-repo validation pass this narrows down to reproducible unit-level // fixtures. // @@ -342,6 +342,27 @@ fn csharp_field_with_braceless_initializer_keeps_its_own_name() { ); } +#[test] +fn csharp_generic_field_type_keeps_nested_commas() { + let document = parse_source( + ".cs", + Language::CSharp, + "class LogEvent {\n\ + readonly Dictionary _properties;\n\ + }\n", + ); + + let declaration = document + .state_declarations + .iter() + .find(|state| state.field == "_properties") + .expect("expected _properties state declaration"); + assert_eq!( + declaration.r#type.as_deref(), + Some("readonly Dictionary") + ); +} + // Real bug, found auditing rich/rich/color.py: Python's state-declaration // heuristic required a `:` type annotation unconditionally, so a plain, // unannotated class-body assignment produced zero state declarations - @@ -450,6 +471,53 @@ fn cpp_struct_with_methods_is_recognized_as_an_owner() { "expected Vec3.increment, got {:?}", document.function_defs ); + assert!( + document + .local_methods + .iter() + .any(|method| method.name == "increment" && method.owner == "Vec3"), + "expected normalized CFG/DFG method Vec3.increment, got {:?}", + document.local_methods + ); +} + +#[test] +fn cpp_template_specialization_methods_reach_cfg_and_complexity_facts() { + let document = parse_source( + ".hpp", + Language::Cpp, + "template \n\ + class Queue;\n\ + template \n\ + class Queue {\n\ + private:\n\ + template \n\ + void dispatch(Event & event, Args &... args) {\n\ + this->directDispatch(event, std::get(args)...);\n\ + }\n\ + };\n", + ); + + let method = document + .local_methods + .iter() + .find(|method| method.name == "dispatch") + .expect("expected a normalized local-flow method for the partial specialization"); + assert_eq!(method.owner, "Queue"); + + let output = profile::extract(&document, Profile::Espalier); + let fact = output + .complexity_facts + .iter() + .find(|fact| fact.function == "dispatch") + .expect("expected complexity facts for the partial-specialization method"); + assert!( + fact.call_contexts + .iter() + .any(|context| context.message == "directDispatch"), + "expected the inherited call to retain its containment context, got {:?}", + fact.call_contexts + ); } // Real bug, found auditing plog's Logger.h: a linkage/visibility macro @@ -501,6 +569,14 @@ fn cpp_linkage_macro_before_class_name_does_not_swallow_the_class_body() { "expected Logger.addAppender to survive the macro-corrupted parse, got {:?}", document.function_defs ); + assert!( + document + .local_methods + .iter() + .any(|method| method.name == "addAppender" && method.owner == "Logger"), + "expected Logger.addAppender to reach the normalized CFG/DFG, got {:?}", + document.local_methods + ); } // Real bug: the linkage-macro fix above rewrites the buffer fed to diff --git a/gems/fact-mine/tests/call_target_oracle.rs b/gems/fact-mine/tests/call_target_oracle.rs index c52e68ce0..e23735374 100644 --- a/gems/fact-mine/tests/call_target_oracle.rs +++ b/gems/fact-mine/tests/call_target_oracle.rs @@ -134,6 +134,28 @@ fn cpp_scoped_free_call_resolves_by_exact_namespace_identity() -> Result<()> { Ok(()) } +#[test] +fn cpp_unqualified_call_in_class_falls_back_to_enclosing_namespace() -> Result<()> { + let output = extract_source( + "namespace demo {\nint helper(int value) { return value; }\nclass Runner { int run() { return helper(1); } };\n}\n", + ".cpp", + Language::Cpp, + )?; + let target = output + .methods + .iter() + .find(|method| method.name == "helper") + .context("missing namespace helper")?; + let call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "helper") + .context("missing unqualified helper call")?; + assert_eq!(call.lexical_symbol.as_deref(), Some("demo::helper")); + assert_eq!(call.target.as_deref(), Some(target.id.as_str())); + Ok(()) +} + #[test] fn cpp_scoped_free_call_never_joins_a_same_name_wrong_namespace() -> Result<()> { let output = extract_source( @@ -151,6 +173,99 @@ fn cpp_scoped_free_call_never_joins_a_same_name_wrong_namespace() -> Result<()> Ok(()) } +#[test] +fn cpp_relative_scoped_call_searches_enclosing_namespaces_across_files() -> Result<()> { + let output = extract_project( + &[ + ( + "util.cpp", + "namespace plog { namespace util { int work(int value) { return value; } } }\n", + ), + ( + "caller.cpp", + "namespace plog { namespace detail { int run() { return util::work(1); } } }\n", + ), + ], + Language::Cpp, + )?; + let target = output + .methods + .iter() + .find(|method| method.lexical_symbol.as_deref() == Some("plog::util::work")) + .context("missing nested C++ namespace target")?; + let call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "util::work") + .context("missing relative scoped call")?; + assert_eq!(call.lexical_symbol.as_deref(), Some("plog::util::work")); + assert_eq!(call.target.as_deref(), Some(target.id.as_str())); + assert_eq!( + call.lexical_symbol_origin.as_deref(), + Some("adapter_relative_lexical_lookup") + ); + Ok(()) +} + +#[test] +fn ruby_relative_module_receiver_resolves_across_nested_files() -> Result<()> { + let output = extract_project( + &[ + ( + "helper.rb", + r#"module App + module Constraints + module FactHelper + module_function + + def render(value) + value + end + end + end +end +"#, + ), + ( + "provider.rb", + r#"module App + module Constraints + module Provider + module_function + + def run(value) + FactHelper.render(value) + end + end + end +end +"#, + ), + ], + Language::Ruby, + )?; + let target = output + .methods + .iter() + .find(|method| method.owner == "App::Constraints::FactHelper" && method.name == "render") + .context("missing nested Ruby module function")?; + let call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "render") + .context("missing nested Ruby module receiver call")?; + assert_eq!( + call.receiver_symbol.as_deref(), + Some("App::Constraints::FactHelper") + ); + assert_eq!( + call.receiver_symbol_origin.as_deref(), + Some("adapter_relative_type_receiver_lookup") + ); + assert_eq!(call.target.as_deref(), Some(target.id.as_str())); + Ok(()) +} + #[test] fn python_explicit_import_resolves_an_exact_project_lexical_target() -> Result<()> { let output = extract_project( @@ -1146,9 +1261,15 @@ fn c_function_like_macros_are_not_reported_as_missing_declarations() -> Result<( .find(|call| call.message == "project_value") .context("missing macro invocation")?; assert!(call.preprocessor_callable); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!( + call.complexity_provenance.as_deref(), + Some("source_preprocessor_definition") + ); assert_eq!( call.empty_domain_cause.as_deref(), - Some("macro_or_preprocessor_surface") + None, + "a bounded source definition is modeled, not a missing declaration" ); Ok(()) } @@ -1328,6 +1449,44 @@ fn conservative_inherited_dispatch_resolves_exact_native_declarations() -> Resul Ok(()) } +#[test] +fn cpp_template_specialization_resolves_implicit_inherited_calls() -> Result<()> { + let output = extract_source( + "template class Base;\n\ + template class Base {\n\ + public: struct Nested { void work() {} }; void work() {}\n\ + };\n\ + template class Child;\n\ + template class Child : public Base {\n\ + public: void run() { work(); }\n\ + };\n", + ".cpp", + Language::Cpp, + )?; + let target = output + .methods + .iter() + .find(|method| { + method.owner.starts_with("Base") + && !method.owner.contains("::Nested") + && method.name == "work" + }) + .context("missing C++ Base::work")?; + let call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "work") + .context("missing inherited C++ work call")?; + + assert!(output + .owners + .iter() + .find(|owner| owner.name.starts_with("Child<")) + .is_some_and(|owner| owner.supertypes.iter().any(|name| name.contains('<')))); + assert_eq!(call.target.as_deref(), Some(target.id.as_str())); + Ok(()) +} + #[test] fn language_adapters_extract_only_native_direct_supertype_clauses() -> Result<()> { for (source, suffix, language, owner_name, expected) in [ @@ -1406,3 +1565,187 @@ fn csharp_namespace_is_retained_as_canonical_owner_identity() -> Result<()> { assert_eq!(target.symbol_owner.as_deref(), Some("Demo.Core.Target")); Ok(()) } + +/// A `base.field` receiver whose base type resolves must inherit the declared +/// field type, so `b.f.work()` resolves to the field type's method. This is a +/// language-neutral path (reads the declared field table), verified across the +/// statically-typed adapters that populate it. +#[test] +fn field_access_receiver_inherits_declared_field_type() -> Result<()> { + let cases: &[(&str, &str, Language)] = &[ + ( + "go", + "package demo\ntype Foo struct{}\nfunc (f Foo) Work() int { return 1 }\ntype Box struct { f Foo }\nfunc Field(b Box) int { return b.f.Work() }\n", + Language::Go, + ), + ( + "rust", + "struct Foo;\nimpl Foo { fn work(&self) -> i32 { 1 } }\nstruct Box { f: Foo }\nfn field(b: &Box) -> i32 { b.f.work() }\n", + Language::Rust, + ), + ( + "java", + "class Foo { int work() { return 1; } }\nclass Box { Foo f; }\nclass M { int field(Box b) { return b.f.work(); } }\n", + Language::Java, + ), + ( + "swift", + "struct Foo { func work() -> Int { return 1 } }\nstruct Box {\n let f: Foo\n}\nfunc field(_ b: Box) -> Int { return b.f.work() }\n", + Language::Swift, + ), + ]; + for (label, source, language) in cases { + let suffix = format!(".{label}"); + let output = extract_source(source, &suffix, *language)?; + let call = output + .calls + .iter() + .find(|call| call.message.eq_ignore_ascii_case("work")) + .with_context(|| format!("{label}: missing work call"))?; + assert!( + call.target.is_some(), + "{label}: b.f.work() must resolve a target (got receiver_type {:?})", + call.receiver_type + ); + assert_eq!( + call.receiver_type_origin.as_deref(), + Some("field_access"), + "{label}: receiver type must come from the field-access resolver", + ); + } + Ok(()) +} + +#[test] +fn explicit_type_arguments_do_not_replace_the_call_message() -> Result<()> { + let cases: &[(&str, &str, Language, &str, &str)] = &[ + ( + "rust-method", + "fn parse_num(text: &str) -> i64 { text.parse::().unwrap_or(0) }\n", + Language::Rust, + "text", + "parse", + ), + ( + "rust-chained", + "fn names(rows: &[String]) -> Vec { rows.iter().cloned().collect::>() }\n", + Language::Rust, + "rows.iter().cloned()", + "collect", + ), + ( + "rust-free-function", + "fn ident(x: T) -> T { x }\nfn run() -> i32 { ident::(1) }\n", + Language::Rust, + "self", + "ident", + ), + ]; + for (label, source, language, receiver, message) in cases { + let output = extract_source(source, ".rs", *language)?; + assert!( + output + .calls + .iter() + .any(|call| call.message == *message && call.receiver == *receiver), + "{label}: expected `{receiver}.{message}` call, got {:?}", + output + .calls + .iter() + .map(|call| (call.receiver.clone(), call.message.clone())) + .collect::>() + ); + assert!( + !output + .calls + .iter() + .any(|call| call.message.starts_with('<')), + "{label}: type arguments must not be extracted as a call message, got {:?}", + output + .calls + .iter() + .map(|call| (call.receiver.clone(), call.message.clone())) + .collect::>() + ); + } + Ok(()) +} + +#[test] +fn synthetic_lambda_names_survive_qualified_name_handling() -> Result<()> { + // `` carries a row:column span, not a namespace. The shared + // qualified-name split reported such a method as `24>`. + let source = "package demo\nfunc helper(v int) int { return v }\nfunc run() { _ = func(x int) int { return helper(x) } }\n"; + let mut file = tempfile::Builder::new().suffix(".go").tempfile()?; + file.write_all(source.as_bytes())?; + let document = syntax::parse_file(file.path().to_path_buf(), Language::Go)?; + let names = document + .protocol_call_paths + .iter() + .map(|path| path.name.clone()) + .chain( + document + .protocol_method_effects + .iter() + .map(|effect| effect.name.clone()), + ) + .collect::>(); + assert!( + names + .iter() + .any(|name| name.starts_with("')), + "expected an intact synthetic lambda name, got {names:?}" + ); + assert!( + !names + .iter() + .any(|name| name.ends_with('>') && !name.starts_with('<')), + "a lambda name was split on its span separator, got {names:?}" + ); + Ok(()) +} + +#[test] +fn closures_are_extracted_as_first_class_functions() -> Result<()> { + // A callback's cost can only be substituted for a callee's parametric C if + // the callable itself was analyzed as a function. + let cases: &[(&str, &str, &str, Language)] = &[ + ( + "rust", + "fn run(xs: &[i32]) -> Vec { xs.iter().map(|x| helper(*x)).collect() }\nfn helper(v: i32) -> i32 { v }\n", + ".rs", + Language::Rust, + ), + ( + "go", + "package demo\nfunc helper(v int) int { return v }\nfunc run(xs []int) { _ = func(x int) int { return helper(x) } }\n", + ".go", + Language::Go, + ), + ]; + for (label, source, suffix, language) in cases { + let output = extract_source(source, suffix, *language)?; + let lambda = output + .methods + .iter() + .find(|method| method.name.starts_with(">() + ) + })?; + assert!( + output + .calls + .iter() + .any(|call| call.source == lambda.id && call.message == "helper"), + "{label}: the closure body's call must attribute to the closure, not its enclosing function" + ); + } + Ok(()) +} diff --git a/gems/fact-mine/tests/fixtures/go_crossfile_type.go b/gems/fact-mine/tests/fixtures/go_crossfile_type.go new file mode 100644 index 000000000..12450c9c6 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_crossfile_type.go @@ -0,0 +1,14 @@ +package pkgx + +type Builder struct { + buf []byte +} + +func (b *Builder) WriteString(s string) int { + b.buf = append(b.buf, s...) + return len(s) +} + +func (b *Builder) Len() int { + return len(b.buf) +} diff --git a/gems/fact-mine/tests/fixtures/go_crossfile_use.go b/gems/fact-mine/tests/fixtures/go_crossfile_use.go new file mode 100644 index 000000000..94b0e4e11 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_crossfile_use.go @@ -0,0 +1,8 @@ +package pkgx + +func writeAll(b *Builder, parts []string) int { + for _, p := range parts { + b.WriteString(p) + } + return b.Len() +} diff --git a/gems/fact-mine/tests/fixtures/go_dispatch_satisfaction.go b/gems/fact-mine/tests/fixtures/go_dispatch_satisfaction.go new file mode 100644 index 000000000..e777b0db9 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_dispatch_satisfaction.go @@ -0,0 +1,15 @@ +package sat + +type Sorter interface { + Len() int + Less(i, j int) bool +} + +type Ints []int + +func (x Ints) Len() int { return len(x) } +func (x Ints) Less(i, j int) bool { return x[i] < x[j] } + +type Partial struct{ n int } + +func (Partial) Len() int { return 0 } diff --git a/gems/fact-mine/tests/fixtures/go_embed_base.go b/gems/fact-mine/tests/fixtures/go_embed_base.go new file mode 100644 index 000000000..e39c5c1b2 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_embed_base.go @@ -0,0 +1,14 @@ +package pkga + +type Buffer struct { + data []byte +} + +func (b *Buffer) WriteString(s string) int { + b.data = append(b.data, s...) + return len(s) +} + +func (b *Buffer) Len() int { + return len(b.data) +} diff --git a/gems/fact-mine/tests/fixtures/go_embed_user.go b/gems/fact-mine/tests/fixtures/go_embed_user.go new file mode 100644 index 000000000..064f55c3b --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_embed_user.go @@ -0,0 +1,13 @@ +package pkgb + +import "example/pkga" + +type encoder struct { + pkga.Buffer + depth int +} + +func (e *encoder) emit(s string) int { + e.WriteString(s) + return e.Len() +} diff --git a/gems/fact-mine/tests/fixtures/go_interface_dispatch.go b/gems/fact-mine/tests/fixtures/go_interface_dispatch.go new file mode 100644 index 000000000..7fb56257e --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_interface_dispatch.go @@ -0,0 +1,15 @@ +package dispatch + +type Comparer interface { + Less(i, j int) bool +} + +func countInversions(c Comparer, n int) int { + total := 0 + for i := 0; i < n; i++ { + if c.Less(i, i+1) { + total++ + } + } + return total +} diff --git a/gems/fact-mine/tests/fixtures/go_named_type_conversion.go b/gems/fact-mine/tests/fixtures/go_named_type_conversion.go new file mode 100644 index 000000000..2366c0a69 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_named_type_conversion.go @@ -0,0 +1,11 @@ +package conv + +type ByteCode byte + +func classify(b byte) ByteCode { + return ByteCode(b) +} + +func classifyAll(bs []byte) ByteCode { + return classify(bs[0]) +} diff --git a/gems/fact-mine/tests/fixtures/go_pkg_func_from_method.go b/gems/fact-mine/tests/fixtures/go_pkg_func_from_method.go new file mode 100644 index 000000000..3f19b2435 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_pkg_func_from_method.go @@ -0,0 +1,13 @@ +package pkg + +type State struct { + n int +} + +func helper(x int) int { + return x + 1 +} + +func (s *State) compute() int { + return helper(s.n) +} diff --git a/gems/fact-mine/tests/fixtures/go_self_calls.go b/gems/fact-mine/tests/fixtures/go_self_calls.go new file mode 100644 index 000000000..63a5cf6e6 --- /dev/null +++ b/gems/fact-mine/tests/fixtures/go_self_calls.go @@ -0,0 +1,40 @@ +package selfcalls + +type Element struct { + next, prev *Element + Value any +} + +type List struct { + root Element + len int +} + +func (l *List) lazyInit() { + if l.root.next == nil { + l.root.next = &l.root + } +} + +func (l *List) insert(e, at *Element) *Element { + e.prev = at + l.len++ + return e +} + +func (l *List) insertValue(v any, at *Element) *Element { + return l.insert(&Element{Value: v}, at) +} + +func (l *List) PushBack(v any) *Element { + l.lazyInit() + return l.insertValue(v, l.root.prev) +} + +func rawHelper(x int) int { + return x + 1 +} + +func rawCaller(x int) int { + return rawHelper(x) +} diff --git a/gems/fact-mine/tests/fixtures/normalizer/oracles/go-go_complex.json b/gems/fact-mine/tests/fixtures/normalizer/oracles/go-go_complex.json index 990acda9b..678dc0496 100644 --- a/gems/fact-mine/tests/fixtures/normalizer/oracles/go-go_complex.json +++ b/gems/fact-mine/tests/fixtures/normalizer/oracles/go-go_complex.json @@ -40,72 +40,58 @@ { "Node": { "children": [ - { - "Node": { - "children": [ - { - "String": "os" - } - ], - "first_column": 31, - "first_lineno": 4, - "last_column": 33, - "last_lineno": 4, - "text": "os", - "type": "LVAR" - } - }, { "Node": { "children": [ { "Node": { "children": [ + { + "String": "os" + }, { "Node": { "children": [ { - "String": "\"darwin\"" - } + "Node": { + "children": [ + { + "String": "runtime" + } + ], + "first_column": 17, + "first_lineno": 4, + "last_column": 24, + "last_lineno": 4, + "text": "runtime", + "type": "LVAR" + } + }, + { + "Symbol": "GOOS" + }, + "Nil" ], - "first_column": 9, - "first_lineno": 5, - "last_column": 17, - "last_lineno": 5, - "text": "\"darwin\"", - "type": "STR" + "first_column": 17, + "first_lineno": 4, + "last_column": 29, + "last_lineno": 4, + "text": "runtime.GOOS", + "type": "CALL" } } ], - "first_column": 4, - "first_lineno": 5, - "last_column": 0, - "last_lineno": 7, - "text": "case \"darwin\":\n fmt.Println(\"OS X.\")\n", - "type": "LIST" + "first_column": 11, + "first_lineno": 4, + "last_column": 29, + "last_lineno": 4, + "text": "os := runtime.GOOS", + "type": "LASGN" } }, { "Node": { "children": [ - { - "Node": { - "children": [ - { - "String": "fmt" - } - ], - "first_column": 8, - "first_lineno": 6, - "last_column": 11, - "last_lineno": 6, - "text": "fmt", - "type": "LVAR" - } - }, - { - "Symbol": "Println" - }, { "Node": { "children": [ @@ -113,33 +99,64 @@ "Node": { "children": [ { - "String": "\"OS X.\"" + "String": "runtime" } ], - "first_column": 20, - "first_lineno": 6, - "last_column": 27, - "last_lineno": 6, - "text": "\"OS X.\"", - "type": "STR" + "first_column": 17, + "first_lineno": 4, + "last_column": 24, + "last_lineno": 4, + "text": "runtime", + "type": "LVAR" } - } + }, + { + "Symbol": "GOOS" + }, + "Nil" ], - "first_column": 8, - "first_lineno": 6, - "last_column": 28, - "last_lineno": 6, - "text": "fmt.Println(\"OS X.\")", - "type": "LIST" + "first_column": 17, + "first_lineno": 4, + "last_column": 29, + "last_lineno": 4, + "text": "runtime.GOOS", + "type": "CALL" } } ], - "first_column": 8, - "first_lineno": 6, - "last_column": 28, - "last_lineno": 6, - "text": "fmt.Println(\"OS X.\")", - "type": "CALL" + "first_column": 17, + "first_lineno": 4, + "last_column": 29, + "last_lineno": 4, + "text": "runtime.GOOS", + "type": "EXPRESSION_LIST" + } + } + ], + "first_column": 11, + "first_lineno": 4, + "last_column": 29, + "last_lineno": 4, + "text": "os := runtime.GOOS", + "type": "SHORT_VAR_DECLARATION" + } + }, + { + "Node": { + "children": [ + { + "Node": { + "children": [ + { + "String": "os" + } + ], + "first_column": 31, + "first_lineno": 4, + "last_column": 33, + "last_lineno": 4, + "text": "os", + "type": "LVAR" } }, { @@ -152,23 +169,23 @@ "Node": { "children": [ { - "String": "\"linux\"" + "String": "\"darwin\"" } ], "first_column": 9, - "first_lineno": 7, - "last_column": 16, - "last_lineno": 7, - "text": "\"linux\"", + "first_lineno": 5, + "last_column": 17, + "last_lineno": 5, + "text": "\"darwin\"", "type": "STR" } } ], "first_column": 4, - "first_lineno": 7, + "first_lineno": 5, "last_column": 0, - "last_lineno": 9, - "text": "case \"linux\":\n fmt.Println(\"Linux.\")\n", + "last_lineno": 7, + "text": "case \"darwin\":\n fmt.Println(\"OS X.\")\n", "type": "LIST" } }, @@ -183,9 +200,9 @@ } ], "first_column": 8, - "first_lineno": 8, + "first_lineno": 6, "last_column": 11, - "last_lineno": 8, + "last_lineno": 6, "text": "fmt", "type": "LVAR" } @@ -200,32 +217,32 @@ "Node": { "children": [ { - "String": "\"Linux.\"" + "String": "\"OS X.\"" } ], "first_column": 20, - "first_lineno": 8, - "last_column": 28, - "last_lineno": 8, - "text": "\"Linux.\"", + "first_lineno": 6, + "last_column": 27, + "last_lineno": 6, + "text": "\"OS X.\"", "type": "STR" } } ], "first_column": 8, - "first_lineno": 8, - "last_column": 29, - "last_lineno": 8, - "text": "fmt.Println(\"Linux.\")", + "first_lineno": 6, + "last_column": 28, + "last_lineno": 6, + "text": "fmt.Println(\"OS X.\")", "type": "LIST" } } ], "first_column": 8, - "first_lineno": 8, - "last_column": 29, - "last_lineno": 8, - "text": "fmt.Println(\"Linux.\")", + "first_lineno": 6, + "last_column": 28, + "last_lineno": 6, + "text": "fmt.Println(\"OS X.\")", "type": "CALL" } }, @@ -236,19 +253,85 @@ "Node": { "children": [ { - "String": "fmt" + "Node": { + "children": [ + { + "String": "\"linux\"" + } + ], + "first_column": 9, + "first_lineno": 7, + "last_column": 16, + "last_lineno": 7, + "text": "\"linux\"", + "type": "STR" + } } ], - "first_column": 8, - "first_lineno": 10, - "last_column": 11, - "last_lineno": 10, - "text": "fmt", - "type": "LVAR" + "first_column": 4, + "first_lineno": 7, + "last_column": 0, + "last_lineno": 9, + "text": "case \"linux\":\n fmt.Println(\"Linux.\")\n", + "type": "LIST" } }, { - "Symbol": "Printf" + "Node": { + "children": [ + { + "Node": { + "children": [ + { + "String": "fmt" + } + ], + "first_column": 8, + "first_lineno": 8, + "last_column": 11, + "last_lineno": 8, + "text": "fmt", + "type": "LVAR" + } + }, + { + "Symbol": "Println" + }, + { + "Node": { + "children": [ + { + "Node": { + "children": [ + { + "String": "\"Linux.\"" + } + ], + "first_column": 20, + "first_lineno": 8, + "last_column": 28, + "last_lineno": 8, + "text": "\"Linux.\"", + "type": "STR" + } + } + ], + "first_column": 8, + "first_lineno": 8, + "last_column": 29, + "last_lineno": 8, + "text": "fmt.Println(\"Linux.\")", + "type": "LIST" + } + } + ], + "first_column": 8, + "first_lineno": 8, + "last_column": 29, + "last_lineno": 8, + "text": "fmt.Println(\"Linux.\")", + "type": "CALL" + } }, { "Node": { @@ -257,30 +340,60 @@ "Node": { "children": [ { - "String": "\"%s.\\n\"" + "String": "fmt" } ], - "first_column": 19, + "first_column": 8, "first_lineno": 10, - "last_column": 26, + "last_column": 11, "last_lineno": 10, - "text": "\"%s.\\n\"", - "type": "STR" + "text": "fmt", + "type": "LVAR" } }, + { + "Symbol": "Printf" + }, { "Node": { "children": [ { - "String": "os" + "Node": { + "children": [ + { + "String": "\"%s.\\n\"" + } + ], + "first_column": 19, + "first_lineno": 10, + "last_column": 26, + "last_lineno": 10, + "text": "\"%s.\\n\"", + "type": "STR" + } + }, + { + "Node": { + "children": [ + { + "String": "os" + } + ], + "first_column": 28, + "first_lineno": 10, + "last_column": 30, + "last_lineno": 10, + "text": "os", + "type": "LVAR" + } } ], - "first_column": 28, + "first_column": 8, "first_lineno": 10, - "last_column": 30, + "last_column": 31, "last_lineno": 10, - "text": "os", - "type": "LVAR" + "text": "fmt.Printf(\"%s.\\n\", os)", + "type": "LIST" } } ], @@ -289,34 +402,34 @@ "last_column": 31, "last_lineno": 10, "text": "fmt.Printf(\"%s.\\n\", os)", - "type": "LIST" + "type": "CALL" } } ], - "first_column": 8, - "first_lineno": 10, - "last_column": 31, - "last_lineno": 10, - "text": "fmt.Printf(\"%s.\\n\", os)", - "type": "CALL" + "first_column": 4, + "first_lineno": 7, + "last_column": 0, + "last_lineno": 9, + "text": "case \"linux\":\n fmt.Println(\"Linux.\")\n", + "type": "WHEN" } } ], "first_column": 4, - "first_lineno": 7, + "first_lineno": 5, "last_column": 0, - "last_lineno": 9, - "text": "case \"linux\":\n fmt.Println(\"Linux.\")\n", + "last_lineno": 7, + "text": "case \"darwin\":\n fmt.Println(\"OS X.\")\n", "type": "WHEN" } } ], "first_column": 4, - "first_lineno": 5, - "last_column": 0, - "last_lineno": 7, - "text": "case \"darwin\":\n fmt.Println(\"OS X.\")\n", - "type": "WHEN" + "first_lineno": 4, + "last_column": 5, + "last_lineno": 11, + "text": "switch os := runtime.GOOS; os {\n case \"darwin\":\n fmt.Println(\"OS X.\")\n case \"linux\":\n fmt.Println(\"Linux.\")\n default:\n fmt.Printf(\"%s.\\n\", os)\n }", + "type": "CASE" } } ], @@ -325,7 +438,7 @@ "last_column": 5, "last_lineno": 11, "text": "switch os := runtime.GOOS; os {\n case \"darwin\":\n fmt.Println(\"OS X.\")\n case \"linux\":\n fmt.Println(\"Linux.\")\n default:\n fmt.Printf(\"%s.\\n\", os)\n }", - "type": "CASE" + "type": "BEGIN" } }, { diff --git a/gems/fact-mine/tests/fixtures/normalizer/oracles/java-java_complex.json b/gems/fact-mine/tests/fixtures/normalizer/oracles/java-java_complex.json index b528c0b5f..dbb954a81 100644 --- a/gems/fact-mine/tests/fixtures/normalizer/oracles/java-java_complex.json +++ b/gems/fact-mine/tests/fixtures/normalizer/oracles/java-java_complex.json @@ -648,15 +648,49 @@ "Node": { "children": [ { - "Symbol": "String" + "Node": { + "children": [ + { + "Symbol": "String" + } + ], + "first_column": 28, + "first_lineno": 16, + "last_column": 34, + "last_lineno": 16, + "text": "String", + "type": "CONST" + } + }, + { + "Node": { + "children": [], + "first_column": 34, + "first_lineno": 16, + "last_column": 36, + "last_lineno": 16, + "text": "[]", + "type": "DIMENSIONS" + } + }, + { + "Node": { + "children": [], + "first_column": 36, + "first_lineno": 16, + "last_column": 38, + "last_lineno": 16, + "text": "{}", + "type": "ARRAY_INITIALIZER" + } } ], - "first_column": 13, + "first_column": 24, "first_lineno": 16, - "last_column": 19, + "last_column": 38, "last_lineno": 16, - "text": "String", - "type": "CONST" + "text": "new String[]{}", + "type": "ARRAY_CREATION_EXPRESSION" } }, "Nil" diff --git a/gems/fact-mine/tests/fixtures/normalizer/oracles/kotlin-kotlin_complex.json b/gems/fact-mine/tests/fixtures/normalizer/oracles/kotlin-kotlin_complex.json index 9a35502b6..c3a95a662 100644 --- a/gems/fact-mine/tests/fixtures/normalizer/oracles/kotlin-kotlin_complex.json +++ b/gems/fact-mine/tests/fixtures/normalizer/oracles/kotlin-kotlin_complex.json @@ -31,13 +31,86 @@ "Node": { "children": [ { - "Symbol": "mixed" + "Symbol": "Billing" }, { "Node": { "children": [ "Nil", "Nil", + "Nil" + ], + "first_column": 0, + "first_lineno": 1, + "last_column": 1, + "last_lineno": 22, + "text": "class Billing {\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", + "type": "SCOPE" + } + } + ], + "first_column": 6, + "first_lineno": 1, + "last_column": 1, + "last_lineno": 22, + "text": "Billing {\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", + "type": "DEFN" + } + }, + { + "Node": { + "children": [ + { + "Symbol": "mixed" + }, + { + "Node": { + "children": [ + "Nil", + { + "Node": { + "children": [ + { + "Node": { + "children": [ + { + "Symbol": "price" + }, + "Nil" + ], + "first_column": 14, + "first_lineno": 2, + "last_column": 24, + "last_lineno": 2, + "text": "price: Int", + "type": "LASGN" + } + }, + { + "Node": { + "children": [ + { + "Symbol": "tax" + }, + "Nil" + ], + "first_column": 26, + "first_lineno": 2, + "last_column": 34, + "last_lineno": 2, + "text": "tax: Int", + "type": "LASGN" + } + } + ], + "first_column": 4, + "first_lineno": 2, + "last_column": 5, + "last_lineno": 11, + "text": "fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }", + "type": "ARGS" + } + }, { "Node": { "children": [ @@ -700,20 +773,20 @@ } } ], - "first_column": 14, + "first_column": 0, "first_lineno": 1, "last_column": 1, "last_lineno": 22, - "text": "{\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", + "text": "class Billing {\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", "type": "BLOCK" } } ], - "first_column": 14, + "first_column": 0, "first_lineno": 1, "last_column": 1, "last_lineno": 22, - "text": "{\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", + "text": "class Billing {\n fun mixed(price: Int, tax: Int): Int {\n val result = try {\n price + tax\n } catch (e: Exception) {\n 0\n } finally {\n println(\"Done\")\n }\n return result\n }\n \n fun loops() {\n for (i in 0..10) {\n if (i % 2 == 0) continue\n }\n var x = 0\n while (x < 10) {\n x++\n }\n }\n}", "type": "SCOPE" } } diff --git a/gems/fact-mine/tests/fixtures/normalizer/oracles/rust-rust_complex.json b/gems/fact-mine/tests/fixtures/normalizer/oracles/rust-rust_complex.json index 43d5cd0bd..bf3ed0b22 100644 --- a/gems/fact-mine/tests/fixtures/normalizer/oracles/rust-rust_complex.json +++ b/gems/fact-mine/tests/fixtures/normalizer/oracles/rust-rust_complex.json @@ -160,6 +160,17 @@ "text": "{\n break;\n }", "type": "BLOCK" } + }, + { + "Node": { + "children": [], + "first_column": 8, + "first_lineno": 8, + "last_column": 13, + "last_lineno": 8, + "text": "break", + "type": "BREAK" + } } ], "first_column": 4, @@ -167,7 +178,7 @@ "last_column": 5, "last_lineno": 9, "text": "loop {\n break;\n }", - "type": "LOOP_EXPRESSION" + "type": "WHILE" } }, { diff --git a/gems/fact-mine/tests/fixtures/widget.go b/gems/fact-mine/tests/fixtures/widget.go new file mode 100644 index 000000000..c6e9b484a --- /dev/null +++ b/gems/fact-mine/tests/fixtures/widget.go @@ -0,0 +1,13 @@ +package widgets + +type widget struct { + n int +} + +func (w *widget) tally() int { + return w.n +} + +func use(w *widget) int { + return w.tally() +} diff --git a/gems/fact-mine/tests/incremental_collect.rs b/gems/fact-mine/tests/incremental_collect.rs new file mode 100644 index 000000000..33e6c0664 --- /dev/null +++ b/gems/fact-mine/tests/incremental_collect.rs @@ -0,0 +1,330 @@ +//! An incremental collect, end to end, with no Ruby but the traced program. +//! +//! The property that matters is not that `--fast` is quick -- it is that the +//! evidence it leaves behind is the evidence a full collect would have left. +//! An increment that reruns too few shards is indistinguishable from a correct +//! one until something downstream reads evidence for code that no longer +//! exists, so every case here checks what was written, not just that it ran. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const BINARY: &str = env!("CARGO_BIN_EXE_fact-mine-rust"); + +/// The collector object the traced program loads. Built out of tree by the +/// gem's extconf, so its absence means "not built here", not "broken". +fn collector() -> Option { + let path = Path::new(BINARY) + .ancestors() + .nth(4)? + .join("gems/nil-kill/ext/nil_kill_trace/nil_kill_trace.so"); + path.is_file().then_some(path) +} + +fn ruby_available() -> bool { + Command::new("ruby").arg("-e").arg("").status().is_ok_and(|status| status.success()) +} + +struct Project { + root: tempfile::TempDir, +} + +impl Project { + fn new() -> Self { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path(); + std::fs::create_dir_all(path.join("lib")).expect("lib"); + std::fs::create_dir_all(path.join("test")).expect("test"); + std::fs::write( + path.join("lib/calculator.rb"), + "class Calculator\n def double(value)\n value * 2\n end\n\n \ + def triple(value)\n value * 3\n end\nend\n", + ) + .expect("write"); + for (name, method, expected, argument) in [ + ("double", "double", 8, 4), + ("triple", "triple", 9, 3), + ] { + std::fs::write( + path.join(format!("test/{name}_test.rb")), + format!( + "require \"minitest/autorun\"\nrequire_relative \"../lib/calculator\"\n\ + class {name}Test < Minitest::Test\n def test_{name}\n \ + assert_equal {expected}, Calculator.new.{method}({argument})\n end\nend\n" + ), + ) + .expect("write"); + } + Self { root } + } + + fn path(&self) -> &Path { + self.root.path() + } + + fn write(&self, relative: &str, contents: &str) { + std::fs::write(self.path().join(relative), contents).expect("write"); + } + + fn edit(&self, relative: &str, from: &str, to: &str) { + let path = self.path().join(relative); + let source = std::fs::read_to_string(&path).expect("read"); + assert!(source.contains(from), "{relative} does not contain {from:?}"); + std::fs::write(&path, source.replace(from, to)).expect("write"); + } + + fn collect(&self, arguments: &[&str]) -> (String, bool) { + let root = self.path(); + let mut command = Command::new(BINARY); + command + .arg("nil-kill-collect") + .arg("--root") + .arg(root) + .args(arguments) + .env("NIL_KILL_ROOT", root) + .env("NIL_KILL_TARGETS", root.join("lib")) + .env("NIL_KILL_TMP_DIR", root.join(".nil-kill")); + if let Some(extension) = collector() { + command.env("NIL_KILL_COLLECTOR_EXTENSION", extension); + } + let output = command.output().expect("collect"); + ( + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + output.status.success(), + ) + } + + /// A full collect over both test files, one shard each. + fn full(&self) -> (String, bool) { + let loader = format!( + "Dir['{}'].sort.each {{ |file| require file }}", + self.path().join("test/*_test.rb").display() + ); + self.collect(&[ + "--", + "ruby", + "-I", + &self.path().join("lib").to_string_lossy(), + "-e", + &loader, + ]) + } + + fn fast(&self) -> (String, bool) { + self.collect(&["--fast"]) + } + + fn read_gz(&self, relative: &str) -> Value { + use std::io::Read; + let bytes = std::fs::read(self.path().join(relative)).expect("read"); + let mut text = String::new(); + flate2::read::GzDecoder::new(&bytes[..]).read_to_string(&mut text).expect("gunzip"); + serde_json::from_str(&text).expect("json") + } + + fn manifest(&self) -> Value { + self.read_gz(".nil-kill/runtime/runtime-snapshot.json.gz") + } + + fn stored_shards(&self) -> usize { + std::fs::read_dir(self.path().join(".nil-kill/runtime/shard-evidence")) + .into_iter() + .flatten() + .flatten() + .count() + } + + /// Anchors with the identity of the run that produced them removed, which + /// is the only thing two collects of the same code may differ in. + fn evidence(&self) -> Vec { + let document = self.read_gz(".nil-kill/runtime/runtime-evidence.v1.json.gz"); + let mut rows = document["anchors"] + .as_array() + .into_iter() + .flatten() + .map(|anchor| { + let mut anchor = anchor.clone(); + anchor["capture"].as_object_mut().map(|map| map.remove("run_ids")); + for execution in anchor["executions"].as_array_mut().into_iter().flatten() { + execution["provenance"].as_object_mut().map(|map| map.remove("run_id")); + } + serde_json::to_string(&anchor).unwrap_or_default() + }) + .collect::>(); + rows.sort(); + rows + } +} + +fn skip_unless_collectable() -> bool { + if collector().is_none() { + eprintln!("skipping: the collector extension is not built"); + return true; + } + if !ruby_available() { + eprintln!("skipping: no ruby to trace"); + return true; + } + false +} + +#[test] +fn an_increment_leaves_the_evidence_a_full_collect_would_have() { + if skip_unless_collectable() { + return; + } + let project = Project::new(); + let (output, ok) = project.full(); + assert!(ok, "full collect failed: {output}"); + assert_eq!(project.manifest()["generation"], 0); + assert_eq!(project.manifest()["mode"], "full"); + assert_eq!(project.stored_shards(), 2); + // What each shard reached is what makes the next increment selective; a + // manifest that recorded nothing would rerun everything forever, silently. + let manifest = project.manifest(); + for field in ["dependencies", "callsites"] { + let recorded = manifest[field].as_object().expect(field); + assert_eq!(recorded.len(), 2, "{field}: {recorded:?}"); + assert!( + recorded.values().all(|entry| !entry.as_array().expect("array").is_empty()), + "{field}: {recorded:?}" + ); + } + + // Nothing changed: the workload does not run at all. + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("workload skipped"), "{output}"); + assert_eq!(project.manifest()["generation"], 0, "a skipped collect is not a generation"); + + // Reformatting is not an edit, so neither is this. + project.edit("lib/calculator.rb", "value * 2", "value * 2 # doubled"); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("workload skipped"), "reformatting retraced: {output}"); + + // A real edit reruns the one shard whose evidence depended on it. + project.edit("lib/calculator.rb", "value * 2 # doubled", "value + value"); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("1 changed functions"), "{output}"); + assert!(output.contains("1 traced shards"), "{output}"); + + // ... and the result is still the whole picture, not just that shard's. + let incremental = project.evidence(); + let reference = Project::new(); + reference.edit("lib/calculator.rb", "value * 2", "value + value"); + let (output, ok) = reference.full(); + assert!(ok, "{output}"); + assert_eq!(incremental, reference.evidence()); +} + +#[test] +fn a_workload_change_reruns_what_it_has_to_and_no_more() { + if skip_unless_collectable() { + return; + } + let project = Project::new(); + let (output, ok) = project.full(); + assert!(ok, "{output}"); + + // A new test file is a new shard. + project.write( + "test/added_test.rb", + "require \"minitest/autorun\"\nrequire_relative \"../lib/calculator\"\n\ + class AddedTest < Minitest::Test\n def test_added\n \ + assert_equal 10, Calculator.new.double(5)\n end\nend\n", + ); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("1 changed tests"), "{output}"); + assert!(output.contains("1 traced shards"), "{output}"); + assert_eq!(project.stored_shards(), 3); + + // A support file is not attributable to any one shard, so all of them go. + project.write("test/test_helper.rb", "HELPER_VERSION = 1\n"); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("3 traced shards"), "{output}"); + assert_eq!(project.manifest()["support_changed"], true); + assert_eq!(project.manifest()["fallback_full"], true); + + // A deleted test takes its stored evidence with it and runs nothing. + std::fs::remove_file(project.path().join("test/added_test.rb")).expect("delete"); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert!(output.contains("0 traced shards"), "{output}"); + assert_eq!(project.manifest()["deleted_tests"], serde_json::json!(["test/added_test.rb"])); + assert_eq!(project.stored_shards(), 2); +} + +#[test] +fn a_failing_shard_leaves_the_previous_evidence_exactly_where_it_was() { + if skip_unless_collectable() { + return; + } + let project = Project::new(); + let (output, ok) = project.full(); + assert!(ok, "{output}"); + let before = project.evidence(); + let generation = project.manifest()["generation"].clone(); + + project.edit("test/double_test.rb", "assert_equal 8", "raise \"trace failure\" #"); + let (output, ok) = project.fast(); + + assert!(!ok, "a failing shard must fail the collect: {output}"); + assert!(output.contains("canonical evidence was not replaced"), "{output}"); + assert_eq!(project.evidence(), before); + assert_eq!(project.manifest()["generation"], generation); + assert_eq!(project.manifest()["complete"], false); + assert_eq!(project.manifest()["potentially_stale"], true); + assert!( + project.manifest()["stale_reason"] + .as_str() + .is_some_and(|reason| reason.contains("required trace shard")), + "{:?}", + project.manifest()["stale_reason"] + ); + + // Fixing the test recovers: a stale snapshot is a state to collect out of, + // not one that needs a full collect to escape. + project.edit("test/double_test.rb", "raise \"trace failure\" #", "assert_equal 8"); + let (output, ok) = project.fast(); + assert!(ok, "{output}"); + assert_eq!(project.manifest()["complete"], true); + assert_eq!(project.manifest()["potentially_stale"], false); + assert_eq!(project.evidence(), before); +} + +#[test] +fn an_incremental_collect_without_a_snapshot_says_so() { + let project = Project::new(); + let (output, ok) = project.collect(&["--fast"]); + + assert!(!ok); + assert!(output.contains("run a full collect first"), "{output}"); +} + +#[test] +fn a_workload_can_be_named_in_a_file_one_command_per_line() { + // A workload too long for a command line is still one collect. + let project = Project::new(); + let listing = project.path().join("commands.txt"); + std::fs::write( + &listing, + "# ignored\nruby -e 'exit 0'\n\nruby -e 'exit 0'\n", + ) + .expect("write"); + + let (output, ok) = project.collect(&["--commands", &listing.to_string_lossy()]); + + // Two shards ran; the collect got as far as needing evidence from them, + // which is what proves both lines parsed into commands. + assert!(ok || output.contains("shard"), "{output}"); + assert!(!output.contains("requires a command"), "{output}"); +} diff --git a/gems/fact-mine/tests/profile_oracle.rs b/gems/fact-mine/tests/profile_oracle.rs index fbcbf0ce4..5cd7583af 100644 --- a/gems/fact-mine/tests/profile_oracle.rs +++ b/gems/fact-mine/tests/profile_oracle.rs @@ -346,6 +346,471 @@ fn java_nullable_receiver_operations_follow_direct_null_flow() -> Result<()> { Ok(()) } +#[test] +fn java_enhanced_for_preserves_iterable_calls_in_the_normalized_cfg() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".java").tempfile()?; + fs::write( + tmp.path(), + r#"class Demo { + Iterable items() { return null; } + void run() { + for (String item : items()) { + item.length(); + } + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Java)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains("items"), "calls={messages:?}"); + assert!(messages.contains("length"), "calls={messages:?}"); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn java_constructor_calls_and_declaration_only_methods_keep_export_proof_honest() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".java").tempfile()?; + fs::write( + tmp.path(), + r#"interface Sized { + int size(); + default int fallback() { return 0; } +} +class Demo { + StringBuilder copy(String value) { + return new StringBuilder(value); + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Java)?; + let output = profile::extract(&document, Profile::Espalier); + let copy = output + .methods + .iter() + .find(|method| method.name == "copy") + .context("copy method")?; + assert!(copy.source_export_eligible); + let copy_calls = output + .calls + .iter() + .filter(|call| call.source == copy.id) + .map(|call| (call.receiver.as_str(), call.message.as_str())) + .collect::>(); + assert!( + copy_calls + .iter() + .any(|(receiver, message)| *receiver == "StringBuilder" && *message == "call"), + "calls={copy_calls:?}" + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + assert!( + !output + .methods + .iter() + .find(|method| method.name == "size") + .context("declaration-only method")? + .source_export_eligible + ); + assert!( + output + .methods + .iter() + .find(|method| method.name == "fallback") + .context("default method")? + .source_export_eligible + ); + Ok(()) +} + +#[test] +fn go_switch_initializers_and_interface_declarations_keep_export_proof_honest() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".go").tempfile()?; + fs::write( + tmp.path(), + r#"package demo + +type Sized interface { + Size() int +} + +func next() int { return 1 } + +func classify() int { + switch value := next(); value { + case 1: + return value + default: + return 0 + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + let classify = output + .methods + .iter() + .find(|method| method.name == "classify") + .context("classify function")?; + assert!(classify.source_export_eligible); + assert!(output + .calls + .iter() + .any(|call| call.source == classify.id && call.message == "next")); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + assert!(output + .methods + .iter() + .filter(|method| method.name == "Size") + .all(|method| !method.source_export_eligible)); + Ok(()) +} + +#[test] +fn rust_for_preserves_iterable_calls_in_the_normalized_cfg() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"fn values() -> Vec { loop {} } +fn run() { + for value in values() { + value.len(); + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains("values"), "calls={messages:?}"); + assert!(messages.contains("len"), "calls={messages:?}"); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_transparent_unary_calls_and_const_generics_keep_runtime_call_coverage_honest() -> Result<()> +{ + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"struct Buffer; + +const fn width() -> usize { 1 } +fn make() -> Option<&'static usize> { Some(&1) } +fn callback() -> Option usize> { Some(width) } + +fn run() -> usize { + let value = *make().unwrap(); + let invoked = callback().unwrap()(); + unsafe { make().unwrap(); } + let _buffer = Buffer::<{ width() }>; + value + invoked +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run function")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains("make"), "calls={messages:?}"); + assert!(messages.contains("unwrap"), "calls={messages:?}"); + assert!(messages.contains("callback"), "calls={messages:?}"); + assert!(messages.contains("call"), "calls={messages:?}"); + assert!( + !messages.contains("width"), + "compile-time call leaked: {messages:?}" + ); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_discard_bindings_preserve_rhs_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"fn helper() {} +fn run() { + let _ = helper(); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + assert!(output + .calls + .iter() + .any(|call| call.source == run.id && call.message == "helper")); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_bindings_preserve_initializer_and_let_else_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"fn helper() -> Option { loop {} } +fn fallback() {} +fn run() { + let Some(value) = helper() else { + fallback(); + return; + }; + let length: usize = value.len(); + println!("{length}"); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains("helper"), "calls={messages:?}"); + assert!(messages.contains("fallback"), "calls={messages:?}"); + assert!(messages.contains("len"), "calls={messages:?}"); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_match_guards_and_local_statics_preserve_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"use std::sync::OnceLock; +fn guard(value: usize) -> bool { value > 0 } +fn run(value: usize) { + static CACHE: OnceLock = OnceLock::new(); + match value { + current if guard(current) => CACHE.get_or_init(String::new), + _ => CACHE.get_or_init(String::new), + }; +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains(&"guard"), "calls={messages:?}"); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_dereferenced_assignment_targets_preserve_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"use std::collections::BTreeMap; +use std::sync::Mutex; +fn run(counts: &mut BTreeMap<&str, usize>, slot: &Mutex) { + *counts.entry("key").or_default() += 1; + *slot.lock().unwrap() = 2; +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let run = output + .methods + .iter() + .find(|method| method.name == "run") + .context("run method")?; + let messages = output + .calls + .iter() + .filter(|call| call.source == run.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.contains(&"entry"), "calls={messages:?}"); + assert!(messages.contains(&"or_default"), "calls={messages:?}"); + assert!(messages.contains(&"lock"), "calls={messages:?}"); + assert!(messages.contains(&"unwrap"), "calls={messages:?}"); + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_bare_tail_identifiers_are_local_reads_not_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"fn output() -> usize { + let out = 42; + out +} +fn run(flag: bool) -> usize { + let value = if flag { 1 } else { 2 }; + value +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + for function in ["output", "run"] { + let method = output + .methods + .iter() + .find(|method| method.name == function) + .with_context(|| format!("{function} method"))?; + let messages = output + .calls + .iter() + .filter(|call| call.source == method.id) + .map(|call| call.message.as_str()) + .collect::>(); + assert!(messages.is_empty(), "{function} calls={messages:?}"); + } + assert_eq!( + output + .call_resolution_coverage + .raw_calls_not_normalized_inside_function, + 0 + ); + Ok(()) +} + +#[test] +fn rust_impl_method_lambdas_receive_complexity_facts() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + fs::write( + tmp.path(), + r#"struct Widget; +impl Widget { + fn any_empty(&self, values: &[String]) -> bool { + values.iter().any(|value| value.is_empty()) + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let lambda = output + .methods + .iter() + .find(|method| method.name.starts_with(" Result<()> { let document = syntax::parse_file(fixture("nullable_csharp.cs"), Language::CSharp)?; @@ -503,17 +968,302 @@ fn ruby_case_equality_disjunction_refines_the_else_path() -> Result<()> { } #[test] -fn go_map_lookup_exports_presence_without_proving_payload_non_null() -> Result<()> { - let document = syntax::parse_file(fixture("nullable_presence.go"), Language::Go)?; - let output = profile::extract(&document, Profile::NilKill); - assert!(output.presence_correlations.iter().any(|correlation| { - correlation.semantics == "map_lookup" - && correlation.branch_refinement == "presence_on_true" - && correlation.complete - && correlation.value_place_id.ends_with(":value") - && correlation.presence_place_id.ends_with(":ok") - })); - assert!(output +fn go_self_calls_resolve_to_sibling_declarations() -> Result<()> { + let document = syntax::parse_file(fixture("go_self_calls.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let method_by_owner_name = output + .methods + .iter() + .map(|method| { + ( + (method.owner.as_str(), method.name.as_str()), + method.id.as_str(), + ) + }) + .collect::>(); + + // A method calling a sibling method on its own receiver (`l.insert(...)` + // inside a `*List` method) is emitted with receiver "self" and owner "List". + // Its target is the same-owner declaration and must resolve. + let self_calls = output + .calls + .iter() + .filter(|call| { + (call.receiver == "self" || call.receiver == "this") + && method_by_owner_name.contains_key(&(call.owner.as_str(), call.message.as_str())) + }) + .collect::>(); + assert!( + self_calls.len() >= 3, + "expected the fixture's self-calls (insert/lazyInit/insertValue), got {}", + self_calls.len() + ); + let unresolved = self_calls + .iter() + .filter(|call| call.target.is_none()) + .map(|call| format!("{}.{}", call.owner, call.message)) + .collect::>(); + assert!( + unresolved.is_empty(), + "self-calls left unresolved: {unresolved:?}" + ); + for call in &self_calls { + let expected = method_by_owner_name[&(call.owner.as_str(), call.message.as_str())]; + assert_eq!( + call.target.as_deref(), + Some(expected), + "{}.{} resolved to the wrong declaration", + call.owner, + call.message + ); + } + + // A bare same-package function call (`rawHelper(x)`) must also resolve. + let helper_id = output + .methods + .iter() + .find(|method| method.name == "rawHelper") + .map(|method| method.id.clone()); + let raw_call = output + .calls + .iter() + .find(|call| call.message == "rawHelper") + .expect("rawCaller's call to rawHelper is present"); + assert_eq!( + raw_call.target, helper_id, + "bare same-package function call did not resolve" + ); + Ok(()) +} + +#[test] +fn go_structural_interface_satisfaction_is_computed() -> Result<()> { + // `Ints` has Len+Less so it structurally satisfies `Sorter`; `Partial` has + // only Len and must not be recorded as an implementer. + let document = syntax::parse_file(fixture("go_dispatch_satisfaction.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let implementers = output + .dispatch_impls + .iter() + .filter(|edge| edge.interface == "Sorter") + .map(|edge| edge.implementer.as_str()) + .collect::>(); + assert!( + implementers.contains(&"Ints"), + "Ints structurally satisfies Sorter, got {implementers:?}" + ); + assert!( + !implementers.contains(&"Partial"), + "Partial (only Len) must not satisfy Sorter" + ); + assert!(output + .dispatch_impls + .iter() + .any(|edge| edge.interface == "Sorter" && edge.basis == "structural")); + Ok(()) +} + +#[test] +fn go_interface_method_call_is_priced_as_a_callback() -> Result<()> { + // A call on an interface-typed receiver (`c.Less` where c is a Comparer) + // dispatches to an unknown implementation, so it is priced as a callback of + // unknown per-call cost - making the enclosing function complete-parametric + // instead of unknown. + let document = syntax::parse_file(fixture("go_interface_dispatch.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let call = output + .calls + .iter() + .find(|call| call.message == "Less") + .context("c.Less call present")?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(C)"), + "interface method call should carry a callback cost" + ); + assert_eq!( + call.complexity_bound_quality.as_deref(), + Some("upper_bound_parametric_callback_once"), + "interface dispatch is a parametric callback bound" + ); + // It must NOT be resolved to a concrete target - there is none. + assert!(call.target.is_none(), "interface call has no single target"); + Ok(()) +} + +#[test] +fn go_named_type_conversion_is_constant_time() -> Result<()> { + // `ByteCode(b)` converts to a declared type; it is a constant-time cast, not + // an unresolved call. Left unpriced it strands otherwise-O(1) functions + // (and everything that calls them) as incomplete. + let document = syntax::parse_file(fixture("go_named_type_conversion.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let classify = output + .complexity_facts + .iter() + .find(|fact| fact.function == "classify") + .context("classify complexity facts present")?; + let conversion = classify + .call_contexts + .iter() + .find(|context| context.message == "ByteCode") + .context("ByteCode conversion recorded")?; + assert_eq!( + conversion.known_time_complexity.as_deref(), + Some("O(1)"), + "named-type conversion should be priced O(1)" + ); + assert_eq!( + conversion.evidence_gap, None, + "a priced conversion carries no evidence gap" + ); + Ok(()) +} + +#[test] +fn go_package_function_called_from_method_resolves() -> Result<()> { + // A bare call to a package-level free function from inside a method + // (`helper(s.n)` in `State.compute`) is not an implicit self dispatch in + // Go, and must resolve to the free function, not stay unattributed. + let document = syntax::parse_file(fixture("go_pkg_func_from_method.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let helper = output + .methods + .iter() + .find(|method| method.name == "helper") + .context("free function helper present")?; + let call = output + .calls + .iter() + .find(|call| call.message == "helper") + .context("helper() call present")?; + assert_eq!( + call.target.as_deref(), + Some(helper.id.as_str()), + "package function called from a method did not resolve" + ); + Ok(()) +} + +#[test] +fn go_method_on_type_named_after_its_file_dispatches_as_instance() -> Result<()> { + // The fixture's file stem ("widget") equals its receiver type, which used + // to collide with the synthetic file owner and mark every method a + // top-level free function - so `w.tally()` could not dispatch. + let document = syntax::parse_file(fixture("widget.go"), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + + let tally = output + .methods + .iter() + .find(|method| method.owner == "widget" && method.name == "tally") + .context("widget.tally method present")?; + assert_eq!( + tally.kind, "instance", + "receiver method must dispatch as instance" + ); + + let call = output + .calls + .iter() + .find(|call| call.message == "tally") + .context("w.tally() call present")?; + assert_eq!( + call.target.as_deref(), + Some(tally.id.as_str()), + "method call on a type named after its file did not resolve" + ); + Ok(()) +} + +#[test] +fn go_embedded_field_promotes_methods_across_packages() -> Result<()> { + // `encoder` embeds `pkga.Buffer`; its promoted methods (`WriteString`, + // `Len`) are absent from `encoder`'s own method set and must resolve to the + // embedded type's declarations through the supertype (embedding) chain. + let doc_base = syntax::parse_file(fixture("go_embed_base.go"), Language::Go)?; + let doc_user = syntax::parse_file(fixture("go_embed_user.go"), Language::Go)?; + let merged = profile::merge( + vec![ + profile::extract(&doc_base, Profile::Espalier), + profile::extract(&doc_user, Profile::Espalier), + ], + Profile::Espalier, + ); + + for message in ["WriteString", "Len"] { + let target_id = merged + .methods + .iter() + .find(|method| method.owner == "Buffer" && method.name == message) + .map(|method| method.id.clone()); + assert!(target_id.is_some(), "Buffer#{message} declaration present"); + let call = merged + .calls + .iter() + .find(|call| call.message == message) + .unwrap_or_else(|| panic!("promoted e.{message} call present")); + assert_eq!( + call.target, target_id, + "promoted embedded method e.{message} did not resolve" + ); + } + Ok(()) +} + +#[test] +fn go_cross_file_receiver_calls_resolve_in_same_namespace() -> Result<()> { + // `Builder` is declared in one file and used through a typed receiver in + // another. The type is absent from the use-site document's owner set, so + // resolution falls to the same-namespace pass, which must reconcile the + // receiver against the canonical owner symbol the declaration carries. + let doc_type = syntax::parse_file(fixture("go_crossfile_type.go"), Language::Go)?; + let doc_use = syntax::parse_file(fixture("go_crossfile_use.go"), Language::Go)?; + let merged = profile::merge( + vec![ + profile::extract(&doc_type, Profile::Espalier), + profile::extract(&doc_use, Profile::Espalier), + ], + Profile::Espalier, + ); + + for message in ["WriteString", "Len"] { + let target_id = merged + .methods + .iter() + .find(|method| method.owner == "Builder" && method.name == message) + .map(|method| method.id.clone()); + assert!(target_id.is_some(), "Builder#{message} declaration present"); + let call = merged + .calls + .iter() + .find(|call| call.message == message && call.receiver == "b") + .unwrap_or_else(|| panic!("cross-file b.{message} call present")); + assert_eq!( + call.target, target_id, + "cross-file receiver call b.{message} did not resolve" + ); + } + Ok(()) +} + +#[test] +fn go_map_lookup_exports_presence_without_proving_payload_non_null() -> Result<()> { + let document = syntax::parse_file(fixture("nullable_presence.go"), Language::Go)?; + let output = profile::extract(&document, Profile::NilKill); + assert!(output.presence_correlations.iter().any(|correlation| { + correlation.semantics == "map_lookup" + && correlation.branch_refinement == "presence_on_true" + && correlation.complete + && correlation.value_place_id.ends_with(":value") + && correlation.presence_place_id.ends_with(":ok") + })); + assert!(output .nullable_states .iter() .all(|state| !state.place_id.ends_with(":value") || state.state != "definitely_non_null")); @@ -529,8 +1279,12 @@ fn go_type_assertions_and_channel_receives_export_presence_without_payload_proof .iter() .map(|correlation| correlation.semantics.as_str()) .collect::>(); + // Order follows group-id sort (lambda-owned correlations sort first); the + // meaningful contract is the multiset plus the per-correlation spans below. + let mut semantics_sorted = semantics.clone(); + semantics_sorted.sort_unstable(); assert_eq!( - semantics, + semantics_sorted, vec![ "channel_receive", "type_assertion", @@ -1090,128 +1844,1776 @@ int analyze(Data* data, int input) { } #[test] -fn go_short_declaration_does_not_reuse_outer_non_nil_proof() -> Result<()> { - let document = syntax::parse_file(fixture("go_shadowing.go"), Language::Go)?; - assert!(document.redundant_nil_guards.is_empty()); +fn cpp_using_aliases_reach_declared_collection_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"#include +struct Queue { + using Items = std::list; + Items items; + bool empty() const { return items.empty(); } + void transfer(Items& other) { + Items local; + local.splice(local.end(), other); + items.splice(items.end(), other); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let empty = output + .calls + .iter() + .find(|call| call.function == "empty" && call.message == "empty") + .context("missing aliased list empty call")?; + assert_eq!(empty.receiver_type.as_deref(), Some("Items")); + assert_eq!(empty.known_time_complexity.as_deref(), Some("O(1)")); + + let splice = output + .calls + .iter() + .find(|call| call.function == "transfer" && call.message == "splice") + .context("missing aliased list splice call")?; + assert_eq!(splice.receiver_type.as_deref(), Some("Items")); + assert_eq!(splice.known_time_complexity.as_deref(), Some("O(N)")); + let local = output + .calls + .iter() + .find(|call| { + call.function == "transfer" && call.receiver == "local" && call.message == "splice" + }) + .context("missing locally declared aliased list call")?; + assert_eq!(local.receiver_type.as_deref(), Some("Items")); + assert_eq!(local.known_time_complexity.as_deref(), Some("O(N)")); Ok(()) } #[test] -fn exact_native_stdlib_calls_emit_normalized_complexity_facts() -> Result<()> { - for (name, language, message, time, space) in [ - ("stdlib_registry.c", Language::C, "strcmp", "O(N)", "O(1)"), - ( - "stdlib_registry.go", - Language::Go, - "BinarySearch", - "O(log N)", - "O(1)", - ), - ( - "stdlib_registry.java", - Language::Java, - "copyOf", - "O(N)", - "O(N)", - ), - ( - "stdlib_registry.cs", - Language::CSharp, - "BinarySearch", - "O(log N)", - "O(1)", - ), - ( - "stdlib_registry.py", - Language::Python, - "casefold", - "O(N)", - "O(N)", - ), +fn cpp_ordered_map_and_set_use_conservative_collection_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"#include +#include +void update(std::map& values, std::set& keys) { + values.find(1); + values.begin(); + keys.insert(1); + keys.empty(); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + for (message, expected) in [ + ("find", "O(N)"), + ("begin", "O(1)"), + ("insert", "O(N)"), + ("empty", "O(1)"), ] { - let document = syntax::parse_file(fixture(name), language)?; - let output = profile::extract(&document, Profile::Espalier); let call = output - .complexity_facts + .calls .iter() - .flat_map(|facts| facts.call_contexts.iter()) - .find(|call| call.message == message) - .with_context(|| format!("missing {name} {message} complexity fact"))?; - assert_eq!(call.known_time_complexity.as_deref(), Some(time), "{name}"); + .find(|call| call.function == "update" && call.message == message) + .with_context(|| format!("missing ordered collection call {message}"))?; assert_eq!( - call.known_space_complexity.as_deref(), - Some(space), - "{name}" + call.known_time_complexity.as_deref(), + Some(expected), + "{message}" ); } Ok(()) } #[test] -fn go_builtins_are_modeled_as_language_intrinsics_without_scip_targets() -> Result<()> { - use std::io::Write; - - let mut tmp = tempfile::Builder::new().suffix(".go").tempfile()?; - tmp.write_all( - br#"package sample - -func builtins(xs []int, values map[string]int, done chan int) int { - result := make([]int, len(xs)) - result = append(result, xs...) - copy(result, xs) - delete(values, "missing") - close(done) - return int(int32(len(result))) -} - -func fail() { - panic("failed") -} - -type holder struct { values []int } - -// Go has no implicit method dispatch: the bare len below remains the -// predeclared function even though its enclosing type has a method named len. -func (h *holder) len() int { - return len(h.values) +fn cpp_proven_file_stream_and_json_receivers_use_reviewed_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"void read(const char * path, nlohmann::json& value) { + std::ifstream input; + input.exceptions(1); + input.open(path); + value.at("key"); + value.get_to(value); } "#, )?; - let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Go)?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; let output = profile::extract(&document, Profile::Espalier); - let expected = [ - ("len", "O(1)", "O(1)"), - ("make", "O(N)", "O(N)"), - ("append", "O(N)", "O(N)"), - ("copy", "O(N)", "O(1)"), - ("delete", "O(1)", "O(1)"), - ("close", "O(1)", "O(1)"), - ("int", "O(1)", "O(1)"), - ("int32", "O(1)", "O(1)"), - ("panic", "O(N)", "O(1)"), - ]; - - for (message, time, space) in expected { + for (message, expected) in [ + ("exceptions", "O(1)"), + ("open", "O(N)"), + ("at", "O(N)"), + ("get_to", "O(N)"), + ] { + let call = output + .calls + .iter() + .find(|call| call.function == "read" && call.message == message) + .with_context(|| format!("missing {message}"))?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some(expected), + "{message}" + ); + } + Ok(()) +} + +#[test] +fn cpp_arrow_field_projections_preserve_atomic_costs_and_full_types() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct strong_weak_compact_ptr_storage_base { + std::atomic_long strong_count = 1, weak_count = 1; +}; +struct strong_weak_compact_ptr_storage : strong_weak_compact_ptr_storage_base { +}; +struct Holder { + strong_weak_compact_ptr_storage* ptr_; + struct Node; + using NodePtr = std::shared_ptr; + struct Node { + NodePtr previous; + }; + NodePtr head; + void increment() { + ptr_->weak_count.fetch_add(1); + } + void clear() { + NodePtr node = head; + node->previous.reset(); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let field = output + .fields + .iter() + .find(|field| field.owner == "Holder" && field.name == "ptr_") + .context("missing pointer field")?; + assert_eq!( + field.declared_type.as_deref(), + Some("strong_weak_compact_ptr_storage*") + ); + let call = output + .calls + .iter() + .find(|call| call.function == "increment" && call.message == "fetch_add") + .context("missing atomic fetch_add")?; + assert_eq!(call.receiver_type.as_deref(), Some("std::atomic_long")); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + let reset = output + .calls + .iter() + .find(|call| call.function == "clear" && call.message == "reset") + .context("missing projected shared-pointer reset")?; + assert_eq!(reset.receiver_type.as_deref(), Some("NodePtr")); + assert_eq!( + reset.known_time_complexity.as_deref(), + Some("O(R)"), + "reset={reset:?}; aliases={:?}", + document.type_aliases + ); + assert_eq!(reset.receiver_call_span, None); + Ok(()) +} + +#[test] +fn cpp_initializer_calls_do_not_hide_local_receiver_types() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"std::wstring convert(const std::wstring& input) { return input; } +void run(const std::wstring& input) { + const std::wstring& wide = convert(input); + std::string output(4, 0); + wide.size(); + output.resize(2); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + + let size = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "size") + .context("missing wide.size call")?; + assert_eq!(size.receiver_type.as_deref(), Some("const std::wstring&")); + assert_eq!(size.known_time_complexity.as_deref(), Some("O(1)")); + + let resize = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "resize") + .context("missing output.resize call")?; + assert_eq!(resize.receiver_type.as_deref(), Some("std::string")); + assert_eq!(resize.known_time_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn cpp_callable_fields_on_parameters_keep_parametric_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"using Hook = void (*)(int); +struct Item { Hook dispatcher; }; +void run(const Item& item) { + item.dispatcher(1); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let dispatcher = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "dispatcher") + .context("missing item.dispatcher call")?; + + assert_eq!(dispatcher.known_time_complexity.as_deref(), Some("O(C)")); + assert_eq!( + dispatcher.complexity_provenance.as_deref(), + Some("parametric_declared_receiver_contract") + ); + Ok(()) +} + +#[test] +fn cpp_project_aliases_converge_across_configuration_branches_and_files() -> Result<()> { + let directory = tempfile::tempdir()?; + let aliases = directory.path().join("aliases.hpp"); + let caller = directory.path().join("caller.cpp"); + fs::write( + &aliases, + r#"#if USE_WIDE +typedef std::wstring native_string; +typedef std::wostringstream native_stream; +#else +typedef std::string native_string; +typedef std::ostringstream native_stream; +#endif +"#, + )?; + fs::write( + &caller, + "void run(native_string& text, native_stream& stream) { text.size(); text.push_back('x'); stream.str(); }\n", + )?; + let documents = syntax::parse_files(&[aliases, caller], Language::Cpp)?; + let outputs = documents + .iter() + .map(|document| profile::extract(document, Profile::Espalier)) + .collect(); + let output = profile::merge(outputs, Profile::Espalier); + let size = output + .calls + .iter() + .find(|call| call.message == "size") + .context("missing cross-file aliased string call")?; + assert_eq!( + size.known_time_complexity.as_deref(), + Some("O(1)"), + "call={size:#?}; aliases={:#?}", + output + .type_definitions + .iter() + .filter(|definition| definition.kind == "type_alias") + .collect::>() + ); + assert_eq!( + size.complexity_provenance.as_deref(), + Some("merged_project_type_alias_registry") + ); + let push = output + .calls + .iter() + .find(|call| call.message == "push_back") + .context("missing cross-file aliased string mutation")?; + assert_eq!(push.known_time_complexity.as_deref(), Some("O(N)")); + let str_call = output + .calls + .iter() + .find(|call| call.message == "str") + .context("missing cross-file aliased stream call")?; + assert_eq!(str_call.known_time_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn cpp_merged_aliases_price_scoped_stdlib_constructors() -> Result<()> { + let directory = tempfile::tempdir()?; + let alias = directory.path().join("alias.cpp"); + let caller = directory.path().join("caller.cpp"); + fs::write(&alias, "namespace util { using Text = std::string; }\n")?; + fs::write( + &caller, + "std::string make_text() { return util::Text(); }\n", + )?; + let alias = syntax::parse_file(alias, Language::Cpp)?; + let caller = syntax::parse_file(caller, Language::Cpp)?; + let output = profile::merge( + vec![ + profile::extract(&alias, Profile::Espalier), + profile::extract(&caller, Profile::Espalier), + ], + Profile::Espalier, + ); + let call = output + .calls + .iter() + .find(|call| call.function == "make_text" && call.message == "util::Text") + .context("missing scoped alias constructor")?; + assert_eq!(call.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!( + call.complexity_provenance.as_deref(), + Some("merged_project_type_alias_registry") + ); + Ok(()) +} + +#[test] +fn cpp_inactive_runtime_spelling_emits_modeled_world_evidence() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + "void run(const char* data) { ::write(1, data, 4); }\n", + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let write = output + .calls + .iter() + .find(|call| call.message == "::write") + .context("missing qualified runtime call")?; + assert_eq!(write.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!( + write.complexity_bound_quality.as_deref(), + Some("upper_bound_modeled_world") + ); + assert_eq!(write.complexity_assumptions.len(), 1); + Ok(()) +} + +#[test] +fn cpp_source_macro_definitions_price_unindexed_calls_only_when_converged() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"#define IDENTITY(value) value +#if MODE +#define MAYBE(value) value +#else +#define MAYBE(value) unknown(value) +#endif +int bounded() { return IDENTITY(1); } +int unknown() { return MAYBE(1); } +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let identity = output + .calls + .iter() + .find(|call| call.function == "bounded" && call.message == "IDENTITY") + .context("missing bounded macro call")?; + assert_eq!(identity.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!( + identity.complexity_provenance.as_deref(), + Some("source_preprocessor_definition") + ); + let maybe = output + .calls + .iter() + .find(|call| call.function == "unknown" && call.message == "MAYBE") + .context("missing divergent macro call")?; + assert_eq!(maybe.known_time_complexity, None); + Ok(()) +} + +#[test] +fn cpp_source_macro_costs_cross_file_shard_boundaries() -> Result<()> { + let directory = tempfile::tempdir()?; + let definition = directory.path().join("macros.h"); + let caller = directory.path().join("caller.cpp"); + fs::write(&definition, "#define IDENTITY(value) value\n")?; + fs::write(&caller, "int bounded() { return IDENTITY(1); }\n")?; + let definition = syntax::parse_file(definition, Language::Cpp)?; + let caller = syntax::parse_file(caller, Language::Cpp)?; + let definition = profile::extract(&definition, Profile::Espalier); + assert_eq!(definition.preprocessor_definition_costs.len(), 1); + let mut caller = profile::extract(&caller, Profile::Espalier); + let call = caller + .calls + .iter_mut() + .find(|call| call.message == "IDENTITY") + .context("missing macro-shaped call")?; + // SCIP marks macro occurrences with the compiler's `!` symbol. This test + // supplies that compiler-owned classification without constructing a + // protobuf index; the source shard supplies the independently priced body. + call.preprocessor_callable = true; + let output = profile::merge(vec![definition, caller], Profile::Espalier); + let call = output + .calls + .iter() + .find(|call| call.message == "IDENTITY") + .context("missing merged macro call")?; + assert_eq!(call.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!( + call.complexity_provenance.as_deref(), + Some("merged_source_preprocessor_definition") + ); + Ok(()) +} + +#[test] +fn cpp_closed_overload_return_types_flow_into_chained_calls() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"namespace demo { +std::string make(int value) { return std::string(); } +const std::string& make(double value) { static std::string result; return result; } // stable view +bool run() { return make(1).size() > 0; } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let make = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "make") + .context("missing overloaded producer call")?; + assert_eq!(make.candidate_targets.len(), 2); + let size = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "size") + .context("missing chained string call")?; + assert_eq!(size.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!( + size.receiver_type_origin.as_deref(), + Some("declared_call_result_candidate_join") + ); + Ok(()) +} + +#[test] +fn cpp_trailing_return_types_flow_through_auto_locals() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct Box { + void work() {} +}; +auto make_box() + -> Box* +{ + return nullptr; +} +void run() { + auto box = make_box(); + box->work(); +} +template +auto make_dependent() + -> std::shared_ptr> +{ + return {}; +} +template +void run_dependent() { + auto box = make_dependent(); + box->work(); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let make_box_type = output + .type_definitions + .iter() + .find(|definition| definition.name == "make_box") + .context("missing trailing-return type definition")?; + assert_eq!( + make_box_type.signature.as_deref(), + Some("auto make_box() -> Box*") + ); + let work = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "work") + .context("missing auto-local receiver call")?; + assert!(work.target.is_some()); + assert_eq!(work.receiver_symbol.as_deref(), Some("Box")); + assert_eq!( + work.receiver_symbol_origin.as_deref(), + Some("declared_call_result") + ); + let dependent_work = output + .calls + .iter() + .find(|call| call.function == "run_dependent" && call.message == "work") + .context("missing dependent auto-local receiver call")?; + assert_eq!( + dependent_work.known_time_complexity.as_deref(), + Some("O(R)") + ); + assert_eq!( + dependent_work.complexity_provenance.as_deref(), + Some("declared_call_result_candidate_join") + ); + Ok(()) +} + +#[test] +fn cpp_template_receiver_calls_keep_symbolic_dispatch_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +struct Runner { + using Queue = typename Policy::Queue; + using Base = Policy; + using Threading = typename Base::Threading; + using Hook = void (*)(int); + Queue queue; + typename Threading::Atomic atomic; + Hook hook; + template + void run(Policy& policy, Callback callback) { + policy.execute(); + queue.flush(); + atomic.load(); + callback(); + hook(1); + Formatter::format(1); + Formatter{}.invoke(1); + Compare(1, 2); + } +}; +template +struct Noise { + using Base = Other; +}; +struct Concrete { + void execute() {} +}; +void invoke(Concrete& concrete) { + concrete.execute(); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + for message in [ + "execute", + "flush", + "load", + "callback", + "Formatter::format", + "invoke", + "Compare", + ] { + let call = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == message) + .with_context(|| format!("missing dependent {message} call"))?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(R)"), + "call={call:?}, template_types={:?}", + document.method_template_types + ); + assert_eq!( + call.complexity_provenance.as_deref(), + Some("parametric_declared_receiver_contract") + ); + } + let hook = output + .calls + .iter() + .find(|call| call.function == "run" && call.message == "hook") + .context("missing function-pointer field call")?; + assert_eq!(hook.known_time_complexity.as_deref(), Some("O(C)")); + let concrete = output + .calls + .iter() + .find(|call| call.function == "invoke" && call.message == "execute") + .context("missing concrete execute call")?; + assert_ne!( + concrete.complexity_provenance.as_deref(), + Some("parametric_declared_receiver_contract") + ); + Ok(()) +} + +#[test] +fn cpp_braced_initializer_reads_do_not_fabricate_local_types() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +struct Wrapper { + CallbackListType callbackList; + template + void append(Condition condition) { + auto data = make_data(Data { condition, callbackList }); + callbackList.append(data); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let append = output + .calls + .iter() + .find(|call| call.function == "append" && call.message == "append") + .context("missing template state receiver call")?; + assert_eq!(append.receiver_type.as_deref(), Some("CallbackListType")); + assert_eq!(append.known_time_complexity.as_deref(), Some("O(R)")); + assert_eq!( + append.complexity_provenance.as_deref(), + Some("parametric_declared_receiver_contract") + ); + Ok(()) +} + +#[test] +fn cpp_inferred_collection_elements_recover_receiver_types() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct Item { + void work() {} +}; +class Interface { +public: + virtual void dispatch() = 0; +}; +class Outer { + class Nested { + virtual void nested() = 0; + }; +public: + void concrete() {} +}; +struct Store { + std::vector> pointers; + std::vector> interfaces; + std::list values; + void range() { + for(const auto & item : pointers) { + item->work(); + } + } + void index() { + auto item = pointers[0]; + item->work(); + } + void iterator() { + auto it = values.begin(); + it->work(); + } + void abstract_range() { + for(const auto & item : interfaces) { + item->dispatch(); + } + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + assert_eq!( + document + .owner_defs + .iter() + .find(|owner| owner.name == "Outer") + .map(|owner| owner.kind.as_str()), + Some("class") + ); + assert_eq!( + document + .owner_defs + .iter() + .find(|owner| owner.name.ends_with("Nested")) + .map(|owner| owner.kind.as_str()), + Some("abstract_class") + ); + let output = profile::extract(&document, Profile::Espalier); + for function in ["range", "index", "iterator"] { + let work = output + .calls + .iter() + .find(|call| call.function == function && call.message == "work") + .with_context(|| format!("missing {function} element call"))?; + assert_eq!(work.receiver_type.as_deref(), Some("Item"), "{work:?}"); + assert_eq!( + work.receiver_type_origin.as_deref(), + Some("inferred_collection_element") + ); + assert!(work.target.is_some(), "{work:?}"); + } + let dispatch = output + .calls + .iter() + .find(|call| call.function == "abstract_range" && call.message == "dispatch") + .context("missing abstract element call")?; + assert_eq!(dispatch.receiver_type.as_deref(), Some("Interface")); + assert_eq!(dispatch.known_time_complexity.as_deref(), Some("O(C)")); + assert_eq!( + dispatch.complexity_provenance.as_deref(), + Some("parametric_declared_receiver_contract") + ); + Ok(()) +} + +#[test] +fn cpp_injected_class_names_resolve_current_template_specialization() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +struct Box; +template +struct Box::type> { + void reset() {} + Box(Box && other) { + other.reset(); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let reset = output + .calls + .iter() + .find(|call| call.function == "Box" && call.message == "reset") + .context("missing injected-class receiver call")?; + assert_eq!(reset.receiver_type.as_deref(), Some("Box &&")); + assert!(reset.target.is_some(), "{reset:?}"); + assert_eq!( + reset.receiver_symbol_origin.as_deref(), + Some("current_owner_declaration") + ); + Ok(()) +} + +#[test] +fn c_export_and_calling_convention_macros_preserve_parameters_and_recursion() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".c").tempfile()?; + fs::write( + tmp.path(), + r#"#define API(type) type +#define PROJECT_CDECL +typedef struct Node { struct Node *child; } Node; +API(void) destroy(Node *item) { + if (item && item->child) { + destroy(item->child); + } +} +static void * PROJECT_CDECL allocate(unsigned long size) { + return 0; +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::C)?; + let output = profile::extract(&document, Profile::Espalier); + let methods = output + .methods + .iter() + .map(|method| (method.name.as_str(), method.params.as_slice())) + .collect::>(); + assert!(methods.contains(&("destroy", ["item".to_string()].as_slice()))); + assert!(methods.contains(&("allocate", ["size".to_string()].as_slice()))); + let destroy = output + .complexity_facts + .iter() + .find(|fact| fact.function == "destroy") + .context("missing destroy complexity facts")?; + assert_eq!(destroy.parameters, vec!["item"]); + assert_eq!(destroy.recursion.structural_calls, 1); + assert_eq!(destroy.recursion.unknown_progress_calls, 0); + Ok(()) +} + +#[test] +fn go_short_declaration_does_not_reuse_outer_non_nil_proof() -> Result<()> { + let document = syntax::parse_file(fixture("go_shadowing.go"), Language::Go)?; + assert!(document.redundant_nil_guards.is_empty()); + Ok(()) +} + +#[test] +fn exact_native_stdlib_calls_emit_normalized_complexity_facts() -> Result<()> { + for (name, language, message, time, space) in [ + ("stdlib_registry.c", Language::C, "strcmp", "O(N)", "O(1)"), + ( + "stdlib_registry.go", + Language::Go, + "BinarySearch", + "O(log N)", + "O(1)", + ), + ( + "stdlib_registry.java", + Language::Java, + "copyOf", + "O(N)", + "O(N)", + ), + ( + "stdlib_registry.cs", + Language::CSharp, + "BinarySearch", + "O(log N)", + "O(1)", + ), + ( + "stdlib_registry.py", + Language::Python, + "casefold", + "O(N)", + "O(N)", + ), + ] { + let document = syntax::parse_file(fixture(name), language)?; + let output = profile::extract(&document, Profile::Espalier); + let call = output + .complexity_facts + .iter() + .flat_map(|facts| facts.call_contexts.iter()) + .find(|call| call.message == message) + .with_context(|| format!("missing {name} {message} complexity fact"))?; + assert_eq!(call.known_time_complexity.as_deref(), Some(time), "{name}"); + assert_eq!( + call.known_space_complexity.as_deref(), + Some(space), + "{name}" + ); + } + Ok(()) +} + +#[test] +fn cpp_qualified_std_algorithm_keeps_predicate_cost_parametric() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +auto locate(Items & items, Predicate predicate) { + return std::find_if(items.begin(), items.end(), predicate); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let call = output + .calls + .iter() + .find(|call| call.function == "locate" && call.message == "std::find_if") + .context("missing std::find_if")?; + assert_eq!(call.known_time_complexity.as_deref(), Some("O(N*C)")); + assert_eq!( + call.complexity_bound_quality.as_deref(), + Some("upper_bound_parametric_callback_linear") + ); + Ok(()) +} + +#[test] +fn typed_atomics_reflect_and_builder_methods_are_constant_time() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".go").tempfile()?; + tmp.write_all( + br#"package sample + +import ( + "strings" + "sync/atomic" + "reflect" +) + +type Counter struct{ n atomic.Uint64 } + +func work(c *Counter, b *strings.Builder, v reflect.Value) { + c.n.CompareAndSwap(1, 2) + c.n.Add(1) + b.WriteRune('x') + _ = v.NumMethod() + _ = v.Cap() +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + let contexts: Vec<_> = output + .complexity_facts + .iter() + .flat_map(|facts| facts.call_contexts.iter()) + .collect(); + for message in ["CompareAndSwap", "Add", "WriteRune", "NumMethod", "Cap"] { + let call = contexts + .iter() + .find(|call| call.message == message) + .with_context(|| format!("missing {message} complexity fact"))?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(1)"), + "{message} must be modeled as constant-time", + ); + } + Ok(()) +} + +#[test] +fn go_builtins_are_modeled_as_language_intrinsics_without_scip_targets() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".go").tempfile()?; + tmp.write_all( + br#"package sample + +func builtins(xs []int, values map[string]int, done chan int) int { + result := make([]int, len(xs)) + result = append(result, xs...) + copy(result, xs) + delete(values, "missing") + close(done) + return int(int32(len(result))) +} + +func fail() { + panic("failed") +} + +type holder struct { values []int } + +// Go has no implicit method dispatch: the bare len below remains the +// predeclared function even though its enclosing type has a method named len. +func (h *holder) len() int { + return len(h.values) +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Go)?; + let output = profile::extract(&document, Profile::Espalier); + let expected = [ + ("len", "O(1)", "O(1)"), + ("make", "O(N)", "O(N)"), + ("append", "O(N)", "O(N)"), + ("copy", "O(N)", "O(1)"), + ("delete", "O(1)", "O(1)"), + ("close", "O(1)", "O(1)"), + ("int", "O(1)", "O(1)"), + ("int32", "O(1)", "O(1)"), + ("panic", "O(N)", "O(1)"), + ]; + + for (message, time, space) in expected { let matching = output .calls .iter() - .filter(|call| call.message == message) - .collect::>(); - assert!(!matching.is_empty(), "missing Go builtin {message}"); - assert!(matching.iter().all(|call| call.target.is_none())); - assert!(matching + .filter(|call| call.message == message) + .collect::>(); + assert!(!matching.is_empty(), "missing Go builtin {message}"); + assert!(matching.iter().all(|call| call.target.is_none())); + assert!(matching + .iter() + .all(|call| call.known_time_complexity.as_deref() == Some(time))); + assert!(matching + .iter() + .all(|call| call.known_space_complexity.as_deref() == Some(space))); + } + let holder_len = output + .complexity_facts + .iter() + .find(|fact| fact.owner == "holder" && fact.function == "len") + .context("missing holder.len complexity facts")?; + assert_eq!(holder_len.recursion.calls, 0); + Ok(()) +} + +#[test] +fn kotlin_expression_body_functions_extract_complexity_facts() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".kt").tempfile()?; + // `fun f() = expr` bodies were dropped as assignment RHS - the function's + // whole body (calls, loops) vanished. Both forms must extract facts. + tmp.write_all( + br#"class Foo { + fun blockBody(xs: List): Int { + var s = 0 + for (x in xs) { s = s + compute(x) } + return s + } + fun exprBody(xs: List): Int = xs.sumOf { compute(it) } + fun compute(x: Int): Int = x * 2 +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Kotlin)?; + let output = profile::extract(&document, Profile::Espalier); + for name in ["blockBody", "exprBody", "compute"] { + let facts = output + .complexity_facts + .iter() + .find(|facts| facts.function == name) + .with_context(|| format!("{name} must produce complexity facts"))?; + assert!( + !facts.call_contexts.is_empty(), + "{name} must extract its body's calls (got none)", + ); + } + Ok(()) +} + +#[test] +fn paren_less_member_reads_are_constant_time_but_not_in_ruby() -> Result<()> { + use std::io::Write; + + // Go: `n.parent` / `n.line` are field reads - O(1), must not block. + let mut go = tempfile::Builder::new().suffix(".go").tempfile()?; + go.write_all( + b"package p\ntype Node struct { line int; parent *Node }\nfunc depth(n *Node) int { return n.parent.line }\n", + )?; + let go_doc = syntax::parse_file(go.path().to_path_buf(), Language::Go)?; + let go_out = profile::extract(&go_doc, Profile::Espalier); + let line = go_out + .complexity_facts + .iter() + .flat_map(|f| f.call_contexts.iter()) + .find(|c| c.message == "line") + .context("missing go line read")?; + assert_eq!(line.known_time_complexity.as_deref(), Some("O(1)")); + + // Ruby: `obj.foo` (no parens) is a real call - must stay unpriced, not O(1). + let mut rb = tempfile::Builder::new().suffix(".rb").tempfile()?; + rb.write_all(b"class Foo\n def run(obj)\n obj.expensive\n end\nend\n")?; + let rb_doc = syntax::parse_file(rb.path().to_path_buf(), Language::Ruby)?; + let rb_out = profile::extract(&rb_doc, Profile::Espalier); + if let Some(call) = rb_out + .complexity_facts + .iter() + .flat_map(|f| f.call_contexts.iter()) + .find(|c| c.message == "expensive") + { + assert_ne!( + call.known_time_complexity.as_deref(), + Some("O(1)"), + "a Ruby paren-less call must not be assumed constant-time", + ); + } + Ok(()) +} + +#[test] +fn c_operators_and_subscript_are_constant_time_intrinsics() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".c").tempfile()?; + tmp.write_all( + br#"int compute(int *a, int n, int k) { + int s = a[0] * k + n - (k >> 1); + if (s == 0 && n < k) s = -s; + return s & 0xff; +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::C)?; + let output = profile::extract(&document, Profile::Espalier); + // C has no operator overloading: every operator and `[]` is constant-time, + // so the function's only cost is O(1) - none of them may block completeness. + let contexts: Vec<_> = output + .complexity_facts + .iter() + .flat_map(|facts| facts.call_contexts.iter()) + .collect(); + for op in ["*", "+", "-", ">>", "==", "<", "&", "[]"] { + if let Some(call) = contexts.iter().find(|call| call.message == op) { + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(1)"), + "operator {op} must be constant-time", + ); + } + } + Ok(()) +} + +#[test] +fn cpp_linkage_macros_preserve_owner_template_dispatch_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"#define LIB_HIDDEN +template +class LIB_HIDDEN Appender { +public: + void write() { + Converter::convert(Formatter::format()); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + for message in ["Formatter::format", "Converter::convert"] { + let call = output + .calls + .iter() + .find(|call| call.function == "write" && call.message == message) + .with_context(|| format!("missing {message}"))?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(R)"), + "{message}" + ); + } + Ok(()) +} + +#[test] +fn cpp_dereferenced_and_cast_receivers_keep_proven_types() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct LargeData { + const void * getAddress() const { return this; } +}; +template +void dispatch(const CallbackList * callableList) { + (*callableList)(1); +} +const void * address(char * buffer) { + return ((const LargeData *)buffer)->getAddress(); +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let callback = output + .calls + .iter() + .find(|call| call.function == "dispatch" && call.message == "call") + .context("missing dereferenced callback call")?; + assert_eq!( + callback.receiver_type.as_deref(), + Some("const CallbackList *") + ); + assert_eq!( + callback.receiver_type_origin.as_deref(), + Some("declared_parameter") + ); + assert_eq!(callback.known_time_complexity.as_deref(), Some("O(R)")); + let address = output + .calls + .iter() + .find(|call| call.function == "address" && call.message == "getAddress") + .context("missing cast receiver call")?; + assert_eq!(address.receiver_type.as_deref(), Some("const LargeData *")); + assert_eq!( + address.receiver_type_origin.as_deref(), + Some("explicit_native_cast") + ); + assert!( + address.target.is_some(), + "cast receiver should resolve LargeData" + ); + Ok(()) +} + +#[test] +fn cpp_dependent_auto_locals_keep_symbolic_dispatch_costs() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +decltype(auto) invoke(P& proxy) { + auto dispatcher = proxy.template meta::dispatcher; + return dispatcher(proxy); +} +template +void release(const Alloc& alloc, T * pointer) { + auto rebound = + typename std::allocator_traits::template rebind_alloc(alloc); + rebound.deallocate(pointer, 1); +} +template +class Ordered : private std::list { + void order() { + auto compare = Compare(); + this->sort([compare](const T & a, const T & b) { + if (a.empty() || b.empty()) return false; + return compare(a.get(), b.get()); + }); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + for (function, message) in [("invoke", "dispatcher"), ("release", "deallocate")] { + let call = output + .calls + .iter() + .find(|call| call.function == function && call.message == message) + .with_context(|| format!("missing {function} {message}"))?; + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(R)"), + "{function} {message}" + ); + } + for call in output.calls.iter().filter(|call| { + call.function == "order" && matches!(call.message.as_str(), "empty" | "get" | "compare") + }) { + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(R)"), + "{call:?}" + ); + } + let sort = output + .calls + .iter() + .find(|call| call.function == "order" && call.message == "sort") + .context("missing inherited list sort")?; + assert_eq!(sort.receiver_type.as_deref(), Some("std::list")); + assert_eq!( + sort.receiver_type_origin.as_deref(), + Some("declared_supertype") + ); + assert_eq!(sort.known_time_complexity.as_deref(), Some("O(N log N*C)")); + Ok(()) +} + +#[test] +fn cpp_function_local_swap_imports_do_not_recurse_into_the_enclosing_method() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct Base { + int value; + void swap(Base & other) { + using std::swap; + swap(value, other.value); + } +}; +struct Item : Base { + friend void swap(Item & first, Item & second) { + first.swap(second); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + + let member = output + .methods + .iter() + .find(|method| method.owner == "Base" && method.name == "swap") + .context("missing Base swap")?; + let friend = output + .methods + .iter() + .find(|method| method.owner == "Item" && method.name == "swap") + .context("missing Item friend swap")?; + assert_eq!(member.kind, "instance"); + assert_eq!(friend.kind, "top"); + + let imported = output + .calls + .iter() + .find(|call| call.source == member.id && call.message == "swap") + .context("missing imported std/ADL swap")?; + assert_eq!(imported.lexical_symbol.as_deref(), Some("std::swap")); + assert_eq!( + imported.lexical_symbol_origin.as_deref(), + Some("function_local_import") + ); + assert_eq!(imported.known_time_complexity.as_deref(), Some("O(R)")); + assert!(imported.target.is_none()); + Ok(()) +} + +#[test] +fn cpp_indexed_dependent_map_receivers_keep_the_mapped_project_type() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"template +struct SelectMap { + using Type = std::map; +}; +struct CallbackList { + void append(int callback) {} + void prepend(int callback) {} + void insert(int callback, int before) {} +}; +struct Dispatcher { + using CallbackList_ = CallbackList; + using Map = typename SelectMap::Type; + Map listeners; + void add(int event, int callback) { + listeners[event].append(callback); + listeners[event].prepend(callback); + listeners[event].insert(callback, 0); + } +}; +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + + for message in ["append", "prepend", "insert"] { + let call = output + .calls .iter() - .all(|call| call.known_time_complexity.as_deref() == Some(time))); - assert!(matching + .find(|call| call.function == "add" && call.message == message) + .with_context(|| format!("missing indexed {message} call"))?; + assert_eq!(call.receiver_type.as_deref(), Some("CallbackList")); + assert_eq!(call.receiver_type_origin.as_deref(), Some("declared_state")); + let target = call + .target + .as_deref() + .with_context(|| format!("missing {message} target"))?; + assert!(output.methods.iter().any(|method| { + method.id == target && method.owner == "CallbackList" && method.name == message + })); + } + Ok(()) +} + +#[test] +fn cpp_operators_are_constant_only_for_proven_scalar_operands() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".cpp").tempfile()?; + fs::write( + tmp.path(), + r#"struct Number {}; +Number operator+(Number left, Number right); +int scalar(int n, int k) { + int sum = n + k; + return sum < k ? -sum : sum; +} +Number overloaded(Number left, Number right) { + return left + right; +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Cpp)?; + let output = profile::extract(&document, Profile::Espalier); + let scalar = output + .complexity_facts + .iter() + .find(|fact| fact.function == "scalar") + .context("missing scalar complexity facts")?; + for operator in ["+", "<"] { + let context = scalar + .call_contexts .iter() - .all(|call| call.known_space_complexity.as_deref() == Some(space))); + .find(|context| context.message == operator) + .with_context(|| format!("missing scalar {operator} context"))?; + assert_eq!( + context.known_time_complexity.as_deref(), + Some("O(1)"), + "primitive {operator} must be constant-time" + ); } - let holder_len = output + + let overloaded = output .complexity_facts .iter() - .find(|fact| fact.owner == "holder" && fact.function == "len") - .context("missing holder.len complexity facts")?; - assert_eq!(holder_len.recursion.calls, 0); + .find(|fact| fact.function == "overloaded") + .context("missing overloaded complexity facts")?; + let addition = overloaded + .call_contexts + .iter() + .find(|context| context.message == "+") + .context("missing overloaded addition context")?; + assert_ne!(addition.known_time_complexity.as_deref(), Some("O(1)")); + Ok(()) +} + +#[test] +fn ruby_normalization_prices_control_flow_literals_locals_and_record_accessors() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"Node = Struct.new(:kind) + +def exercise(values) + @memo ||= values + cache = {} + cache[:items] ||= [] + rows = [] + rows.concat(values) + smallest = [rows.length, 1].min + node = Node.new(:sample) + [smallest, node.kind, Dir.pwd] +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let calls = output + .calls + .iter() + .filter(|call| call.function == "exercise") + .collect::>(); + + let logical = calls + .iter() + .find(|call| call.message == "||") + .context("missing normalized ||= control-flow operation")?; + assert_eq!(logical.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(logical.known_space_complexity.as_deref(), Some("O(1)")); + + let concat = calls + .iter() + .find(|call| call.message == "concat") + .context("missing local Array#concat")?; + assert_eq!(concat.receiver_type.as_deref(), Some("T::Array[T.untyped]")); + assert_eq!(concat.known_time_complexity.as_deref(), Some("O(N)")); + + let min = calls + .iter() + .find(|call| call.message == "min") + .context("missing literal Array#min")?; + assert_eq!(min.receiver_type.as_deref(), Some("T::Array[T.untyped]")); + assert_eq!(min.known_time_complexity.as_deref(), Some("O(N)")); + + let accessor = calls + .iter() + .find(|call| call.message == "kind") + .context("missing Struct reader")?; + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("generated_record_contract") + ); + + let pwd = calls + .iter() + .find(|call| call.message == "pwd") + .context("missing Dir.pwd")?; + assert_eq!(pwd.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(pwd.known_space_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn ruby_chained_stdlib_return_types_propagate_to_nested_receivers() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"def owner_name + "Outer::Inner".split("::").last.to_s +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let calls = output + .calls + .iter() + .filter(|call| call.function == "owner_name") + .collect::>(); + + let last = calls + .iter() + .find(|call| call.message == "last") + .context("missing nested Array#last")?; + assert_eq!( + last.receiver_type.as_deref(), + Some("T::Array[String]"), + "String#split's language-owned return contract must type Array#last" + ); + assert_eq!(last.known_time_complexity.as_deref(), Some("O(1)")); + + let to_s = calls + .iter() + .find(|call| call.message == "to_s") + .context("missing nested #to_s")?; + assert_eq!( + to_s.receiver_type.as_deref(), + Some("T.nilable(String)"), + "Array#last's language-owned return contract must type the next receiver" + ); + assert!(to_s.known_time_complexity.is_some()); + assert!(to_s.known_space_complexity.is_some()); + Ok(()) +} + +#[test] +fn ruby_default_parameter_receiver_is_not_emitted_as_a_phantom_call() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"def build(evidence, root: evidence["root"]) + [root, evidence["owners"]] +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let calls = output + .calls + .iter() + .filter(|call| call.function == "build") + .collect::>(); + + assert!( + calls.iter().all(|call| call.message != "evidence"), + "a parameter used as an index receiver must not become an implicit call" + ); + assert_eq!( + calls.iter().filter(|call| call.message == "[]").count(), + 2, + "both actual Hash reads must remain normalized" + ); + Ok(()) +} + +#[test] +fn ruby_regexp_last_match_return_contract_types_index_access() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"def capture + Regexp.last_match[1] +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let index = output + .calls + .iter() + .find(|call| call.function == "capture" && call.message == "[]") + .context("missing MatchData index access")?; + + assert_eq!(index.receiver_type.as_deref(), Some("T.nilable(MatchData)")); + assert_eq!(index.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(index.known_space_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn ruby_truthy_loop_carried_record_value_preserves_generated_reader_identity() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"Node = Struct.new(:kind) + +def labels(text) + current = nil + labels = [] + text.each_line do |line| + current = Node.new(:sample) if line.start_with?("node:") + labels << current.kind if current && current.kind + end + labels +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let accessors = output + .calls + .iter() + .filter(|call| call.function == "labels" && call.message == "kind") + .collect::>(); + + assert_eq!( + accessors.len(), + 2, + "both the short-circuit predicate and guarded body reader must be normalized" + ); + for accessor in accessors { + assert_eq!( + accessor.receiver_symbol.as_deref(), + Some("Node"), + "the truthiness guard must exclude the initial nil definition while retaining the loop-carried constructor definition" + ); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("generated_record_contract") + ); + } + Ok(()) +} + +#[test] +fn ruby_chained_iterators_keep_callback_bindings_in_their_own_cfg_nodes() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"def ordered(rows) + rows.map { |row| row }.sort_by { |result| result.kind } +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let callback_rows = output + .flow_local_types + .iter() + .filter(|row| row["function"] == "ordered" && !row["callback_binding_position"].is_null()) + .collect::>(); + let bindings = callback_rows + .iter() + .map(|row| { + ( + row["node_id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["callback_binding_position"].as_u64(), + ) + }) + .collect::>(); + + assert_eq!(bindings.len(), 2, "each iterator owns exactly one binding"); + assert_eq!( + bindings + .iter() + .map(|(_, name, position)| (*name, *position)) + .collect::>(), + BTreeSet::from([("result", Some(0)), ("row", Some(0))]) + ); + assert_ne!( + bindings[0].0, bindings[1].0, + "nested iterator bindings must have distinct CFG identities" + ); + assert!( + callback_rows.iter().all(|row| { + row["reaching_definitions"] + .as_array() + .is_some_and(Vec::is_empty) + && row["definition_call_sources"] + .as_object() + .is_some_and(serde_json::Map::is_empty) + }), + "each callback parameter must be a fresh definition, not inherit a sibling local's reaching set" + ); + Ok(()) +} + +#[test] +fn ruby_generated_record_contract_accepts_runtime_scip_receiver_identity() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"Node = Struct.new(:kind) + +def label(node) + node.kind +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let mut output = profile::extract(&document, Profile::Espalier); + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": ["--fact-mine-index-authority=runtime-modeled-world"] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": tmp.path().file_name().unwrap().to_string_lossy(), + "language": "ruby", + "occurrences": [{ + "range": [3, 7, 11], + "symbol": "nil-kill-runtime workspace demo 1 Node#kind().", + "symbolRoles": 0 + }] + }] + }); + fact_mine_rust::scip::apply_json(&mut output, &index.to_string())?; + let accessor = output + .calls + .iter() + .find(|call| call.function == "label" && call.message == "kind") + .context("missing generated record reader")?; + + assert_eq!(accessor.receiver_symbol.as_deref(), Some("Node")); + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("generated_record_contract") + ); + Ok(()) +} + +#[test] +fn ruby_generated_record_contract_accepts_portable_scip_nested_owner_identity() -> Result<()> { + let tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + fs::write( + tmp.path(), + r#"module Demo + Node = Struct.new(:kind) + + def self.label(node) + node.kind + end +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let mut output = profile::extract(&document, Profile::Espalier); + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": ["--fact-mine-index-authority=runtime-modeled-world"] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": tmp.path().file_name().unwrap().to_string_lossy(), + "language": "ruby", + "occurrences": [{ + "range": [4, 9, 13], + "symbol": "nil-kill-runtime workspace demo 1 Demo/Node#kind().", + "symbolRoles": 0 + }] + }] + }); + fact_mine_rust::scip::apply_json(&mut output, &index.to_string())?; + let accessor = output + .calls + .iter() + .find(|call| call.function == "self.label" && call.message == "kind") + .context("missing generated record reader")?; + + assert_eq!(accessor.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(accessor.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!( + accessor.complexity_provenance.as_deref(), + Some("generated_callable_declaration") + ); Ok(()) } @@ -1409,6 +3811,49 @@ func (w *wrapper) projected() { w.worker.fn(1) } Ok(()) } +#[test] +fn csharp_declared_delegate_fields_are_parametric_callbacks() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".cs").tempfile()?; + tmp.write_all( + br#"class Worker { + readonly System.Action _send; + readonly System.Func _accept; + + public Worker(System.Action send, System.Func accept) { + _send = send; + _accept = accept; + } + + public bool Run(int value) { + _send(value); + return _accept(value); + } +} +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::CSharp)?; + let output = profile::extract(&document, Profile::Espalier); + let calls = output + .calls + .iter() + .filter(|call| call.message == "_send" || call.message == "_accept") + .collect::>(); + + assert_eq!(calls.len(), 2, "{calls:#?}"); + assert!( + calls.iter().all(|call| { + call.callback_receiver + && call.known_time_complexity.as_deref() == Some("O(C)") + && call.complexity_bound_quality.as_deref() + == Some("upper_bound_parametric_callback_once") + }), + "{calls:#?}" + ); + Ok(()) +} + #[test] fn ruby_calculator_extracts_methods() -> Result<()> { let file = examples_dir().join("ruby_calculator.rb"); @@ -1983,7 +4428,19 @@ end let output = profile::extract(&document, Profile::TracePlan); - assert_eq!(output.methods.len(), 1); + // `call`, the `factory: -> { [] }` lambda, and the two `Data.define` + // readers are all first-class methods so their complexity resolves like + // any explicitly declared function. + let method_names = output + .methods + .iter() + .map(|method| method.name.as_str()) + .collect::>(); + assert!(method_names.contains(&"call")); + assert!(method_names.iter().any(|name| name.starts_with(" { for (key, child) in map { - if matches!(key.as_str(), "path" | "file" | "id" | "key") { + if key == "path" + || key == "file" + || key == "key" + || key == "symbol" + || key == "symbol_owner" + || key.ends_with("id") + { if let Value::String(identity) = child { for component in identity .split('\0') @@ -2579,3 +5046,383 @@ class Greeter { Ok(()) } + +#[test] +fn rust_enum_constructors_and_transmute_are_constant_time() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rs").tempfile()?; + tmp.write_all( + b"fn wrap(x: i32) -> Option { Some(x) }\nfn ok() -> Result { Ok(1) }\nfn bad() -> Result { Err(()) }\nfn nope() -> Option { None }\n", + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Rust)?; + let output = profile::extract(&document, Profile::Espalier); + let contexts: Vec<_> = output + .complexity_facts + .iter() + .flat_map(|facts| facts.call_contexts.iter()) + .collect(); + for message in ["Some", "Ok", "Err", "None"] { + if let Some(call) = contexts.iter().find(|call| call.message == message) { + assert_eq!( + call.known_time_complexity.as_deref(), + Some("O(1)"), + "enum constructor {message} must be O(1)", + ); + } + } + Ok(()) +} + +#[test] +fn injected_state_parameter_dispatch_is_a_parametric_callback() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +class Pipeline + def initialize(runner:, items:) + @runner = runner + @items = items + end + + def execute(command) + @runner.run!(command) + end + + def scan(index) + @items.length + @items[index] + end +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let call = output + .calls + .iter() + .find(|call| call.message == "run!") + .context("injected runner call present")?; + + assert!( + call.state_receiver, + "the ivar read must retain state identity" + ); + assert!( + call.callback_receiver, + "constructor injection proves a callback boundary" + ); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(C)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(S)")); + assert_eq!( + call.complexity_bound_quality.as_deref(), + Some("upper_bound_parametric_callback_once") + ); + for message in ["length", "[]"] { + let call = output + .calls + .iter() + .find(|call| call.function == "scan" && call.message == message) + .with_context(|| format!("injected collection-shaped {message} call present"))?; + assert!( + call.callback_receiver, + "an injected object is not proven to have native collection costs merely because it responds to {message}" + ); + assert_eq!(call.known_time_complexity.as_deref(), Some("O(C)")); + assert_eq!(call.known_space_complexity.as_deref(), Some("O(S)")); + assert_eq!( + call.complexity_bound_quality.as_deref(), + Some("upper_bound_parametric_callback_once") + ); + } + Ok(()) +} + +#[test] +fn ruby_hash_default_block_is_deferred_not_an_unbounded_loop() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +def group(rows) + index = Hash.new { |hash, key| hash[key] = [] } + fallback = {} + fallback.fetch(:missing) { [] } + rows.each { |row| index[row] << row } + index +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let facts = output + .complexity_facts + .iter() + .find(|facts| facts.function == "group") + .context("group complexity facts present")?; + + assert!( + facts + .iterations + .iter() + .all(|iteration| iteration.message.as_deref() != Some("new")), + "Hash.new stores its fallback block and must not create an unknown loop" + ); + assert!(facts + .iterations + .iter() + .all(|iteration| iteration.cardinality_relation != "unknown")); + Ok(()) +} + +#[test] +fn runtime_scip_hash_fetch_identity_proves_its_block_runs_at_most_once() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +def lookup(table, key) + table.fetch(key) { key.to_s } +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let mut output = profile::extract(&document, Profile::Espalier); + let facts = output + .complexity_facts + .iter() + .find(|facts| facts.function == "lookup") + .context("lookup complexity facts present")?; + assert!( + facts.iterations.iter().any(|iteration| { + iteration.message.as_deref() == Some("fetch") + && iteration.cardinality_relation == "unknown" + }), + "without receiver identity, fetch must remain conservative" + ); + + let index = json!({ + "metadata": { + "toolInfo": { + "name": "nil-kill-runtime", + "version": "1", + "arguments": ["--fact-mine-index-authority=runtime-modeled-world"] + }, + "textDocumentEncoding": 1 + }, + "documents": [{ + "relativePath": tmp.path().file_name().unwrap().to_string_lossy(), + "language": "ruby", + "occurrences": [{ + "range": [2, 8, 13], + "symbol": "nil-kill-runtime ruby ruby 3.2.3 Hash#fetch().", + "symbolRoles": 0 + }] + }] + }); + fact_mine_rust::scip::apply_json(&mut output, &index.to_string())?; + + let facts = output + .complexity_facts + .iter() + .find(|facts| facts.function == "lookup") + .context("lookup complexity facts remain present")?; + assert!( + facts + .iterations + .iter() + .all(|iteration| iteration.message.as_deref() != Some("fetch")), + "exact Hash#fetch identity must remove the false unbounded iteration" + ); + let nested = facts + .call_contexts + .iter() + .find(|context| context.message == "to_s") + .context("fetch fallback call context present")?; + assert_eq!(nested.execution_multiplicity, "O(1)"); + assert!(nested + .symbolic_execution + .as_ref() + .is_some_and(|symbolic| symbolic.complete)); + Ok(()) +} + +#[test] +fn ruby_class_method_recursion_keeps_its_unique_local_target() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +module Types + def self.unwrap(value) + value.is_a?(Array) ? value.map { |item| unwrap(item) } : value + end +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let method = output + .methods + .iter() + .find(|method| method.dispatch_name == "unwrap") + .context("unwrap method present")?; + let recursive = output + .calls + .iter() + .find(|call| call.source == method.id && call.message == "unwrap") + .context("recursive unwrap call present")?; + + assert_eq!(recursive.target.as_deref(), Some(method.id.as_str())); + assert_eq!(recursive.kind, "internal_call"); + Ok(()) +} + +#[test] +fn ruby_option_parser_calls_use_reviewed_stdlib_costs() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +def parse_options + parser = OptionParser.new + parser.banner = "Usage" + parser.parse! + parser.to_s +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let parse = output + .calls + .iter() + .find(|call| call.message == "parse!") + .context("OptionParser#parse! call present")?; + let render = output + .calls + .iter() + .find(|call| call.message == "to_s") + .context("OptionParser#to_s call present")?; + let banner = output + .calls + .iter() + .find(|call| call.message == "banner=") + .context("OptionParser#banner= call present")?; + + assert_eq!(banner.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(banner.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(parse.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(parse.known_space_complexity.as_deref(), Some("O(1)")); + assert_eq!(render.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(render.known_space_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn ruby_string_capitalize_uses_reviewed_stdlib_cost() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +def title + "sample".capitalize +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let capitalize = output + .calls + .iter() + .find(|call| call.message == "capitalize") + .context("String#capitalize call present")?; + + assert_eq!(capitalize.known_time_complexity.as_deref(), Some("O(N)")); + assert_eq!(capitalize.known_space_complexity.as_deref(), Some("O(N)")); + Ok(()) +} + +#[test] +fn ruby_env_uses_its_hashlike_receiver_contract_without_trace_coverage() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +def setting + ENV.fetch("SETTING", "default") +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let fetch = output + .calls + .iter() + .find(|call| call.receiver == "ENV" && call.message == "fetch") + .context("ENV.fetch call present")?; + + assert_eq!(fetch.receiver_type.as_deref(), Some("Hash")); + assert_eq!(fetch.known_time_complexity.as_deref(), Some("O(1)")); + assert_eq!(fetch.known_space_complexity.as_deref(), Some("O(1)")); + Ok(()) +} + +#[test] +fn ruby_module_function_dispatch_matches_runtime_scip_class_symbols() -> Result<()> { + use std::io::Write; + + let mut tmp = tempfile::Builder::new().suffix(".rb").tempfile()?; + tmp.write_all( + br#" +module Toolkit + module_function + + def render(value) + value.to_s + end + + public + + def helper + :ok + end +end + +def report(value) + Toolkit.render(value) +end +"#, + )?; + let document = syntax::parse_file(tmp.path().to_path_buf(), Language::Ruby)?; + let output = profile::extract(&document, Profile::Espalier); + let render = output + .methods + .iter() + .find(|method| method.owner == "Toolkit" && method.dispatch_name == "render") + .context("module function present")?; + let call = output + .calls + .iter() + .find(|call| call.message == "render") + .context("module function call present")?; + let helper = output + .methods + .iter() + .find(|method| method.owner == "Toolkit" && method.dispatch_name == "helper") + .context("ordinary instance method present")?; + + assert_eq!(render.kind, "class"); + assert_eq!(helper.kind, "instance"); + assert_eq!(call.target.as_deref(), Some(render.id.as_str())); + assert_eq!(call.kind, "resolved_call"); + Ok(()) +} diff --git a/gems/fact-mine/tests/runtime_evidence_conformance.rs b/gems/fact-mine/tests/runtime_evidence_conformance.rs new file mode 100644 index 000000000..88bf026fb --- /dev/null +++ b/gems/fact-mine/tests/runtime_evidence_conformance.rs @@ -0,0 +1,1786 @@ +use fact_mine_rust::profile::{self, Profile}; +use fact_mine_rust::runtime_evidence; +use fact_mine_rust::runtime_protocol::{ + self, AnchorEvidence, Authority, BuiltTracePlan, CaptureStatus, CaptureSummary, + CorrelationEvidence, EvidenceKind, ExecutionBucket, MappingEntry, MappingShape, Provenance, + RecordMember, RecordShape, Run, RunStatus, RuntimeEvidence, RuntimeTarget, RuntimeValue, + SequenceShape, SourceRole, ToolInfo, TupleShape, ValueSet, WeightedValue, +}; +use fact_mine_rust::syntax::{self, Language}; +use protobuf::{Enum, EnumOrUnknown, MessageField}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Deserialize)] +struct Catalog { + version: u32, + wire_matrix: WireMatrix, + fixture: Fixture, + static_closures: Vec, + cases: Vec, + boundary_cases: Vec, + merge_cases: Vec, +} + +#[derive(Debug, Deserialize)] +struct StaticClosure { + id: String, + capabilities: Vec, + anchor: AnchorSelector, + expect: StaticClosureExpectation, +} + +#[derive(Debug, Deserialize)] +struct StaticClosureExpectation { + target_owner: String, + target_name: String, +} + +#[derive(Debug, Deserialize)] +struct WireMatrix { + anchor_kinds: Vec, + planner_anchor_kinds: Vec, + reserved_anchor_kinds: BTreeMap, + evidence_kinds: Vec, + capture_statuses: Vec, + source_roles: Vec, + value_shapes: Vec, + negative_controls: Vec, + request_contracts: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct Fixture { + language: String, + source: String, + driver: String, + #[serde(default)] + support: Vec, +} + +#[derive(Debug, Deserialize)] +struct Case { + id: String, + capabilities: Vec, + anchor: Option, + #[serde(default)] + anchors: Vec, + expect: Expectation, +} + +#[derive(Clone, Debug, Deserialize)] +struct AnchorSelector { + method: String, + selector: String, + occurrence: usize, +} + +#[derive(Debug, Deserialize)] +struct Expectation { + required: Vec, + allowed_status: Option, + #[serde(default)] + complete_kinds: Vec, + #[serde(default)] + correlation: bool, + receiver_type: Option, + #[serde(default)] + receiver_types: Vec, + target_owner: Option, + #[serde(default)] + target_owners: Vec, + target_name: Option, + forbidden_target_name: Option, + target_kind: Option, + excluded_target_owner: Option, + excluded_target_owner_prefix: Option, + source_role: Option, + result_type: Option, + #[serde(default)] + result_types: Vec, + result_element_type: Option, + result_shape: Option, + boolean_result: Option, + observed_executions: Option, + call_time: Option, + call_space: Option, + iteration_multiplicity: Option, + #[serde(default)] + factmine_infers: Vec, +} + +#[derive(Debug, Deserialize)] +struct InferredExpectation { + method: String, + selector: String, + target_owner: String, +} + +#[derive(Debug, Deserialize)] +struct MergeCase { + id: String, + capabilities: Vec, + expected_runs: Vec, + #[serde(default)] + forbidden_runs: Vec, + expected_count: Option, +} + +#[derive(Debug, Deserialize)] +struct BoundaryCase { + id: String, + capabilities: Vec, + method: String, + display_name: String, + anchor_kind: String, + evidence_kind: String, + expected_type: Option, + allowed_status: Option, +} + +fn conformance_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../protocol/runtime-evidence/v1/conformance") +} + +fn load_catalog() -> Catalog { + serde_yaml::from_str( + &fs::read_to_string(conformance_root().join("capabilities.yml")) + .expect("shared runtime evidence capability catalog"), + ) + .expect("valid capability catalog") +} + +fn built_fixture() -> (Catalog, PathBuf, profile::ProfileOutput, BuiltTracePlan) { + let catalog = load_catalog(); + assert_eq!(catalog.version, 1); + assert_eq!(catalog.fixture.language, "ruby"); + assert!( + conformance_root().join(&catalog.fixture.driver).is_file(), + "collector driver named by the shared catalog must exist" + ); + assert!(catalog + .fixture + .support + .iter() + .all(|path| conformance_root().join(path).is_file())); + let source = conformance_root().join(&catalog.fixture.source); + let document = + syntax::parse_file(source.clone(), Language::Ruby).expect("parse conformance fixture"); + let plan_profile = profile::extract(&document, Profile::TracePlan); + let built = runtime_protocol::build_trace_plan_with_bindings( + &plan_profile, + std::slice::from_ref(&source), + &conformance_root(), + ) + .expect("build conformance trace plan"); + let document = + syntax::parse_file(source.clone(), Language::Ruby).expect("parse profile fixture"); + let output = profile::extract(&document, Profile::Espalier); + (catalog, source, output, built) +} + +fn request_symbols( + built: &BuiltTracePlan, + output: &profile::ProfileOutput, + selector: &AnchorSelector, +) -> Vec { + let calls = output + .calls + .iter() + .map(|call| (call.id.as_str(), call)) + .collect::>(); + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let mut matching = built + .plan + .requests + .iter() + .filter_map(|request| { + let anchor = request.anchor.as_ref()?; + let runtime_protocol::AnchorBinding::Call { call_id } = + built.bindings.get(&anchor.symbol)? + else { + return None; + }; + let call = calls.get(call_id.as_str())?; + let method = methods.get(call.source.as_str())?; + (anchor.display_name == selector.selector && method.name == selector.method).then_some( + ( + anchor + .range + .as_ref() + .map(|range| { + ( + range.start_line, + range.start_character, + range.end_line, + range.end_character, + ) + }) + .unwrap_or_default(), + anchor.symbol.clone(), + ), + ) + }) + .collect::>(); + matching.sort(); + matching.into_iter().map(|(_, symbol)| symbol).collect() +} + +fn selected_symbol( + built: &BuiltTracePlan, + output: &profile::ProfileOutput, + selector: &AnchorSelector, +) -> String { + let symbols = request_symbols(built, output, selector); + symbols + .get(selector.occurrence.saturating_sub(1)) + .unwrap_or_else(|| { + panic!( + "missing occurrence {} of {} in {} (found {})", + selector.occurrence, + selector.selector, + selector.method, + symbols.len() + ) + }) + .clone() +} + +fn selected_call<'a>( + output: &'a profile::ProfileOutput, + selector: &AnchorSelector, +) -> &'a profile::CallRecord { + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let mut matching = output + .calls + .iter() + .filter(|call| { + call.message == selector.selector + && methods + .get(call.source.as_str()) + .is_some_and(|method| method.name == selector.method) + }) + .collect::>(); + matching.sort_by_key(|call| call.span); + matching + .get(selector.occurrence.saturating_sub(1)) + .copied() + .unwrap_or_else(|| { + panic!( + "missing occurrence {} of {} in {} (found {})", + selector.occurrence, + selector.selector, + selector.method, + matching.len() + ) + }) +} + +fn boundary_symbol( + built: &BuiltTracePlan, + output: &profile::ProfileOutput, + boundary: &BoundaryCase, +) -> String { + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + let calls = output + .calls + .iter() + .map(|call| (call.id.as_str(), call)) + .collect::>(); + let accesses = output + .state_accesses + .iter() + .map(|access| (access.id.as_str(), access)) + .collect::>(); + let mut symbols = built + .plan + .requests + .iter() + .filter_map(|request| { + let anchor = request.anchor.as_ref()?; + if format!("{:?}", anchor.kind.enum_value().ok()?) != boundary.anchor_kind + || anchor.display_name != boundary.display_name + { + return None; + } + let method_id = match built.bindings.get(&anchor.symbol)? { + runtime_protocol::AnchorBinding::Parameter { method_id, .. } + | runtime_protocol::AnchorBinding::Return { method_id } => method_id, + runtime_protocol::AnchorBinding::Call { call_id } => { + &calls.get(call_id.as_str())?.source + } + runtime_protocol::AnchorBinding::State { access_id } => { + &accesses.get(access_id.as_str())?.function_id + } + }; + (methods.get(method_id.as_str())?.name == boundary.method) + .then_some(anchor.symbol.clone()) + }) + .collect::>(); + symbols.sort(); + assert_eq!( + symbols.len(), + 1, + "{} must select exactly one planned boundary, got {:?}", + boundary.id, + symbols + ); + symbols.remove(0) +} + +fn ruby_type_symbol(name: &str) -> String { + format!( + "nil-kill-runtime ruby ruby 3.2.3 {}#", + descriptor_owner(name) + ) +} + +fn descriptor_name(name: &str) -> String { + if name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_+$-".contains(character)) + { + name.to_string() + } else { + format!("`{}`", name.replace('`', "``")) + } +} + +fn descriptor_owner(name: &str) -> String { + name.split("::") + .map(descriptor_name) + .collect::>() + .join("/") +} + +fn value_set(name: &str, element: Option<&str>, role: SourceRole) -> ValueSet { + let mut value = RuntimeValue { + type_symbol: ruby_type_symbol(name), + source_role: EnumOrUnknown::new(role), + ..RuntimeValue::default() + }; + if let Some(element) = element { + value.shape = Some(runtime_protocol::runtime_value::Shape::Sequence( + runtime_protocol::SequenceShape { + elements: MessageField::some(ValueSet { + alternatives: vec![WeightedValue { + value: MessageField::some(RuntimeValue { + type_symbol: ruby_type_symbol(element), + source_role: EnumOrUnknown::new(role), + ..RuntimeValue::default() + }), + count: 1, + ..WeightedValue::default() + }], + ..ValueSet::default() + }), + ..runtime_protocol::SequenceShape::default() + }, + )); + } + ValueSet { + alternatives: vec![WeightedValue { + value: MessageField::some(value), + count: 1, + ..WeightedValue::default() + }], + ..ValueSet::default() + } +} + +fn shaped_value_set(shape: &str, role: SourceRole) -> ValueSet { + let child = || RuntimeValue { + type_symbol: ruby_type_symbol("String"), + source_role: EnumOrUnknown::new(role), + ..RuntimeValue::default() + }; + let child_set = || ValueSet { + alternatives: vec![WeightedValue { + value: MessageField::some(child()), + count: 1, + ..WeightedValue::default() + }], + ..ValueSet::default() + }; + let shape = match shape { + "sequence" => runtime_protocol::runtime_value::Shape::Sequence(SequenceShape { + elements: MessageField::some(child_set()), + ..SequenceShape::default() + }), + "mapping" => runtime_protocol::runtime_value::Shape::Mapping(MappingShape { + entries: vec![MappingEntry { + key: MessageField::some(child()), + value: MessageField::some(child()), + count: 1, + ..MappingEntry::default() + }], + ..MappingShape::default() + }), + "record" => runtime_protocol::runtime_value::Shape::Record(RecordShape { + members: vec![RecordMember { + name: "value".to_string(), + values: MessageField::some(child_set()), + ..RecordMember::default() + }], + ..RecordShape::default() + }), + "tuple" => runtime_protocol::runtime_value::Shape::Tuple(TupleShape { + elements: vec![child_set(), child_set()], + ..TupleShape::default() + }), + other => panic!("unknown catalog value shape {other}"), + }; + ValueSet { + alternatives: vec![WeightedValue { + value: MessageField::some(RuntimeValue { + type_symbol: ruby_type_symbol("Object"), + source_role: EnumOrUnknown::new(role), + shape: Some(shape), + ..RuntimeValue::default() + }), + count: 1, + ..WeightedValue::default() + }], + ..ValueSet::default() + } +} + +fn role(value: Option<&str>, owner: Option<&str>) -> SourceRole { + match value { + Some("PRODUCTION") => SourceRole::PRODUCTION, + Some("NON_PRODUCTION") => SourceRole::NON_PRODUCTION, + Some("STANDARD_LIBRARY") => SourceRole::STANDARD_LIBRARY, + Some("DEPENDENCY") => SourceRole::DEPENDENCY, + _ if matches!(owner, Some("String" | "Hash" | "Array" | "Process::Status")) => { + SourceRole::STANDARD_LIBRARY + } + _ => SourceRole::PRODUCTION, + } +} + +fn target(owner: &str, name: &str, kind: Option<&str>, role: SourceRole) -> RuntimeTarget { + let (manager, package, version) = if role == SourceRole::STANDARD_LIBRARY { + ("ruby", "ruby", "3.2.3") + } else { + ("workspace", "runtime-evidence-conformance", "workspace") + }; + RuntimeTarget { + symbol: format!( + "nil-kill-runtime {manager} {package} {version} {}{}{}().", + descriptor_owner(owner), + if kind == Some("class") { "." } else { "#" }, + descriptor_name(name) + ), + source_role: EnumOrUnknown::new(role), + package_manager: manager.to_string(), + package_name: package.to_string(), + package_version: version.to_string(), + ..RuntimeTarget::default() + } +} + +fn evidence_for_catalog( + catalog: &Catalog, + output: &profile::ProfileOutput, + built: &BuiltTracePlan, +) -> RuntimeEvidence { + let requests = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("validated plan anchor"); + (anchor.symbol.clone(), request) + }) + .collect::>(); + let mut exact = BTreeMap::::new(); + let boundaries = catalog + .boundary_cases + .iter() + .map(|boundary| (boundary_symbol(built, output, boundary), boundary)) + .collect::>(); + let mut correlations = Vec::<(&Case, Vec)>::new(); + for case in &catalog.cases { + if let Some(selector) = &case.anchor { + assert!( + exact + .insert(selected_symbol(built, output, selector), case) + .is_none(), + "a conformance anchor must have one owner" + ); + } else { + let symbols = case + .anchors + .iter() + .map(|selector| selected_symbol(built, output, selector)) + .collect::>(); + correlations.push((case, symbols)); + } + } + let correlated = correlations + .iter() + .flat_map(|(_, symbols)| symbols.iter().cloned()) + .collect::>(); + + let anchors = built + .plan + .requests + .iter() + .map(|request| { + let anchor = request.anchor.as_ref().expect("anchor"); + let (status, complete_kinds, executions, reason) = if let Some(case) = + exact.get(&anchor.symbol) + { + let expected = &case.expect; + let receiver_names = if expected.receiver_types.is_empty() { + vec![expected + .receiver_type + .as_deref() + .or(expected.target_owner.as_deref()) + .unwrap_or("Object")] + } else { + expected + .receiver_types + .iter() + .map(String::as_str) + .collect::>() + }; + let target_owners = if expected.target_owners.is_empty() { + vec![expected + .target_owner + .as_deref() + .unwrap_or(receiver_names[0])] + } else { + expected + .target_owners + .iter() + .map(String::as_str) + .collect::>() + }; + let target_name = expected + .target_name + .as_deref() + .unwrap_or(&anchor.display_name); + let required = request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + .collect::>(); + let complete_kind_names = if expected.allowed_status.as_deref() == Some("PARTIAL") { + expected + .complete_kinds + .iter() + .cloned() + .collect::>() + } else { + request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok()) + .map(|kind| format!("{kind:?}")) + .collect::>() + }; + let complete_kind = + |kind: EvidenceKind| complete_kind_names.contains(format!("{kind:?}").as_str()); + let alternative_count = receiver_names.len().max(target_owners.len()); + let result_names = if expected.result_types.is_empty() { + vec![expected.result_type.as_deref().unwrap_or("Object")] + } else { + expected + .result_types + .iter() + .map(String::as_str) + .collect::>() + }; + let alternative_count = alternative_count.max(result_names.len()); + assert!( + receiver_names.len() == 1 || receiver_names.len() == alternative_count, + "{} receiver alternatives do not align with targets", + case.id + ); + assert!( + target_owners.len() == 1 || target_owners.len() == alternative_count, + "{} target alternatives do not align with receivers", + case.id + ); + assert!( + result_names.len() == 1 || result_names.len() == alternative_count, + "{} result alternatives do not align with receivers and targets", + case.id + ); + let mut executions = (0..alternative_count) + .map(|index| { + let receiver_name = receiver_names[index.min(receiver_names.len() - 1)]; + let owner = target_owners[index.min(target_owners.len() - 1)]; + let source_role = role(expected.source_role.as_deref(), Some(owner)); + let result = if let Some(shape) = expected.result_shape.as_deref() { + shaped_value_set(shape, SourceRole::PRODUCTION) + } else { + value_set( + result_names[index.min(result_names.len() - 1)], + expected.result_element_type.as_deref(), + SourceRole::PRODUCTION, + ) + }; + let mut bucket = ExecutionBucket { + count: if alternative_count == 1 { + expected.observed_executions.unwrap_or(1) + } else { + 1 + }, + receiver: (complete_kind(EvidenceKind::RECEIVER_VALUE) + || complete_kind(EvidenceKind::COLLECTION_VALUE)) + .then(|| value_set(receiver_name, None, source_role)) + .into(), + target: complete_kind(EvidenceKind::CALL_TARGET) + .then(|| { + target( + owner, + target_name, + expected.target_kind.as_deref(), + source_role, + ) + }) + .into(), + result: complete_kind(EvidenceKind::RESULT_VALUE) + .then_some(result) + .into(), + provenance: MessageField::some(Provenance { + run_id: "oracle-run".to_string(), + provider: "canonical-conformance".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }; + if complete_kind(EvidenceKind::BOOLEAN_RESULT) { + bucket.boolean_result = Some(expected.boolean_result.unwrap_or(false)); + } + bucket + }) + .collect::>(); + if let Some(excluded_owner) = expected.excluded_target_owner.as_deref() { + executions.push(ExecutionBucket { + count: 1, + receiver: required + .contains(&EvidenceKind::RECEIVER_VALUE.value()) + .then(|| value_set(excluded_owner, None, SourceRole::NON_PRODUCTION)) + .into(), + target: required + .contains(&EvidenceKind::CALL_TARGET.value()) + .then(|| { + target( + excluded_owner, + target_name, + expected.target_kind.as_deref(), + SourceRole::NON_PRODUCTION, + ) + }) + .into(), + provenance: MessageField::some(Provenance { + run_id: "oracle-run".to_string(), + provider: "canonical-conformance".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }); + } + if let Some(excluded_prefix) = expected.excluded_target_owner_prefix.as_deref() { + let excluded_owner = format!("{excluded_prefix}fixture.rb:1)"); + executions.push(ExecutionBucket { + count: 1, + receiver: required + .contains(&EvidenceKind::RECEIVER_VALUE.value()) + .then(|| value_set(&excluded_owner, None, SourceRole::NON_PRODUCTION)) + .into(), + target: required + .contains(&EvidenceKind::CALL_TARGET.value()) + .then(|| { + target( + &excluded_owner, + target_name, + expected.target_kind.as_deref(), + SourceRole::NON_PRODUCTION, + ) + }) + .into(), + provenance: MessageField::some(Provenance { + run_id: "oracle-run".to_string(), + provider: "canonical-conformance".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }); + } + let status = match expected.allowed_status.as_deref() { + None | Some("COMPLETE_FOR_RUNS") => CaptureStatus::COMPLETE_FOR_RUNS, + Some("PARTIAL") => CaptureStatus::PARTIAL, + other => panic!("unsupported case status {other:?}"), + }; + let completed = request + .required + .iter() + .filter(|kind| kind.enum_value().ok().is_some_and(&complete_kind)) + .cloned() + .collect(); + ( + status, + completed, + executions, + if status == CaptureStatus::COMPLETE_FOR_RUNS { + String::new() + } else { + "call raised before producing its requested result".to_string() + }, + ) + } else if let Some(boundary) = boundaries.get(&anchor.symbol) { + if boundary.allowed_status.is_some() { + let status = match boundary.allowed_status.as_deref() { + Some("NOT_EXECUTED") => CaptureStatus::NOT_EXECUTED, + Some("NOT_INSTRUMENTED") => CaptureStatus::NOT_INSTRUMENTED, + other => panic!("unsupported boundary status {other:?}"), + }; + ( + status, + if status == CaptureStatus::NOT_EXECUTED { + request.required.clone() + } else { + Vec::new() + }, + Vec::new(), + "function entered but did not produce a return value".to_string(), + ) + } else { + ( + CaptureStatus::COMPLETE_FOR_RUNS, + request.required.clone(), + vec![ExecutionBucket { + count: 1, + value: MessageField::some(value_set( + boundary + .expected_type + .as_deref() + .expect("complete boundary expected type"), + None, + SourceRole::PRODUCTION, + )), + provenance: MessageField::some(Provenance { + run_id: "oracle-run".to_string(), + provider: "canonical-conformance".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }], + String::new(), + ) + } + } else if correlated.contains(&anchor.symbol) { + ( + CaptureStatus::PARTIAL, + Vec::new(), + Vec::new(), + "execution is represented by an exact candidate correlation".to_string(), + ) + } else { + ( + CaptureStatus::NOT_EXECUTED, + request.required.clone(), + Vec::new(), + "anchor did not execute in the canonical modeled run".to_string(), + ) + }; + AnchorEvidence { + anchor_symbol: anchor.symbol.clone(), + anchor_semantic_digest: anchor.semantic_digest.clone(), + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(status), + run_ids: vec!["oracle-run".to_string()], + observed_executions: executions.iter().map(|bucket| bucket.count).sum(), + reason, + complete_kinds, + ..CaptureSummary::default() + }), + executions, + ..AnchorEvidence::default() + } + }) + .collect(); + + let correlations = correlations + .into_iter() + .map(|(case, mut symbols)| { + symbols.sort(); + let requested = symbols + .iter() + .flat_map(|symbol| { + requests[symbol] + .required + .iter() + .filter_map(|kind| kind.enum_value().ok().map(|kind| kind.value())) + }) + .collect::>(); + let owner = case + .expect + .target_owner + .as_deref() + .unwrap_or("RuntimeEvidenceConformance::Value"); + let name = case.expect.target_name.as_deref().unwrap_or("normalize"); + CorrelationEvidence { + group_id: format!("conformance-{}", case.id), + candidate_anchor_symbols: symbols, + capture: MessageField::some(CaptureSummary { + status: EnumOrUnknown::new(CaptureStatus::COMPLETE_FOR_RUNS), + run_ids: vec!["oracle-run".to_string()], + observed_executions: 1, + complete_kinds: requested + .iter() + .filter_map(|value| EvidenceKind::from_i32(*value)) + .map(EnumOrUnknown::new) + .collect(), + ..CaptureSummary::default() + }), + executions: vec![ExecutionBucket { + count: 1, + receiver: MessageField::some(value_set( + case.expect + .receiver_type + .as_deref() + .unwrap_or("RuntimeEvidenceConformance::Value"), + None, + SourceRole::PRODUCTION, + )), + target: MessageField::some(target(owner, name, None, SourceRole::PRODUCTION)), + provenance: MessageField::some(Provenance { + run_id: "oracle-run".to_string(), + provider: "canonical-conformance".to_string(), + provider_version: "1".to_string(), + ..Provenance::default() + }), + ..ExecutionBucket::default() + }], + ..CorrelationEvidence::default() + } + }) + .collect(); + + RuntimeEvidence { + protocol_version: runtime_protocol::PROTOCOL_VERSION, + producer: MessageField::some(ToolInfo { + name: "runtime-evidence-conformance".to_string(), + version: "1".to_string(), + ..ToolInfo::default() + }), + authority: EnumOrUnknown::new(Authority::MODELED_RUNS), + trace_plan_digest: built.plan.plan_digest.clone(), + runs: vec![Run { + id: "oracle-run".to_string(), + status: EnumOrUnknown::new(RunStatus::SUCCEEDED), + test_ids: catalog.cases.iter().map(|case| case.id.clone()).collect(), + ..Run::default() + }], + anchors, + correlations, + ..RuntimeEvidence::default() + } +} + +fn occurrence_symbols(index: &serde_json::Value) -> Vec { + index["documents"] + .as_array() + .into_iter() + .flatten() + .flat_map(|document| { + document["occurrences"] + .as_array() + .into_iter() + .flatten() + .filter_map(|occurrence| occurrence["symbol"].as_str().map(str::to_string)) + }) + .collect() +} + +fn occurrence_symbols_at_anchor( + index: &serde_json::Value, + anchor: &runtime_protocol::SourceAnchor, +) -> Vec { + let range = anchor.range.as_ref().expect("anchor range"); + index["documents"] + .as_array() + .into_iter() + .flatten() + .filter(|document| { + document["relativePath"] + .as_str() + .is_some_and(|path| path.ends_with(&anchor.relative_path)) + }) + .flat_map(|document| { + document["occurrences"] + .as_array() + .into_iter() + .flatten() + .filter_map(|occurrence| { + let occurrence_range = occurrence["range"].as_array()?; + let numbers = occurrence_range + .iter() + .filter_map(serde_json::Value::as_u64) + .collect::>(); + let (start_line, start_character, end_line, end_character) = + match numbers.as_slice() { + [line, start, end] => (*line, *start, *line, *end), + [start_line, start, end_line, end] => { + (*start_line, *start, *end_line, *end) + } + _ => return None, + }; + let contains = (start_line, start_character) + <= (range.start_line as u64, range.start_character as u64) + && (end_line, end_character) + >= (range.end_line as u64, range.end_character as u64); + contains.then(|| occurrence["symbol"].as_str().map(str::to_string))? + }) + }) + .collect() +} + +fn assert_validation_error( + plan: &runtime_protocol::TracePlan, + evidence: &RuntimeEvidence, + expected: &str, +) { + let error = match runtime_protocol::validate_runtime_evidence(plan, evidence) { + Err(error) => error.to_string(), + Ok(()) => panic!("negative control accepted; expected an error containing {expected:?}"), + }; + assert!( + error.contains(expected), + "expected error containing {expected:?}, got {error:?}" + ); +} + +#[test] +fn shared_catalog_covers_the_runtime_evidence_v1_behavior_matrix() { + let (catalog, _source, _output, built) = built_fixture(); + let capabilities = catalog + .cases + .iter() + .flat_map(|case| case.capabilities.iter().map(String::as_str)) + .chain( + catalog + .static_closures + .iter() + .flat_map(|case| case.capabilities.iter().map(String::as_str)), + ) + .chain( + catalog + .boundary_cases + .iter() + .flat_map(|case| case.capabilities.iter().map(String::as_str)), + ) + .chain( + catalog + .merge_cases + .iter() + .flat_map(|case| case.capabilities.iter().map(String::as_str)), + ) + .collect::>(); + for required in [ + "exact-anchor", + "ambiguous-anchor", + "same-line", + "exact-execution-range", + "nested-receiver", + "chained-call", + "assignment", + "destructuring", + "short-circuit-assignment", + "short-circuit-call", + "skipped-execution", + "native-call", + "set", + "chained-index", + "kernel-conversion", + "sorbet", + "typed-record", + "open-struct", + "string-builder", + "module-function", + "project-call", + "statically-indexed-target", + "binary-search", + "logarithmic-iteration", + "callback-multiplicity", + "setter-method", + "runtime-symbol-escaping", + "excluded-source", + "structural-runtime-identity", + "generated-accessor", + "anonymous-class", + "transparent-wrapper", + "callback", + "yield", + "block-parameter", + "attached-block-range", + "nested-attached-blocks", + "generated-setter", + "dynamic-dispatch", + "test-replacement", + "container-shape", + "exception", + "non-returning-call", + "subprocess", + "result-object", + "nonproduction-provenance", + "dependency-provenance", + "third-party-target", + "repeated-run", + "sharded-run", + "incremental", + "replacement", + ] { + assert!( + capabilities.contains(required), + "shared catalog lacks required capability {required}" + ); + } + assert!(catalog + .cases + .iter() + .all(|case| case.anchor.is_some() ^ !case.anchors.is_empty())); + assert!(catalog + .merge_cases + .iter() + .any(|case| case.id == "repeated_runs_are_additive" + && case.expected_runs == ["run-a", "run-b"] + && case.expected_count == Some(2))); + assert!(catalog + .merge_cases + .iter() + .any(|case| case.id == "changed_shard_replaces_owned_evidence" + && case.expected_runs == ["run-new"] + && case.forbidden_runs == ["run-old"])); + assert_eq!( + catalog.wire_matrix.anchor_kinds, + [ + "FUNCTION_ENTRY", + "FUNCTION_RETURN", + "CALL_SELECTOR", + "STATE_READ", + "STATE_WRITE", + "CALLBACK_ENTRY", + "COLLECTION_OPERATION", + "BRANCH_PREDICATE", + ] + ); + let planned = built + .plan + .requests + .iter() + .filter_map(|request| request.anchor.as_ref()) + .filter_map(|anchor| anchor.kind.enum_value().ok()) + .map(|kind| format!("{kind:?}")) + .collect::>(); + assert_eq!( + planned, + catalog + .wire_matrix + .planner_anchor_kinds + .iter() + .cloned() + .collect(), + "the real FactMine planner surface must equal its executable contract" + ); + let reserved = catalog + .wire_matrix + .reserved_anchor_kinds + .keys() + .cloned() + .collect::>(); + assert!( + reserved.intersection(&planned).next().is_none(), + "a planner kind cannot remain declared reserved" + ); + assert_eq!( + reserved.union(&planned).cloned().collect::>(), + catalog.wire_matrix.anchor_kinds.iter().cloned().collect(), + "every wire anchor kind must be executable or explicitly reserved" + ); + assert!(catalog + .wire_matrix + .reserved_anchor_kinds + .values() + .all(|reason| !reason.trim().is_empty())); + assert_eq!( + catalog.wire_matrix.evidence_kinds, + [ + "PARAMETER_VALUE", + "RETURN_VALUE", + "RECEIVER_VALUE", + "CALL_TARGET", + "RESULT_VALUE", + "BOOLEAN_RESULT", + "STATE_VALUE", + "COLLECTION_VALUE", + ] + ); + assert_eq!(catalog.wire_matrix.capture_statuses.len(), 7); + assert_eq!(catalog.wire_matrix.source_roles.len(), 6); + assert_eq!( + catalog.wire_matrix.value_shapes, + ["sequence", "mapping", "record", "tuple"] + ); + assert!(catalog.wire_matrix.negative_controls.len() >= 10); + assert_eq!( + catalog + .wire_matrix + .request_contracts + .keys() + .cloned() + .collect::>(), + catalog + .wire_matrix + .anchor_kinds + .iter() + .cloned() + .collect::>() + ); + let declared_evidence = catalog + .wire_matrix + .evidence_kinds + .iter() + .cloned() + .collect::>(); + assert!(catalog + .wire_matrix + .request_contracts + .values() + .flatten() + .all(|kind| declared_evidence.contains(kind))); + for boundary in &catalog.boundary_cases { + assert!(!boundary.id.is_empty()); + assert!(!boundary.method.is_empty()); + assert!(!boundary.display_name.is_empty()); + assert!(catalog + .wire_matrix + .anchor_kinds + .contains(&boundary.anchor_kind)); + assert!(catalog + .wire_matrix + .evidence_kinds + .contains(&boundary.evidence_kind)); + if boundary.allowed_status.is_none() { + assert!(boundary + .expected_type + .as_deref() + .is_some_and(|name| !name.is_empty())); + } + } +} + +#[test] +fn statically_closed_project_calls_are_exact_and_not_retraced() { + let (catalog, _source, output, built) = built_fixture(); + let methods = output + .methods + .iter() + .map(|method| (method.id.as_str(), method)) + .collect::>(); + for case in &catalog.static_closures { + let call = selected_call(&output, &case.anchor); + let target_id = call + .target + .as_deref() + .unwrap_or_else(|| panic!("{} lacks a static target", case.id)); + let target = methods + .get(target_id) + .unwrap_or_else(|| panic!("{} target is outside the analyzed corpus", case.id)); + assert_eq!(target.owner, case.expect.target_owner, "{} owner", case.id); + assert_eq!(target.name, case.expect.target_name, "{} name", case.id); + assert!( + !built.bindings.values().any( + |binding| matches!(binding, runtime_protocol::AnchorBinding::Call { call_id } if call_id == &call.id) + ), + "{} redundantly requested runtime evidence for an exact analyzed target", + case.id + ); + } +} + +#[test] +fn every_planned_call_has_a_closed_execution_range_owned_by_factmine() { + let (catalog, _source, output, built) = built_fixture(); + for case in &catalog.cases { + let Some(selector) = &case.anchor else { + continue; + }; + let symbol = selected_symbol(&built, &output, selector); + let request = built + .plan + .requests + .iter() + .find(|request| request.anchor.as_ref().unwrap().symbol == symbol) + .expect("catalog request"); + let anchor = request.anchor.as_ref().unwrap().range.as_ref().unwrap(); + let execution = request + .execution_range + .as_ref() + .unwrap_or_else(|| panic!("{} has no execution range", case.id)); + assert!( + (execution.start_line, execution.start_character) + <= (anchor.start_line, anchor.start_character) + && (execution.end_line, execution.end_character) + >= (anchor.end_line, anchor.end_character), + "{} execution range does not contain its selector", + case.id + ); + if case.capabilities.iter().any(|capability| { + matches!( + capability.as_str(), + "attached-block-range" | "nested-attached-blocks" + ) + }) { + assert!( + (execution.end_line, execution.end_character) + > (anchor.end_line, anchor.end_character), + "{} did not retain its attached callback body", + case.id + ); + } + } +} + +#[test] +fn factmine_oracle_joins_every_canonical_capability_through_its_cfg_and_dfg() { + let (catalog, _source, mut output, built) = built_fixture(); + let evidence = evidence_for_catalog(&catalog, &output, &built); + runtime_protocol::validate_runtime_evidence(&built.plan, &evidence) + .expect("canonical catalog evidence satisfies protocol"); + let overlay = runtime_evidence::apply_protocol_to_profile(&mut output, &built, &evidence) + .expect("FactMine consumes canonical catalog evidence"); + let symbols = occurrence_symbols(&overlay.index); + + for case in &catalog.cases { + let expected = &case.expect; + let declared = expected.required.iter().collect::>(); + let selectors = case + .anchor + .iter() + .chain(case.anchors.iter()) + .collect::>(); + for selector in selectors { + let symbol = selected_symbol(&built, &output, selector); + let request = built + .plan + .requests + .iter() + .find(|request| request.anchor.as_ref().unwrap().symbol == symbol) + .unwrap(); + let actual = request + .required + .iter() + .filter_map(|kind| kind.enum_value().ok()) + .map(|kind| format!("{kind:?}")) + .collect::>(); + assert!( + declared.iter().all(|kind| actual.contains(kind.as_str())), + "{} expects {:?}, plan requested {:?}", + case.id, + declared, + actual + ); + let anchor = request.anchor.as_ref().unwrap(); + let at_anchor = occurrence_symbols_at_anchor(&overlay.index, anchor); + let expected_owners = expected + .target_owner + .iter() + .map(String::as_str) + .chain(expected.target_owners.iter().map(String::as_str)) + .collect::>(); + if let Some(name) = expected.target_name.as_deref() { + for owner in expected_owners { + let expected_suffix = format!( + "{}{}{}().", + descriptor_owner(owner), + if expected.target_kind.as_deref() == Some("class") { + "." + } else { + "#" + }, + descriptor_name(name) + ); + if expected.source_role.as_deref() == Some("NON_PRODUCTION") { + assert!( + !at_anchor + .iter() + .any(|symbol| symbol.ends_with(&expected_suffix)), + "{} published nonproduction target {} at its callsite", + case.id, + expected_suffix + ); + } else { + assert!( + at_anchor + .iter() + .any(|symbol| symbol.ends_with(&expected_suffix)), + "{} did not join target {} at its callsite; emitted {:?}", + case.id, + expected_suffix, + at_anchor + ); + } + } + } + if let (Some(owner), Some(name)) = ( + expected.target_owner.as_deref(), + expected.forbidden_target_name.as_deref(), + ) { + let forbidden_suffix = + format!("{}#{}().", descriptor_owner(owner), descriptor_name(name)); + assert!( + at_anchor + .iter() + .all(|symbol| !symbol.ends_with(&forbidden_suffix)), + "{} published forbidden same-range target {} at its callsite", + case.id, + forbidden_suffix + ); + } + if expected.call_time.is_some() || expected.call_space.is_some() { + let runtime_protocol::AnchorBinding::Call { call_id } = built + .bindings + .get(&symbol) + .expect("canonical call anchor binding") + else { + panic!("{} complexity expectation is not a call", case.id); + }; + let call = output + .calls + .iter() + .find(|call| call.id == *call_id) + .unwrap_or_else(|| panic!("{} call disappeared after overlay", case.id)); + assert_eq!( + call.known_time_complexity.as_deref(), + expected.call_time.as_deref(), + "{} time complexity mismatch for {} with semantic target {:?}", + case.id, + call.message, + call.semantic_symbol + ); + assert_eq!( + call.known_space_complexity.as_deref(), + expected.call_space.as_deref(), + "{} space complexity mismatch for {}", + case.id, + call.message + ); + } + if let Some(excluded) = expected.excluded_target_owner.as_deref() { + assert!( + at_anchor + .iter() + .all(|symbol| !symbol.contains(&excluded.replace("::", "/"))), + "{} published excluded target {} at its callsite", + case.id, + excluded + ); + } + if let Some(excluded) = expected.excluded_target_owner_prefix.as_deref() { + assert!( + at_anchor.iter().all(|symbol| !symbol.contains(excluded)), + "{} published excluded anonymous target {} at its callsite", + case.id, + excluded + ); + } + } + for inferred in &expected.factmine_infers { + let expected_suffix = format!( + "{}#{}().", + inferred.target_owner.replace("::", "/"), + descriptor_name(&inferred.selector) + ); + assert!( + symbols + .iter() + .any(|symbol| symbol.ends_with(&expected_suffix)), + "{} did not produce inferred {} in {}", + case.id, + expected_suffix, + inferred.method + ); + } + if let Some(multiplicity) = expected.iteration_multiplicity.as_deref() { + let selector = case.anchor.as_ref().expect("iteration case anchor"); + let fact = output + .complexity_facts + .iter() + .find(|fact| fact.function == selector.method) + .unwrap_or_else(|| panic!("{} method has no complexity facts", case.id)); + let iteration = fact + .iterations + .iter() + .find(|iteration| iteration.message.as_deref() == Some(&selector.selector)) + .unwrap_or_else(|| panic!("{} has no normalized iteration", case.id)); + assert_eq!( + iteration.execution_multiplicity, multiplicity, + "{} callback/iteration multiplicity", + case.id + ); + assert!( + iteration.evidence_gap.is_none(), + "{} retained iteration evidence gap {:?}", + case.id, + iteration.evidence_gap + ); + assert!( + fact.call_contexts.iter().any(|context| { + context.message != selector.selector + && context.execution_multiplicity == multiplicity + && context.span[0] >= iteration.span[0] + && context.span[2] <= iteration.span[2] + }), + "{} did not apply logarithmic multiplicity to its callback body", + case.id + ); + } + assert_eq!( + case.expect.correlation, + !case.anchors.is_empty(), + "{} correlation declaration disagrees with its anchor shape", + case.id + ); + } + for boundary in &catalog.boundary_cases { + let symbol = boundary_symbol(&built, &output, boundary); + let row = evidence + .anchors + .iter() + .find(|row| row.anchor_symbol == symbol) + .expect("canonical boundary evidence"); + let expected_status = match boundary.allowed_status.as_deref() { + Some("NOT_EXECUTED") => CaptureStatus::NOT_EXECUTED, + Some("NOT_INSTRUMENTED") => CaptureStatus::NOT_INSTRUMENTED, + None => CaptureStatus::COMPLETE_FOR_RUNS, + other => panic!("unsupported boundary status {other:?}"), + }; + assert_eq!( + row.capture.as_ref().unwrap().status.enum_value_or_default(), + expected_status, + "{} has the wrong canonical status", + boundary.id + ); + assert_eq!( + row.executions.len(), + usize::from(expected_status == CaptureStatus::COMPLETE_FOR_RUNS), + "{} retained the wrong number of canonical value buckets", + boundary.id + ); + } +} + +#[test] +fn shared_negative_controls_fail_closed_at_the_protocol_boundary() { + let (catalog, _source, output, built) = built_fixture(); + let canonical = evidence_for_catalog(&catalog, &output, &built); + runtime_protocol::validate_runtime_evidence(&built.plan, &canonical) + .expect("negative controls start from canonical evidence"); + let mut exercised = BTreeSet::new(); + + let json = runtime_protocol::to_json(&canonical).expect("canonical ProtoJSON"); + let unknown = json.replacen('{', "{\"unknown_contract_field\":true,", 1); + assert!(runtime_protocol::parse_runtime_evidence_json(&unknown).is_err()); + exercised.insert("unknown-field"); + + // Evidence went sparse: an anchor with no entry did not execute, so + // omitting one is a valid document. Evidence for an anchor the plan never + // requested is not -- it claims an observation nothing asked for. + let mut evidence = canonical.clone(); + evidence.anchors[0].anchor_symbol = "nil-kill-runtime ruby ruby 0 Absent#gone().".to_string(); + assert_validation_error(&built.plan, &evidence, "unknown plan anchor"); + exercised.insert("unknown-anchor"); + + let mut evidence = canonical.clone(); + evidence.anchors.push(evidence.anchors[0].clone()); + assert_validation_error(&built.plan, &evidence, "duplicate evidence"); + exercised.insert("duplicate-anchor"); + + let mut evidence = canonical.clone(); + evidence.trace_plan_digest[0] ^= 0xff; + assert_validation_error(&built.plan, &evidence, "trace_plan_digest"); + exercised.insert("stale-plan-digest"); + + let mut evidence = canonical.clone(); + evidence.anchors[0].anchor_semantic_digest[0] ^= 0xff; + assert_validation_error(&built.plan, &evidence, "semantic digest"); + exercised.insert("stale-anchor-digest"); + + let mut evidence = canonical.clone(); + let row = evidence + .anchors + .iter_mut() + .find(|row| { + row.executions + .iter() + .any(|bucket| bucket.receiver.is_some()) + }) + .expect("canonical receiver evidence"); + row.executions[0].receiver = MessageField::none(); + assert_validation_error(&built.plan, &evidence, "lacks required receiver"); + exercised.insert("incomplete-kind-without-field"); + + let mut evidence = canonical.clone(); + evidence + .anchors + .iter_mut() + .find(|row| { + row.capture.as_ref().is_some_and(|capture| { + capture.status.enum_value_or_default() == CaptureStatus::COMPLETE_FOR_RUNS + }) && !row.executions.is_empty() + }) + .expect("canonical complete execution") + .capture + .as_mut() + .unwrap() + .dropped_executions = 1; + assert_validation_error(&built.plan, &evidence, "dropped"); + exercised.insert("complete-with-dropped-execution"); + + let mut evidence = canonical.clone(); + let row = evidence + .anchors + .iter_mut() + .find(|row| { + row.executions + .iter() + .any(|bucket| bucket.receiver.is_some()) + }) + .expect("canonical receiver evidence"); + row.executions[0].receiver.as_mut().unwrap().truncated = true; + assert_validation_error(&built.plan, &evidence, "truncated"); + exercised.insert("complete-with-truncated-value"); + + let mut evidence = canonical.clone(); + let bucket = evidence + .anchors + .iter_mut() + .flat_map(|row| row.executions.iter_mut()) + .next() + .expect("canonical execution"); + bucket.provenance.as_mut().unwrap().run_id = "unknown-run".to_string(); + assert_validation_error(&built.plan, &evidence, "outside capture runs"); + exercised.insert("unknown-run-provenance"); + + let mut plan = built.plan.clone(); + plan.documents[0].relative_path = "./noncanonical.rb".to_string(); + assert!(runtime_protocol::validate_trace_plan(&plan) + .unwrap_err() + .to_string() + .contains("canonical")); + exercised.insert("noncanonical-path"); + + let mut plan = built.plan.clone(); + let request = plan + .requests + .iter_mut() + .find(|request| request.execution_range.is_some()) + .expect("canonical call request"); + request.execution_range = MessageField::none(); + assert!(runtime_protocol::validate_trace_plan(&plan) + .unwrap_err() + .to_string() + .contains("execution_range is required")); + exercised.insert("missing-call-execution-range"); + + let mut plan = built.plan.clone(); + let request = plan + .requests + .iter_mut() + .find(|request| request.execution_range.is_some()) + .expect("canonical call request"); + let anchor_range = request.anchor.as_ref().unwrap().range.as_ref().unwrap(); + let execution_range = request.execution_range.as_mut().unwrap(); + execution_range.start_line = anchor_range.end_line; + execution_range.start_character = anchor_range.end_character; + assert!(runtime_protocol::validate_trace_plan(&plan) + .unwrap_err() + .to_string() + .contains("must contain the complete anchor range")); + exercised.insert("execution-range-excludes-selector"); + + let mut plan = built.plan.clone(); + let request = plan + .requests + .iter_mut() + .find(|request| { + request + .anchor + .as_ref() + .unwrap() + .kind + .enum_value_or_default() + == runtime_protocol::AnchorKind::FUNCTION_ENTRY + }) + .expect("canonical function-entry request"); + request.required = vec![EvidenceKind::CALL_TARGET.into()]; + assert!(runtime_protocol::validate_trace_plan(&plan) + .unwrap_err() + .to_string() + .contains("incompatible")); + exercised.insert("incompatible-anchor-evidence"); + + let mut evidence = canonical; + let target = evidence + .anchors + .iter_mut() + .flat_map(|row| row.executions.iter_mut()) + .find_map(|bucket| bucket.target.as_mut()) + .expect("canonical target"); + target.symbol = "not a canonical SCIP symbol".to_string(); + assert_validation_error(&built.plan, &evidence, "target.symbol"); + exercised.insert("noncanonical-symbol"); + + assert_eq!( + exercised, + catalog + .wire_matrix + .negative_controls + .iter() + .map(String::as_str) + .collect(), + "every declared negative control must execute" + ); +} + +#[test] +fn every_capture_status_is_executable_and_noncomplete_statuses_require_reasons() { + let (catalog, _source, output, built) = built_fixture(); + let canonical = evidence_for_catalog(&catalog, &output, &built); + let mut exercised = BTreeSet::new(); + for status_name in &catalog.wire_matrix.capture_statuses { + let status = match status_name.as_str() { + "COMPLETE_FOR_RUNS" => CaptureStatus::COMPLETE_FOR_RUNS, + "NOT_EXECUTED" => CaptureStatus::NOT_EXECUTED, + "PARTIAL" => CaptureStatus::PARTIAL, + "NOT_INSTRUMENTED" => CaptureStatus::NOT_INSTRUMENTED, + "UNSUPPORTED" => CaptureStatus::UNSUPPORTED, + "STALE" => CaptureStatus::STALE, + "FAILED_CAPTURE" => CaptureStatus::FAILED_CAPTURE, + other => panic!("unknown catalog status {other}"), + }; + let mut evidence = canonical.clone(); + let row_index = evidence + .anchors + .iter() + .position(|row| { + row.capture.as_ref().is_some_and(|capture| { + capture.status.enum_value_or_default() == CaptureStatus::COMPLETE_FOR_RUNS + }) && !row.executions.is_empty() + }) + .expect("canonical complete execution"); + { + let row = &mut evidence.anchors[row_index]; + let capture = row.capture.as_mut().unwrap(); + capture.status = EnumOrUnknown::new(status); + if status == CaptureStatus::COMPLETE_FOR_RUNS { + capture.reason.clear(); + } else { + row.executions.clear(); + capture.observed_executions = 0; + capture.dropped_executions = 0; + capture.complete_kinds.clear(); + capture.reason = format!("{status_name} conformance explanation"); + } + } + runtime_protocol::validate_runtime_evidence(&built.plan, &evidence) + .unwrap_or_else(|error| panic!("{status_name} must be valid: {error:#}")); + exercised.insert(status_name.as_str()); + + if status != CaptureStatus::COMPLETE_FOR_RUNS { + evidence.anchors[row_index] + .capture + .as_mut() + .unwrap() + .reason + .clear(); + assert_validation_error(&built.plan, &evidence, "requires a precise reason"); + } + } + assert_eq!( + exercised, + catalog + .wire_matrix + .capture_statuses + .iter() + .map(String::as_str) + .collect() + ); +} + +#[test] +fn every_value_shape_and_source_role_validates_and_joins_through_factmine() { + let (catalog, _source, output, built) = built_fixture(); + let parameter = catalog + .boundary_cases + .iter() + .find(|boundary| boundary.evidence_kind == "PARAMETER_VALUE") + .expect("parameter boundary"); + let parameter_symbol = boundary_symbol(&built, &output, parameter); + let roles = catalog + .wire_matrix + .source_roles + .iter() + .map(|name| { + ( + name, + match name.as_str() { + "PRODUCTION" => SourceRole::PRODUCTION, + "NON_PRODUCTION" => SourceRole::NON_PRODUCTION, + "STANDARD_LIBRARY" => SourceRole::STANDARD_LIBRARY, + "DEPENDENCY" => SourceRole::DEPENDENCY, + "RUNTIME" => SourceRole::RUNTIME, + "UNKNOWN_SOURCE" => SourceRole::UNKNOWN_SOURCE, + other => panic!("unknown catalog source role {other}"), + }, + ) + }) + .collect::>(); + + let mut exercised = BTreeSet::new(); + for shape in &catalog.wire_matrix.value_shapes { + for (role_name, role) in &roles { + let mut evidence = evidence_for_catalog(&catalog, &output, &built); + let row = evidence + .anchors + .iter_mut() + .find(|row| row.anchor_symbol == parameter_symbol) + .expect("parameter evidence"); + row.executions[0].value = MessageField::some(shaped_value_set(shape, *role)); + runtime_protocol::validate_runtime_evidence(&built.plan, &evidence) + .unwrap_or_else(|error| panic!("{shape}/{role_name} must validate: {error:#}")); + let mut joined = output.clone(); + runtime_evidence::apply_protocol_to_profile(&mut joined, &built, &evidence) + .unwrap_or_else(|error| panic!("{shape}/{role_name} must join: {error:#}")); + exercised.insert((shape.as_str(), role_name.as_str())); + } + } + assert_eq!( + exercised.len(), + catalog.wire_matrix.value_shapes.len() * catalog.wire_matrix.source_roles.len() + ); +} diff --git a/gems/fact-mine/tests/syntax_helpers.rs b/gems/fact-mine/tests/syntax_helpers.rs index 845be3de2..322a3a54d 100644 --- a/gems/fact-mine/tests/syntax_helpers.rs +++ b/gems/fact-mine/tests/syntax_helpers.rs @@ -208,3 +208,49 @@ fn nil_kill_profile_cli_is_deterministic_across_worker_counts() { .unwrap() .is_empty()); } + +// `--fields` narrows what crosses the pipe without changing what is measured: +// a consumer reading five of the forty document keys should get values +// identical to the ones a full projection would have produced for those keys. +#[test] +fn fields_selects_document_keys_without_altering_their_values() { + use std::process::Command; + let bin_path = env!("CARGO_BIN_EXE_fact-mine-rust"); + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/nullable_ruby.rb"); + + let project = |args: &[&str]| -> serde_json::Value { + let out = Command::new(bin_path) + .args(["syntax-facts", "--language", "ruby"]) + .args(args) + .arg(fixture) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() + }; + + let full = project(&[]); + let selected = project(&["--fields=file,language,imports,functions,calls"]); + + let full_document = full["documents"][0].as_object().unwrap(); + let selected_document = selected["documents"][0].as_object().unwrap(); + + // The full projection carries far more than the selection asked for. + assert!(full_document.len() > selected_document.len()); + let mut keys = selected_document.keys().cloned().collect::>(); + keys.sort(); + assert_eq!(keys, ["calls", "file", "functions", "imports", "language"]); + + // Every retained key is byte-for-byte what the full projection emitted. + for (key, value) in selected_document { + assert_eq!(value, &full_document[key], "field {key} changed under --fields"); + } + + // An empty selection is a usage error, not a silently empty document. + let out = Command::new(bin_path) + .args(["syntax-facts", "--language", "ruby", "--fields=", fixture]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("--fields requires at least one field name")); +} diff --git a/gems/fact-mine/tests/value_domain_parity.rs b/gems/fact-mine/tests/value_domain_parity.rs new file mode 100644 index 000000000..ea1973103 --- /dev/null +++ b/gems/fact-mine/tests/value_domain_parity.rs @@ -0,0 +1,107 @@ +//! The collector's C rules are the oracle: whatever it derives today is what +//! this port has to keep deriving. +//! +//! The corpus is one ordered sequence, not a set of independent cases. A +//! collection's shape is remembered against the classes it was carrying, so a +//! later answer depends on the earlier ones -- which means the port has to be +//! fed the same observations in the same order and rebuild the same memo. + +use fact_mine_rust::value_domain::{DomainDeriver, RawObservation}; +use serde::Deserialize; +use std::path::Path; + +#[derive(Deserialize)] +struct Pair { + #[serde(rename = "case")] + label: String, + raw: RawObservation, + domain: serde_json::Value, +} + +struct Corpus { + pairs: Vec, +} + +/// One pair per line, so a re-recorded case is a one-line diff rather than a +/// two-hundred-line block of reindented JSON. +fn corpus() -> Corpus { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../nil-kill/spec/fixtures/value_domain_parity.jsonl"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + let pairs = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("parity pair")) + .collect(); + Corpus { pairs } +} + +#[test] +fn derives_every_recorded_domain_from_the_raw_observation_alone() { + let corpus = corpus(); + // No source roles are configured for the corpus, so nothing is + // non-production; the verdict is still recorded wherever a class had a + // declaring file, and absent where it had none. + let mut deriver = DomainDeriver::new(Vec::new()); + // A corpus that failed to load would pass this test by describing nothing. + assert_eq!(corpus.pairs.len(), 53, "parity corpus size"); + assert!( + corpus.pairs.iter().any(|pair| !pair.domain["shapes"].as_array().unwrap().is_empty()), + "corpus must contain shaped values" + ); + let mut mismatches = Vec::new(); + for pair in &corpus.pairs { + let derived = deriver.derive(&pair.raw).to_value(); + if derived != pair.domain { + mismatches.push(format!( + "{}\n collector {}\n derived {}", + pair.label, + serde_json::to_string(&pair.domain).unwrap(), + serde_json::to_string(&derived).unwrap() + )); + } + } + assert!( + mismatches.is_empty(), + "{} of {} cases diverge from the collector:\n{}", + mismatches.len(), + corpus.pairs.len(), + mismatches.iter().take(6).cloned().collect::>().join("\n") + ); +} + +/// The tuple rule, on the same corpus. A fixed-length mixed array is a tuple; a +/// long uniform one is an array and nothing else. +#[test] +fn recognizes_a_tuple_only_where_the_collector_would() { + use fact_mine_rust::value_domain::tuple_of; + + let corpus = corpus(); + let named = |label: &str| { + corpus.pairs.iter().find(|pair| pair.label == label).expect("case").raw.clone() + }; + + // Complete, uniform: still a tuple. Every position was observed, so the + // length is a fact about the value even where the classes agree. + let uniform = tuple_of(&named("integer array"), 20).expect("complete uniform is a tuple"); + assert_eq!(uniform.types, vec!["Integer", "Integer", "Integer"]); + assert!(uniform.complete && !uniform.mixed); + // Mixed and complete: each position has to be spelled out. + let mixed = tuple_of(&named("mixed array"), 20).expect("mixed array is a tuple"); + assert_eq!(mixed.types, vec!["Integer", "String", "Symbol"]); + assert_eq!(mixed.size, "3"); + assert!(mixed.complete && mixed.mixed); + // Longer than the sample and uniform: not a tuple. The tail went + // unobserved, and the head says nothing the element type does not. + assert!(tuple_of(&named("oversampled strings"), 20).is_none()); + assert!(tuple_of(&named("oversampled array"), 20).is_none()); + // Longer than the sample but already disagreeing: a tuple of unknown + // length, because the positions observed cannot be one element type. + let long = tuple_of(&named("long mixed array"), 20).expect("incomplete mixed is a tuple"); + assert_eq!(long.size, ">=20"); + assert!(!long.complete && long.mixed); + // Only arrays. A hash of the same contents is a mapping. + assert!(tuple_of(&named("string hash"), 20).is_none()); + assert!(tuple_of(&named("empty array"), 20).is_none()); +} diff --git a/gems/lineage/.gitignore b/gems/gigasail/.gitignore similarity index 100% rename from gems/lineage/.gitignore rename to gems/gigasail/.gitignore diff --git a/gems/lineage/CONTRIBUTING.md b/gems/gigasail/CONTRIBUTING.md similarity index 83% rename from gems/lineage/CONTRIBUTING.md rename to gems/gigasail/CONTRIBUTING.md index aabe430fa..66a5ce493 100644 --- a/gems/lineage/CONTRIBUTING.md +++ b/gems/gigasail/CONTRIBUTING.md @@ -1,7 +1,7 @@ -# Contributing To Lineage +# Contributing To Gigasail Start with the repository-level [../../CONTRIBUTING.md](../../CONTRIBUTING.md). -This file only covers Lineage-specific architecture and contribution +This file only covers Gigasail-specific architecture and contribution rules. ## Local Development Setup @@ -11,13 +11,13 @@ See [Get Started](README.md#getting-started). Common checks: ```sh -cargo test --manifest-path gems/lineage/Cargo.toml -cargo build --manifest-path gems/lineage/Cargo.toml --release +cargo test --manifest-path gems/gigasail/Cargo.toml +cargo build --manifest-path gems/gigasail/Cargo.toml --release ``` ## Architecture -Lineage is a Rust history and evidence engine with deliberately separate +Gigasail is a Rust history and evidence engine with deliberately separate boundaries: - `src/vcs.rs` and `src/git.rs` own repository traversal. @@ -32,20 +32,20 @@ boundaries: - `src/lsp.rs` exposes editor diagnostics and CodeLens data. Keep these boundaries intact. Provider-specific parsing should not write -directly to the database; Lineage core should verify commits, paths, and +directly to the database; Gigasail core should verify commits, paths, and logical-unit identity before recording evidence. ## Boundaries -Lineage should track evidence over time and make that evidence easy to +Gigasail should track evidence over time and make that evidence easy to query. It should not become a replacement for mature tools that already solve their own domain. - Linting and smells are solved problems. Import them as SARIF or another normalized artifact format instead of building custom lint or smell - engines in Lineage. + engines in Gigasail. - Static risk tools such as Decomplex, SlopCop, Boobytrap, Nil-Kill, and - Espalier should publish SARIF or normalized artifacts. Lineage should + Espalier should publish SARIF or normalized artifacts. Gigasail should preserve, scope, and display those findings. - Coverage, test exposure, mutant evidence, hazards, and crash data should be commit-scoped and idempotent on re-ingest. @@ -84,7 +84,7 @@ returning no units over inventing low-confidence boundaries. ## UI And LSP -The UI and LSP should render evidence that Lineage has already ingested. +The UI and LSP should render evidence that Gigasail has already ingested. They should not run source analyzers, coverage tools, mutation tools, or quality gates themselves. @@ -105,6 +105,6 @@ client-side application stack. ## Testing -Use `cargo test --manifest-path gems/lineage/Cargo.toml` for Lineage +Use `cargo test --manifest-path gems/gigasail/Cargo.toml` for Gigasail changes. Tests should cover both the parser/adapter and the resulting stored evidence whenever possible. diff --git a/gems/lineage/Cargo.lock b/gems/gigasail/Cargo.lock similarity index 89% rename from gems/lineage/Cargo.lock rename to gems/gigasail/Cargo.lock index 9ce919d35..963851d77 100644 --- a/gems/lineage/Cargo.lock +++ b/gems/gigasail/Cargo.lock @@ -271,9 +271,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -281,6 +281,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.64" @@ -339,7 +354,7 @@ version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -357,6 +372,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -406,6 +435,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -506,6 +560,7 @@ name = "fact-mine-rust" version = "0.1.0" dependencies = [ "anyhow", + "flate2", "hazard-contract", "regex", "serde", @@ -566,6 +621,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -685,6 +746,94 @@ dependencies = [ "wasip2", ] +[[package]] +name = "giga-core" +version = "0.0.1" +dependencies = [ + "anyhow", + "fact-mine-rust", + "flate2", + "git2", + "hazard-contract", + "hex", + "idna_adapter", + "libc", + "openssl-sys", + "rayon", + "roxmltree", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "streaming-iterator", + "tempfile", + "toml", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-ng", + "tree-sitter-language", + "tree-sitter-lua", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", + "tree-sitter-zig", + "ts-rs", + "url", +] + +[[package]] +name = "giga-ui" +version = "0.0.1" +dependencies = [ + "anyhow", + "askama", + "axum", + "clap", + "giga-core", + "git2", + "rayon", + "rmcp", + "rusqlite", + "rust-embed", + "serde", + "serde_json", + "tempfile", + "tokio", + "tower-http", + "tower-lsp", + "url", +] + +[[package]] +name = "gigasail" +version = "0.0.1" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "flate2", + "giga-core", + "git2", + "hex", + "ratatui", + "rmcp", + "rusqlite", + "serde_json", + "sha2", + "tempfile", + "tokio", + "ts-rs", +] + [[package]] name = "git2" version = "0.18.3" @@ -710,6 +859,17 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -739,6 +899,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -891,12 +1057,43 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -993,58 +1190,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "lineage" -version = "0.1.0" -dependencies = [ - "anyhow", - "askama", - "axum", - "clap", - "fact-mine-rust", - "flate2", - "git2", - "hazard-contract", - "hex", - "idna_adapter", - "libc", - "openssl-sys", - "rayon", - "rmcp", - "roxmltree", - "rusqlite", - "rust-embed", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "streaming-iterator", - "tempfile", - "tokio", - "toml", - "tower-http", - "tower-lsp", - "tree-sitter", - "tree-sitter-c", - "tree-sitter-c-sharp", - "tree-sitter-cpp", - "tree-sitter-go", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-kotlin-ng", - "tree-sitter-language", - "tree-sitter-lua", - "tree-sitter-php", - "tree-sitter-python", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-swift", - "tree-sitter-typescript", - "tree-sitter-zig", - "ts-rs", - "url", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1066,6 +1211,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lsp-types" version = "0.94.1" @@ -1130,6 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1183,6 +1338,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1196,6 +1361,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -1264,6 +1435,27 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1625,6 +1817,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1653,6 +1876,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -1671,6 +1900,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "syn" version = "2.0.117" @@ -2156,6 +2407,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2269,6 +2549,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2278,6 +2574,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/gems/gigasail/Cargo.toml b/gems/gigasail/Cargo.toml new file mode 100644 index 000000000..88daad238 --- /dev/null +++ b/gems/gigasail/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +members = ["giga-core", "giga-ui"] +resolver = "2" + +[package] +name = "gigasail" +version = "0.0.1" +edition = "2021" +description = "Risk-weighted diff review and logical-unit history engine" +license = "PolyForm-Noncommercial-1.0.0" + +[lib] +name = "gigasail" +path = "src/lib.rs" + +[[bin]] +name = "giga" +path = "src/main.rs" + +[dependencies] +giga-core = { path = "giga-core", version = "0.0.1" } +anyhow = "1.0" +clap = { version = "=4.4.18", features = ["derive"] } +crossterm = "0.28" +git2 = "0.18" +ratatui = "0.29" +serde_json = "1.0" +ts-rs = "10.1" +# The MCP server (`giga mcp`) is a stdio protocol adapter over giga-core; +# it lives with the CLI, not the axum web UI. See docs/agents/tuning-configs.md §0. +rmcp = { version = "2.2.0", features = ["server", "transport-io"] } +tokio = { version = "1", features = ["io-std", "macros", "rt", "rt-multi-thread"] } +rusqlite = { version = "0.30", features = ["bundled"] } +flate2 = "1.1" + +[dev-dependencies] +tempfile = "=3.10.1" +sha2 = "0.10" +hex = "0.4" +rusqlite = { version = "0.30", features = ["bundled"] } diff --git a/gems/lineage/README.md b/gems/gigasail/README.md similarity index 51% rename from gems/lineage/README.md rename to gems/gigasail/README.md index a023a024d..b4f217e65 100644 --- a/gems/lineage/README.md +++ b/gems/gigasail/README.md @@ -1,6 +1,6 @@ -# Lineage +# Gigasail -Lineage is a Rust history and evidence engine for reviewing code at +Gigasail is a Rust history and evidence engine for reviewing code at scale. It tracks logical code units across renames, moves, and refactors, then overlays verification evidence such as coverage, mutation results, systems hazards, and stack traces. @@ -25,17 +25,17 @@ If you want to contribute, see [CONTRIBUTING.md](CONTRIBUTING.md). - Optional coverage, mutation, hazard, lint, SARIF, or stack-trace artifacts For a fully populated local database from a repo checkout, use the import -wrapper. It builds the required Lineage/analyzer binaries, builds the -Lineage database, discovers supported coverage artifacts, runs bundled +wrapper. It builds the required Gigasail/analyzer binaries, builds the +Gigasail database, discovers supported coverage artifacts, runs bundled hazard providers, generates first-party SARIF, runs available Go/Rust/Ruby/Zig lints, ingests extra SARIF inputs, refreshes UI summaries, and can start the UI server: ```bash -gems/lineage/bin/lineage-import \ +gems/gigasail/bin/gigasail-import \ --repo . \ - --db lineage.db \ - --out-dir tmp/lineage-import \ + --db gigasail.db \ + --out-dir tmp/gigasail-import \ --fresh \ --serve \ --daemon \ @@ -46,33 +46,33 @@ gems/lineage/bin/lineage-import \ Useful iteration flags: ```bash -gems/lineage/bin/lineage-import --repo . --db tmp/lineage.db --fresh --max-commits 100 -gems/lineage/bin/lineage-import --repo . --db tmp/lineage.db --fresh --no-coverage --no-lints -gems/lineage/bin/lineage-import --repo . --db tmp/lineage.db --sarif-input tmp/vendor-sarif +gems/gigasail/bin/gigasail-import --repo . --db tmp/gigasail.db --fresh --max-commits 100 +gems/gigasail/bin/gigasail-import --repo . --db tmp/gigasail.db --fresh --no-coverage --no-lints +gems/gigasail/bin/gigasail-import --repo . --db tmp/gigasail.db --sarif-input tmp/vendor-sarif ``` -Build a Lineage database for this repository: +Build a Gigasail database for this repository: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- build \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- build \ --repo . \ - --db /tmp/lineage.db + --db /tmp/gigasail.db ``` To cap analysis while iterating: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- build \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- build \ --repo . \ - --db /tmp/lineage.db \ + --db /tmp/gigasail.db \ --max-commits 100 ``` Inspect the highest-risk logical units: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- summary \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- summary \ + --db /tmp/gigasail.db \ --top 20 \ --only src/ \ --only gems/ \ @@ -82,8 +82,8 @@ cargo run --manifest-path gems/lineage/Cargo.toml -- summary \ Serve the local UI: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ui \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ui \ + --db /tmp/gigasail.db \ --repo . \ --overlay tmp/slopcop-constraints.json \ --port 8080 @@ -93,7 +93,7 @@ Build the embedded React/Monaco diff view after changing its Rust API contract or frontend source: ```bash -gems/lineage/tools/build_diff_ui.sh +gems/gigasail/tools/build_diff_ui.sh ``` ### Focused architecture view @@ -106,14 +106,14 @@ FACT_MINE_RUST_BINARY="$PWD/gems/fact-mine/target/release/fact-mine-rust" \ ruby -Igems/espalier/lib gems/espalier/exe/espalier \ --format architecture \ --output tmp/espalier-architecture.json \ - gems/espalier/lib gems/lineage/src + gems/espalier/lib gems/gigasail/src -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-architecture \ - --db lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-architecture \ + --db gigasail.db \ --input tmp/espalier-architecture.json -cargo run --manifest-path gems/lineage/Cargo.toml -- ui \ - --db lineage.db --repo . --port 8080 +cargo run --manifest-path gems/gigasail/Cargo.toml -- ui \ + --db gigasail.db --repo . --port 8080 ``` Architecture actions then appear beside matched symbols in the source outline. @@ -121,12 +121,12 @@ The graph APIs are also available under `/api/architecture`. ## Outputs -Lineage can output a SQLite evidence database, text or JSON risk +Gigasail can output a SQLite evidence database, text or JSON risk summaries, a local source-review UI, and LSP diagnostics/CodeLens data for editor integrations. > [!NOTE] -> CLEAR uses Lineage as its experimental UI for reviewing +> CLEAR uses Gigasail as its experimental UI for reviewing > LLM-assisted code at scale. Decomplex, SlopCop, Boobytrap, Nil-kill, > and mutation evidence become much easier to interpret when they are > rendered next to the source lines they describe. @@ -137,9 +137,9 @@ The build command writes a portable SQLite database with logical code units and history events: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- build \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- build \ --repo . \ - --db /tmp/lineage.db + --db /tmp/gigasail.db ``` Core tables include: @@ -161,8 +161,8 @@ Core tables include: Inspect the unit-level signal: ```sh -cargo run --manifest-path gems/lineage/Cargo.toml -- summary \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- summary \ + --db /tmp/gigasail.db \ --top 20 \ --only src/ \ --only gems/ \ @@ -171,7 +171,7 @@ cargo run --manifest-path gems/lineage/Cargo.toml -- summary \ ## Supported Data Sources -Lineage treats every uploaded artifact as data for a specific commit. +Gigasail treats every uploaded artifact as data for a specific commit. Use `--commit "$(git rev-parse HEAD)"` for current-run artifacts, and use `--replace` when the uploaded artifact should replace previous rows from the same source and commit. @@ -180,7 +180,7 @@ from the same source and commit. | --- | --- | --- | | Git history | `build` | local Git repository | | Coverage | `ingest-coverage` | Codecov JSON, SimpleCov JSON, Cobertura XML, kcov Cobertura XML, SQL-COV JSON (`--format sqlcov`) | -| Test exposure | `ingest-test-exposure` | Lineage `test-exposure` JSON | +| Test exposure | `ingest-test-exposure` | Gigasail `test-exposure` JSON | | Mutation testing | `ingest-mutants` | Ruby `mutant-facts/v1` | | Systems hazards | `ingest-hazards` | Zig, Go, Rust, C, C++, C# hazard providers | | Stack traces | `ingest` | Sentry-style event JSON | @@ -189,9 +189,9 @@ from the same source and commit. Standalone `.sql` files are indexed as query logical units. A leading `-- query-id:` comment supplies the stable unit name, allowing SQL-COV branch coverage, SQL hazard SARIF, and SQL-COV plan-complexity observations to attach to -the same source view. Lineage only stores and presents those observations; all +the same source view. Gigasail only stores and presents those observations; all database/dialect analysis remains in SQL-COV. -| One-line repository import | `gems/lineage/bin/lineage-import` | Git history, coverage discovery, hazards, bundled first-party SARIF, Go/Rust/Ruby/Zig lint SARIF, extra SARIF | +| One-line repository import | `gems/gigasail/bin/gigasail-import` | Git history, coverage discovery, hazards, bundled first-party SARIF, Go/Rust/Ruby/Zig lint SARIF, extra SARIF | ### SARIF Findings @@ -202,16 +202,16 @@ upload a mixed artifact directory. Rows are keyed by same findings is idempotent. `--replace` deletes prior SARIF rows for the same `source` and `commit` before loading the new artifact set. -`gems/lineage/bin/lineage-import` generates and ingests the bundled +`gems/gigasail/bin/gigasail-import` generates and ingests the bundled first-party SARIF set automatically: Decomplex, SlopCop, Boobytrap, Nil-Kill, and Espalier. To generate that bundle without a full import, -run `tools/generate_generalized_gem_sarif.rb --repo . --out-dir tmp/lineage-sarif`. +run `tools/generate_generalized_gem_sarif.rb --repo . --out-dir tmp/gigasail-sarif`. Live plan analysis is opt-in because it requires an explicit test schema and database connection. The importer delegates it to SQL-COV and ingests the SARIF: ```sh -gems/lineage/bin/lineage-import \ +gems/gigasail/bin/gigasail-import \ --sql-queries=queries/ \ --sql-setup=test/schema.sql \ --sql-dialect=postgres \ @@ -224,8 +224,8 @@ a directory and use a source name that identifies the provider or CI lane: ```sh -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-sarif \ - --db lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-sarif \ + --db gigasail.db \ --repo . \ --input tmp/vendor-sarif \ --source rubocop \ @@ -243,8 +243,8 @@ dark-arm rendering as transient UI overlays. `summary` ranks logical units by history and verification risk: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- summary \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- summary \ + --db /tmp/gigasail.db \ --top 20 \ --format json ``` @@ -257,8 +257,8 @@ tools, dashboards, and LLM review workflows. `ui` serves a local source and verification browser: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ui \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ui \ + --db /tmp/gigasail.db \ --repo . \ --overlay tmp/slopcop-constraints.json \ --port 8080 @@ -279,8 +279,8 @@ curl http://127.0.0.1:8080/api/dashboard `lsp` runs a stdio language server for editor integrations: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- lsp \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- lsp \ + --db /tmp/gigasail.db \ --repo . \ --overlay tmp/slopcop-constraints.json ``` @@ -291,7 +291,7 @@ summaries, and a custom gutter-update notification for editor wrappers. ## Evidence Ingestion -Lineage is most useful after loading verification artifacts for the +Gigasail is most useful after loading verification artifacts for the current commit. ### Coverage @@ -299,8 +299,8 @@ current commit. Ingest line coverage: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-coverage \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-coverage \ + --db /tmp/gigasail.db \ --repo . \ --format simplecov \ --commit "$(git rev-parse HEAD)" \ @@ -314,7 +314,7 @@ authoritative for that commit and should replace prior rows for the same source. > [!NOTE] -> For `cobertura` format, Lineage automatically parses `` tags to combine them with class filenames. This resolves path resolution ambiguity for common file names (such as `src/lib.rs` or `src/main.rs`) when ingesting coverage from monorepo sub-projects. +> For `cobertura` format, Gigasail automatically parses `` tags to combine them with class filenames. This resolves path resolution ambiguity for common file names (such as `src/lib.rs` or `src/main.rs`) when ingesting coverage from monorepo sub-projects. Recommended CLEAR lanes: @@ -336,11 +336,11 @@ Recommended CLEAR lanes: Ingest named test exposure facts: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-test-exposure \ + --db /tmp/gigasail.db \ --repo . \ --commit "$(git rev-parse HEAD)" \ - --input gems/lineage/test/fixtures/test-exposure-clear.json + --input gems/gigasail/test/fixtures/test-exposure-clear.json ``` Each record maps a commit, logical unit, and test to optional line, @@ -349,11 +349,11 @@ branch, test type, and mutation status fields. ### Mutants Ingest `mutant-facts/v1` after running a converter under -`gems/lineage/tools/mutant-converters/`: +`gems/gigasail/tools/mutant-converters/`: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-mutants \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-mutants \ + --db /tmp/gigasail.db \ --repo . \ --commit "$(git rev-parse HEAD)" \ --input /tmp/clear-ruby-mutants/mutant-facts.json \ @@ -361,7 +361,7 @@ cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-mutants \ ``` The Ruby mutant converter and `zig-mutants` both emit the -`mutant-facts/v1` shape Lineage consumes. +`mutant-facts/v1` shape Gigasail consumes. Targeted ratchet mutants use narrower semantics. The transpile-test and fuzz mutant runners emit `test-exposure/v1` records for only the source @@ -379,8 +379,8 @@ ruby tools/fuzz/mutants/run.rb --all \ --out /tmp/clear-fuzz-mutants \ --exposure /tmp/clear-fuzz-mutants/test-exposure.json -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-test-exposure \ + --db /tmp/gigasail.db \ --repo . \ --commit "$(git rev-parse HEAD)" \ --input /tmp/clear-fuzz-mutants/test-exposure.json @@ -390,8 +390,8 @@ After ingesting new coverage, SARIF, or test-exposure artifacts into a DB that has UI summaries, refresh the read model: ```sh -cargo run --manifest-path gems/lineage/Cargo.toml -- refresh-ui \ - --db /tmp/lineage.db +cargo run --manifest-path gems/gigasail/Cargo.toml -- refresh-ui \ + --db /tmp/gigasail.db ``` ### Hazards @@ -399,8 +399,8 @@ cargo run --manifest-path gems/lineage/Cargo.toml -- refresh-ui \ Ingest current provider hazards: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-hazards \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest-hazards \ + --db /tmp/gigasail.db \ --repo . \ --provider zig \ --commit "$(git rev-parse HEAD)" @@ -419,11 +419,11 @@ Ingest Sentry-style stack traces and anchor verified frames to logical units: ```bash -cargo run --manifest-path gems/lineage/Cargo.toml -- ingest \ - --db /tmp/lineage.db \ +cargo run --manifest-path gems/gigasail/Cargo.toml -- ingest \ + --db /tmp/gigasail.db \ --repo . \ --provider sentry \ - --input gems/lineage/test/fixtures/sentry-clear-event.json + --input gems/gigasail/test/fixtures/sentry-clear-event.json ``` Stack-trace ingestion is commit-scoped. Re-ingesting the same event is @@ -433,7 +433,7 @@ file. ### Runtime profiling (pprof) hotness Static Big-O says which functions can be expensive; a profile says which -ones are. Lineage ingests `profile-hotness/v1` and uses it to rank the +ones are. Gigasail ingests `profile-hotness/v1` and uses it to rank the Expensive Operations view (Big-O first, then measured share), badge critical functions with a flame icon in the file view, and annotate lines in the info popup. @@ -442,13 +442,13 @@ in the info popup. # capture with your language's profiler, e.g. Go: go tool pprof -top -lines cpu.pb.gz > pprof-top.txt # convert (also accepts stackprof JSON and perf script output): -ruby gems/lineage/tools/pprof_to_hotness.rb --pprof-top pprof-top.txt > hotness.json +ruby gems/gigasail/tools/pprof_to_hotness.rb --pprof-top pprof-top.txt > hotness.json # ingest: -lineage ingest-hotness --db lineage.db --repo . --input hotness.json +giga ingest-hotness --db gigasail.db --repo . --input hotness.json ``` For this repository, `ruby tools/profile_hotness.rb --target NAME --ingest ---db lineage.db` packages the whole flow per sub-project. For perf-based +--db gigasail.db` packages the whole flow per sub-project. For perf-based languages the binary must carry DWARF or frames arrive without file:line - build Rust with `cargo build --profile profiling` and do not strip Zig/C/C++ binaries. Frames without paths are resolved against the @@ -459,17 +459,143 @@ for per-language recipes, resolution tiers, and known gaps. ### MCP server -`lineage mcp --db lineage.db --repo .` exposes five read-only, -workflow-shaped tools (file risk, unit context, verification gaps, change -history, find-definition) over stdio MCP - not one tool per table. `--db` -is optional: omitting it runs a DB-less mode serving live-disk structure -and in-process hazard scans only. See -[mcp.md](docs/agents/mcp.md) for the tool list, why 5 and not 17, the -uncommitted-changes and DB-less designs, and known gaps. +`giga mcp --db gigasail.db --repo .` exposes seven read-only, +workflow-shaped tools over stdio MCP - not one tool per table. Five are +context tools (file risk, unit context, verification gaps, change history, +find-definition); two are review tools (see below). `--db` is optional: +omitting it runs a DB-less mode serving live-disk structure and in-process +hazard scans only. See [mcp.md](docs/agents/mcp.md) for the context tools, +why 7 and not 17, the uncommitted-changes and DB-less designs, and known gaps. + +### Review: verify before you ship + +Two MCP tools give an agent (or CI) a machine-checked verdict instead of a +raw test log: + +- **Setup.** `giga build`/`giga sync` first, then point the agent at + `giga mcp`. `giga_precommit` reviews `HEAD~1..HEAD`; `giga_premerge` + reviews `merge-base(HEAD, target)..HEAD` (whole branch, `target` defaults + to `master`). +- **What it does.** Returns `verdict` (`pass` / `needs_review` / `critical`), + the gates it triggered, and the ranked *new* findings by tier with each + line's coverage - a compact JSON report, not test output. A `critical` + verdict blocks. +- **Configure.** A `review:` block in `giga.yml` sets which findings matter + (show / deprioritize / ignore), ranking weights, purity thresholds, and the + gates. Absent config gates only on an uncovered T1. See + [tuning-configs.md](docs/agents/tuning-configs.md). +- **Token impact.** The evaluator runs the checks and hands back a + ~500-2k-token verdict, keeping the 5k-50k-token raw suite/coverage log out + of the model's context. The one fixed cost is the two extra tool schemas in + the tool list every turn; on any non-trivial change the verdict is far + cheaper - and harder to misread - than piping a truncated log through the + window. Rationale and the cost analysis: tuning-configs.md §9. + +### `giga test`: run only the tests a change needs + +`giga test` runs the test producers your `giga.yml` declares, chosen by what +changed and by review stage, then ingests coverage/mutation evidence. It +orchestrates plain commands - it is **not** a build system (delegate to Bazel by +putting `bazel test ...` in a producer's argv; see +[tuning-configs.md](docs/agents/tuning-configs.md) §12-§13). + +```bash +giga test # precommit: affected packages' fast tests, no mutation +giga test --premerge # premerge: + fuzz suites + mutation +giga test --mutants # add mutation even at precommit +giga test --no-cov # skip coverage-only producers +giga test --unit # only producers tagged evidence_scope.test_set: unit +giga test --changed P... # treat P... as the changed set (preview / bypass git) +giga test --checks # run pre-test lint/format gates (--no-checks forces off) +giga test --dry-run # print the resolved plan, run nothing +``` + +**Configure which files trigger which tests** with a project graph under +`review.packages`. Each package names its files (`paths` globs), the packages it +`depends_on`, the `producers` to run, and extra `premerge`-only producers (fuzz). +A change runs the **affected** packages - the ones whose files changed *plus* +every package that transitively depends on them: + +```yaml +review: + packages: + compiler: { paths: [compiler/ruby/**], producers: [compiler-spec, transpile], premerge: [fuzz-compiler] } + zig: { paths: [zig/**], producers: [zig-test, transpile], premerge: [fuzz-zig] } + fact-mine: { paths: [gems/fact-mine/**], producers: [fact-mine-test] } + decomplex: { paths: [gems/decomplex/**], depends_on: [fact-mine], producers: [decomplex-test] } + boobytrap: { paths: [gems/boobytrap/**], producers: [boobytrap-test] } + slopcop: { paths: [gems/slopcop/**], depends_on: [fact-mine, boobytrap], producers: [slopcop-test] } +``` + +So editing `gems/fact-mine/**` runs fact-mine's tests **and** decomplex's (it +depends on fact-mine) **and** slopcop's (depends on fact-mine); editing +`compiler/ruby/**` runs the spec + transpile suites (and fuzz at premerge); +`zig/**` runs zig + transpile. `precommit` runs each package's `producers`; +`premerge` adds its `premerge` producers and turns mutation on. With no +`packages` graph, `giga test` falls back to the `review.tests.` profiles. +This is the same affected-set idea as Nx/Turborepo, kept to a declarative graph +rather than a build system. `depends_on` edges must be your project's **real** +dependencies (gemspec/import) - a wrong edge over-runs (false dependent) or, worse, +under-runs (a missing dependent skips tests it should have run). Preview any +change with `giga test --dry-run --changed `. + +**Optional pre-test gates.** Set `checks_enabled: true` (or pass `--checks`) to +run lint/format gates *before* a package's tests, stopping early on failure. Each +package lists `checks`; a check is either `contrib::` (a bundled +recommended script - `contrib:lint:ruby`, `contrib:lint:rust`, `contrib:fmt:zig`, +each scoped to the changed files and skipped if the tool is absent) or a +repo-relative script path. Every check gets `$GIGA_CHANGED`. This is a gate, not +CI - keep anything heavier in a producer's argv. See +[tuning-configs.md](docs/agents/tuning-configs.md) §13-§14. + +```yaml +review: + checks_enabled: false # opt-in; or `giga test --checks` + packages: + compiler: { paths: [compiler/ruby/**], producers: [compiler-spec], checks: [contrib:lint:ruby] } + zig: { paths: [zig/**], producers: [zig-test], checks: [contrib:fmt:zig] } +``` + +### Dogfooding on CLEAR: is the overhead worth it? + +The workflow: let the agent run `giga_precommit` on every commit (fast, keeps +each commit green), then `giga_premerge` once at the end (exhaustive - catches +the tech debt accrued along the way) before merging. + +Measured on the CLEAR compiler (`~/clear`, ~2,800 commits of history, 255 +source files, Ruby unit suite via `prspec`). The question is whether tracking +coverage/analysis on top of the tests you already run is a rounding error or a +tax: + +| Step | Time | Notes | +|---|---|---| +| Unit suite, no coverage (know it *passes*) | **1m45s** | parallel `prspec` | +| Unit suite **with** branch coverage | **2m12s** | +26% - SimpleCov instrumentation | +| Ingest that coverage into giga | **+19s** | 255 files, 84k line events | +| **Precommit total** (tests + coverage of the delta) | **~2m31s** | +44% over bare tests | +| One-time history index (`giga build`) | 52s once | incremental after: **~0.07s/commit** | +| Static analysis (espalier graph + SARIF), premerge only | +23s analyze | see note | +| Ingest the architecture graph | +4s | was 59s; fixed by materializing the unit-reconcile join once | + +**Takeaway.** Coverage *of the delta* costs ~26% on the test run plus a flat +~19s ingest - a clear win: you already ran the tests, and now you know which +*changed* lines are actually covered. The incremental cost per later commit is +near-zero (the history index is one-time; coverage re-ingests one file). + +**Mutation (honest gap).** A project-wide mutant database for a whole compiler +is a multi-hour one-time build, and it is **not yet run here** (CLEAR's mutant +tooling is currently Go-only; the Ruby `mutant` runner for `compiler/ruby` is a +TODO). The design intent is what makes it viable: incremental mutation re-runs +only the *changed subjects*, so per-commit mutant time scales with the diff, not +the project - which is exactly why `giga_precommit` skips mutation by default and +`giga_premerge` runs it. The rule of thumb we are validating: if per-commit +mutant feedback (after the initial DB) stays within a small multiple of the test +run, it earns its place at premerge; if it is 10x, it will not get used. That +number will be filled in here once the Ruby mutant runner lands. ## Supported Languages Roadmap -Lineage uses Tree-sitter-backed logical-unit extraction for the core +Gigasail uses Tree-sitter-backed logical-unit extraction for the core languages it aims to track as a ground-truth risk ledger. For those languages, parse failures produce no units instead of falling back to regex boundaries. Heuristic extraction remains only for secondary @@ -489,8 +615,8 @@ experimental languages. ## Boundaries -The `lineage` binary stores, joins, and renders evidence. The -`lineage-import` wrapper can orchestrate bundled producers and import +The `gigasail` binary stores, joins, and renders evidence. The +`gigasail-import` wrapper can orchestrate bundled producers and import artifacts for a repository checkout. The core binary does not: - run tests; @@ -501,7 +627,7 @@ artifacts for a repository checkout. The core binary does not: - post GitHub comments or call the GitHub API; - replace the source tools that generate quality evidence. -It stores, joins, and renders evidence. A good Lineage view should make a +It stores, joins, and renders evidence. A good Gigasail view should make a human say: "this line is risky, and here is the history and verification evidence explaining why." diff --git a/gems/gigasail/TODO.md b/gems/gigasail/TODO.md new file mode 100644 index 000000000..00554b17f --- /dev/null +++ b/gems/gigasail/TODO.md @@ -0,0 +1,39 @@ +# GigaSail TODO + +## Prune stale analysis reports on sync (branch-aware) + +On a new `giga sync`, reclaim disk by deleting persisted analysis reports that +are no longer up to date — the large ones especially (e.g. nil-kill's +static/evidence intermediates, which are tens of MB even for a small repo). + +Doing this correctly requires **branch knowledge**, because "old" is not the +same as "superseded": + +- **Two commits in-line on a single branch** (one is an ancestor of the other, + no branch point between them): keep the evidence for **each**. A diff can be + requested against either, so both are live. +- **One commit at a branch base and another off of it** (a fork point plus a + commit on the child branch): keep **each**. They are on different lines of + history and both are reachable review targets. + +Only delete an artifact when **all** of these hold: + +1. **Sequential** — it is superseded by a newer artifact on the *same* line of + history (a linear ancestor chain with no intervening branch/merge that keeps + the older commit independently reachable). +2. **Not needed for incremental processing** — the engine's per-commit + `engine_state` resume checkpoint (and anything else the incremental indexer + reads back) must not depend on it. Never delete an artifact the incremental + path would re-read. +3. **Truly temporary** — any artifact meant to be scratch and cleaned after + every run regardless of history (if such a class exists) is always removed. + (Producer intermediates like nil-kill static/evidence are already kept in a + `.giga/scratch-*` dir and deleted per run — those are the model; the durable + *ingested* reports are what this task is about.) + +Implementation notes: +- Needs a reachability/branch model over the commits that have persisted + artifacts (`sarif_artifacts`, `architecture_artifacts`, coverage/mutation + events, run-store `runs/`), not just a timestamp sort. +- The gzipped run-store artifacts under `.giga/artifacts/runs/` are the durable + raw copies; pruning should consider both the DB rows and the run dirs. diff --git a/gems/lineage/bin/lineage-import b/gems/gigasail/bin/giga-import similarity index 100% rename from gems/lineage/bin/lineage-import rename to gems/gigasail/bin/giga-import diff --git a/gems/gigasail/contrib/fmt/go.sh b/gems/gigasail/contrib/fmt/go.sh new file mode 100755 index 000000000..f1b72fb10 --- /dev/null +++ b/gems/gigasail/contrib/fmt/go.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Recommended Go pre-test gate. Runs `gofmt -l` on the changed .go files (from +# $GIGA_CHANGED) and fails if any are unformatted. Skips cleanly when the Go +# toolchain is unavailable. +set -eu + +if ! command -v gofmt >/dev/null 2>&1; then + echo "contrib:fmt:go - gofmt not installed, skipping" + exit 0 +fi + +files="" +for f in ${GIGA_CHANGED:-}; do + case "$f" in *.go) [ -f "$f" ] && files="$files $f" ;; esac +done + +if [ -z "$files" ]; then + echo "contrib:fmt:go - no changed .go files" + exit 0 +fi + +unformatted=$(gofmt -l $files) +if [ -n "$unformatted" ]; then + echo "contrib:fmt:go - unformatted files:" + echo "$unformatted" + exit 1 +fi +echo "contrib:fmt:go - ok$files" diff --git a/gems/gigasail/contrib/fmt/zig.sh b/gems/gigasail/contrib/fmt/zig.sh new file mode 100755 index 000000000..ff56cf9e7 --- /dev/null +++ b/gems/gigasail/contrib/fmt/zig.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Recommended Zig pre-test gate. Runs `zig fmt --check` on the changed .zig files +# (from $GIGA_CHANGED). Skips cleanly when zig is unavailable. Fails when a file +# is not formatted. +set -eu + +if ! command -v zig >/dev/null 2>&1; then + echo "contrib:fmt:zig - zig not installed, skipping" + exit 0 +fi + +files="" +for f in ${GIGA_CHANGED:-}; do + case "$f" in *.zig) [ -f "$f" ] && files="$files $f" ;; esac +done + +if [ -z "$files" ]; then + echo "contrib:fmt:zig - no changed .zig files" + exit 0 +fi + +echo "contrib:fmt:zig - zig fmt --check$files" +exec zig fmt --check $files diff --git a/gems/gigasail/contrib/lint/ruby.sh b/gems/gigasail/contrib/lint/ruby.sh new file mode 100755 index 000000000..f9d09efec --- /dev/null +++ b/gems/gigasail/contrib/lint/ruby.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Recommended Ruby pre-test gate. Lints only the changed .rb files (from +# $GIGA_CHANGED) with rubocop. Skips cleanly when rubocop is unavailable - a +# recommended gate should not become a hard dependency. Fails on violations. +set -eu + +if ! command -v rubocop >/dev/null 2>&1; then + echo "contrib:lint:ruby - rubocop not installed, skipping" + exit 0 +fi + +files="" +for f in ${GIGA_CHANGED:-}; do + case "$f" in *.rb) [ -f "$f" ] && files="$files $f" ;; esac +done + +if [ -z "$files" ]; then + echo "contrib:lint:ruby - no changed .rb files" + exit 0 +fi + +echo "contrib:lint:ruby - rubocop$files" +exec rubocop --force-exclusion $files diff --git a/gems/gigasail/contrib/lint/rust.sh b/gems/gigasail/contrib/lint/rust.sh new file mode 100755 index 000000000..490b1178e --- /dev/null +++ b/gems/gigasail/contrib/lint/rust.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Recommended Rust pre-test gate. Runs `rustfmt --check` on the changed .rs files +# (from $GIGA_CHANGED). Skips cleanly when rustfmt is unavailable. Fails when a +# file is not formatted. +set -eu + +if ! command -v rustfmt >/dev/null 2>&1; then + echo "contrib:lint:rust - rustfmt not installed, skipping" + exit 0 +fi + +files="" +for f in ${GIGA_CHANGED:-}; do + case "$f" in *.rs) [ -f "$f" ] && files="$files $f" ;; esac +done + +if [ -z "$files" ]; then + echo "contrib:lint:rust - no changed .rs files" + exit 0 +fi + +echo "contrib:lint:rust - rustfmt --check$files" +exec rustfmt --edition 2021 --check $files diff --git a/gems/lineage/docs/agents/arch-view.md b/gems/gigasail/docs/agents/arch-view.md similarity index 96% rename from gems/lineage/docs/agents/arch-view.md rename to gems/gigasail/docs/agents/arch-view.md index 541871f28..29ec1c113 100644 --- a/gems/lineage/docs/agents/arch-view.md +++ b/gems/gigasail/docs/agents/arch-view.md @@ -8,7 +8,7 @@ from SARIF messages. ## Product Decision -Lineage should provide an architecture view centered on one class, module, +Gigasail should provide an architecture view centered on one class, module, function, or state member. It should not attempt to render the repository's complete function graph. @@ -30,7 +30,7 @@ architecturally difficult?" rather than merely show that many edges exist. - Explain a function through a bounded caller, callee, delegation, and state neighborhood. - Explain a state member through all known readers and writers. -- Join Espalier structure with Lineage complexity, hazards, history, coverage, +- Join Espalier structure with Gigasail complexity, hazards, history, coverage, mutation, and test exposure. - Keep every graph readable on large repositories through focus and progressive disclosure. @@ -76,7 +76,7 @@ highlighted. ### Direct route -Use a stable route based on a Lineage logical-unit ID, not a display name: +Use a stable route based on a Gigasail logical-unit ID, not a display name: ```text /architecture/unit/:logical_unit_id @@ -130,7 +130,7 @@ Show: - direct caller and callee counts; - state members read and written; - unresolved-call count; -- existing Lineage hazard indicator; +- existing Gigasail hazard indicator; - source link. Default order is descending architectural pressure. Other sorts are name, @@ -342,11 +342,11 @@ Espalier's existing `DependencyGraph` already represents owner/function nodes, internal calls, delegation, owner calls, external calls, edge weights, and cycles. It also attaches per-function `EFFECTS.reads` and `EFFECTS.writes`. The production contract must extend that graph with first-class state nodes and -read/write edges rather than forcing Lineage to infer them from labels. +read/write edges rather than forcing Gigasail to infer them from labels. -### Lineage +### Gigasail -Lineage owns: +Gigasail owns: - durable logical identity across commits; - joining graph facts to source, history, coverage, mutations, tests, and @@ -363,7 +363,7 @@ graph JSON artifact for structured ingestion. The current `owner#function-name` shape is insufficient for overloads, same-named nested functions, and moves. Every graph entity needs an analyzer -identity plus an optional matched Lineage logical-unit identity. +identity plus an optional matched Gigasail logical-unit identity. ```text owner_id = hash(language, normalized owner path/name, definition span) @@ -372,7 +372,7 @@ state_id = hash(language, path, owner_id, member name, declaration span) edge_id = hash(source_id, target_id, kind, source span, analyzer version) ``` -Lineage should reconcile functions to existing logical units during import and +Gigasail should reconcile functions to existing logical units during import and retain aliases when units move or rename. IDs must not rely on display names alone. @@ -434,7 +434,7 @@ Use a versioned JSON artifact. A compact conceptual form is: Every edge should retain one or more source evidence spans. A graph without citations is difficult to trust or debug. -## Lineage Storage +## Gigasail Storage Use normalized tables rather than storing the entire artifact as opaque JSON: @@ -504,7 +504,7 @@ how to regenerate it. ## Rendering Do not embed a complete Graphviz SVG and attempt to make it the application. -Use structured graph JSON and render the bounded neighborhood in Lineage. +Use structured graph JSON and render the bounded neighborhood in Gigasail. The first implementation can use accessible SVG with a deterministic layered layout: @@ -588,8 +588,8 @@ a bounded neighborhood. It never loads the repository graph into the browser. ### Phase 2: Import and API -- Add transactional Lineage tables and indexes. -- Reconcile analyzer functions with Lineage logical units. +- Add transactional Gigasail tables and indexes. +- Reconcile analyzer functions with Gigasail logical units. - Implement owner inventory, function neighborhood, state access, and search endpoints. - Add artifact freshness and language-quality metadata. @@ -619,7 +619,7 @@ a bounded neighborhood. It never loads the repository graph into the browser. - overloaded and same-named methods receive distinct IDs; - nested owners and functions resolve correctly; -- rename/move reconciliation retains Lineage history; +- rename/move reconciliation retains Gigasail history; - state access forms for every supported trial language; - self/this/instance-variable/static/member access; - unresolved dynamic calls retain uncertainty; @@ -664,7 +664,7 @@ The initial feature is complete when: - selecting state shows every known reader and writer in a complete table and bounded graph; - every visible edge links to source evidence; -- graph facts join to existing Lineage source, hazards, complexity, and test +- graph facts join to existing Gigasail source, hazards, complexity, and test data; - incomplete or stale analysis is visibly qualified; - a class with hundreds of functions remains usable without drawing hundreds @@ -674,7 +674,7 @@ The initial feature is complete when: ## Kill Criteria -Do not ship the graph as a primary Lineage feature if validation shows that: +Do not ship the graph as a primary Gigasail feature if validation shows that: - state read/write facts are too incomplete to distinguish no access from missing analysis; diff --git a/gems/lineage/docs/agents/call-resolution-mini-corpus.md b/gems/gigasail/docs/agents/call-resolution-mini-corpus.md similarity index 100% rename from gems/lineage/docs/agents/call-resolution-mini-corpus.md rename to gems/gigasail/docs/agents/call-resolution-mini-corpus.md diff --git a/gems/lineage/docs/agents/cli.md b/gems/gigasail/docs/agents/cli.md similarity index 74% rename from gems/lineage/docs/agents/cli.md rename to gems/gigasail/docs/agents/cli.md index cd6e46845..92543f9e2 100644 --- a/gems/lineage/docs/agents/cli.md +++ b/gems/gigasail/docs/agents/cli.md @@ -1,29 +1,29 @@ -# Lineage CLI and Evidence Pipeline +# Gigasail CLI and Evidence Pipeline Status: Proposed design ## Summary -Lineage should provide a coherent command-line workflow for producing, +Gigasail should provide a coherent command-line workflow for producing, ingesting, and reviewing architectural and verification evidence: ```text -lineage analyse ─┐ - ├─> run manifest + artifacts ─> lineage ingest ─> lineage diff -lineage verify ──┘ +giga analyse ─┐ + ├─> run manifest + artifacts ─> giga ingest ─> giga diff +gigasail verify ──┘ ``` -The CLI is an evidence pipeline, not a build system. Lineage owns evidence +The CLI is an evidence pipeline, not a build system. Gigasail owns evidence identity, normalization, completeness, ingestion, and presentation. Existing build and test systems continue to own dependency graphs, compilation, incrementality, caching, sandboxing, and test execution. Bazel is a first-class optional executor. It is not a prerequisite for any -Lineage command. +Gigasail command. ## Goals -- Make `lineage diff` a useful architectural and risk-oriented alternative to +- Make `giga diff` a useful architectural and risk-oriented alternative to reading a raw `git diff` first. - Allow bundled static analysis to run without repository configuration. - Provide a reproducible configured path for tests, coverage, mutation, @@ -40,7 +40,7 @@ Lineage command. ## Non-goals -Lineage must not become responsible for: +Gigasail must not become responsible for: - Modeling source and generated-file dependency graphs. - Determining which compilation actions are invalidated by a source change. @@ -50,27 +50,27 @@ Lineage must not become responsible for: - Replacing Cargo, Go, Bazel, Gradle, Maven, npm, Make, CMake, or repository scripts. - Inferring every repository's complete test corpus without configuration. -- Deploying software. The convenience command is therefore `lineage ci`, not - `lineage cicd`. +- Deploying software. The convenience command is therefore `giga ci`, not + `gigasail cicd`. ## Command Model | Command | Configuration required | Bazel required | Primary effect | | --- | --- | --- | --- | -| `lineage diff` | No | No | Render a revision or working-tree architectural diff using available evidence. | -| `lineage analyse` | No for embedded providers; yes for repository commands | No | Run static analyzers and produce normalized findings. | -| `lineage diff --analyse` | No for embedded providers; yes for repository commands | No | Refresh static analysis before rendering the diff. | -| `lineage verify --profile NAME` | Yes | No | Run configured verification producers and write artifacts plus a run manifest. | -| `lineage ingest` | No | No | Validate and transactionally ingest explicit artifacts or a run manifest. | -| `lineage ci` | Yes | No | Run analyse, verify, ingest, and configured policy gates. | -| `lineage diff --full` | No | No | Render every available evidence category and disclose gaps. | +| `giga diff` | No | No | Render a revision or working-tree architectural diff using available evidence. | +| `giga analyse` | No for embedded providers; yes for repository commands | No | Run static analyzers and produce normalized findings. | +| `giga diff --analyse` | No for embedded providers; yes for repository commands | No | Refresh static analysis before rendering the diff. | +| `gigasail verify --profile NAME` | Yes | No | Run configured verification producers and write artifacts plus a run manifest. | +| `giga ingest` | No | No | Validate and transactionally ingest explicit artifacts or a run manifest. | +| `giga ci` | Yes | No | Run analyse, verify, ingest, and configured policy gates. | +| `giga diff --full` | No | No | Render every available evidence category and disclose gaps. | The documented spelling is `analyse`. An `analyze` alias may be provided for discoverability, but both spellings must invoke exactly the same implementation. -### `lineage diff` +### `giga diff` -`lineage diff` is read-only unless an explicit refresh flag is supplied. It +`giga diff` is read-only unless an explicit refresh flag is supplied. It must not unexpectedly run tests, mutation, fuzzing, or other expensive tools. The command should: @@ -88,14 +88,14 @@ The command should: Suggested forms: ```sh -lineage diff -lineage diff BASE HEAD -lineage diff --format text -lineage diff --format json -lineage diff --ui -lineage diff --analyse -lineage diff --full -lineage diff --full --require-profile full +giga diff +giga diff BASE HEAD +giga diff --format text +giga diff --format json +giga diff --ui +giga diff --analyse +giga diff --full +giga diff --full --require-profile full ``` `--analyse` runs only embedded, allowlisted static analysis by default. Use @@ -110,9 +110,9 @@ For automation that requires complete evidence, `--require-profile PROFILE` fails if that profile has not been ingested for the exact selected revision, tree, configuration, and corpus. -### `lineage analyse` +### `giga analyse` -`lineage analyse` runs embedded source-derived analysis that does not require a +`giga analyse` runs embedded source-derived analysis that does not require a project build or dynamic test execution. FactMine is currently the sole embedded provider; Decomplex, Espalier, NilKill, and SlopCop remain external artifact producers until they have constrained, first-class adapters. @@ -128,20 +128,20 @@ paths, set thresholds, or add external SARIF-producing analyzers, but command execution requires `--trust-current-config`. Analysis produces normalized artifacts and a run manifest. It may print the -merged finding list, but it does not implicitly mutate the Lineage database. -Users can request ingestion explicitly or use `lineage ci`. +merged finding list, but it does not implicitly mutate the Gigasail database. +Users can request ingestion explicitly or use `giga ci`. Suggested forms: ```sh -lineage analyse -lineage analyse --ingest -lineage analyse --profile security --trust-current-config +giga analyse +giga analyse --ingest +giga analyse --profile security --trust-current-config ``` -### `lineage verify` +### `gigasail verify` -`lineage verify` runs a repository-defined verification profile. It invokes +`gigasail verify` runs a repository-defined verification profile. It invokes existing tools and records their outputs; it does not model their internal build graph. @@ -156,24 +156,24 @@ Typical profiles are: Suggested forms: ```sh -lineage verify --profile quick -lineage verify --profile full -lineage verify --profile full --ingest +gigasail verify --profile quick +gigasail verify --profile full +gigasail verify --profile full --ingest ``` Every producer reports `complete`, `partial`, `failed`, `cancelled`, or `skipped`. A successful command alone does not prove that its artifact covers the entire repository or test corpus. -### `lineage ingest` +### `giga ingest` -Ingestion must remain independently useful. It must not require `lineage.yml`, -`lineage verify`, Bazel, or a Lineage-created CI run. +Ingestion must remain independently useful. It must not require `giga.yml`, +`gigasail verify`, Bazel, or a Gigasail-created CI run. Direct artifact ingestion remains supported: ```sh -lineage ingest \ +giga ingest \ --kind coverage \ --format cobertura \ --input coverage.xml \ @@ -189,19 +189,19 @@ transactional ingestion. The preferred complete form consumes a run manifest: ```sh -lineage ingest --run .lineage/runs/RUN_ID/manifest.json +giga ingest --run .giga/runs/RUN_ID/manifest.json ``` This permits ingestion from GitHub Actions, Buildkite, Jenkins, Bazel, Codecov, mutation services, security scanners, and organization-specific pipelines. -Lineage core remains responsible for verifying revisions, source content, +Gigasail core remains responsible for verifying revisions, source content, paths, scopes, and logical-unit identity before writing the database. Providers and external plugins never write trusted tables directly. -### `lineage ci` +### `giga ci` -`lineage ci` is a convenience composition rather than a separate execution +`giga ci` is a convenience composition rather than a separate execution engine: ```text @@ -211,27 +211,27 @@ analyse -> verify -> ingest -> policy evaluation Example: ```sh -lineage ci --profile full --require-complete +giga ci --profile full --require-complete ``` The equivalent explicit sequence is: ```sh -lineage analyse -lineage verify --profile full -lineage ingest --latest-run -lineage diff --full --require-profile full +giga analyse +gigasail verify --profile full +giga ingest --latest-run +giga diff --full --require-profile full ``` -Lineage does not perform deployment, so the command must not be named or grow +Gigasail does not perform deployment, so the command must not be named or grow into a general CI/CD deployment system. ## Evidence Completeness -Missing evidence must never prevent Lineage from showing facts it can support. +Missing evidence must never prevent Gigasail from showing facts it can support. It must also never be interpreted as negative evidence. -Example `lineage diff --full` output: +Example `giga diff --full` output: ```text Evidence completeness: 72% @@ -260,17 +260,17 @@ honestly. ## Configuration -The canonical authoring format is `lineage.yml` at the repository root. A -`lineage.json` representation may be accepted through the same typed schema, +The canonical authoring format is `giga.yml` at the repository root. A +`giga.json` representation may be accepted through the same typed schema, primarily for generated configuration. A repository containing both is an error; there must be no precedence ambiguity. -Lineage should publish a JSON Schema for validation, editor completion, and +Gigasail should publish a JSON Schema for validation, editor completion, and stable versioning. -The existing `.lineage/diff.toml` classification overrides should migrate into +The existing `.giga/diff.toml` classification overrides should migrate into this schema. A compatibility reader may remain for a bounded migration period, -but Lineage must not maintain two independent long-term configuration models. +but Gigasail must not maintain two independent long-term configuration models. Example: @@ -295,7 +295,7 @@ profiles: producers: static-analysis: - executor: lineage + executor: gigasail providers: [decomplex, espalier, nil-kill, slopcop] unit-tests: @@ -313,7 +313,7 @@ producers: targets: ["//..."] produces: - kind: bazel-bep - path: .lineage/artifacts/bep.json + path: .giga/artifacts/bep.json ``` The config describes evidence producers and their declared artifacts. It must @@ -325,7 +325,7 @@ Shell execution, if supported at all, must be explicit and visibly less safe. ## Executors -### Lineage executor +### Gigasail executor Runs bundled analyzers directly and records their versions, settings, source corpus, and outputs. @@ -339,7 +339,7 @@ custom project scripts. ### Bazel executor -Bazel is an optional high-quality integration. Lineage should invoke declared +Bazel is an optional high-quality integration. Gigasail should invoke declared targets and consume the Build Event Protocol for: - Expanded and configured targets. @@ -350,12 +350,12 @@ targets and consume the Build Event Protocol for: - Effective invocation and build configuration. Optional Bazel aspects may later produce dependency, source-ownership, lint, or -other metadata. Lineage must not require custom aspects for basic use and must +other metadata. Gigasail must not require custom aspects for basic use and must not reconstruct Bazel's action graph. ## Run Manifest -Both `analyse` and `verify` write a versioned `lineage-run/v1` manifest. At +Both `analyse` and `verify` write a versioned `gigasail-run/v1` manifest. At minimum it records: - Repository identity. @@ -376,17 +376,17 @@ their declared root. Ingestion verifies hashes before parsing. ## Working-tree Evidence Commit evidence cannot be presented as exact evidence for a modified working -tree. `lineage diff` may always show Git-derived and source-derived changes, but +tree. `giga diff` may always show Git-derived and source-derived changes, but dynamic evidence is exact only when its run manifest matches a deterministic dirty-tree fingerprint. -If a manifest matches `HEAD` but files are modified, Lineage marks affected +If a manifest matches `HEAD` but files are modified, Gigasail marks affected evidence stale or out of scope rather than silently attributing it to the working tree. ## Trust and Security -Verification configuration contains executable commands. A hosted Lineage +Verification configuration contains executable commands. A hosted Gigasail service must never execute commands newly introduced or changed by an untrusted pull request without approval. @@ -394,7 +394,7 @@ Required protections include: - Execute commands from a trusted base revision or separately approved repository configuration. -- Treat a change to `lineage.yml`, build scripts, or producer scripts as a +- Treat a change to `giga.yml`, build scripts, or producer scripts as a security-sensitive configuration change. - Permit revision-scoped classification metadata to affect presentation, but do not confuse that with authority to execute commands. @@ -419,29 +419,29 @@ Exact numeric exit codes should be stable and documented for CI consumers. ## Migration Strategy -1. Introduce the typed config schema and `lineage-run/v1` manifest. -2. Add the generic command and bundled-Lineage executors. +1. Introduce the typed config schema and `gigasail-run/v1` manifest. +2. Add the generic command and bundled-Gigasail executors. 3. Implement `analyse` and `verify` as artifact producers. 4. Extend `ingest` to consume run manifests while preserving every existing direct ingestion command. -5. Add text and JSON renderers over the existing `DiffPlan` for `lineage diff`. +5. Add text and JSON renderers over the existing `DiffPlan` for `giga diff`. 6. Add `--analyse`, `--full`, and `--require-profile` semantics. -7. Add `lineage ci` as a thin composition of existing commands. +7. Add `giga ci` as a thin composition of existing commands. 8. Add the Bazel executor and BEP adapter. 9. Convert `tools/import_repo.rb` into a compatibility wrapper around the new commands, then remove its duplicated orchestration. -10. Migrate `.lineage/diff.toml` classification into `lineage.yml`. +10. Migrate `.giga/diff.toml` classification into `giga.yml`. ## Acceptance Criteria -- `lineage diff` and `lineage analyse` work in an unconfigured supported +- `giga diff` and `giga analyse` work in an unconfigured supported repository without Bazel. - A configured non-Bazel repository can produce and ingest a complete evidence profile using its existing scripts. -- A Bazel repository can use BEP-backed verification without a parallel Lineage +- A Bazel repository can use BEP-backed verification without a parallel Gigasail build graph. - Direct artifact ingestion continues to work without configuration. -- `lineage diff --full` remains useful with partial evidence and labels every +- `giga diff --full` remains useful with partial evidence and labels every gap honestly. - `--require-profile full` fails when any required evidence is not exact and complete. @@ -453,7 +453,7 @@ Exact numeric exit codes should be stable and documented for CI consumers. ## Final Decision -Lineage will provide a declarative evidence pipeline through `lineage.yml`, but +Gigasail will provide a declarative evidence pipeline through `giga.yml`, but will not become a build system. Bazel is supported as an optional executor and rich evidence source. All commands remain independent of Bazel; ingestion remains open to external artifacts; and diffs remain useful even when the full diff --git a/gems/lineage/docs/agents/coverage-history.md b/gems/gigasail/docs/agents/coverage-history.md similarity index 87% rename from gems/lineage/docs/agents/coverage-history.md rename to gems/gigasail/docs/agents/coverage-history.md index ec5a9c2e3..d9b141fef 100644 --- a/gems/lineage/docs/agents/coverage-history.md +++ b/gems/gigasail/docs/agents/coverage-history.md @@ -1,6 +1,6 @@ # Coverage and Quality History: The "Delta" Model -This document outlines the design for ingesting and tracking coverage and mutation testing data over time within the `Lineage` engine. By anchoring quality metrics to the historical graph, the toolchain can answer the critical question: *"Is this fragile code getting safer or more dangerous?"* +This document outlines the design for ingesting and tracking coverage and mutation testing data over time within the `Gigasail` engine. By anchoring quality metrics to the historical graph, the toolchain can answer the critical question: *"Is this fragile code getting safer or more dangerous?"* ## 1. The Value of Historical Coverage @@ -11,7 +11,7 @@ Tracking this metric over time allows `Boobytrap` to distinguish between "Histor ## 2. The "Delta" Storage Model (Aggregate Quality) -Storing full line-by-line coverage bitmasks for every commit would cause the SQLite database to bloat to gigabytes. Instead, `Lineage` stores **Aggregate Logical Metadata**. +Storing full line-by-line coverage bitmasks for every commit would cause the SQLite database to bloat to gigabytes. Instead, `Gigasail` stores **Aggregate Logical Metadata**. The engine tracks the "Current Known State" of a Logical Unit and records Deltas over time. @@ -35,7 +35,7 @@ mutation-verified?" `Boobytrap` needs that second shape to avoid over-ranking code that was historically buggy but has since been covered by meaningful tests. -`Lineage` therefore also stores a first-class `test_exposure_events` +`Gigasail` therefore also stores a first-class `test_exposure_events` ledger. Each row represents one named test hitting one logical unit at a specific commit. Records may include: @@ -58,19 +58,19 @@ survived mutant. When mutation facts are present, they can strengthen the To ensure the integrity of the data and support flexible CI setups, ingestion is strictly decoupled into a three-pass model. The passes respect the lifecycle of data availability. ### Pass 1: The Backbone (Git/VCS) -- **Command:** `lineage build` +- **Command:** `giga build` - **Execution:** First step. Builds the `logical_units` table and the commit graph. All subsequent passes anchor to the IDs generated here. ### Pass 2: The Empirical Feed (Stack Traces) -- **Command:** `lineage ingest --provider sentry ...` +- **Command:** `giga ingest --provider sentry ...` - **Execution:** Async/Continuous. Ingests production events, maps them to commits and Logical IDs, and records `crash_events`. ### Pass 3: The Quality Feed (Coverage/Mutant) -- **Command:** `lineage ingest-coverage --format boobytrap ...` +- **Command:** `giga ingest-coverage --format boobytrap ...` - **Execution:** End of a CI run. Ingests `coverage.json` and `mutant.json` artifacts, mapping them to the specific commit and updating the Quality State of the associated Logical Units. ### Pass 4: The Named-Test Exposure Feed -- **Command:** `lineage ingest-test-exposure ...` +- **Command:** `giga ingest-test-exposure ...` - **Execution:** End of a test run. Ingests `test-exposure/v1` facts, maps each record to source at the commit, and records one event per logical-unit/test hit. This feed is valuable even without mutation @@ -129,4 +129,4 @@ CREATE TABLE test_exposure_events ( ## 5. Strategic Verdict -Tracking aggregate coverage and mutation testing over time is a **Must Build** feature. By providing a database for this data, `Lineage` creates a "Quality Ledger." It completes the "Quadrants of Risk" (Structure, History, Production, Protection), allowing LLMs and developers to confidently refactor previously dangerous code. +Tracking aggregate coverage and mutation testing over time is a **Must Build** feature. By providing a database for this data, `Gigasail` creates a "Quality Ledger." It completes the "Quadrants of Risk" (Structure, History, Production, Protection), allowing LLMs and developers to confidently refactor previously dangerous code. diff --git a/gems/lineage/docs/agents/cross-lang-support.md b/gems/gigasail/docs/agents/cross-lang-support.md similarity index 82% rename from gems/lineage/docs/agents/cross-lang-support.md rename to gems/gigasail/docs/agents/cross-lang-support.md index 9fa03da07..ccfab328e 100644 --- a/gems/lineage/docs/agents/cross-lang-support.md +++ b/gems/gigasail/docs/agents/cross-lang-support.md @@ -1,16 +1,16 @@ # Cross-Language Support Validation -This document tracks the first practical validation pass for building Lineage databases from non-CLEAR repositories and ingesting analyzer, lint, coverage, hazard, and runtime evidence. +This document tracks the first practical validation pass for building Gigasail databases from non-CLEAR repositories and ingesting analyzer, lint, coverage, hazard, and runtime evidence. -`gems/lineage/docs/agents/plugins.md` describes the plugin architecture and broad language targets. It does not prescribe exact repositories, so this pass used representative active OSS projects with enough real code to exercise the adapters. +`gems/gigasail/docs/agents/plugins.md` describes the plugin architecture and broad language targets. It does not prescribe exact repositories, so this pass used representative active OSS projects with enough real code to exercise the adapters. ## Goal -Create one `lineage.db` per target repository, ingest the best available evidence, start a Lineage UI server for each on `0.0.0.0`, and spot check that the UI can review the project with cross-language data. +Create one `gigasail.db` per target repository, ingest the best available evidence, start a Gigasail UI server for each on `0.0.0.0`, and spot check that the UI can review the project with cross-language data. ## Standard Import Command -Use the same import wrapper for every checkout. The wrapper builds Lineage and +Use the same import wrapper for every checkout. The wrapper builds Gigasail and bundled analyzers, indexes the whole repository, discovers supported coverage artifacts, ingests hazards, generates and ingests first-party SARIF (Decomplex, SlopCop, Boobytrap, Nil-Kill, Espalier), generates Go/Rust/Ruby/Zig @@ -18,10 +18,10 @@ lint SARIF where the repository has relevant code, ingests extra SARIF inputs, refreshes summaries, and can start the UI: ```bash -/home/yahn/litedb/gems/lineage/bin/lineage-import \ +/home/yahn/litedb/gems/gigasail/bin/gigasail-import \ --repo /path/to/repo \ - --db /path/to/repo/lineage.db \ - --out-dir /path/to/repo/tmp/lineage-import \ + --db /path/to/repo/gigasail.db \ + --out-dir /path/to/repo/tmp/gigasail-import \ --fresh \ --serve \ --daemon \ @@ -36,24 +36,24 @@ artifacts, repeat `--coverage path/to/artifact` or `--sarif-input path/to/dir`. | Language | Repository | Local Clone | Database | UI Port | Status | | --- | --- | --- | --- | --- | --- | -| Python | `https://github.com/Textualize/rich` | `/tmp/lineage-one-line-repos/rich` | `/tmp/lineage-one-line-repos/rich/lineage.db` | `18101` | Complete | -| TypeScript | `https://github.com/colinhacks/zod` | `/tmp/lineage-one-line-repos/zod` | `/tmp/lineage-one-line-repos/zod/lineage.db` | `18102` | Complete | -| JavaScript | `https://github.com/fastify/fastify` | `/tmp/fastify` | `/tmp/fastify/lineage.db` | `18111` | Added for analyzer validation | -| Go | `https://github.com/junegunn/fzf` | `/tmp/lineage-one-line-repos/fzf` | `/tmp/lineage-one-line-repos/fzf/lineage.db` | `18103` | Complete, partial Go coverage | -| Lua | `https://github.com/luarocks/luarocks` | `/tmp/lineage-one-line-repos/luarocks` | `/tmp/lineage-one-line-repos/luarocks/lineage.db` | `18104` | Complete, no coverage artifact | -| C | `https://github.com/libuv/libuv` | `/tmp/lineage-one-line-repos/libuv` | `/tmp/lineage-one-line-repos/libuv/lineage.db` | `18105` | Complete, no coverage artifact | -| C++ | `https://github.com/fmtlib/fmt` | `/tmp/lineage-one-line-repos/fmt` | `/tmp/lineage-one-line-repos/fmt/lineage.db` | `18106` | Complete, no coverage artifact | -| C# | `https://github.com/serilog/serilog` | `/tmp/lineage-one-line-repos/serilog` | `/tmp/lineage-one-line-repos/serilog/lineage.db` | `18107` | Complete, no coverage artifact | -| Java | `https://github.com/google/gson` | `/tmp/lineage-one-line-repos/gson` | `/tmp/lineage-one-line-repos/gson/lineage.db` | `18108` | Complete, no coverage artifact | -| Swift | `https://github.com/apple/swift-argument-parser` | `/tmp/lineage-one-line-repos/swift-argument-parser` | `/tmp/lineage-one-line-repos/swift-argument-parser/lineage.db` | `18109` | Complete, no coverage artifact | -| Kotlin | `https://github.com/square/okio` | `/tmp/lineage-one-line-repos/okio` | `/tmp/lineage-one-line-repos/okio/lineage.db` | `18110` | Complete, no coverage artifact | +| Python | `https://github.com/Textualize/rich` | `/tmp/gigasail-one-line-repos/rich` | `/tmp/gigasail-one-line-repos/rich/gigasail.db` | `18101` | Complete | +| TypeScript | `https://github.com/colinhacks/zod` | `/tmp/gigasail-one-line-repos/zod` | `/tmp/gigasail-one-line-repos/zod/gigasail.db` | `18102` | Complete | +| JavaScript | `https://github.com/fastify/fastify` | `/tmp/fastify` | `/tmp/fastify/gigasail.db` | `18111` | Added for analyzer validation | +| Go | `https://github.com/junegunn/fzf` | `/tmp/gigasail-one-line-repos/fzf` | `/tmp/gigasail-one-line-repos/fzf/gigasail.db` | `18103` | Complete, partial Go coverage | +| Lua | `https://github.com/luarocks/luarocks` | `/tmp/gigasail-one-line-repos/luarocks` | `/tmp/gigasail-one-line-repos/luarocks/gigasail.db` | `18104` | Complete, no coverage artifact | +| C | `https://github.com/libuv/libuv` | `/tmp/gigasail-one-line-repos/libuv` | `/tmp/gigasail-one-line-repos/libuv/gigasail.db` | `18105` | Complete, no coverage artifact | +| C++ | `https://github.com/fmtlib/fmt` | `/tmp/gigasail-one-line-repos/fmt` | `/tmp/gigasail-one-line-repos/fmt/gigasail.db` | `18106` | Complete, no coverage artifact | +| C# | `https://github.com/serilog/serilog` | `/tmp/gigasail-one-line-repos/serilog` | `/tmp/gigasail-one-line-repos/serilog/gigasail.db` | `18107` | Complete, no coverage artifact | +| Java | `https://github.com/google/gson` | `/tmp/gigasail-one-line-repos/gson` | `/tmp/gigasail-one-line-repos/gson/gigasail.db` | `18108` | Complete, no coverage artifact | +| Swift | `https://github.com/apple/swift-argument-parser` | `/tmp/gigasail-one-line-repos/swift-argument-parser` | `/tmp/gigasail-one-line-repos/swift-argument-parser/gigasail.db` | `18109` | Complete, no coverage artifact | +| Kotlin | `https://github.com/square/okio` | `/tmp/gigasail-one-line-repos/okio` | `/tmp/gigasail-one-line-repos/okio/gigasail.db` | `18110` | Complete, no coverage artifact | All UI servers were restarted with detached sessions and smoke checked through `curl` on ports `18101` through `18110`. ## Mini-Corpus: Bounded Manual-Review Validation The validation matrix above answers a different question from analyzer quality: -can Lineage ingest a large, realistic repository? It cannot cheaply establish +can Gigasail ingest a large, realistic repository? It cannot cheaply establish whether a high-ranked finding is true, whether an important function was missed, or which adapter is responsible when either happens. Large projects also combine too many unrelated language features to make a regression @@ -178,14 +178,14 @@ language, and turn every repeatable blind spot into a regression. Each repository received as much of this evidence as the current tools could produce without repository-specific hacks: -- `lineage build`: Git history, logical units, churn, and ownership. +- `giga build`: Git history, logical units, churn, and ownership. - Decomplex SARIF: structural complexity findings. - SlopCop SARIF: coverage gaps and constraint findings. - Boobytrap SARIF: bug-risk findings derived from churn, complexity, and coverage. - Nil-kill SARIF: optionality, union, hidden enum, and primitive pressure findings where the language adapter supports them. - Espalier SARIF: architectural pressure findings where the language adapter supports them. - Lint SARIF: native lint output converted or emitted as SARIF where the repository already had a reasonable local toolchain. -- Coverage: native coverage output ingested through Lineage-supported formats when the toolchain was available. +- Coverage: native coverage output ingested through Gigasail-supported formats when the toolchain was available. - Runtime traces: Sentry-style stack trace ingestion for Python smoke coverage. - Hazards: Go concurrency hazards for `fzf`. @@ -216,10 +216,10 @@ CI/test artifact is passed with `--coverage`. - Added Swift member access and `switch_entry` support. - Added Kotlin `when_expression` and `when_entry` support. - Added grammar candidate support for packages that ship `tree_sitter_*_binding.node`, needed by `tree-sitter-kotlin`. -- Added Go concurrency hazard detection through SlopCop/Lineage. -- Fixed Lineage source extraction and coverage ingestion issues found during TypeScript/Go validation. +- Added Go concurrency hazard detection through SlopCop/Gigasail. +- Fixed Gigasail source extraction and coverage ingestion issues found during TypeScript/Go validation. - Fixed Nil-kill static-only normalization so non-Ruby languages do not accidentally depend on stale runtime traces. -- Replaced Lineage regex-first logical-unit extraction for Ruby, Python, JavaScript/TypeScript, Go, Rust, Zig, C/C++, and C# with Tree-sitter-backed extraction. The regex heuristic path is now only for secondary experimental languages. +- Replaced Gigasail regex-first logical-unit extraction for Ruby, Python, JavaScript/TypeScript, Go, Rust, Zig, C/C++, and C# with Tree-sitter-backed extraction. The regex heuristic path is now only for secondary experimental languages. ## Environment Gaps @@ -228,4 +228,4 @@ CI/test artifact is passed with `--coverage`. - C#, Java, Swift, and Kotlin native build/lint/coverage were limited by missing `dotnet`, Java, Swift, and Kotlin toolchains in this environment. - TypeScript and Go runtime tracing are still out of scope for this pass. -These are environment/toolchain gaps, not Lineage ingestion blockers. The DBs and UIs exist for all requested languages. +These are environment/toolchain gaps, not Gigasail ingestion blockers. The DBs and UIs exist for all requested languages. diff --git a/gems/lineage/docs/agents/diff-view.md b/gems/gigasail/docs/agents/diff-view.md similarity index 97% rename from gems/lineage/docs/agents/diff-view.md rename to gems/gigasail/docs/agents/diff-view.md index 785ce20dc..f6da06787 100644 --- a/gems/lineage/docs/agents/diff-view.md +++ b/gems/gigasail/docs/agents/diff-view.md @@ -2,7 +2,7 @@ ## Status -Implemented through delivery slice 6. This is a Lineage UI feature, not a +Implemented through delivery slice 6. This is a Gigasail UI feature, not a replacement for GitHub's Files Changed view. It explains the review risk of a revision pair using the same line-level coverage, mutation, hazard, and SARIF evidence already shown in the source view. Assertion observations (slice 7) @@ -11,7 +11,7 @@ and migration of the existing source, dashboard, and architecture routes ## Product decision -Lineage will provide a revision-pinned diff view that answers two questions +Gigasail will provide a revision-pinned diff view that answers two questions before showing code: 1. What meaningful production and test code was added, by language and by @@ -24,7 +24,7 @@ separate raw presentation remains available for reviewers who need a normal patch in source order. The diff view will be the first canonical screen in a new React + Monaco -frontend. The long-term Lineage UI should move to that frontend, but the +frontend. The long-term Gigasail UI should move to that frontend, but the existing Askama source and dashboard screens will migrate incrementally after the diff view is complete. Rewriting every current screen before delivering the diff would create a long, high-risk gap without improving its evidence @@ -36,7 +36,7 @@ evidence gap, not an uncovered line. ## Goals -- Show the normal Lineage source experience in a diff: coverage and mutation +- Show the normal Gigasail source experience in a diff: coverage and mutation state, hazards, all SARIF findings, dark-arm overlays, source links, and finding detail. - Rank changed production files by added unverified semantic code, added @@ -53,7 +53,7 @@ evidence gap, not an uncovered line. semantic grouping, score, or evidence. - Preserve a one-click standard, source-ordered diff for residual lines and for reviewers who prefer the conventional patch. -- Work across every language for which Lineage has a Tree-sitter parser, while +- Work across every language for which Gigasail has a Tree-sitter parser, while being explicit when semantic classification or visibility is unavailable. ## Non-goals @@ -225,7 +225,7 @@ the private aggregate. `U`, `P`, `DC`, and `H1` are always shown beside the score; no hidden severity multipliers are allowed. Ties sort by `H1`, then `U`, then added meaningful lines, then path/name for deterministic output. -`DC` is a portable AST decision-point delta, not a claim that Lineage has +`DC` is a portable AST decision-point delta, not a claim that Gigasail has proven a full Big-O bound. It counts the language adapter's control-decision nodes for the construct at each revision. A new function is compared with the language baseline complexity of one. This independent metric avoids depending @@ -304,9 +304,9 @@ raw diff. ### Configuration catalog -Add a declarative `ConfigCatalog` owned by Lineage, with repository overrides. +Add a declarative `ConfigCatalog` owned by Gigasail, with repository overrides. It classifies a path before attempting to parse its contents. The initial -catalog must cover every language with a Lineage Tree-sitter adapter, plus +catalog must cover every language with a Gigasail Tree-sitter adapter, plus repository/CI configuration: | Ecosystem / role | Configuration and manifest candidates | Lock / generated candidates | @@ -331,8 +331,8 @@ performed per path and nearest manifest root, not only at repository root. Repository configuration can add custom manifests, change a category, or disable an overly broad rule. -The shipped override file is revision-scoped: Lineage reads -`.lineage/diff.toml` from the selected immutable head revision. It supports +The shipped override file is revision-scoped: Gigasail reads +`.giga/diff.toml` from the selected immutable head revision. It supports auditable exact-path and directory-prefix source-role overrides; overrides win over catalog and convention classification: @@ -410,7 +410,7 @@ to rendering a diff. Before implementing the catalog, run a dependency/configuration discovery spike with these deliverables: -1. Enumerate all current Lineage-supported languages from the parser registry +1. Enumerate all current Gigasail-supported languages from the parser registry and map real validation-corpus repositories to their manifests, lockfiles, CI files, documentation, and generated paths. 2. Compare maintained parser libraries or official parsers for each structured @@ -469,7 +469,7 @@ additions, verification slice bar, new tier-1 hazard count, all-SARIF count, and an evidence freshness indicator. ```text -1. gems/lineage/src/ui/diff.rs risk 31.5 +1. gems/gigasail/src/ui/diff.rs risk 31.5 production · Rust · +28 code · +4 comments 12 uncovered + 3 partial + complexity +2 + 2 new tier-1 hazards [SARIF 6] [coverage exact] [mutation exact] @@ -620,7 +620,7 @@ Paths use the same repository-relative validation as the source controller. Symbols are parsed at both revisions. Matching proceeds in this order: -1. Lineage logical-unit identity when a revision-stable unit mapping exists; +1. Gigasail logical-unit identity when a revision-stable unit mapping exists; 2. rename-aware file identity plus declaration kind/qualified name; 3. bounded structural fingerprint of the declaration header and AST shape. @@ -690,7 +690,7 @@ Askama templates, CSS checkbox state, and a growing global JavaScript file would turn the UI into an implicit frontend framework without the testing or type safety of one. -Create a strict TypeScript React application at `gems/lineage/ui/` now. React +Create a strict TypeScript React application at `gems/gigasail/ui/` now. React is the application shell and semantic review surface; Monaco is the code and raw-diff renderer. Do not use Monaco to lay out the risk-ranked page itself: React owns summary cards, file ordering, collapsed groups, controls, and @@ -698,7 +698,7 @@ accessibility; an expanded group mounts a Monaco `DiffEditor` for its code. This separation prevents a large editable-editor abstraction from swallowing the review model. -The destination is a full React/Monaco Lineage UI. The migration is a +The destination is a full React/Monaco Gigasail UI. The migration is a strangler, not a big-bang rewrite: 1. Ship `/diff` as React/Monaco with no duplicated Askama implementation. @@ -719,7 +719,7 @@ The project is a real, independently testable frontend package, not TypeScript sprinkled into `src/ui/assets/`: ```text -gems/lineage/ui/ +gems/gigasail/ui/ package.json pnpm-lock.yaml tsconfig.json @@ -845,7 +845,7 @@ claims. Required tests include: ## Delivery slices -1. Create `gems/lineage/ui` with strict TypeScript, React, Vite, Monaco, the +1. Create `gems/gigasail/ui` with strict TypeScript, React, Vite, Monaco, the generated Rust API contract, and embedded-asset build tooling. Ship a small route smoke test before any complex UI is added. 2. Add revision-pinned diff parsing, source-role classification, typed plan @@ -909,4 +909,4 @@ claims. Required tests include: - Both Monaco diff layouts show the same exact SARIF and verification evidence, and the raw view remains available from the same revision-pinned API. - The implementation is covered by adversarial multi-language fixtures and - does not require a Node runtime in the shipped Lineage binary. + does not require a Node runtime in the shipped Gigasail binary. diff --git a/gems/lineage/docs/agents/features.md b/gems/gigasail/docs/agents/features.md similarity index 94% rename from gems/lineage/docs/agents/features.md rename to gems/gigasail/docs/agents/features.md index 2956004b8..375c7d3e9 100644 --- a/gems/lineage/docs/agents/features.md +++ b/gems/gigasail/docs/agents/features.md @@ -9,7 +9,7 @@ * Create a warning / caution banner if coverage or mutant or any important data is ever stale or out of sink. B. The "Fix Effectiveness" Metric - Lineage knows when a unit was "fixed" and when it "crashed." + Gigasail knows when a unit was "fixed" and when it "crashed." * The Gap: It doesn't explicitly link the two to measure "Fix Regressions." * High-Value Fix: Add a reopened_count to the UnitSummary. * Logic: How many crash_events occurred on a line after a FIX event for that same line? diff --git a/gems/lineage/docs/agents/gem-ui.md b/gems/gigasail/docs/agents/gem-ui.md similarity index 84% rename from gems/lineage/docs/agents/gem-ui.md rename to gems/gigasail/docs/agents/gem-ui.md index eca4cc8bf..357c6f7b0 100644 --- a/gems/lineage/docs/agents/gem-ui.md +++ b/gems/gigasail/docs/agents/gem-ui.md @@ -1,8 +1,8 @@ -# Lineage UI: SARIF-Aware Refactoring Cockpit +# Gigasail UI: SARIF-Aware Refactoring Cockpit -This document defines how the Lineage UI should aggregate first-party gem +This document defines how the Gigasail UI should aggregate first-party gem findings and later ecosystem analysis artifacts. The goal is not to make every -tool depend on Lineage. The goal is to make Lineage the durable place where +tool depend on Gigasail. The goal is to make Gigasail the durable place where findings from many tools are normalized, attached to source, and displayed with history, coverage, hazards, and test evidence. @@ -10,7 +10,7 @@ history, coverage, hazards, and test evidence. - Treat SARIF as the shared interchange format for first-party and third-party analysis results. -- Persist SARIF findings in the Lineage SQLite database so UI, LSP, and future +- Persist SARIF findings in the Gigasail SQLite database so UI, LSP, and future risk ranking use the same source of truth. - Keep the existing `--overlay` UI/LSP option as a lightweight preview/debug mode for local artifacts. @@ -24,7 +24,7 @@ history, coverage, hazards, and test evidence. ## Current State -Lineage already accepts repeated `--overlay` paths for the UI and LSP. The +Gigasail already accepts repeated `--overlay` paths for the UI and LSP. The overlay loader can read SARIF 2.1.0 and JSON-like dark-arm payloads, but it is intentionally transient: the artifacts are parsed at server startup, attached to line annotations in memory, and not recorded in SQLite. @@ -40,15 +40,15 @@ That is useful for spot checks, but it is not enough for the full product: ## Target Model -Lineage has two SARIF intake modes. +Gigasail has two SARIF intake modes. ### 1. Ephemeral Overlay Mode Command shape: ```sh -lineage ui --db lineage.db --repo . --overlay tmp/slopcop.sarif --overlay tmp/decomplex.sarif -lineage lsp --db lineage.db --repo . --overlay tmp/slopcop.sarif +giga ui --db gigasail.db --repo . --overlay tmp/slopcop.sarif --overlay tmp/decomplex.sarif +giga lsp --db gigasail.db --repo . --overlay tmp/slopcop.sarif ``` Use this for local experiments and debugging. It should remain fast and @@ -59,10 +59,10 @@ forgiving. It does not update the database. Command shape: ```sh -lineage ingest-sarif \ - --db lineage.db \ +giga ingest-sarif \ + --db gigasail.db \ --repo . \ - --input tmp/lineage-sarif \ + --input tmp/gigasail-sarif \ --source first-party \ --commit "$(git rev-parse HEAD)" \ --replace @@ -75,7 +75,7 @@ inserting the new run, making CI reruns idempotent. ## Normalized Storage Contract -Lineage stores two levels of data. +Gigasail stores two levels of data. ### `sarif_artifacts` @@ -105,18 +105,18 @@ Required fields: - `category`: best-effort category from SARIF properties or rule id. - `is_dark_arm`: true when the result represents a dark branch arm. - `unit_id`: nullable link to the current logical unit containing the line. -- `fingerprint`: SARIF partial fingerprint or Lineage-computed natural key. +- `fingerprint`: SARIF partial fingerprint or Gigasail-computed natural key. - `properties_json`, `raw_json`. This model is intentionally generic. First-party gems may put richer structured -data in `properties`; Lineage stores it without needing custom columns for every +data in `properties`; Gigasail stores it without needing custom columns for every detector. ## First-Party Source Roles ### Decomplex -Decomplex emits structural complexity and similarity findings as SARIF. Lineage +Decomplex emits structural complexity and similarity findings as SARIF. Gigasail should ingest it directly. UI display belongs in the Structural or Audit tab, and line popups should show the rule, level, and message. @@ -124,25 +124,25 @@ line popups should show the rule, level, and message. SlopCop currently acts as a near-term aggregator for Boobytrap and Decomplex coverage-risk signals. That is acceptable for now because it owns dark-arm -classification and PR annotations. Lineage should ingest SlopCop SARIF directly +classification and PR annotations. Gigasail should ingest SlopCop SARIF directly and preserve `dark_arm` properties so exact spans can render in the source viewer. -Long term, Lineage should become the top-level aggregator and SlopCop should be +Long term, Gigasail should become the top-level aggregator and SlopCop should be one source among many. That migration should not block current ingestion. ### Nil-Kill For this phase, ingest only Nil-Kill static analysis SARIF. Do not ingest full runtime trace bundles into SARIF tables. Runtime traces remain under Nil-Kill -evidence or Lineage test-exposure/coverage tables. +evidence or Gigasail test-exposure/coverage tables. Nil-Kill static SARIF can populate the Evidence tab and line popups with type-system or nullability findings. ### Espalier -Espalier emits architecture SARIF. Lineage should ingest it directly and render +Espalier emits architecture SARIF. Gigasail should ingest it directly and render findings in an Architecture tab or generic line detail panel until the dedicated architecture panel exists. @@ -152,7 +152,7 @@ CI and local scripts should be able to write first-party artifacts to one directory: ```text -tmp/lineage-sarif/ +tmp/gigasail-sarif/ decomplex.sarif slopcop.sarif nil-kill-static.sarif @@ -162,7 +162,7 @@ tmp/lineage-sarif/ The same directory convention should work later for external SARIF: ```text -tmp/lineage-sarif/ecosystem/ +tmp/gigasail-sarif/ecosystem/ ruff.sarif eslint.sarif clippy.sarif @@ -198,7 +198,7 @@ severity, provider, and category. Near-term tabs: -- Coverage and hazards: Lineage, Boobytrap, SlopCop. +- Coverage and hazards: Gigasail, Boobytrap, SlopCop. - Structural: Decomplex. - Evidence: Nil-Kill static findings. - Architecture: Espalier. @@ -235,12 +235,12 @@ Persistent SARIF ingestion must be safe to run repeatedly in CI. - Do not build custom linter/smell/flay adapters. - Do not re-score every SARIF provider into Boobytrap risk. -- Do not move all SlopCop aggregation into Lineage yet. +- Do not move all SlopCop aggregation into Gigasail yet. - Do not store Nil-Kill runtime trace event streams as SARIF. ## Acceptance Criteria -- `lineage ingest-sarif` accepts a file or directory of SARIF artifacts. +- `giga ingest-sarif` accepts a file or directory of SARIF artifacts. - Decomplex, SlopCop, Nil-Kill static analysis, and Espalier SARIF can be ingested for the current commit. - Re-running ingest with `--replace` does not double-count findings. diff --git a/gems/gigasail/docs/agents/giga-watch.md b/gems/gigasail/docs/agents/giga-watch.md new file mode 100644 index 000000000..69e5edf88 --- /dev/null +++ b/gems/gigasail/docs/agents/giga-watch.md @@ -0,0 +1,73 @@ +# giga watch + +`giga watch` keeps a repository's evidence database current by analysing and +ingesting every new commit, and coordinates with readers so nobody sees a +half-written database. + +## What it does + +``` +giga watch [--repo .] [--db .giga/gigasail.db] [--profile analyse] + [--interval 2] [--trust-current-config] [--once] +``` + +The watcher polls `HEAD`. When `HEAD` advances to a commit it has not yet +processed, it: + +1. Takes the `.giga/` coordination lock for that commit (`operation = analyse`). +2. Runs the analysis profile and ingests the run into the database + (`analyse --ingest`, i.e. "ci then sync"). +3. Releases the lock. + +- A tick that finds the lock already held by a live peer is **Busy**: the commit + is left for a later poll once the peer releases. +- A tick whose analysis fails logs the error and advances past the commit, so a + single bad commit never spins the loop; the next new commit is still attempted. +- `--once` processes the current `HEAD` and exits (scripting and tests). + +## The `.giga/` lock + +`giga_core::lock` implements a single PID-bearing lock file, `.giga/lock.json`: + +```json +{ "pid": 12345, "commit": "<40-hex>", "operation": "analyse", "started_at": 1720000000 } +``` + +- **Race-free acquisition.** The record is written to a per-call temp file, then + atomically `hard_link`ed into place. `link(2)` fails with `EEXIST` when the + lock is held, so a peer never observes a half-written lock. +- **Zombie reclaim.** A lock left by a dead process is detected with + `kill(pid, 0)` (ESRCH) and reclaimed automatically. +- **RAII release.** Dropping the `GigaLock` removes the file, but only if this + process is still the recorded owner. + +This is what stops a second `giga watch` — or any writer — from indexing the +database at the same time as an in-flight run. + +## How readers coordinate (`giga diff`, MCP) + +Readers do not take the lock; they consult it via +`giga_core::lock::wait_while_locked_for(dir, commit, ...)`: + +- If the lock is held **for the exact commit** the reader is about to render, + the reader waits (polling) until analysis of that commit finishes, so it shows + complete evidence. `giga diff` prints `waiting for analysis of ...`. +- If the lock is free, or held **for a different commit**, the reader proceeds + immediately. Diffing a previously analysed commit just shows it. +- Waiting is bounded (10 min ceiling); past that the reader renders whatever is + available rather than blocking forever. + +`giga diff` applies this before preparing the plan (every output format). The +MCP server applies it at the start of each tool call (off the async executor via +`spawn_blocking`), so tool results reflect a fully ingested database. + +## Launching the servers + +`giga watch` only maintains the database. Serve it separately: + +- Web UI: `giga-ui serve --db .giga/gigasail.db --repo .` +- Language server: `giga-ui lsp --db .giga/gigasail.db --repo .` +- MCP (for coding agents): `giga-ui mcp --db .giga/gigasail.db --repo .` + +A typical local setup runs `giga watch` in the background and one of the +`giga-ui` servers in the foreground; the lock keeps them consistent. diff --git a/gems/lineage/docs/agents/hazard-tracking.md b/gems/gigasail/docs/agents/hazard-tracking.md similarity index 77% rename from gems/lineage/docs/agents/hazard-tracking.md rename to gems/gigasail/docs/agents/hazard-tracking.md index cd58604c4..f0bb16fe1 100644 --- a/gems/lineage/docs/agents/hazard-tracking.md +++ b/gems/gigasail/docs/agents/hazard-tracking.md @@ -1,11 +1,11 @@ # Hazard Tracking: Persistent Safety Constraints -This document outlines the design for tracking "Safety Hazards" (Atomics, Locks, Manual Memory) within the `Lineage` engine. By storing hazard tags at the logical-unit level, the toolchain ensures that safety requirements follow the code through renames and refactors. +This document outlines the design for tracking "Safety Hazards" (Atomics, Locks, Manual Memory) within the `Gigasail` engine. By storing hazard tags at the logical-unit level, the toolchain ensures that safety requirements follow the code through renames and refactors. -## 1. Why Store Hazards in Lineage? +## 1. Why Store Hazards in Gigasail? -- **Rename Stability:** If a function containing an atomic hazard is moved from `io.zig` to `transport.zig`, Lineage ensures the hazard tag is preserved. Static lists or simple AST scans lose this historical context. -- **Temporal Lifecycle:** Lineage tracks the birth and death of hazards. A function may be a "Memory Hazard" in v1 (raw pointers) but become "Safe" in v2 (refactored to abstractions). +- **Rename Stability:** If a function containing an atomic hazard is moved from `io.zig` to `transport.zig`, Gigasail ensures the hazard tag is preserved. Static lists or simple AST scans lose this historical context. +- **Temporal Lifecycle:** Gigasail tracks the birth and death of hazards. A function may be a "Memory Hazard" in v1 (raw pointers) but become "Safe" in v2 (refactored to abstractions). - **Verification Anchor:** By persisting hazards in SQLite, `SlopCop` can instantly query for "Verification Gaps" without re-scanning the entire repository AST. ## 2. Storage Model: The Hazard Ledger @@ -28,7 +28,7 @@ CREATE INDEX idx_hazards_unit_id ON unit_hazards(unit_id); ### Recommendation: Stick to Tree-sitter / Specialized Tools While CodeQL is powerful for deep semantic analysis, it is **overkill** for "Hazard Tagging." Identifying that a function contains an `atomic.load` or a `Mutex` is a surgical syntactic task. -- **The Path:** Use the existing **Zig-backend tool** (and expand it via Tree-sitter for other languages) to identify "Dangerous Primitives" during the `lineage build` pass. +- **The Path:** Use the existing **Zig-backend tool** (and expand it via Tree-sitter for other languages) to identify "Dangerous Primitives" during the `giga build` pass. - **Why:** This is 100x faster than building a CodeQL database and much easier for contributors to extend. Precision is high because these primitives are explicitly named in the grammar. ## 4. GitHub Integration: The "Virtual Gutter" diff --git a/gems/lineage/docs/agents/lang-support-quality.md b/gems/gigasail/docs/agents/lang-support-quality.md similarity index 92% rename from gems/lineage/docs/agents/lang-support-quality.md rename to gems/gigasail/docs/agents/lang-support-quality.md index 0bf035584..0b68c097c 100644 --- a/gems/lineage/docs/agents/lang-support-quality.md +++ b/gems/gigasail/docs/agents/lang-support-quality.md @@ -1,10 +1,10 @@ # Multi-Language Support Quality Pass -This pass spot checked the validation DBs created for Python, TypeScript, Go, Lua, C, C++, C#, Java, Swift, and Kotlin. The goal was not to prove feature parity with Ruby, but to verify that Lineage can ingest and display useful SARIF/coverage/risk evidence for each language, and to fix clear cross-language false positives found during review. +This pass spot checked the validation DBs created for Python, TypeScript, Go, Lua, C, C++, C#, Java, Swift, and Kotlin. The goal was not to prove feature parity with Ruby, but to verify that Gigasail can ingest and display useful SARIF/coverage/risk evidence for each language, and to fix clear cross-language false positives found during review. ## Quality Checklist -- Lineage DB exists and UI serves the repository. +- Gigasail DB exists and UI serves the repository. - SARIF artifacts ingest into `sarif_findings` with stable paths and line anchors. - Decomplex findings include enough detector-specific context to be actionable. - Nil-kill static pressure findings do not flag obviously typed or non-null constructs as loose contracts. @@ -47,7 +47,7 @@ All UI servers responded with HTTP 200 on ports `8081` through `8090` after SARI | Swift | Argument Parser | 8089 | 1,938 | 6 | 1,129 | 0 | 0 | | Kotlin | Okio | 8090 | 3,357 | 6 | 2,243 | 0 | 0 | -Swift and Kotlin SARIF reingest skipped two non-SARIF JSON evidence files in each `tmp/lineage-sarif` directory. That is expected because the ingest command accepts directories and ignores JSON files that are not SARIF documents. +Swift and Kotlin SARIF reingest skipped two non-SARIF JSON evidence files in each `tmp/gigasail-sarif` directory. That is expected because the ingest command accepts directories and ignores JSON files that are not SARIF documents. ## Nil-kill Static Evidence Spot Check @@ -59,7 +59,7 @@ Static evidence was generated through `NilKill::StaticEvidence.build` with Tree- | Python | Rich | 16.773s | 213 | 1,792 | 496 | 68 | 211 | 470 | Python | | TypeScript | Zod | 36.036s | 405 | 1,143 | 113 | 15 | 94 | 12 | JavaScript, TypeScript | | Lua | LuaRocks | 11.602s | 172 | 1,008 | 20 | 17 | 12 | 2 | C, C++, Lua | -| Rust | `gems/lineage` | 14.192s | 19 | 772 | 701 | 701 | 121 | 0 | JavaScript, Ruby, Rust | +| Rust | `gems/gigasail` | 14.192s | 19 | 772 | 701 | 701 | 121 | 0 | JavaScript, Ruby, Rust | | Zig | `zig/` | 39.656s | 279 | 2,907 | 2,289 | 2,228 | 1,422 | 178 | C++, Zig | | C | libuv | 31.700s | 368 | 3,249 | 1,201 | 961 | 590 | 0 | C, JavaScript, Python | | C++ | fmt | 37.257s | 77 | 3,291 | 322 | 306 | 112 | 12 | C, C++, JavaScript, Python | @@ -83,7 +83,7 @@ Spot checks of the generated JSON confirmed anchored facts for representative re Status: good. -The strongest path is covered: Lineage DB, Decomplex, Nil-kill, Espalier, SlopCop, Boobytrap, native lint, coverage, quality events, and one runtime stack-trace smoke event all ingest. Rich is the best multi-language validation target after CLEAR Ruby because it has meaningful Python type annotations and coverage. +The strongest path is covered: Gigasail DB, Decomplex, Nil-kill, Espalier, SlopCop, Boobytrap, native lint, coverage, quality events, and one runtime stack-trace smoke event all ingest. Rich is the best multi-language validation target after CLEAR Ruby because it has meaningful Python type annotations and coverage. Spot checks: @@ -92,7 +92,7 @@ Spot checks: - SlopCop and Boobytrap findings are anchored to real coverage/churn data. - Native lint SARIF from Black is visible and path-anchored. -Remaining caveat: test/example files are included in the validation DB. That is useful for ingestion coverage, but production review should use source-role filtering in Lineage. +Remaining caveat: test/example files are included in the validation DB. That is useful for ingestion coverage, but production review should use source-role filtering in Gigasail. ### TypeScript / Zod @@ -112,7 +112,7 @@ Remaining caveat: broken-protocol and Boobytrap rows in test suites are noisy. T Status: good. -Go has the best non-Ruby systems-language story in this pass. Lineage ingests coverage, SlopCop coverage gaps, Boobytrap risk, Decomplex, Nil-kill static facts, and Go concurrency hazard SARIF. +Go has the best non-Ruby systems-language story in this pass. Gigasail ingests coverage, SlopCop coverage gaps, Boobytrap risk, Decomplex, Nil-kill static facts, and Go concurrency hazard SARIF. Spot checks: @@ -126,7 +126,7 @@ Remaining caveat: Go hazard support is currently concurrency-focused. Other safe Status: usable static ingestion, experimental analysis quality. -Lineage DB and SARIF ingestion work. Decomplex produces useful Lua findings after generated Teal prelude suppression. Nil-kill and Espalier are sparse, which matches the current maturity of Lua ownership/type extraction. +Gigasail DB and SARIF ingestion work. Decomplex produces useful Lua findings after generated Teal prelude suppression. Nil-kill and Espalier are sparse, which matches the current maturity of Lua ownership/type extraction. Spot checks: @@ -140,7 +140,7 @@ Remaining caveat: Lua needs better function ownership and module/type convention Status: strong SARIF ingestion, experimental analysis quality. -Lineage handles the large libuv DB and ingests Decomplex, SlopCop, Boobytrap, Nil-kill, Espalier, and syntax-lint SARIF. Decomplex results are plentiful and anchored. +Gigasail handles the large libuv DB and ingests Decomplex, SlopCop, Boobytrap, Nil-kill, Espalier, and syntax-lint SARIF. Decomplex results are plentiful and anchored. Spot checks: @@ -154,7 +154,7 @@ Remaining caveat: C has no coverage here, and C header/platform conditionals cre Status: strong SARIF ingestion, experimental analysis quality. -Lineage ingests fmt SARIF and the UI handles template-heavy headers. Decomplex and Nil-kill produce anchored findings; Espalier has limited but nonzero ownership extraction. +Gigasail ingests fmt SARIF and the UI handles template-heavy headers. Decomplex and Nil-kill produce anchored findings; Espalier has limited but nonzero ownership extraction. Spot checks: @@ -196,7 +196,7 @@ Remaining caveat: no Java coverage or native lint was available in this environm Status: usable static ingestion, experimental analysis quality. -Lineage DB and SARIF ingestion work. Decomplex and Espalier produce anchored Swift findings; Nil-kill static evidence ingests. SlopCop is empty because no coverage was generated. +Gigasail DB and SARIF ingestion work. Decomplex and Espalier produce anchored Swift findings; Nil-kill static evidence ingests. SlopCop is empty because no coverage was generated. Spot checks: @@ -296,7 +296,7 @@ Espalier is useful where class/function ownership extraction is mature. It is sp ## Recommended Next Work -- Add source-role filtering in Lineage views and ranking so `src`/production findings can be reviewed separately from tests, examples, vendored code, and generated code. +- Add source-role filtering in Gigasail views and ranking so `src`/production findings can be reviewed separately from tests, examples, vendored code, and generated code. - Add explicit generated/vendor detection to the shared source filter for common language artifacts. - Improve C/C++ native build-aware lint/coverage collection; static parser output alone is not enough for high-confidence systems-language review. - Add coverage ingestion recipes for Lua, C#, Java, Swift, and Kotlin validation repos. @@ -305,7 +305,7 @@ Espalier is useful where class/function ownership extraction is mature. It is sp ## Second Validation Round (2026-07-21): Mini-Corpus Audit and Fixes Following the first pass above, a second round audited Espalier/Decomplex -output against the `gems/lineage/docs/agents/cross-lang-support.md` +output against the `gems/gigasail/docs/agents/cross-lang-support.md` mini-corpus repositories (already on disk, real production code, not synthetic fixtures) across all eight of those languages plus a systemic Decomplex issue. Every language produced at least one real, verifiable bug diff --git a/gems/lineage/docs/agents/lsp.md b/gems/gigasail/docs/agents/lsp.md similarity index 62% rename from gems/lineage/docs/agents/lsp.md rename to gems/gigasail/docs/agents/lsp.md index ede56b6b0..1064f0b29 100644 --- a/gems/lineage/docs/agents/lsp.md +++ b/gems/gigasail/docs/agents/lsp.md @@ -1,14 +1,14 @@ -# Lineage LSP: Real-Time Risk Context in the Editor +# Gigasail LSP: Real-Time Risk Context in the Editor -This document outlines the design for the `Lineage` Language Server Protocol (LSP) implementation. The goal is to surface deep historical and operational risk (churn, bugs, missing sanitizer coverage) directly in the developer’s editor via gutters, hovers, and diagnostics. +This document outlines the design for the `Gigasail` Language Server Protocol (LSP) implementation. The goal is to surface deep historical and operational risk (churn, bugs, missing sanitizer coverage) directly in the developer’s editor via gutters, hovers, and diagnostics. ## 1. The Strategy: "Write with History" -To be effective, risk awareness cannot be relegated to a CI step or a separate dashboard. It must be visible at the moment the code is being changed. By implementing an LSP, `Lineage` becomes a continuous background "Safety Copilot" for VS Code, Neovim, Emacs, and IntelliJ. +To be effective, risk awareness cannot be relegated to a CI step or a separate dashboard. It must be visible at the moment the code is being changed. By implementing an LSP, `Gigasail` becomes a continuous background "Safety Copilot" for VS Code, Neovim, Emacs, and IntelliJ. ## 2. LSP Feature Implementation -The `lineage` Rust crate will implement a fast, non-blocking LSP using the `tower-lsp` ecosystem. +The `gigasail` Rust crate will implement a fast, non-blocking LSP using the `tower-lsp` ecosystem. ### A. Diagnostics (`textDocument/publishDiagnostics`) - **What:** Surfaces "Integrity Gaps" (e.g., Unverified Atomics from `SlopCop`, High-Risk Churn from `Boobytrap`). @@ -31,38 +31,38 @@ The `lineage` Rust crate will implement a fast, non-blocking LSP using the `towe The LSP specification does not yet natively support injecting visual icons into the line-number gutter. To achieve the "Red Bolt" (Unverified Atomic) or "Green Shield" (Hard-Gated) experience: -- **VS Code Extension (TypeScript Wrapper):** We will provide a thin extension that launches the Rust binary and listens for custom LSP notifications (`lineage/gutterUpdate`). It uses VS Code’s `DecorationOptions` API to draw the glyphs. +- **VS Code Extension (TypeScript Wrapper):** We will provide a thin extension that launches the Rust binary and listens for custom LSP notifications (`gigasail/gutterUpdate`). It uses VS Code’s `DecorationOptions` API to draw the glyphs. - **Neovim (Lua Wrapper):** A simple Lua plugin that maps the same custom notifications to Neovim `signs` in the sign column. ## 4. Architecture: The Universal Agent -The `lineage` binary will support three execution modes to ensure maximum code reuse: -1. `lineage build` / `lineage ingest` (CLI data pipelines). -2. `lineage ui` (Local Axum-based web dashboard). -3. `lineage lsp` (Background process communicating via `stdio`). +The `gigasail` binary will support three execution modes to ensure maximum code reuse: +1. `giga build` / `giga ingest` (CLI data pipelines). +2. `giga ui` (Local Axum-based web dashboard). +3. `giga lsp` (Background process communicating via `stdio`). ## 5. Implementation Roadmap - **Phase 1: LSP Scaffold.** Implement `tower-lsp` and bind to the `Storage` SQLite queries. - **Phase 2: Standard Features.** Implement Hover and Diagnostics using existing `LogicalUnit` facts. - **Phase 3: CodeLens.** Surface aggregate risk scores above functions. -- **Phase 4: Editor Wrappers.** Build the VS Code and Neovim plugins to render the custom `lineage/gutterUpdate` messages. +- **Phase 4: Editor Wrappers.** Build the VS Code and Neovim plugins to render the custom `gigasail/gutterUpdate` messages. ## 6. MVP Implementation Status -The Rust crate now exposes `lineage lsp --repo . --db lineage.db` as a stdio LSP server. It deliberately reuses the same Lineage annotation query path as the HTML UI so editor gutters and the browser view do not drift. +The Rust crate now exposes `giga lsp --repo . --db gigasail.db` as a stdio LSP server. It deliberately reuses the same Gigasail annotation query path as the HTML UI so editor gutters and the browser view do not drift. Implemented: - `textDocument/publishDiagnostics` for uncovered dark arms and open systems hazards. - `textDocument/hover` with logical-unit risk, bugfix/change counts, test evidence, line hits, hazards, and dark-arm details. - `textDocument/codeLens` with unit-level risk/test summaries above tracked units. -- Custom `lineage/gutterUpdate` notifications containing covered-line, mutant-tested, hazard, and dark-arm gutter items. +- Custom `gigasail/gutterUpdate` notifications containing covered-line, mutant-tested, hazard, and dark-arm gutter items. Not yet implemented: -- VS Code extension wrapper that maps `lineage/gutterUpdate` to `DecorationOptions`. -- Neovim Lua wrapper that maps `lineage/gutterUpdate` to signs/extmarks. +- VS Code extension wrapper that maps `gigasail/gutterUpdate` to `DecorationOptions`. +- Neovim Lua wrapper that maps `gigasail/gutterUpdate` to signs/extmarks. - Pull diagnostics via `textDocument/diagnostic`; the MVP publishes diagnostics on open/change/save because that works broadly across current clients. ## 7. Strategic Impact -The LSP bridges the gap between the "Architect" (who reads the markdown reports) and the "Developer" (who is writing the code). By embedding the `Lineage` signal directly into the editor, the toolchain becomes a daily dependency that actively prevents regressions before they are committed. +The LSP bridges the gap between the "Architect" (who reads the markdown reports) and the "Developer" (who is writing the code). By embedding the `Gigasail` signal directly into the editor, the toolchain becomes a daily dependency that actively prevents regressions before they are committed. diff --git a/gems/lineage/docs/agents/mcp.md b/gems/gigasail/docs/agents/mcp.md similarity index 86% rename from gems/lineage/docs/agents/mcp.md rename to gems/gigasail/docs/agents/mcp.md index ada1a669c..86d2ed46a 100644 --- a/gems/lineage/docs/agents/mcp.md +++ b/gems/gigasail/docs/agents/mcp.md @@ -1,15 +1,15 @@ # MCP server -`lineage mcp` exposes `lineage.db` - and, for a bounded set of static facts, +`giga mcp` exposes `gigasail.db` - and, for a bounded set of static facts, live disk content - to LLM coding agents over the Model Context Protocol. Runs as a Rust subcommand (`src/ui/mcp.rs`) over the official [`rmcp`][rmcp] -SDK, alongside `lineage lsp` and `lineage ui`. +SDK, alongside `giga lsp` and `giga ui`. [rmcp]: https://crates.io/crates/rmcp ## Why 5 tools, not 17 tables -`lineage.db` has 17 tables. A tool-per-table MCP surface would mean an agent +`gigasail.db` has 17 tables. A tool-per-table MCP surface would mean an agent choosing among 17+ near-identical CRUD tools on every turn, which measurably degrades tool-selection accuracy. Instead, each tool answers one workflow question a coding agent asks before or during an edit, composing whichever @@ -17,11 +17,11 @@ tables that question needs: | Tool | Question it answers | Tables / source it reads | |---|---|---| -| `lineage_file_risk` | Should I be careful in this file/directory? | `logical_units`, `events`, `unit_hazards` | -| `lineage_unit_context` | Give me everything about this function before I touch it | `logical_units`, `events`, `unit_hazards`, `unit_hotness`, `sarif_findings` - or, live disk + in-process hazard scan (see below) | -| `lineage_verification_gaps` | Is this trustworthy, specifically why not? | `unit_hazards`, `current_sarif_findings` - or, live scan | -| `lineage_change_history` | How fragile has this area been? | `events`, `crash_events` | -| `lineage_find_definition` | Where is this defined? | `logical_units`, `events`, `engine_state` | +| `giga_file_risk` | Should I be careful in this file/directory? | `logical_units`, `events`, `unit_hazards` | +| `giga_unit_context` | Give me everything about this function before I touch it | `logical_units`, `events`, `unit_hazards`, `unit_hotness`, `sarif_findings` - or, live disk + in-process hazard scan (see below) | +| `giga_verification_gaps` | Is this trustworthy, specifically why not? | `unit_hazards`, `current_sarif_findings` - or, live scan | +| `giga_change_history` | How fragile has this area been? | `events`, `crash_events` | +| `giga_find_definition` | Where is this defined? | `logical_units`, `events`, `engine_state` | Query logic is reused, not reimplemented: three tools call the exact typed `Storage` methods the UI/LSP already use @@ -36,11 +36,11 @@ small direct query for a shape with no existing UI/LSP equivalent ## Running it ```bash -lineage mcp --db lineage.db --repo . +giga mcp --db gigasail.db --repo . ``` Point an MCP-capable client's stdio server config at that command. See -`gems/lineage/test/mcp_server_test.rb` for a worked example driving it over +`gems/gigasail/test/mcp_server_test.rb` for a worked example driving it over the real protocol, including the uncommitted-changes and DB-less cases below. @@ -60,16 +60,16 @@ modes on purpose: definition). Good for glanceable awareness while editing. - **MCP**: active, on-demand, agent-invoked. Good for deliberate investigation before an edit, including cross-file questions - (`lineage_change_history`, directory-scoped `lineage_file_risk`) that have + (`giga_change_history`, directory-scoped `giga_file_risk`) that have no natural per-line LSP representation. ## Uncommitted and added-but-not-committed changes -**The question:** given a `lineage.db` already built from committed +**The question:** given a `gigasail.db` already built from committed history, what's the most effective way to serve accurate results for a file with uncommitted edits or a new, not-yet-committed file? -**Does Lineage already do this? No - not as a general mechanism.** Two +**Does Gigasail already do this? No - not as a general mechanism.** Two narrow, pre-existing seams touch the problem, and neither solves it: - `read_source` (`src/ui/ui.rs`) already reads live disk content when no @@ -90,14 +90,14 @@ working-tree or index capability, and `LineageEngine::run_inner` requires a real `VcsProvider::list_commits()` walk - there is no pseudo-commit or working-tree injection path into the incremental engine. -**What was implemented:** `lineage_unit_context` and -`lineage_verification_gaps` now detect a dirty file via `git2`'s working-tree +**What was implemented:** `giga_unit_context` and +`giga_verification_gaps` now detect a dirty file via `git2`'s working-tree status (`Repository::status_file`, checking `WT_NEW`/`INDEX_NEW` for added-but-not-committed and `WT_MODIFIED`/`INDEX_MODIFIED` for uncommitted edits to a tracked file - not `GitProvider`, which stays deliberately committed-history-only; this is a new, narrowly-scoped use of `git2` directly in `mcp.rs`). When the target file is dirty, the response gains a -`dirty` field and, for the languages Lineage already hazard-scans +`dirty` field and, for the languages Gigasail already hazard-scans in-process, a separate `live_hazards` field: ```json @@ -113,13 +113,13 @@ in-process, a separate `live_hazards` field: in, so a caller can always see both what the database currently believes and what's actually on disk right now, and reconcile deliberately - consistent with this session's "no silent truncation/substitution" pattern -elsewhere in Lineage's tooling. +elsewhere in Gigasail's tooling. **Why this is cheap:** `hazard.rs`'s hazard scanner (`hazard::scan_rust_sites`, `scan_go_sites`, `scan_zig_sites`, `scan_c_sites`, `scan_cpp_sites`, `scan_csharp_sites`) already runs entirely in-process - tree-sitter parse + `.scm` query match against an in-memory string, no -subprocess, no filesystem writes. `lineage ingest-hazards` already calls +subprocess, no filesystem writes. `giga ingest-hazards` already calls these same functions against corpus files; the MCP server just points them at a single file's live disk content instead. Reusing them directly (made `pub(crate)` for this) means the live-rescan path is the *same* hazard @@ -136,10 +136,10 @@ degraded: exactly what the last build recorded, and the response does not pretend otherwise. - **Dynamic-language hazards (Ruby/Python/JS/TS/Java/Kotlin/Swift/Lua/PHP) - have no in-process scanner in Lineage's own binary** - those hazard + have no in-process scanner in Gigasail's own binary** - those hazard queries only exist in `fact-mine`'s `.scm` files and are invoked via `ingest-hazards`'s corresponding provider running against a full corpus - walk, not a single-file call Lineage can make itself. Live-rescanning + walk, not a single-file call Gigasail can make itself. Live-rescanning these would need a `fact-mine-rust` subprocess call (see DB-less mode below for the cost of that path) - not implemented for the dirty-file case, since it would add subprocess latency to every `unit_context` call @@ -170,13 +170,13 @@ not spot-checked. ## DB-less mode -**The question:** can MCP be useful with *no* `lineage.db` at all - on +**The question:** can MCP be useful with *no* `gigasail.db` at all - on demand fact-mining per request, cached and invalidated? -`--db` is optional. Without it, `lineage mcp --repo .` starts in DB-less +`--db` is optional. Without it, `giga mcp --repo .` starts in DB-less mode: -- `lineage_unit_context` and `lineage_verification_gaps` degrade to +- `giga_unit_context` and `giga_verification_gaps` degrade to structure-plus-live-hazards: unit boundaries come from `HeuristicExtractor::extract_units` (`src/db/extract.rs`) - already fully git-decoupled, operating on an in-memory `BlobFile { path, contents }`, @@ -186,11 +186,11 @@ mode: mutation, or hotness data exists without a database, and the response says so via its `note` field rather than returning empty arrays that look like "verified clean." -- `lineage_file_risk`, `lineage_change_history`, and - `lineage_find_definition` are fundamentally database-shaped questions +- `giga_file_risk`, `giga_change_history`, and + `giga_find_definition` are fundamentally database-shaped questions (aggregate risk across a corpus, commit history, rename-stable identity across renames) with no live-recomputation equivalent. They fail with a - clear `isError` message ("requires a lineage.db; server was started + clear `isError` message ("requires a gigasail.db; server was started without --db") rather than crashing or returning misleading partial data. **The cost tiering that shapes what's feasible here** (measured this @@ -217,7 +217,7 @@ implemented - there is nothing to cache until a tool needs tier two): container/CI filesystems that don't preserve it; content hashing is the same invalidation primitive `annotate_sarif_freshness` already uses (byte-compare against blob content) rather than a new one. -- Cache location: `/.lineage-mcp-cache/.json`, one file per +- Cache location: `/.gigasail-mcp-cache/.json`, one file per distinct `(fact-mine flags, file content)` pair (the flags matter because `syntax-facts` supports scoped extraction; a full-mode cache entry cannot serve a narrower request cheaply without re-filtering, so it is keyed @@ -242,9 +242,9 @@ implemented - there is nothing to cache until a tool needs tier two): - **No `lineage_architecture_neighborhood` tool.** The architecture graph (`architecture_*` tables) needs an Espalier ingestion run, which is Ruby- focused; there was no architecture data to validate this tool against for - Lineage's own (Rust) codebase, so it was cut from the MVP rather than + Gigasail's own (Rust) codebase, so it was cut from the MVP rather than shipped unvalidated. -- **`lineage_verification_gaps` on a directory prefix returns raw active- +- **`giga_verification_gaps` on a directory prefix returns raw active- hazard counts, not the verified/unverified evidence join** a single-file lookup gets via `apply_hazards.sql`. Exact-file lookups get full fidelity; directory-prefix lookups trade fidelity for coverage. Noted in the tool's @@ -260,24 +260,24 @@ implemented - there is nothing to cache until a tool needs tier two): fact-mine output) is designed but not implemented** (see "DB-less mode" above) - no tool currently needs it. - **Skill guidance for *when* to call these tools does not exist yet** - - this MVP is the server; the calling convention (call `lineage_unit_context` - before editing unfamiliar code, `lineage_verification_gaps` before + this MVP is the server; the calling convention (call `giga_unit_context` + before editing unfamiliar code, `giga_verification_gaps` before trusting a coverage number, etc.) needs to live in a SKILL.md, not here. ## Findings from dogfooding -Validated against a real `lineage.db` built for Lineage's own repository - +Validated against a real `gigasail.db` built for Gigasail's own repository - 300 commits of real git history, real `cargo llvm-cov` coverage, and a real `ingest-hazards --provider rust` scan - not synthetic fixtures. Building that corpus is itself a finding: 300 commits took ~30s, but a full coverage run took over a minute and produced 2.1GB of `llvm-cov-target` build cache. "Point the MCP server at your repo" is not a zero-setup -story; populating a genuinely useful `lineage.db` needs the same +story; populating a genuinely useful `gigasail.db` needs the same build/coverage/hazard pipeline CI already runs, scheduled or cached, not run ad hoc per investigation. **The tool is real, not a toy - it caught something true in code written -minutes earlier.** `lineage_verification_gaps` on `src/ui/lsp.rs` flagged +minutes earlier.** `giga_verification_gaps` on `src/ui/lsp.rs` flagged the `documents: Arc>>` field added for this session's go-to-definition fix as `rust_loom_concurrency`, evidence `concurrency`, currently unaddressed - a real, non-obvious signal, not a @@ -285,21 +285,21 @@ hallucination or a stale finding, produced by a plain tool call with no manual review. **One call surfaced a real prioritization signal with zero custom -analysis.** `lineage_file_risk("gems/lineage/src/ui/")` showed the HTTP +analysis.** `giga_file_risk("gems/gigasail/src/ui/")` showed the HTTP controllers (`architecture.rs`, `index.rs`, `source.rs`: 8.6-20.8% coverage) sitting far below the core logic they call into (`ui.rs`, `lsp.rs`: 72.8-86.8%) - a genuine "review this next" candidate a human would otherwise have to notice by eyeballing multiple files. -**Rename-stable identity - Lineage's actual core value proposition - -survives the MCP layer intact.** `lineage_unit_context` on a moved +**Rename-stable identity - Gigasail's actual core value proposition - +survives the MCP layer intact.** `giga_unit_context` on a moved function (`apply_espalier_effect_spans`) correctly reported one continuous history (`CHANGE` + `MOVE`) and the unit's current post-move span, not two disconnected identities. This wasn't a given; it would have been easy for a hand-rolled MVP query to silently break that guarantee. **Dogfooding found and fixed a real usability gap in the original Ruby -MVP.** Both `lineage_unit_context` and `lineage_verification_gaps` +MVP.** Both `giga_unit_context` and `giga_verification_gaps` originally dropped the hazard's actual source line (`unit_hazards.source`, exposed as `snippet`) even though the reused query already carried it - every hazard result forced a redundant file read just to see what was @@ -313,7 +313,7 @@ LSP client, no editor in the loop at all - useful specifically for an agent working through file tools alone, which is the majority of current coding-agent deployments. -**Known limitation surfaced, then fixed.** `lineage_file_risk`'s +**Known limitation surfaced, then fixed.** `giga_file_risk`'s `avg_line_coverage`/`avg_mutant_coverage` were unweighted averages across a path's units, so a 3-line getter and a 200-line function counted equally. Didn't matter for the finding above (8-20% vs 72-86% is stark either way), @@ -378,7 +378,7 @@ protocol/design change. **Adding a new dependency (`rmcp`) required freeing disk first.** The dev VM was at 99% disk (1.6G free) when this work started, mostly gitignored -Rust build cache (`gems/lineage/target`, 3.4-4.2G, safely removable via +Rust build cache (`gems/gigasail/target`, 3.4-4.2G, safely removable via `cargo clean` - confirmed gitignored before deleting) plus several gigabytes of other sessions' scratch output under `/tmp` and this repo's own `tmp/` (left untouched - not confirmed safe to delete, unlike the diff --git a/gems/lineage/docs/agents/plugins.md b/gems/gigasail/docs/agents/plugins.md similarity index 87% rename from gems/lineage/docs/agents/plugins.md rename to gems/gigasail/docs/agents/plugins.md index 48fa00104..0cbc10dd7 100644 --- a/gems/lineage/docs/agents/plugins.md +++ b/gems/gigasail/docs/agents/plugins.md @@ -1,10 +1,10 @@ -# Lineage Plugin System Findings +# Gigasail Plugin System Findings ## Verdict Build a plugin system, but keep the first version deliberately boring: plugins should be external adapters that parse provider-specific inputs -and emit a stable Lineage ingest envelope. Lineage core should still own +and emit a stable Gigasail ingest envelope. Gigasail core should still own commit validation, source verification, line-to-logical-unit mapping, transactions, and database writes. @@ -12,14 +12,14 @@ Do not start with in-process Lua plugins or dynamically loaded Rust plugins. That is overkill right now. Dynamic Rust plugins create ABI, versioning, build, and safety problems; embedded Lua adds another runtime and makes database writes harder to audit. The useful boundary is -not "run arbitrary code inside Lineage"; it is "let arbitrary tools -normalize side-inputs into a contract Lineage can verify." +not "run arbitrary code inside Gigasail"; it is "let arbitrary tools +normalize side-inputs into a contract Gigasail can verify." The right architecture is: 1. Built-in Rust adapters for first-party/common providers. 2. External executable plugins for everything else. -3. A stable JSON ingest envelope between the plugin and Lineage. +3. A stable JSON ingest envelope between the plugin and Gigasail. 4. A generic `plugin_events` table for provider-specific payloads, plus promotion into first-class tables when a provider becomes common. @@ -43,12 +43,12 @@ need to map into: - optional provider-specific details Stack traces are another version of the same problem. The provider -parses a format, but Lineage must verify the commit and source lines +parses a format, but Gigasail must verify the commit and source lines before trusting it. So yes, a plugin system is worth it. The overkill part would be giving plugins direct database authority or loading arbitrary code into the -Lineage process. +Gigasail process. ## Ownership Boundary @@ -61,7 +61,7 @@ Plugins should own: - classifying provider-specific meaning such as `unit`, `integration`, `fuzz`, `transpile`, `runtime`, or `mutant` -Lineage core should own: +Gigasail core should own: - verifying the commit exists in `metadata` - reading source at that commit @@ -81,15 +81,15 @@ verification. Use an external process interface: ```sh -lineage ingest-plugin \ - --db lineage.db \ +giga ingest-plugin \ + --db gigasail.db \ --repo . \ - --plugin ./tools/lineage-mutant-plugin \ + --plugin ./tools/gigasail-mutant-plugin \ --input mutant-results.json \ --commit "$GITHUB_SHA" ``` -Lineage invokes the plugin with a small JSON request on stdin: +Gigasail invokes the plugin with a small JSON request on stdin: ```json { @@ -258,12 +258,12 @@ specific providers: - one-off migration/backfill scripts External plugins can be written in Rust, Lua, Ruby, Python, JavaScript, -or anything else. Lineage should not care as long as stdout is valid +or anything else. Gigasail should not care as long as stdout is valid JSON. ## Lua and Rust Assessment -Lua embedded in Lineage is not worth it for the MVP. It makes sense only +Lua embedded in Gigasail is not worth it for the MVP. It makes sense only if we need lightweight user-defined transformations after the ingest envelope is stable. @@ -277,7 +277,7 @@ external JSON contract has proven insufficient. ## Minimal Implementation Plan -1. Add `lineage ingest-plugin --plugin CMD --input PATH`. +1. Add `giga ingest-plugin --plugin CMD --input PATH`. 2. Define `lineage_plugin_api: 1` request/response JSON. 3. Add `plugin_events`. 4. Route `quality_metric` records into `quality_events` plus @@ -292,18 +292,18 @@ external JSON contract has proven insufficient. ## Recommendation Do the plugin system, but keep it as a normalized ingest adapter layer. -Do not give plugins direct database writes yet. This will let Lineage add +Do not give plugins direct database writes yet. This will let Gigasail add Mutant, richer Codecov/GitHub artifact classification, Sentry-like providers, and language-specific quality tools without turning the core engine into a pile of provider-specific parsers. ## Future Language Test Matrix -To verify the generalization of the toolchain (Lineage, Boobytrap, SlopCop, Decomplex, Nil-Kill, Espalier), the following repositories are identified as "Gold Standard" test targets for tomorrow’s fire drill and future language support verification. These repos are in the 10k–25k LOC range with high-alpha logic and rigorous test suites. +To verify the generalization of the toolchain (Gigasail, Boobytrap, SlopCop, Decomplex, Nil-Kill, Espalier), the following repositories are identified as "Gold Standard" test targets for tomorrow’s fire drill and future language support verification. These repos are in the 10k–25k LOC range with high-alpha logic and rigorous test suites. | Language | Repository | Approx. LOC | Test Target Focus | | :--- | :--- | :--- | :--- | -| **C** | **[libuv](https://github.com/libuv/libuv)** | ~25,000 | **Lineage:** Rename stability and deep temporal risk tracking. | +| **C** | **[libuv](https://github.com/libuv/libuv)** | ~25,000 | **Gigasail:** Rename stability and deep temporal risk tracking. | | **C++** | **[Google Test](https://github.com/google/googletest)** | ~20,000 | **SlopCop:** TSan/Loom gap detection in self-hosted test logic. | | **C#** | **[Polly](https://github.com/App-vNext/Polly)** | ~15,000 | **VOPR:** Resiliency and retry-loop simulation coverage. | | **Java** | **[Gson](https://github.com/google/gson)** | ~15,000 | **Nil-kill:** Complex reflection and nullability edge cases. | @@ -312,7 +312,7 @@ To verify the generalization of the toolchain (Lineage, Boobytrap, SlopCop, Deco | **Lua** | **[Lapis](https://github.com/leafo/lapis)** | ~15,000 | **Universal Syntax:** Testing Tree-sitter normalization boundaries. | ### Fire Drill Protocol -1. **Lineage Build:** Run `lineage build` to verify performance and logical identity stability. +1. **Gigasail Build:** Run `giga build` to verify performance and logical identity stability. 2. **Decomplex Audit:** Run `decomplex report` to verify "Decision Pressure" and "Root Cause" accuracy. 3. **SlopCop Check:** Verify "Constraint-Aware Coverage" (e.g., Go race-detector/Zig Loom gaps). 4. **Nil-Kill Inference:** Run Nil-Kill to verify SMT solver consistency across language-specific type systems. diff --git a/gems/lineage/docs/agents/profiling-data-integration.md b/gems/gigasail/docs/agents/profiling-data-integration.md similarity index 93% rename from gems/lineage/docs/agents/profiling-data-integration.md rename to gems/gigasail/docs/agents/profiling-data-integration.md index 664c6219e..5980e2a72 100644 --- a/gems/lineage/docs/agents/profiling-data-integration.md +++ b/gems/gigasail/docs/agents/profiling-data-integration.md @@ -1,15 +1,15 @@ # Profiling-data integration (profile-hotness/v1) -How runtime profiles become path-attributed hotness in `lineage.db`, per +How runtime profiles become path-attributed hotness in `gigasail.db`, per language, and what is known not to work. ## Pipeline ``` -profiler capture -> tools/pprof_to_hotness.rb -> lineage ingest-hotness -> UI +profiler capture -> tools/pprof_to_hotness.rb -> giga ingest-hotness -> UI ``` -`ruby tools/profile_hotness.rb --target NAME [--ingest --db lineage.db]` +`ruby tools/profile_hotness.rb --target NAME [--ingest --db gigasail.db]` packages capture + convert + ingest per sub-project of this repository; `--list` shows targets. @@ -45,7 +45,7 @@ tier across sources. `--path-prefix` drops harness/vendor frames. Profiler frames without a usable path are resolved by `ingest-hotness` against the logical-unit inventory already in the database (no parsing - -run `lineage build` first). Tiers, recorded in `unit_hotness.resolution`: +run `giga build` first). Tiers, recorded in `unit_hotness.resolution`: 1. `exact` / `declared` - the profile's own repo-relative path. 2. `basename` - DWARF basename corroborated against a unique project path diff --git a/gems/lineage/docs/agents/scm_migration.md b/gems/gigasail/docs/agents/scm_migration.md similarity index 86% rename from gems/lineage/docs/agents/scm_migration.md rename to gems/gigasail/docs/agents/scm_migration.md index 059008b1b..beaf341ed 100644 --- a/gems/lineage/docs/agents/scm_migration.md +++ b/gems/gigasail/docs/agents/scm_migration.md @@ -1,6 +1,6 @@ # Epic: Migrate to Declarative Tree-Sitter Query Files (.scm) -This epic covers migrating Lineage's language-specific AST parsing and hazard-scanning heuristics to declarative Tree-Sitter query (`.scm`) files. This removes the complex, fragile, and hard-to-maintain language-specific Rust logic in [extract.rs](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs) and [hazard.rs](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs). +This epic covers migrating Gigasail's language-specific AST parsing and hazard-scanning heuristics to declarative Tree-Sitter query (`.scm`) files. This removes the complex, fragile, and hard-to-maintain language-specific Rust logic in [extract.rs](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs) and [hazard.rs](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs). --- @@ -19,7 +19,7 @@ Captures match `@hazard.`, which maps directly to the hazard event * `@hazard.go_race_goroutine` * `@hazard.rust_unsafe_block` * `@hazard.zig_unsafe_memory` -* (and all other hazards defined in [hazard.rs](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs)). +* (and all other hazards defined in [hazard.rs](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs)). --- @@ -156,6 +156,6 @@ mod tests { ## 4. Implementation Steps 1. **Define Embedded Assets / Direct Paths**: Embed `.scm` query assets into the binary or establish a configuration path (e.g. `queries/`). -2. **Rewrite [extract.rs](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs)**: Re-route `tree_sitter_candidates` to call the new agnostic query runner. -3. **Rewrite [hazard.rs](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs)**: Map the hazard scanning functions to the language-agnostic hazard query runner. -4. **Validate Tests & Coverage**: Ensure all existing tests in `gems/lineage/` pass and check that Rust's test coverage in `src/db/` remains above 95%. +2. **Rewrite [extract.rs](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs)**: Re-route `tree_sitter_candidates` to call the new agnostic query runner. +3. **Rewrite [hazard.rs](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs)**: Map the hazard scanning functions to the language-agnostic hazard query runner. +4. **Validate Tests & Coverage**: Ensure all existing tests in `gems/gigasail/` pass and check that Rust's test coverage in `src/db/` remains above 95%. diff --git a/gems/lineage/docs/agents/sql.md b/gems/gigasail/docs/agents/sql.md similarity index 93% rename from gems/lineage/docs/agents/sql.md rename to gems/gigasail/docs/agents/sql.md index 412b64c7a..a5410e789 100644 --- a/gems/lineage/docs/agents/sql.md +++ b/gems/gigasail/docs/agents/sql.md @@ -2,7 +2,7 @@ ## Status -Proposed architecture. This document does not require moving Lineage's current +Proposed architecture. This document does not require moving Gigasail's current inline SQL immediately. New and materially changed queries should follow this layout, and existing queries can migrate incrementally. @@ -32,7 +32,7 @@ of complete query files. Values remain bound parameters. ## Proposed Layout ```text -gems/lineage/sql/ +gems/gigasail/sql/ schema/ 001_core.sql 002_architecture.sql @@ -73,7 +73,7 @@ Each query should begin with machine-readable comments: ``` The metadata may later move to a sidecar manifest, but the query ID must remain -stable. Lineage events, hazards, plans, and tests should refer to the ID rather +stable. Gigasail events, hazards, plans, and tests should refer to the ID rather than a Rust function name. ## Independent Query Tests @@ -120,7 +120,7 @@ have a column-contract test. ### 3. Repository workflow tests -Exercise query results through the public Lineage API and UI. These tests cover +Exercise query results through the public Gigasail API and UI. These tests cover scope, artifact freshness, logical-unit reconciliation, pagination, and HTML or JSON presentation. They should not be the first place where NULL or JOIN semantics are tested. @@ -215,7 +215,7 @@ duplicate matching right rows when cardinality is not proven one-to-one Test cases declare which fixture rows and expected outcomes they cover. The SQL test runner records query ID, hazard ID, fixture witness IDs, and assertion -results. Lineage can then distinguish: +results. Gigasail can then distinguish: - hazard not exercised; - SQL statement executed but NULL witness absent; @@ -266,7 +266,7 @@ The same SQL corpus can exercise the sibling tools without blurring ownership: | Espalier | Project table/view/procedure dependencies, query-to-table effects, ownership boundaries, and architecture pressure. | | SlopCop | Report actionable SQL hazards and policy violations from FactMine facts. | | Boobytrap | Capture runtime query identity, observed parameter/null/cardinality shapes, latency, errors, and production witnesses without recording sensitive values. | -| Lineage | Join query identity to history, tests, hazards, runtime evidence, mutations, ownership, and artifact freshness; present review queues and source navigation. | +| Gigasail | Join query identity to history, tests, hazards, runtime evidence, mutations, ownership, and artifact freshness; present review queues and source navigation. | The corpus should contain: @@ -279,14 +279,14 @@ The corpus should contain: - expected Espalier dependency edges; - SQL test cases and mutations; - sanitized Boobytrap observation fixtures; -- Lineage import/API/UI expectations. +- Gigasail import/API/UI expectations. Each example needs a stable corpus ID so results from every tool can be joined without parsing messages. ## Dialects -Start with SQLite because Lineage already embeds it and can run fixtures without +Start with SQLite because Gigasail already embeds it and can run fixtures without external services. Keep the public fact schema dialect-neutral, but never assume all SQL semantics are identical. @@ -296,14 +296,14 @@ must declare its dialect, required extensions, and minimum engine capabilities. ## Migration Plan -1. Move the new architecture queries into `gems/lineage/sql/queries` without +1. Move the new architecture queries into `gems/gigasail/sql/queries` without changing behavior. 2. Add query IDs, parameter/result contracts, and direct fixture tests. 3. Move other high-risk multi-join and aggregation queries incrementally. 4. Add the NULL/join/cardinality fixture matrix. 5. Add a FactMine SQL fact schema and oracle corpus. 6. Implement conservative hazard rules with explicit confidence. -7. Record semantic witnesses and connect them to Lineage test exposure. +7. Record semantic witnesses and connect them to Gigasail test exposure. 8. Add constrained SQL mutation testing. 9. Expand to runtime observations and additional dialect engines. diff --git a/gems/lineage/docs/agents/stack-trace-support.md b/gems/gigasail/docs/agents/stack-trace-support.md similarity index 86% rename from gems/lineage/docs/agents/stack-trace-support.md rename to gems/gigasail/docs/agents/stack-trace-support.md index fd033f641..8c51fa2bf 100644 --- a/gems/lineage/docs/agents/stack-trace-support.md +++ b/gems/gigasail/docs/agents/stack-trace-support.md @@ -1,6 +1,6 @@ # Universal Stack Trace Support: Verification-Anchored Ingestion -This document outlines the design for ingesting external crash data (e.g., Sentry stack traces) into the `Lineage` engine. The system is designed to anchor runtime errors to specific AST units (Logical IDs) using a pluggable, two-layered adapter architecture. +This document outlines the design for ingesting external crash data (e.g., Sentry stack traces) into the `Gigasail` engine. The system is designed to anchor runtime errors to specific AST units (Logical IDs) using a pluggable, two-layered adapter architecture. ## 1. The Goal: Historical Verification @@ -53,4 +53,4 @@ CREATE TABLE crash_events ( ## 6. Strategic Impact -Adding stack trace support transforms `Lineage` from a Git parser into a **Real-World Risk Oracle**. It allows the toolchain to bridge the gap between "What the code looks like" (Structural) and "How it behaves in production" (Empirical). +Adding stack trace support transforms `Gigasail` from a Git parser into a **Real-World Risk Oracle**. It allows the toolchain to bridge the gap between "What the code looks like" (Structural) and "How it behaves in production" (Empirical). diff --git a/gems/lineage/docs/agents/tag_queries.md b/gems/gigasail/docs/agents/tag_queries.md similarity index 69% rename from gems/lineage/docs/agents/tag_queries.md rename to gems/gigasail/docs/agents/tag_queries.md index eed770b71..734f692c7 100644 --- a/gems/lineage/docs/agents/tag_queries.md +++ b/gems/gigasail/docs/agents/tag_queries.md @@ -1,13 +1,13 @@ # Refactoring Language-Specific Extraction with Tree-Sitter Tag Queries -This document outlines how the language-specific logical unit extraction and hazard scanning within `gems/lineage` can be refactored into a language-agnostic query engine using Tree-Sitter declarative query files (`.scm`). +This document outlines how the language-specific logical unit extraction and hazard scanning within `gems/gigasail` can be refactored into a language-agnostic query engine using Tree-Sitter declarative query files (`.scm`). --- ## Language-Agnostic Engine Architecture ### Is it language-agnostic by default? -- **The Core Rust Engine**: Yes. The Rust code in [extract.rs](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs) and [hazard.rs](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs) becomes entirely language-agnostic. It will only handle file loading, tree-sitter parsing, query execution, and database mapping. +- **The Core Rust Engine**: Yes. The Rust code in [extract.rs](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs) and [hazard.rs](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs) becomes entirely language-agnostic. It will only handle file loading, tree-sitter parsing, query execution, and database mapping. - **The Query Assets**: No. We must author and maintain **one query file (`.scm`) per language** (e.g., `queries/python/tags.scm`, `queries/go/tags.scm`). This is standard for Tree-Sitter integrations (used by GitHub, Sourcegraph, and Neovim) because AST node structures differ across languages. ```mermaid @@ -24,26 +24,26 @@ graph TD ## Current Language-Specific Code Locations ### 1. Logical Unit Extraction -In [extract.rs](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs), logical units are extracted by traversing the AST and matching on concrete node kinds in language-specific functions: -- [ruby_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L389) -- [python_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L423) -- [javascript_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L452) -- [typescript_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L471) -- [go_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L494) -- [c_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L521) -- [cpp_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L533) -- [csharp_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L554) -- [rust_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L586) -- [zig_candidate_for_node](file:///home/yahn/litedb/gems/lineage/src/db/extract.rs#L610) +In [extract.rs](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs), logical units are extracted by traversing the AST and matching on concrete node kinds in language-specific functions: +- [ruby_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L389) +- [python_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L423) +- [javascript_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L452) +- [typescript_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L471) +- [go_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L494) +- [c_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L521) +- [cpp_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L533) +- [csharp_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L554) +- [rust_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L586) +- [zig_candidate_for_node](file:///home/yahn/litedb/gems/gigasail/src/db/extract.rs#L610) ### 2. Hazard Scanning -In [hazard.rs](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs), hazards are detected using line-level substring or regex searches on file contents: -- [scan_zig_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L347) -- [scan_go_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L406) -- [scan_rust_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L434) -- [scan_c_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L468) -- [scan_cpp_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L499) -- [scan_csharp_sites](file:///home/yahn/litedb/gems/lineage/src/db/hazard.rs#L530) +In [hazard.rs](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs), hazards are detected using line-level substring or regex searches on file contents: +- [scan_zig_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L347) +- [scan_go_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L406) +- [scan_rust_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L434) +- [scan_c_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L468) +- [scan_cpp_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L499) +- [scan_csharp_sites](file:///home/yahn/litedb/gems/gigasail/src/db/hazard.rs#L530) --- diff --git a/gems/gigasail/docs/agents/tuning-configs.md b/gems/gigasail/docs/agents/tuning-configs.md new file mode 100644 index 000000000..97603b097 --- /dev/null +++ b/gems/gigasail/docs/agents/tuning-configs.md @@ -0,0 +1,924 @@ +# Tuning configs: review gates, metric weighting, and the review MCP surface + +Status: **design / not yet built.** This specs: the crate placement of the +review surfaces (§0 — MCP/LSP move to the CLI); a `review:` section for +`giga.yml` (metric weighting, gates, purity, test depth, tags, perf, retention); +two review-oriented MCP tools (`giga_precommit`, `giga_premerge`) and the LLM report +they return; and how the same config later retunes the `giga diff` UI. It is +deliberately **forward-compatible**: several fields (perf, tags, test depth, +class purity, per-span branch coverage) are reserved and specced now so they can +light up later without a schema break, even though only the core review path is +built first. It also ties into the artifact-pruning TODO so pruning never +deletes evidence a review still needs. **§9 is the design rationale for the MCP +surface itself** — the four ways to get an LLM to verify before "done", their +context-token costs (grounded in published agent-behavior research), why agents +miss failures even when they run the tests, and why a mis-wired harness can +launder a failure into a pass. **§10 specs a harness to *measure* the naive vs. +tool token cost + miss rate on this repo**, so the §9 claims are measured, not +asserted. + +The goal: let a project declare **which findings matter, how much, and when a +change must be blocked for human/LLM review** — once, in `giga.yml` — and have +that single declaration drive (a) the MCP review report an agent reads, (b) the +diff-UI ranking and line visibility, and (c) the CI gate. + +--- + +## 0. Architecture: where the review surfaces live + +**Decision: MCP and LSP are protocol adapters, not UI. They move to the `giga` +CLI crate; `giga-ui` becomes web-only.** Today `giga-ui` bundles three surfaces +— the axum web UI, the LSP (`tower-lsp`), and the MCP server (`rmcp`) — so +running the stdio MCP server links the entire web stack it never uses, and the +crate name mislabels LSP/MCP as "UI". + +- **`giga-core` stays a dependency-light library** (rusqlite, tree-sitter, + git2 — *zero* server deps today). It must never gain `rmcp`/`tower-lsp`/ + `axum`/`tokio`, or every embedder inherits them. The shared query layer + (`Storage`) already lives here; that is the right amount. +- **Target:** + ``` + giga-core (engine + Storage + per-line annotation layer — no server deps) + giga-ui (axum web UI only) + gigasail/giga (CLI) + ├─ giga mcp (rmcp) + ├─ giga lsp (tower-lsp) + └─ giga diff/build/sync/review/... + ``` + +### Sequencing (a real finding, not a formality) +- **MCP is self-contained** over `giga-core` (`mcp.rs` imports only + `crate::{storage,hazard,extract,model}`, all re-exported from `giga-core`). + The root crate already does `pub use giga_core::*`, so **MCP folds into the + CLI with no logic changes** — add `rmcp`+`tokio`, move the file, add a `giga + mcp` subcommand. Do this first. +- **LSP is entangled with the web UI.** `lsp.rs` depends on + `line_annotations()` and the `UiOverlays`/`UiLineAnnotation`/`UiHazard` types, + which are buried in the 11,986-line, axum-coupled `giga-ui/src/ui/ui.rs`. + That per-line annotation computation is **pure query logic, not rendering** — + the web UI, the LSP, and (partly) the MCP `giga_unit_context` tool all need + it. **Prerequisite for the LSP move: lift the annotation layer + (`line_annotations` + the `Ui*` annotation structs) out of `ui.rs` into + `giga-core`.** Then the web UI, LSP, and MCP all consume one giga-core + annotation API, and LSP folds into the CLI as cleanly as MCP. + +This ordering (MCP now → extract annotation layer → LSP) keeps each step +build-green and independently committable. + +--- + +## 1. What exists today (grounding — do not re-derive) + +Concrete types this design builds on. See `giga-core/src/diff.rs`, +`db/model.rs`, `db/hazard.rs`, `pipeline.rs`, `giga-ui/src/ui/mcp.rs`. + +- **SARIF findings** carry tiers. `SarifFindingSummary` (`diff.rs`): `tier: + Option` (1/2/3), `tier_one: bool`, `status` (`"new"`/`"resolved"`/ + `"uncompared"`/`"active"`), `rule_id`, `level`, `category`, `message`, + `tool`, `source`, `fingerprint`, `proof_boundary: Vec`, `provenance: + BTreeMap`, `start_line`/`end_line`. **`tier` is + provider-supplied** (parsed from the SARIF `properties.tier` / `risk_tier`), + not computed by giga. +- **The diff already labels findings `new` vs `resolved`** by comparing base↔head + SARIF (`diff.rs` ~800-855). "SARIFs added between two commits" == findings with + `status == "new"`. This is the entire data source for `review`. +- **Hazards are a separate model**, not SARIF. `HazardSite{path, line, + hazard_type, required_evidence, source}`. **No tier.** Their severity axis is + `required_evidence` (`loom`/`hammer`/`vopr`/…). Persisted in `unit_hazards` + with `is_active` and a verified/evidence-present join. "Uncovered hazard" == + active hazard whose `required_evidence` family has no covering evidence. +- **Coverage** is `VerificationSlices{covered_and_killed, covered, + partially_covered, not_covered, unknown}` (line counts). **Branch coverage is + measured but collapsed** into `is_partial` + a per-line `coverage_percent` + (`db/quality.rs`); the branch-arm concept surfaces separately as + `is_dark_arm` SARIF findings. There is **no first-class numeric + `branch_coverage` per unit yet.** +- **Mutation is per-unit**: `logical_units.current_mutant_cov`, + `current_mutant_killed_tests`, `current_mutant_verified_tests`, + `current_distinct_tests`; per-line via `MutationKillObservation` (presence == + killed at that line). +- **Purity has no typed field anywhere.** The only signals: espalier emits + read/write **effect edges** (`reads`/`writes` in `espalier.architecture.v1`, + already ingested and surfaced as `DiffGroup.added_state = ["read:x", + "write:y"]`), and a derived SARIF note (`espalier.function`: "read-only + function" / "impure function"). A function with **no `write:` state edges and + no side-effecting hazard** is derivably pure. +- **Risk ranking is a hardcoded formula** (`diff.rs apply_tier_one_hazards`): + `score = not_covered + 0.5·partially_covered + 2·added_complexity + + 8·tier_one_hazards`. This is the single hook the UI/CI ranking uses. +- **MCP has no diff/review tool.** The five tools (`giga_file_risk`, + `giga_unit_context`, `giga_verification_gaps`, `giga_change_history`, + `giga_find_definition`) are file/unit-scoped. Review is UI-only today. +- **Run retention already exists**: `ArtifactStoreConfig{retain_runs, + stale_run_age_seconds, compression}`. Pruning is the natural home for the + branch-aware retention this doc's reviews depend on. + +**What must be built** (called out again in §7): a `review:` config section on +`LineageConfig`; two review MCP tools; branch coverage as a numeric axis; a +derived purity signal; and swapping the hardcoded risk weights for the +configured ones. + +--- + +## 2. The review flow + +Two workflow questions, two MCP tools, one shared report builder. This matches +the existing "one tool per question, not per table" philosophy (`mcp.md`). + +### `giga_precommit` — "What did I just change that needs review?" +- **Default range: `HEAD~1..HEAD`** (or `HEAD..WORKTREE` when the tree is dirty). +- Input: `{ base?: string, head?: string }`. Omitted → the default above. +- Answers the everyday "review my last commit" question. + +### `giga_premerge` — "What does merging this branch introduce?" +- **Range: `merge-base(head, target)..head`**, i.e. every commit the branch adds + on top of where it forked. `git merge_base` already backs `default_diff_base` + (`db/git.rs`), so `giga premerge` and a `branch..master` review are the same + computation with `base = merge-base`. +- Input: `{ target?: string = "master", head?: string = current branch }`. + +Both build the same `DiffPlan` the UI uses (via `build_structured_diff`), then +run the **review evaluator** (§4) over it: collect `status == "new"` SARIF +findings + active hazards + per-unit coverage/mutation posture, apply the +`review:` config (visibility/weight/gates), and emit the report (§5). + +`review` shows the delta of *one step*; `pre-merge` shows the delta of the +*whole branch*. Nothing else differs in the *diff* — but they run **different +verification depths**: + +- **`giga_precommit` runs the fast set** — the `ci`/unit tests + coverage, quick + static analyzers. It is the tight inner-loop check an agent runs after each + commit, so it must stay seconds-fast (see `review.tests.fast`, §3g). +- **`giga_premerge` runs the exhaustive set** — the full suite, mutation, and + **benchmarks / performance tests** (`review.tests.exhaustive` + + `review.perf`, §3g/§3h). It is the gate before a branch merges, so it can + afford minutes. Only pre-merge surfaces **performance regressions**. + +The verification depth is config, not code: each tool names a test set that maps +to giga.yml profiles, so a project decides what "fast" and "exhaustive" mean. + +--- + +## 3. `review:` config schema (`giga.yml`) + +A new top-level `review:` key on `LineageConfig` (add the field; all config +structs are `#[serde(deny_unknown_fields)]`, so it must be declared). Every +subsection has reasonable defaults so an empty/absent `review:` behaves sanely +(show everything, weight by the current formula, gate only on uncovered T1 and +unverified hazards). + +```yaml +review: + # ── 3a. Per-metric visibility & ranking weight ───────────────────────────── + # Keys match a SARIF rule_id, a bare tier ("T1"/"T2"/"T3"), a hazard + # required_evidence family ("hazard:loom"), or "dark_arm". Most specific wins + # (rule_id > tier). `policy`: show | deprioritize | ignore. + metrics: + "T3": + policy: deprioritize # visible, but zero weight in ranking + gates + weight: 0.0 + "espalier.complexity": + policy: deprioritize + threshold: 0.7 # only engages when the finding's metric >= 0.7 + weight: 0.0 # below-threshold instances are dropped entirely + "test-miser.redundant": + policy: ignore # never shown, never counted, never gates + + # ── 3b. Ranking weights (replace the hardcoded risk formula) ─────────────── + # Omitted keys keep today's defaults. These feed both the diff-UI file ranking + # and the per-finding `weight` an LLM sees in the review report. + weights: + not_covered: 1.0 + partially_covered: 0.5 + added_complexity: 2.0 + tier_one_finding: 8.0 + tier_two_finding: 3.0 + tier_three_finding: 0.0 # T3 present but weightless by default + unverified_hazard: 8.0 + uncovered_mutant: 4.0 + + # ── 3c. Purity classification + per-bucket coverage requirements ─────────── + purity: + source: effects # effects (architecture write-edges) | sarif + # (espalier.function note) | off + pure: + line_coverage: 0.95 + branch_coverage: 0.75 + mutation_kill_rate: 0.80 # optional; omit ⇒ not required + stateful: + line_coverage: 1.00 + branch_coverage: 1.00 + # mutation not required for stateful by default (see §4 hazard note) + + # ── 3d. Gates: conditions that escalate a change to CRITICAL review ──────── + # Evaluated per changed unit / per finding on ADDED lines only. A gate that + # fires sets the report verdict to `critical` and lists itself in + # gates_triggered. `severity`: critical | warn. + gates: + - id: uncovered-tier1 + when: { tier: 1, on: added, coverage: uncovered } + severity: critical + + - id: unverified-hazard + when: { hazard: any, verified: false } + severity: critical + + # A hazard whose covering test ran mutation but no mutant was killed is + # unproven. Skip evidence families that legitimately don't run mutants + # (a Hammer/Loom/VOPR test asserts scheduling/invariants, not mutant kills). + - id: unkilled-hazard-mutant + when: { hazard: any, verified: true, mutant_killed: false } + unless_evidence: [hammer, loom, vopr] + severity: critical + + - id: stateful-undercovered + when: { purity: stateful } + require: { line_coverage: 1.00, branch_coverage: 1.00 } + severity: critical + + - id: pure-undercovered + when: { purity: pure } + require: { line_coverage: 0.95, branch_coverage: 0.75, mutation_kill_rate: 0.80 } + severity: critical + + # ── 3e. Report shaping ───────────────────────────────────────────────────── + report: + include_resolved: false # also surface findings this change fixed + max_findings_per_tier: 25 # cap; overflow reported as a count + group_by: tier # tier | file | unit + + # ── 3f. Retention needed by reviews (ties into the pruning TODO) ─────────── + # Evidence for any commit reachable as a review base must survive pruning. + retain: + review_window: 20 # keep evidence for the last N commits on a line + keep_branch_bases: true # never prune a merge-base a pre-merge would use + + # ── 3g. Test depth per stage (IMPLEMENTED) ───────────────────────────────── + # Each stage names giga.yml profiles whose producers run, and whether mutation + # runs. Defaults: precommit = [ci], mutation off (fast); premerge = [ci, + # analyse], mutation on. Turning mutation off at a stage forfeits the "covered + # but not killed" signal (a test that executes a line without asserting on it). + # A profile groups producers by `test_type` tag (unit/integration/fuzz), so a + # stage mixes suites without naming each producer. See §11. + tests: + precommit: { profiles: [unit], mutation: false } + premerge: { profiles: [unit, integration, fuzz], mutation: true } + + # ── 3h. Performance testing & regression (pre-merge only, for now) ───────── + # A perf producer emits per-benchmark numbers; the evaluator compares head vs + # base and flags regressions beyond tolerance. Not built now — the config and + # report field are reserved so it can light up later without a schema break. + perf: + enabled: false + regression_tolerance: 0.05 # >5% slower than base = regression + gate: warn # warn | critical when a regression is found + # producer emits { benchmark, ns_per_op, allocs } per name; base comes from + # the merge-base's stored perf artifact. + + # ── 3i. Semantic tags on functions/classes ───────────────────────────────── + # Tags let a project mark units that deserve extra scrutiny or that the diff + # summary should call out (e.g. revenue paths, security-critical entrypoints). + # Sources compose: manual annotations in source, path globs, or a SARIF rule. + # Tags feed gates (§3d `when: { tag: critical }`), ranking weight, and the + # `giga diff` summary. Reserved now; wired incrementally. + tags: + critical: + match: { paths: ["internal/auth/**", "internal/billing/**"] } + weight_bonus: 4.0 # added to a tagged unit's ranking score + gate: critical # any finding on a `critical` unit escalates + revenue: + match: { annotation: "giga:revenue" } # e.g. a `// giga:revenue` marker + # revenue-generating paths: highlighted in the summary; perf-sensitive + summary: true + +### Defaults when `review:` is absent +- `metrics`: everything `show`. +- `weights`: today's hardcoded formula (T2/T3 weightless, T1/hazard = 8). +- `purity.source: effects`; pure/stateful thresholds as above. +- `gates`: `uncovered-tier1` + `unverified-hazard` only. +- `report`: `group_by: tier`, `max_findings_per_tier: 25`. + +--- + +## 4. The review evaluator (semantics) + +Pure function over a `DiffPlan` + the `review:` config → a `ReviewReport` +(§5). No new scanning; it reads the plan the diff already produces. + +1. **Collect candidates** on added lines: `status == "new"` SARIF findings + (grouped by `tier`), active hazards (`unit_hazards.is_active`), per-unit + coverage/mutation posture. +2. **Apply metric policy** (§3a) to each finding: + - `ignore` → dropped from the report, totals, and ranking. + - `deprioritize` → kept and shown, but `weight = 0`; excluded from the tier + totals used by gates and from the file-ranking sum. The LLM is told it is + deprioritized so it can still note it without treating it as blocking. + - `threshold` → a finding whose metric value is below the threshold is + dropped entirely (as if `ignore` for that instance). +3. **Classify purity** per unit (§3c source), for **functions and classes**: + - **Function**, `effects`: **no `write:` state edge** (from the architecture + graph) **and no side-effecting active hazard** ⇒ `pure`; otherwise + `stateful`. Unknown when no architecture graph is ingested. + - **Class**, `effects`: a class is `pure` iff **none of its methods write + state** (no `write:` edge sourced from any member) **and it declares no + mutable field** — i.e. the class is derivably immutable/stateless. + Otherwise `stateful`. This reuses the same `write:` edges, rolled up over + the class's members (the architecture graph already links members to their + owner node). + - `sarif`: presence of an `espalier.function` "impure function" note ⇒ + `stateful`; "read-only function" ⇒ `pure` (class-level would need an + analogous `espalier.class` note — not emitted today; prefer `effects`). + - `off`: purity gates are skipped. + Purity gates (§3d) apply the class bucket to method-less/aggregate units and + the function bucket to individual methods. +4. **Evaluate gates** (§3d). A `when` selects units/findings; `require` checks + coverage/branch/mutation thresholds (branch coverage needs §7 item 3). Any + fired `critical` gate ⇒ `verdict = critical`; only `warn` gates ⇒ + `verdict = needs_review`; none ⇒ `pass`. +5. **Rank** files/units by the configured weights (§3b), replacing the + hardcoded formula. Deprioritized/ignored findings contribute 0. + +**Hazard/mutation nuance (the user's key case):** a hazard's proof is its +`required_evidence` family, not a mutant. `unkilled-hazard-mutant` only fires +for families that *do* run mutants; `hammer`/`loom`/`vopr` are exempt via +`unless_evidence`, because a scheduling/invariant test has no mutant to kill. + +--- + +## 5. MCP review report format + +Design principles (from how LLM review agents consume tool output): **verdict +first**, deterministic ordering, bounded size, every finding *actionable*, and +the analyzer's own limits (`proof_boundary`) surfaced so the model knows what +was *not* checked. Findings are grouped by tier and capped; overflow is a count, +never a silent truncation. Deprioritized findings are segregated so they don't +compete for the model's attention. + +```jsonc +{ + "mode": "review", // "review" | "pre-merge" + "range": { "base": "", "head": "", "commits": 3 }, + "verdict": "critical", // pass | needs_review | critical + "gates_triggered": [ + { "id": "uncovered-tier1", "severity": "critical", "count": 2, + "reason": "2 new T1 findings on uncovered added lines" }, + { "id": "stateful-undercovered", "severity": "critical", "count": 1, + "reason": "GitAnalyzer#Detect: branch coverage 0.62 < 1.00 (stateful)" } + ], + "summary": { + "added_lines": 1678, "changed_units": 42, + "findings": { "t1": 3, "t2": 12, "t3": 40, "hazards_open": 2 }, + "coverage": { "line": 0.87, "branch": 0.61, "mutation_kill": 0.55 }, + "deprioritized": 40, "ignored": 6 + }, + "findings": [ // ranked; T1 first; capped per tier + { "tier": 1, "rule_id": "espalier.nil-deref", "tool": "nil-kill", + "file": "internal/repo/git.go", "line": 142, + "unit": "GitAnalyzer#Detect", "purity": "stateful", + "message": "possible nil dereference of `cmd`", + "status": "new", "weight": 8.0, + "coverage": "uncovered", "mutant": "n/a", + "proof_boundary": ["interprocedural aliasing not modeled"], + "action": "Add a test covering line 142; a new T1 on an uncovered line blocks merge." } + ], + "units_below_gate": [ + { "unit": "GitAnalyzer#Detect", "purity": "stateful", + "line_coverage": 1.00, "branch_coverage": 0.62, + "required": { "line": 1.00, "branch": 1.00 }, "gate": "stateful-undercovered" } + ], + "deprioritized": { "count": 40, "note": "T3 shown with 0 weight per giga.yml review.metrics" }, + "new_dependencies": ["GitAnalyzer#populateFileSizes"], // from the architecture graph + "new_state": ["write:count"], + + // ── reserved / depth-dependent fields (present when the data exists) ── + "tests": { "set": "fast", "passed": 128, "failed": 0, "skipped": 3 }, + "tags": [ { "unit": "internal/billing/Charge", "tag": "revenue" } ], + "perf": { // giga_premerge only + "regressions": [ + { "benchmark": "ParseLedger", "base_ns": 1200, "head_ns": 1470, + "delta": 0.225, "tolerance": 0.05, "gate": "warn" } + ] + } +} +``` + +`giga_precommit` omits `perf` (fast set, no benchmarks) and reports +`tests.set: "fast"`. `giga_premerge` includes `perf` and `tests.set: +"exhaustive"`. A field that has no data yet is simply absent — the shape is +forward-compatible, so tags/perf/tests can light up without a schema break. + +- **`verdict` + `gates_triggered`** are the first thing the agent reads: a CI + gate and an LLM instruction in one. `critical` == block. +- **`weight`** lets the agent rank its own attention identically to the UI. +- **`coverage`/`mutant`** per finding tell the agent whether the risky line is + even tested — the single most useful signal for "should I write a test". +- **`proof_boundary`** prevents false confidence: the agent knows what the + analyzer could not prove. +- **Deprioritized/ignored** are counts, not walls of text — the agent spends + tokens on what gates. + +`giga_premerge` returns the identical shape with `mode: "pre-merge"` and the +merge-base range. + +--- + +## 6. UI impact (later) + +The same `review:` config retunes `giga diff` with **zero new UI concepts** — +it only changes weights and visibility of what already renders: + +- **Ranking** (`RiskSummary.score`): the hardcoded formula becomes + `review.weights`. Files/units re-sort by the project's weighting. A + deprioritized tier contributes 0, so a T3-heavy file stops out-ranking a + T1-bearing one. +- **`policy: ignore`** — the finding is absent everywhere: not on the line + gutter, not in the per-file/tier totals (`¤×N T1×N …`), not in ranking. +- **`policy: deprioritize`** — the finding still renders on the line (dimmed) + and in a separate "deprioritized" count, but adds 0 to totals and ranking. + This is the user's "show it but give it 0 weight" mode. +- **`threshold`** — a metric finding below threshold is treated as `ignore` for + that instance (not shown, not counted). +- **Gates** surface as a banner on the `[SUMMARY]` funnel ("CRITICAL: 2 gates — + uncovered-tier1, stateful-undercovered") and a `units_below_gate` filter in + the tree, reusing the existing risk-sort machinery. +- **Tags** (§3i) render on the summary and beside tagged units (`revenue`, + `critical`), and their `weight_bonus` re-ranks. When revenue/criticality data + exists it belongs on the summary, exactly like the language/coverage funnel. +- **Performance** (when `review.perf` is on): the summary shows a perf line and + flags regressions from the pre-merge run, next to the coverage/hazard lines. +- **Partially-covered spans** — today the CLI only shows a per-line covered/ + uncovered gutter; the web UI shows the *exact partially-covered spans*. Once + branch coverage is uncollapsed (§7 item 3) the CLI diff can highlight the + same partial spans inline (the yellow `-` bar already exists per line; this + extends it to the sub-line arm ranges the web UI renders). + +Because the MCP report, the CI gate, and the UI all read the *same* evaluator +(§4), a finding the user chose not to see in the UI is also absent from the +LLM's feedback and from the merge gate — one config, one behavior everywhere. + +--- + +## 7. Data gaps to build (honest list) + +1. **`review:` config section** on `LineageConfig` + validation (weights ≥ 0, + known gate `when` keys, `unless_evidence` families). `deny_unknown_fields` + means the struct must exist before any `review:` key is accepted. +2. **Two MCP tools** (`giga_precommit`, `giga_premerge`) wrapping the §4 evaluator; + register in `tool_defs()`/`call_tool` (`mcp.rs`). Reuse `build_structured_diff`. +3. **Uncollapse branch coverage into a per-unit AND per-span axis.** Today + branch data only sets `is_partial` + a per-line `coverage_percent` + (`db/quality.rs`) — the arm detail is thrown away. Persist + `covered_branches/total_branches` per unit (for the gates) **and the + partially-covered sub-line spans** (for display). The web UI already renders + exact partial spans; storing them lets the CLI diff do the same (§6) instead + of a whole-line yellow bar. This is the one genuinely new *metric* plumbing. +4. **Derived purity signal for functions and classes.** No typed field exists. + Compute `pure` in the evaluator from data already in the plan: a function is + pure with no `write:` state edge + no side-effecting hazard; a class is pure + when no member writes state and it declares no mutable field (§4). Consider + persisting purity on `logical_units` later so the LSP/UI/summary can show it + without recomputation. +5. **Config-driven risk weights.** Replace the constants in + `apply_tier_one_hazards` with `review.weights` (fall back to today's values). +6. **Semantic tags** (§3i): a `unit_tags` store keyed by logical-unit id + (rename-stable), populated from annotations / path globs / SARIF rules; + consumed by gates, ranking `weight_bonus`, and the summary. This is where + "which functions are critical / revenue-generating" lives, and it must ride + the same rename-stable identity as hazards so a tag follows a moved function. +7. **Test-depth selection** (§3g): `giga_precommit` → `fast`, `giga_premerge` → + `exhaustive`; wire the tool to run the named profiles and fold pass/fail into + the report. The profiles already exist; this only selects and reports them. +8. **Performance harness** (§3h): a perf producer (`kind: perf`?) emitting + per-benchmark numbers, a base-vs-head comparator, and the `perf` report + block + summary line. Pre-merge only. Reserved in config now so it slots in + without a schema break. +9. **Crate move** (§0): fold MCP into the `giga` CLI now (clean); lift the + `line_annotations`/`Ui*` annotation layer from the axum-coupled `ui.rs` into + `giga-core`; then fold LSP into the CLI. `giga-ui` ends web-only. +10. **Branch-aware retention** (the `TODO.md` item). Reviews read evidence for + *both* endpoints of a range; `review.retain` (§3f) must be honored by the + pruner so a `pre-merge` never loses its merge-base's evidence. Pruning + remains safe only when an artifact is (a) sequential/superseded on the same + line, (b) not in the `review_window`, and (c) not an incremental-processing + input — exactly the TODO's three conditions. + +--- + +## 8. Relationship to the pruning TODO + +`TODO.md` asks to prune stale reports without deleting anything MCP might use. +This doc pins down *what MCP uses*: a `review`/`pre-merge` needs the SARIF, +coverage, mutation, and architecture evidence for **every commit reachable as a +review base** — the last `review_window` commits on the current line, plus any +branch merge-base. `review.retain` (§3f) is the declaration the pruner consults; +until it exists, the pruner must be conservative (keep evidence for HEAD and its +first parent at minimum, plus anything the incremental `engine_state` reads). + +--- + +## 9. Getting an LLM to actually verify before "done" — four strategies and their cost + +Computing whether a change is safe is the easy half (§4). The hard half is +getting an agent to *consult that verdict and honor it* instead of declaring +victory. Two failure modes dominate: + +- **Skip-and-assert** — the agent never runs verification and writes "all tests + pass / fully covered." +- **See-red-and-rationalize** — it runs the check, sees a FAIL/`critical`, and + reframes it as out-of-scope / pre-existing / acceptable, then ships. + +**No mechanism eliminates the second failure.** An LLM can always read a red +result and lie about it. The realistic goal is to make the honest path the +*cheap* path, make a red result *hard to reinterpret*, and keep a backstop the +agent cannot talk past. + +There is also a cost that is easy to forget: **MCP tool schemas and skill +instructions occupy context on every turn.** A tool's name + description + +parameter schema sits in the tool list whether or not it is called; a skill's +teaching text is resident whenever it is loaded. "Add a tool" is a *fixed +per-turn token tax* paid across the whole session. So the real design question +is *net* context: does a tool save more — by keeping verbose verification output +out of the window — than its schema costs by sitting in the tool list? + +### The four strategies + +| Strategy | LLM effort | Token / context cost | Reliability | Residual failure | +|---|---|---|---|---| +| **1. YOLO** (no tests, no gate) | none | ~0 | ~0% | everything ships unverified | +| **2. Tests exist, hope it runs them / rely on CI** | high, self-directed: discover the command, run it, parse a large log | **huge & variable** — a raw suite/coverage dump is 5k–50k tokens into the window; or a slow out-of-band CI round-trip | low–medium | declares done before running, or before CI finishes; CI catches it late and out of band | +| **3. Plain-English gates in AGENTS.md** (or link CI config) | medium: read prose, self-enforce | **persistent** — gate text sits in context every turn; linking CI trades that for a fetch+parse of the CI YAML | medium | advisory only; interpretation drifts ("this change is trivial, skipping") | +| **4. MCP review tool** (deterministic verdict) | one tool call; the tool runs the gate | **small fixed schema tax + a bounded verdict** (verdict-first, capped) — the verbose work happens *outside* the window | highest | can still see `critical` and lie — but it is now a single unambiguous field, and auditable | + +### Why the MCP tool wins the *net* context math + +Strategy 2's real cost is not running the tests — it is that the agent must pull +the entire test/coverage/SARIF output *into its context* to interpret it, and +that output is large and unbounded. Strategy 4 inverts this: the evaluator (§4) +runs the suite and reduces ~50k tokens of raw output to a ~500–2k-token verdict +(`verdict`, `gates_triggered`, top-N findings with actions — §5). The agent pays +a small fixed schema tax for the tool but avoids the large variable dump. **On +any non-trivial change the tool is strictly cheaper *and* more reliable.** + +This is exactly why the MCP surface must stay **small and terse**: + +- **Few tools.** Each schema is resident every turn; many near-identical tools + measurably degrade tool-selection accuracy (see `mcp.md`'s "5 tools, not 17"). + Two review tools (`giga_precommit`, `giga_premerge`) — **not** one per metric. +- **Verdict-first, bounded responses.** Never return raw logs; return the + conclusion and capped, actionable findings (§5). Overflow is a *count*, not a + wall of text. +- **Config in `giga.yml`, not in context.** The gates live in a file the *tool* + reads (§3), not in AGENTS.md prose the *agent* must keep resident. This moves + strategy 3's persistent token cost off the context budget entirely — the + English gate is paid once, on disk, by the evaluator, not on every turn. + +### Recommended layering (defense in depth, one source of truth) + +No single strategy suffices; combine them so each covers the others' failure: + +1. **MCP review tool** — the in-loop, cheap, hard-to-fake check the agent runs + after each commit (`giga_precommit`) and before merge (`giga_premerge`). Fast + feedback, bounded context. +2. **A one-line AGENTS.md pointer** — *not* the gates, just: "before declaring + done, call `giga_precommit`; a `critical` verdict blocks." A few resident tokens + telling the agent the tool exists and is mandatory. The *rules* stay in + `giga.yml`. +3. **CI runs the identical evaluator** — the non-bypassable backstop. Because CI + and the MCP tool share one evaluator and one `giga.yml`, the agent cannot + declare done past a red CI, and a human sees the same verdict the agent saw. + This is what bounds the irreducible "saw-red-and-lied" case: the lie is caught + out of band and is auditable against the same machine verdict. + +**The single-evaluator invariant is the crux:** MCP, CI, and the diff UI must +all read the same `review:` config and the same evaluator (§4). One source of +truth means the agent's in-loop check, the merge gate, and the human's view can +never disagree — so an agent that games the in-loop check still hits an +identical wall at CI. Design the surface to make the honest path the cheapest +one, and let CI make the dishonest path fail loudly. + +### Why agents miss failures *even when they run the tests* (grounded, not folklore) + +Strategy 2 fails more subtly than "the agent is lazy." Published behavior: + +- **Tool output already eats the context budget.** In a typical terminal-agent + session, tool outputs (file reads, command output, search hits) consume + ~70–80% of the window — before the model reasons at all. A raw test/coverage + dump lands on top of that. +- **Large output is silently truncated — and truncation drops the *middle*.** + A documented case: an agent ran a coverage suite, got 419 KB of output + truncated at 235 KB, tried to `grep` the results, re-ran the whole suite, and + truncated again — spending **6–9× longer understanding the output than the + tests took to run**. Truncation strategies frequently remove the *critical + error lines in the middle*, so the agent's `grep FAIL` / last-N-lines scan can + return clean while a real failure sits in the excised section. +- **Per-task token use is wildly variable** (up to ~30× on the *same* task), and + accuracy peaks at an *intermediate* token budget — dumping more raw log is not + just costly, it *lowers* success. + +So the anecdote — "they run the tests, grep for FAIL/ERROR, and miss a clear +failure because they only read the tail" — is the *normal* outcome of piping an +unbounded log through a truncating context window, not an aberration. A +verdict-first tool (§5) is the direct fix the same literature recommends +(Memory-Pointer / structured-output patterns: keep the blob out of the window, +pass a short pointer/summary). + +### The harness must actually fail (or the verdict is a lie the tool faithfully repeats) + +A verdict tool is only as honest as the exit codes it reads. **Real example in +this repo:** `clear test ` printed `MEMORY LEAKS: N` in red but its exit +was `exit(failed_names.any? ? 1 : 0)` — leaks were excluded, so the process +**exited 0 on a detected leak**. Every layer downstream inherited the lie: an +agent (or CI, or a human checking `$?`) was told the suite was clean, and an +agent that *only* scanned the tail would not even see the red `MEMORY LEAKS` +line. (Fixed: the exit now includes `leak_tests.any?`.) The lesson for the +review evaluator: **do not trust a producer's exit code alone** — key +conditions (leaks, sanitizer output, zero-assertion tests) must be asserted by +the evaluator from structured evidence, so a mis-wired harness cannot launder a +failure into a pass. This is why gates (§3d) are evaluated over ingested +facts, not over "did the test command exit 0". + +--- + +## 10. Measuring the cost: a test-behavior harness + +Before committing to a surface, measure the thing we claim to improve: **how +many tokens an agent spends verifying a change the naive way, versus through a +`giga_precommit`/`giga_premerge` verdict.** The research above gives priors +(agentic coding uses ~3500× the tokens of single-round reasoning; output +dominates; runs vary up to ~30×) but not *our* numbers on *our* repos. + +**Harness shape (deterministic, offline-replayable):** + +1. **A fixed task set** — N real changes on this repo with known verdicts (some + clean, some with an uncovered T1, a leak, a perf regression, a resolved + finding). Include the `clear test` leak case as a regression fixture. +2. **Two arms per task, same model + same prompt seed:** + - *Naive*: the agent has only shell; it must run tests/coverage itself and + decide. Record total input+output tokens, wall-clock, and **whether it + reached the correct verdict** (caught/missed the leak, the T1, etc.). + - *Tool*: the agent has `giga_precommit`/`giga_premerge`. Record the same. +3. **Metrics to report together** (per the cost-analysis literature, cost is + only comparable when reported jointly): total tokens, input/output split, + cache-hit rate, wall-clock, and the **miss rate** (false "looks clean"). A + cheaper arm that misses failures is worse, not better — plot cost *and* + correctness. +4. **Attribute the tool's fixed tax honestly:** count the tool schema's resident + tokens (paid every turn) against the raw-log tokens it avoids, so the *net* + claim in §9 is measured, not asserted. + +**What good looks like:** the tool arm should show a large drop in output/ +context tokens (the raw-log dump is replaced by a bounded verdict) *and* a lower +miss rate (the verdict names the leak/T1 the naive tail-scan skipped). If the +tool arm is not both cheaper and more correct on this set, the surface is wrong +— shrink the response, or fix the gate, before shipping it. + +This harness also becomes a **regression guard on the MCP surface itself**: if a +future change bloats a response or drops a gate, the token/miss numbers move. + +Sources for §9–§10: +[token consumption in agentic coding](https://digitaleconomy.stanford.edu/publication/how-do-ai-agents-spend-your-money-analyzing-and-predicting-token-consumption-in-agentic-coding-tasks/), +[tools talk too much / byte caps](https://dev.to/teppana88/your-ai-coding-agents-are-slow-because-your-tools-talk-too-much-24h6), +[Codex truncates critical error lines](https://github.com/openai/codex/issues/9502), +[large tool output overflows the window](https://github.com/openai/codex/issues/4398), +[context-window overflow / Memory-Pointer pattern](https://dev.to/aws/ai-context-window-overflow-memory-pointer-fix-3akc). + +--- + +## 11. First-party tool integration: stages, tags, and coverage of the delta + +How giga.yml wires the 1p analyzers (gotest/coverage, gremlins+test-miser for +mutation, slopcop, espalier, nil-kill, decomplex) to the right stage, and how +the review reads "coverage **of the delta**" rather than "delta of coverage". + +### Stages → producers (IMPLEMENTED: config + resolution) + +`review.tests.{precommit,premerge}` names giga.yml profiles to run and whether +mutation runs. `ReviewConfig::stage_tests(mode)` resolves the run: + +- **precommit** — fast, inner-loop: unit coverage + quick static analyzers. + **No mutation by default** (it is slow, and precommit must stay seconds-fast). +- **premerge** — the gate before merge: adds integration/fuzz suites and + **mutation on by default**. Set `premerge.mutation: false` to skip it, at the + cost of the covered-but-not-killed signal (a suite can execute every changed + line yet assert nothing — only mutation catches that). + +**Mutation-requirement coupling (IMPLEMENTED).** If any gate's +`require.mutation_kill_rate` or a purity bucket's `mutation_kill_rate` is set, +`requires_mutation()` is true and `stage_tests` forces mutation **on at that +stage** — even precommit — with `mutation_forced: true` in the report. Without +this, a precommit that gates on kill rate would be permanently `critical` ("no +mutants killed") and an agent could never satisfy it. So: *don't gate on kills +at a stage where you won't run mutants* — and if you do, the runner will run +them for you rather than fail you. + +### Test-type tags (unit / integration / fuzz) — already plumbed + +The unit/integration/fuzz tag is `test_type`, which **already flows end to +end**: coverage (`coverage_line_events.test_type`), mutation, and test-exposure +ingestion all carry it, it rolls up to `logical_units.current_test_types`, and +the LSP/web-UI already render it (hover "Test types: …"). A producer tags its +artifact via `evidence_scope.test_set`; several tagged coverage/mutant files may +be produced per commit (one per suite). **To surface a new tag you only pick it +in a producer's `evidence_scope` — no new giga-core or UI code.** The cli-ui +should show the same `test_type` set the web-UI does (a small render add, listed +in §7). + +Recommended producer layout (test-miser especially): + +```yaml +profiles: + unit: { producers: [gotest-unit] } + integration: { producers: [gotest-integration] } + fuzz: { producers: [gotest-fuzz] } + analyse: { producers: [espalier-arch, espalier-sarif, decomplex, nil-kill, slopcop] } +producers: + gotest-unit: # produces coverage, evidence_scope.test_set: unit + gotest-integration: # produces coverage, evidence_scope.test_set: integration + gremlins: # produces mutants (kind: mutants) -> test-miser audit + slopcop: # produces sarif; NEEDS coverage input to gap-check (below) +``` + +test-miser consumes gremlins' `mutant-facts/v1`; giga ingests the result as +mutation exposure (kind: mutants). slopcop is coverage-gap-driven — it wants +**full** coverage to report against, so it must run *after* the coverage +producers in the same stage (profile order is honored). + +### Coverage OF the delta, not delta OF coverage + +The review measures whether the **changed lines** are covered/killed — not how +overall coverage moved. Consequences (design intent, some still to build): + +- **No historical coverage diffing is needed.** We never compare head coverage + to base coverage; we intersect head coverage with the diff's *added lines* + (this is exactly what the evaluator's `coverage_posture` already does). +- **Retention can be aggressive.** Coverage artifacts are large, so keep only + the most recent commits' coverage **per branch** (the lineage index still + holds the per-line events it needs; the bulky raw artifacts can go). This is + the branch-aware pruning in §3f / `TODO.md` — coverage is the first thing it + should reclaim. +- **A diff N commits back does not re-run anything.** If that commit's coverage + is already indexed, the review reads it from the DB. The only reason to run a + suite is that a *needed* `test_type`/stage has **no** evidence for the head + commit — then run just that suite (precommit vs premerge set), not everything. + +### Background run + re-trigger (extends the existing auto-sync) + +`giga diff` already spawns `giga sync` in the background when evidence is stale +and renders a bottom progress bar (`spawn_background_sync` / `render_sync_bar`). +Extend it for coverage/gap tools: when the head commit lacks full coverage for +the stage's `test_type` set, run the missing suites in the background, show +progress at the bottom of the cli-ui, then **re-trigger the coverage-dependent +1p metrics** (slopcop's gap check, test-miser's audit) now that their input +exists, and refresh the view. Same mechanism, driven by "which test_type has no +head evidence" instead of "no analysis run at all". + +### Build order for this section (adds to §7) + +11. **cli-ui `test_type` render** — show the tag set the web-UI/LSP already + compute (small). +12. **Stage-driven background runner** — from `stage_tests(mode)` + "missing + head evidence per test_type", run only the missing suites, then re-trigger + coverage-dependent producers (slopcop/test-miser) and refresh. +13. **Coverage retention** — keep the most recent N commits' coverage per branch + (the aggressive half of §3f); a diff further back reads indexed events, never + re-runs. + +--- + +## 12. `giga test` and the "don't reinvent Bazel" boundary + +`giga test` (IMPLEMENTED, first cut) runs the test producers a project already +declares in `giga.yml`, selected by stage and flags, then ingests the evidence: + +``` +giga test # precommit set: fast unit coverage, no mutation +giga test --premerge # premerge set: + integration/fuzz + mutation +giga test --mutants # add mutation producers even at precommit +giga test --no-cov # skip coverage-only producers +giga test --unit # only producers tagged evidence_scope.test_set: unit +giga test --changed P... # treat P... as the changed set (bypass git diff) +giga test --checks # run pre-test lint/format gates (§14); --no-checks forces off +giga test --dry-run # print the resolved producer plan, run nothing +``` + +It resolves the set from `review.tests.` + the flags (see +`application::test::resolve_producers`), builds a synthetic profile, and runs it +through the existing profile/producer + ingest machinery. Tests run against the +current checkout (a dirty tree is fine - you test your working changes). Mutation +producers are pulled in from anywhere in the config when mutation is wanted, +since they aren't tied to a stage's profile list. + +### The design boundary: an orchestrator, not a build system + +**`giga` must not reinvent Bazel.** A producer is a plain shell command - "run +these tests" - so for the vast majority of projects the whole requirement is +*"run this command when these files change,"* which the stage/tag config already +expresses. That is deliberately all `giga test` does at its core: pick the right +commands and run them, then attribute coverage/mutants to the changed lines. + +- **Mutants with kill attribution** fan out to the language runners + (`ruby_mutant.rb`, zig-mutants, ...) and to **test-miser** for the audit; + `giga` ingests the resulting `mutant-facts/v1`. `giga test` orchestrates; it + does not own the mutation engine. +- **Bazel (or any real build graph): delegate, never rebuild.** If a project + uses Bazel, they put `bazel test //...` (or a targeted query) in a producer's + `argv` and `giga` runs it like any command. The only thing `giga` would gain + from *knowing* about Bazel is a better "which targets are affected by this + diff" answer than path-glob change detection - and that is exactly the kind of + feature to **gate behind an optional Bazel (or better) dependency**, not to + reimplement. Plan, not built now: a `change_detection: paths | bazel` knob per + producer, where `bazel` shells out to `bazel query 'rdeps(...)'`; absent it, + the default stays simple path/tag matching. + +### Incremental "only what's out of date" (planned) + +The stage resolver already narrows *which* producers run. The remaining piece +(build-order §11 item 12) is to skip a producer whose `test_type`/stage evidence +for the head commit is already indexed - so a re-run of `giga test` after an +unrelated edit does nothing, and a diff N commits back reads indexed evidence +rather than re-running. This is change detection at the producer level: simple +path/tag rules by default, delegating to Bazel's affected-targets query only +when a project opts in. + +## 13. The project graph: which files trigger which tests + +`review.packages` (IMPLEMENTED) is an Nx/Turborepo-style "affected" graph. Each +package declares its `paths`, the packages it `depends_on`, its `producers`, and +optional `premerge`-only producers: + +```yaml +review: + packages: + fact-mine: { paths: [gems/fact-mine/**], producers: [fact-mine-test] } + decomplex: { paths: [gems/decomplex/**], depends_on: [fact-mine], producers: [decomplex-test] } + compiler: { paths: [compiler/ruby/**], producers: [compiler-spec, transpile], premerge: [fuzz-compiler] } +``` + +`affected_producers(changed_paths, mode)` computes the changed packages, closes +over the **reverse** dependency edges (a change to `fact-mine` pulls in every +package that transitively depends on it), and unions their producers - adding +`premerge` producers only at premerge. `giga test` diffs the stage base +(`HEAD~1` precommit, `merge-base` premerge) for the changed set, or takes +`--changed P...` to preview a specific change deterministically. With no +`packages` graph configured, `giga test` falls back to the stage's profiles. + +`depends_on` edges are the project's real dependencies (gemspec/import), not +guesses - a wrong edge either over-runs (a false dependent) or, worse, under-runs +(a missing dependent silently skips tests that should have run). `paths` globs +support a trailing `/**` for a subtree; other entries match exactly. + +### `giga affected`: the change-detection primitive (for CI) + +`giga affected --changed [--premerge] --format json` (IMPLEMENTED) runs +the same resolution as `giga test --dry-run` but prints only the affected set - +`{mode, changed, packages, producers, checks}` - and runs nothing. It is the +general "changed files + declared graph -> affected work" primitive: CI diffs the +base, pipes the paths in, and gates its job matrix on the JSON. No DB, no repo +knowledge baked in - the only project-specific input is `giga.yml`, exactly like +Nx `affected` or a Bazel query. It replaces a hand-written path classifier (e.g. +a `ci_change_scopes.rb`) with the same package graph the rest of `giga` uses. + +**Under-run safety (important, especially for Ruby).** An *unclassified* path - +one matching no package's `paths` - contributes **nothing** to the affected set. +That is the dangerous direction: in a dynamic language a change to an unmatched +file can still break seemingly unrelated code (monkey-patch, global state, +metaprogramming). A safe CI consumer must therefore treat "non-empty `changed` +but empty `producers`" as *"fall back to the full run,"* not *"run nothing"* - +one line of glue, no `giga` change. The alternative is a catch-all package (broad +`paths`, all suites as `producers`) so an unexpected change fans out conservatively. +Package-level selection is a **coverage-reducing heuristic backed by a +conservative fallback**, never a soundness proof - keep the fallback. + +## 14. Pre-test check gates (lint/format) + +`review.checks_enabled` + per-package `checks` (IMPLEMENTED) add optional +fail-fast gates that run **before** a package's producers and stop the run early +on failure - lint, format, or any custom script. They are **off by default**; +turn them on globally (`checks_enabled: true`) or per-run (`giga test --checks`, +`--no-checks` to force off). They ride the same affected-package set as producers +(§13), so a change runs only the affected packages' checks, deduplicated. + +```yaml +review: + checks_enabled: false # opt-in + packages: + compiler: { paths: [compiler/ruby/**], producers: [compiler-spec], checks: [contrib:lint:ruby] } + zig: { paths: [zig/**], producers: [zig-test], checks: [contrib:fmt:zig] } +``` + +A check ref is one of: + +- **`contrib::`** - a bundled recommended script at + `gems/gigasail/contrib//.sh` (override the dir with + `$GIGA_CONTRIB_DIR`). Shipped: `contrib:lint:ruby` (rubocop), + `contrib:lint:rust` (rustfmt --check), `contrib:fmt:zig` (zig fmt --check). + Each scopes itself to the changed files of its language and **skips cleanly** + when the tool isn't installed - a recommended gate must not become a hard dep. +- **a repo-relative script path** (`tools/my_check.rb`) - run by extension + (`.rb` -> ruby, `.sh` -> sh, else executed directly). A missing script is an + error, not a silent skip. + +Every check receives `$GIGA_CHANGED` (space-separated changed paths) so it can +lint only what changed rather than the whole tree. This is a gate, not CI: it +runs a command and reads the exit code. Anything heavier (matrices, caching, +remote execution) belongs in a producer's `argv` or a real CI system, not here. diff --git a/gems/lineage/docs/agents/ui.md b/gems/gigasail/docs/agents/ui.md similarity index 70% rename from gems/lineage/docs/agents/ui.md rename to gems/gigasail/docs/agents/ui.md index a686423c4..44cc4d3dd 100644 --- a/gems/lineage/docs/agents/ui.md +++ b/gems/gigasail/docs/agents/ui.md @@ -1,18 +1,18 @@ -# Lineage UI: Local-First Observability Portal +# Gigasail UI: Local-First Observability Portal -This document outlines the design for the `Lineage UI`, a lightweight, local-first web interface for visualizing historical risk, verification gaps, and logical-unit lineage. The UI is served directly from the `lineage` Rust binary. +This document outlines the design for the `Gigasail UI`, a lightweight, local-first web interface for visualizing historical risk, verification gaps, and logical-unit gigasail. The UI is served directly from the `gigasail` Rust binary. ## 1. Product Philosophy: "High-Alpha Safety" -The goal of the UI is to transform the "Ground Truth" stored in the `lineage.db` into a visceral, actionable experience for developers and LLMs. It moves risk from a JSON log to a visual "Heatmap" embedded directly in the source code context. +The goal of the UI is to transform the "Ground Truth" stored in the `gigasail.db` into a visceral, actionable experience for developers and LLMs. It moves risk from a JSON log to a visual "Heatmap" embedded directly in the source code context. ## 2. Architecture: Single-Binary Delivery To ensure zero-config installation and high performance, the UI follows a "Local-First" architecture. -- **Backend (Rust/Axum):** A high-performance web server embedded in the `lineage` crate. It provides JSON endpoints for file navigation and risk-querying. +- **Backend (Rust/Axum):** A high-performance web server embedded in the `gigasail` crate. It provides JSON endpoints for file navigation and risk-querying. - **Frontend (React/Monaco):** A modern, high-density dashboard. The core view uses the **Monaco Editor** (VS Code engine) to render source code with custom gutter decorations. -- **Asset Embedding:** The compiled React frontend is embedded into the Rust binary using `rust-embed`, allowing the entire portal to be served via `lineage ui --port 8080`. +- **Asset Embedding:** The compiled React frontend is embedded into the Rust binary using `rust-embed`, allowing the entire portal to be served via `giga ui --port 8080`. ## 3. Core Features @@ -45,10 +45,10 @@ A repository-wide treemap visualizing the "Integrity Gap": ## 5. Implementation Roadmap -- **Phase 1: API (150 LoC):** Implement Axum routes for `/files`, `/source/:path`, and `/lineage/:path`. +- **Phase 1: API (150 LoC):** Implement Axum routes for `/files`, `/source/:path`, and `/gigasail/:path`. - **Phase 2: Monaco Wrapper (300 LoC):** Build the React component for the code view with decoration support. -- **Phase 3: Integration (100 LoC):** Join `lineage.db` queries to the API and embed assets. +- **Phase 3: Integration (100 LoC):** Join `gigasail.db` queries to the API and embed assets. ## 6. Strategic Narrative -The Lineage UI is the "Front Window" of the suite. It is the tool you show on the v0.1 launch landing page to prove that your "Deep History" and "Systems Integrity" claims are not just theoretical—they are visible, navigable, and ready for the real world. +The Gigasail UI is the "Front Window" of the suite. It is the tool you show on the v0.1 launch landing page to prove that your "Deep History" and "Systems Integrity" claims are not just theoretical—they are visible, navigable, and ready for the real world. diff --git a/gems/lineage/docs/agents/zig-production-sources.txt b/gems/gigasail/docs/agents/zig-production-sources.txt similarity index 100% rename from gems/lineage/docs/agents/zig-production-sources.txt rename to gems/gigasail/docs/agents/zig-production-sources.txt diff --git a/gems/lineage/docs/agents/zig-semantic-index-spike.md b/gems/gigasail/docs/agents/zig-semantic-index-spike.md similarity index 100% rename from gems/lineage/docs/agents/zig-semantic-index-spike.md rename to gems/gigasail/docs/agents/zig-semantic-index-spike.md diff --git a/gems/lineage/docs/repo-notes/README.md b/gems/gigasail/docs/repo-notes/README.md similarity index 100% rename from gems/lineage/docs/repo-notes/README.md rename to gems/gigasail/docs/repo-notes/README.md diff --git a/gems/lineage/docs/repo-notes/ants.md b/gems/gigasail/docs/repo-notes/ants.md similarity index 100% rename from gems/lineage/docs/repo-notes/ants.md rename to gems/gigasail/docs/repo-notes/ants.md diff --git a/gems/lineage/docs/repo-notes/cjson.md b/gems/gigasail/docs/repo-notes/cjson.md similarity index 100% rename from gems/lineage/docs/repo-notes/cjson.md rename to gems/gigasail/docs/repo-notes/cjson.md diff --git a/gems/lineage/docs/repo-notes/commons-cli.md b/gems/gigasail/docs/repo-notes/commons-cli.md similarity index 100% rename from gems/lineage/docs/repo-notes/commons-cli.md rename to gems/gigasail/docs/repo-notes/commons-cli.md diff --git a/gems/lineage/docs/repo-notes/costura.md b/gems/gigasail/docs/repo-notes/costura.md similarity index 100% rename from gems/lineage/docs/repo-notes/costura.md rename to gems/gigasail/docs/repo-notes/costura.md diff --git a/gems/lineage/docs/repo-notes/eventpp.md b/gems/gigasail/docs/repo-notes/eventpp.md similarity index 100% rename from gems/lineage/docs/repo-notes/eventpp.md rename to gems/gigasail/docs/repo-notes/eventpp.md diff --git a/gems/lineage/docs/repo-notes/fast-json-stringify.md b/gems/gigasail/docs/repo-notes/fast-json-stringify.md similarity index 100% rename from gems/lineage/docs/repo-notes/fast-json-stringify.md rename to gems/gigasail/docs/repo-notes/fast-json-stringify.md diff --git a/gems/lineage/docs/repo-notes/go-immutable-radix.md b/gems/gigasail/docs/repo-notes/go-immutable-radix.md similarity index 100% rename from gems/lineage/docs/repo-notes/go-immutable-radix.md rename to gems/gigasail/docs/repo-notes/go-immutable-radix.md diff --git a/gems/lineage/docs/repo-notes/javapoet.md b/gems/gigasail/docs/repo-notes/javapoet.md similarity index 100% rename from gems/lineage/docs/repo-notes/javapoet.md rename to gems/gigasail/docs/repo-notes/javapoet.md diff --git a/gems/lineage/docs/repo-notes/jwt.md b/gems/gigasail/docs/repo-notes/jwt.md similarity index 100% rename from gems/lineage/docs/repo-notes/jwt.md rename to gems/gigasail/docs/repo-notes/jwt.md diff --git a/gems/lineage/docs/repo-notes/mapstructure.md b/gems/gigasail/docs/repo-notes/mapstructure.md similarity index 100% rename from gems/lineage/docs/repo-notes/mapstructure.md rename to gems/gigasail/docs/repo-notes/mapstructure.md diff --git a/gems/lineage/docs/repo-notes/mistune.md b/gems/gigasail/docs/repo-notes/mistune.md similarity index 100% rename from gems/lineage/docs/repo-notes/mistune.md rename to gems/gigasail/docs/repo-notes/mistune.md diff --git a/gems/lineage/docs/repo-notes/mockhttp.md b/gems/gigasail/docs/repo-notes/mockhttp.md similarity index 100% rename from gems/lineage/docs/repo-notes/mockhttp.md rename to gems/gigasail/docs/repo-notes/mockhttp.md diff --git a/gems/lineage/docs/repo-notes/mpc.md b/gems/gigasail/docs/repo-notes/mpc.md similarity index 100% rename from gems/lineage/docs/repo-notes/mpc.md rename to gems/gigasail/docs/repo-notes/mpc.md diff --git a/gems/lineage/docs/repo-notes/pino.md b/gems/gigasail/docs/repo-notes/pino.md similarity index 100% rename from gems/lineage/docs/repo-notes/pino.md rename to gems/gigasail/docs/repo-notes/pino.md diff --git a/gems/lineage/docs/repo-notes/plog.md b/gems/gigasail/docs/repo-notes/plog.md similarity index 100% rename from gems/lineage/docs/repo-notes/plog.md rename to gems/gigasail/docs/repo-notes/plog.md diff --git a/gems/lineage/docs/repo-notes/pluggy.md b/gems/gigasail/docs/repo-notes/pluggy.md similarity index 100% rename from gems/lineage/docs/repo-notes/pluggy.md rename to gems/gigasail/docs/repo-notes/pluggy.md diff --git a/gems/lineage/docs/repo-notes/proxy.md b/gems/gigasail/docs/repo-notes/proxy.md similarity index 100% rename from gems/lineage/docs/repo-notes/proxy.md rename to gems/gigasail/docs/repo-notes/proxy.md diff --git a/gems/lineage/docs/repo-notes/pydantic-settings.md b/gems/gigasail/docs/repo-notes/pydantic-settings.md similarity index 100% rename from gems/lineage/docs/repo-notes/pydantic-settings.md rename to gems/gigasail/docs/repo-notes/pydantic-settings.md diff --git a/gems/lineage/docs/repo-notes/requests.md b/gems/gigasail/docs/repo-notes/requests.md similarity index 100% rename from gems/lineage/docs/repo-notes/requests.md rename to gems/gigasail/docs/repo-notes/requests.md diff --git a/gems/lineage/docs/repo-notes/rtree.md b/gems/gigasail/docs/repo-notes/rtree.md similarity index 100% rename from gems/lineage/docs/repo-notes/rtree.md rename to gems/gigasail/docs/repo-notes/rtree.md diff --git a/gems/lineage/docs/repo-notes/smart-enum.md b/gems/gigasail/docs/repo-notes/smart-enum.md similarity index 100% rename from gems/lineage/docs/repo-notes/smart-enum.md rename to gems/gigasail/docs/repo-notes/smart-enum.md diff --git a/gems/lineage/docs/repo-notes/summary.md b/gems/gigasail/docs/repo-notes/summary.md similarity index 100% rename from gems/lineage/docs/repo-notes/summary.md rename to gems/gigasail/docs/repo-notes/summary.md diff --git a/gems/lineage/docs/repo-notes/tsup.md b/gems/gigasail/docs/repo-notes/tsup.md similarity index 100% rename from gems/lineage/docs/repo-notes/tsup.md rename to gems/gigasail/docs/repo-notes/tsup.md diff --git a/gems/lineage/docs/repo-notes/tsyringe.md b/gems/gigasail/docs/repo-notes/tsyringe.md similarity index 100% rename from gems/lineage/docs/repo-notes/tsyringe.md rename to gems/gigasail/docs/repo-notes/tsyringe.md diff --git a/gems/lineage/docs/repo-notes/wrk.md b/gems/gigasail/docs/repo-notes/wrk.md similarity index 100% rename from gems/lineage/docs/repo-notes/wrk.md rename to gems/gigasail/docs/repo-notes/wrk.md diff --git a/gems/lineage/Cargo.toml b/gems/gigasail/giga-core/Cargo.toml similarity index 62% rename from gems/lineage/Cargo.toml rename to gems/gigasail/giga-core/Cargo.toml index 2fe302b27..c3ba2be07 100644 --- a/gems/lineage/Cargo.toml +++ b/gems/gigasail/giga-core/Cargo.toml @@ -1,45 +1,34 @@ [package] -name = "lineage" -version = "0.1.0" +name = "giga-core" +version = "0.0.1" edition = "2021" -description = "Logical-unit history engine for Boobytrap and sibling gems" +description = "Risk-weighted diff review and logical-unit history engine (core)" license = "PolyForm-Noncommercial-1.0.0" [lib] -name = "lineage" +name = "giga_core" path = "src/lib.rs" -[[bin]] -name = "lineage" -path = "src/main.rs" - [dependencies] -hazard-contract = { path = "../hazard-contract" } -fact-mine-rust = { path = "../fact-mine" } +hazard-contract = { path = "../../hazard-contract" } +fact-mine-rust = { path = "../../fact-mine" } anyhow = "1.0" flate2 = "1.1" -askama = "0.12" -axum = "0.7" -clap = { version = "=4.4.18", features = ["derive"] } git2 = "0.18" hex = "0.4" idna_adapter = "=1.0.0" libc = "0.2" openssl-sys = "=0.9.102" -rusqlite = { version = "0.30", features = ["bundled"] } +rayon = "1.8" roxmltree = "0.19" -rust-embed = "8" +rusqlite = { version = "0.30", features = ["bundled"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" -url = "2.5" sha2 = "0.10" -rayon = "1.8" -tokio = { version = "1", features = ["io-std", "macros", "net", "rt", "rt-multi-thread"] } toml = "0.8" -tower-http = { version = "0.5", features = ["set-header", "trace"] } -tower-lsp = "0.20" ts-rs = "10.1" +url = "2.5" tree-sitter = "=0.25.8" tree-sitter-c = "=0.24.1" tree-sitter-c-sharp = "=0.23.5" @@ -58,7 +47,6 @@ tree-sitter-lua = "=0.4.1" tree-sitter-php = "=0.24.2" tree-sitter-swift = "=0.7.1" streaming-iterator = "0.1.9" -rmcp = { version = "2.2.0", features = ["server", "transport-io"] } [dev-dependencies] tempfile = "=3.10.1" diff --git a/gems/lineage/sql/README.md b/gems/gigasail/giga-core/sql/README.md similarity index 90% rename from gems/lineage/sql/README.md rename to gems/gigasail/giga-core/sql/README.md index 9f84d6aa4..4d7eac1cd 100644 --- a/gems/lineage/sql/README.md +++ b/gems/gigasail/giga-core/sql/README.md @@ -1,11 +1,11 @@ -# Lineage SQL +# Gigasail SQL Production queries are stored as standalone files and embedded into Rust with `include_str!`. Architecture queries live under `architecture/`; 42 core storage statements live under `storage/`; and 29 dashboard/source read-model queries live under `ui/runtime/`. -Lineage prepares the storage and UI corpus against its real SQLite schema. +Gigasail prepares the storage and UI corpus against its real SQLite schema. SQL-COV parses and analyzes the executable corpus independently, while the focused architecture fixture also executes queries for statement and expression coverage. Schema and PRAGMA scripts remain standalone but are not diff --git a/gems/lineage/sql/architecture/artifact_health.sql b/gems/gigasail/giga-core/sql/architecture/artifact_health.sql similarity index 100% rename from gems/lineage/sql/architecture/artifact_health.sql rename to gems/gigasail/giga-core/sql/architecture/artifact_health.sql diff --git a/gems/lineage/sql/architecture/delete_snapshot.sql b/gems/gigasail/giga-core/sql/architecture/delete_snapshot.sql similarity index 100% rename from gems/lineage/sql/architecture/delete_snapshot.sql rename to gems/gigasail/giga-core/sql/architecture/delete_snapshot.sql diff --git a/gems/lineage/sql/architecture/insert_artifact.sql b/gems/gigasail/giga-core/sql/architecture/insert_artifact.sql similarity index 100% rename from gems/lineage/sql/architecture/insert_artifact.sql rename to gems/gigasail/giga-core/sql/architecture/insert_artifact.sql diff --git a/gems/lineage/sql/architecture/insert_edge.sql b/gems/gigasail/giga-core/sql/architecture/insert_edge.sql similarity index 100% rename from gems/lineage/sql/architecture/insert_edge.sql rename to gems/gigasail/giga-core/sql/architecture/insert_edge.sql diff --git a/gems/lineage/sql/architecture/insert_edge_span.sql b/gems/gigasail/giga-core/sql/architecture/insert_edge_span.sql similarity index 100% rename from gems/lineage/sql/architecture/insert_edge_span.sql rename to gems/gigasail/giga-core/sql/architecture/insert_edge_span.sql diff --git a/gems/lineage/sql/architecture/insert_node.sql b/gems/gigasail/giga-core/sql/architecture/insert_node.sql similarity index 100% rename from gems/lineage/sql/architecture/insert_node.sql rename to gems/gigasail/giga-core/sql/architecture/insert_node.sql diff --git a/gems/lineage/sql/architecture/insert_pressure.sql b/gems/gigasail/giga-core/sql/architecture/insert_pressure.sql similarity index 100% rename from gems/lineage/sql/architecture/insert_pressure.sql rename to gems/gigasail/giga-core/sql/architecture/insert_pressure.sql diff --git a/gems/lineage/sql/architecture/latest_artifact.sql b/gems/gigasail/giga-core/sql/architecture/latest_artifact.sql similarity index 100% rename from gems/lineage/sql/architecture/latest_artifact.sql rename to gems/gigasail/giga-core/sql/architecture/latest_artifact.sql diff --git a/gems/lineage/sql/architecture/load_edges.sql b/gems/gigasail/giga-core/sql/architecture/load_edges.sql similarity index 100% rename from gems/lineage/sql/architecture/load_edges.sql rename to gems/gigasail/giga-core/sql/architecture/load_edges.sql diff --git a/gems/lineage/sql/architecture/load_node.sql b/gems/gigasail/giga-core/sql/architecture/load_node.sql similarity index 100% rename from gems/lineage/sql/architecture/load_node.sql rename to gems/gigasail/giga-core/sql/architecture/load_node.sql diff --git a/gems/lineage/sql/architecture/owner_inventory.sql b/gems/gigasail/giga-core/sql/architecture/owner_inventory.sql similarity index 100% rename from gems/lineage/sql/architecture/owner_inventory.sql rename to gems/gigasail/giga-core/sql/architecture/owner_inventory.sql diff --git a/gems/lineage/sql/architecture/search.sql b/gems/gigasail/giga-core/sql/architecture/search.sql similarity index 100% rename from gems/lineage/sql/architecture/search.sql rename to gems/gigasail/giga-core/sql/architecture/search.sql diff --git a/gems/lineage/sql/ui/runtime/apply_hazards.sql b/gems/gigasail/giga-core/sql/core/apply_hazards.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_hazards.sql rename to gems/gigasail/giga-core/sql/core/apply_hazards.sql diff --git a/gems/lineage/sql/ui/runtime/apply_hotness.sql b/gems/gigasail/giga-core/sql/core/apply_hotness.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_hotness.sql rename to gems/gigasail/giga-core/sql/core/apply_hotness.sql diff --git a/gems/lineage/sql/ui/runtime/top_hotness.sql b/gems/gigasail/giga-core/sql/core/top_hotness.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/top_hotness.sql rename to gems/gigasail/giga-core/sql/core/top_hotness.sql diff --git a/gems/lineage/sql/storage/apply_decayed_risk.sql b/gems/gigasail/giga-core/sql/storage/apply_decayed_risk.sql similarity index 100% rename from gems/lineage/sql/storage/apply_decayed_risk.sql rename to gems/gigasail/giga-core/sql/storage/apply_decayed_risk.sql diff --git a/gems/lineage/sql/storage/backfill_mutation_kind.sql b/gems/gigasail/giga-core/sql/storage/backfill_mutation_kind.sql similarity index 100% rename from gems/lineage/sql/storage/backfill_mutation_kind.sql rename to gems/gigasail/giga-core/sql/storage/backfill_mutation_kind.sql diff --git a/gems/lineage/sql/storage/configure_connection.sql b/gems/gigasail/giga-core/sql/storage/configure_connection.sql similarity index 100% rename from gems/lineage/sql/storage/configure_connection.sql rename to gems/gigasail/giga-core/sql/storage/configure_connection.sql diff --git a/gems/lineage/sql/storage/current_unit_spans.sql b/gems/gigasail/giga-core/sql/storage/current_unit_spans.sql similarity index 100% rename from gems/lineage/sql/storage/current_unit_spans.sql rename to gems/gigasail/giga-core/sql/storage/current_unit_spans.sql diff --git a/gems/lineage/sql/storage/current_unit_spans_for_path.sql b/gems/gigasail/giga-core/sql/storage/current_unit_spans_for_path.sql similarity index 100% rename from gems/lineage/sql/storage/current_unit_spans_for_path.sql rename to gems/gigasail/giga-core/sql/storage/current_unit_spans_for_path.sql diff --git a/gems/lineage/sql/storage/delete_coverage_for_commit.sql b/gems/gigasail/giga-core/sql/storage/delete_coverage_for_commit.sql similarity index 100% rename from gems/lineage/sql/storage/delete_coverage_for_commit.sql rename to gems/gigasail/giga-core/sql/storage/delete_coverage_for_commit.sql diff --git a/gems/lineage/sql/storage/delete_test_exposure_for_commit_test.sql b/gems/gigasail/giga-core/sql/storage/delete_test_exposure_for_commit_test.sql similarity index 100% rename from gems/lineage/sql/storage/delete_test_exposure_for_commit_test.sql rename to gems/gigasail/giga-core/sql/storage/delete_test_exposure_for_commit_test.sql diff --git a/gems/lineage/sql/storage/delete_test_exposure_for_commit_test_2.sql b/gems/gigasail/giga-core/sql/storage/delete_test_exposure_for_commit_test_2.sql similarity index 100% rename from gems/lineage/sql/storage/delete_test_exposure_for_commit_test_2.sql rename to gems/gigasail/giga-core/sql/storage/delete_test_exposure_for_commit_test_2.sql diff --git a/gems/lineage/sql/storage/ensure_natural_key_indexes.sql b/gems/gigasail/giga-core/sql/storage/ensure_natural_key_indexes.sql similarity index 100% rename from gems/lineage/sql/storage/ensure_natural_key_indexes.sql rename to gems/gigasail/giga-core/sql/storage/ensure_natural_key_indexes.sql diff --git a/gems/lineage/sql/storage/existing_quality_event.sql b/gems/gigasail/giga-core/sql/storage/existing_quality_event.sql similarity index 100% rename from gems/lineage/sql/storage/existing_quality_event.sql rename to gems/gigasail/giga-core/sql/storage/existing_quality_event.sql diff --git a/gems/lineage/sql/storage/find_definitions.sql b/gems/gigasail/giga-core/sql/storage/find_definitions.sql similarity index 100% rename from gems/lineage/sql/storage/find_definitions.sql rename to gems/gigasail/giga-core/sql/storage/find_definitions.sql diff --git a/gems/lineage/sql/storage/init_schema.sql b/gems/gigasail/giga-core/sql/storage/init_schema.sql similarity index 100% rename from gems/lineage/sql/storage/init_schema.sql rename to gems/gigasail/giga-core/sql/storage/init_schema.sql diff --git a/gems/lineage/sql/storage/insert_crash_event.sql b/gems/gigasail/giga-core/sql/storage/insert_crash_event.sql similarity index 100% rename from gems/lineage/sql/storage/insert_crash_event.sql rename to gems/gigasail/giga-core/sql/storage/insert_crash_event.sql diff --git a/gems/lineage/sql/storage/insert_crash_event_2.sql b/gems/gigasail/giga-core/sql/storage/insert_crash_event_2.sql similarity index 100% rename from gems/lineage/sql/storage/insert_crash_event_2.sql rename to gems/gigasail/giga-core/sql/storage/insert_crash_event_2.sql diff --git a/gems/lineage/sql/storage/insert_event.sql b/gems/gigasail/giga-core/sql/storage/insert_event.sql similarity index 100% rename from gems/lineage/sql/storage/insert_event.sql rename to gems/gigasail/giga-core/sql/storage/insert_event.sql diff --git a/gems/lineage/sql/storage/insert_hazard_event.sql b/gems/gigasail/giga-core/sql/storage/insert_hazard_event.sql similarity index 100% rename from gems/lineage/sql/storage/insert_hazard_event.sql rename to gems/gigasail/giga-core/sql/storage/insert_hazard_event.sql diff --git a/gems/lineage/sql/storage/insert_metadata.sql b/gems/gigasail/giga-core/sql/storage/insert_metadata.sql similarity index 100% rename from gems/lineage/sql/storage/insert_metadata.sql rename to gems/gigasail/giga-core/sql/storage/insert_metadata.sql diff --git a/gems/lineage/sql/storage/insert_sarif_artifact.sql b/gems/gigasail/giga-core/sql/storage/insert_sarif_artifact.sql similarity index 100% rename from gems/lineage/sql/storage/insert_sarif_artifact.sql rename to gems/gigasail/giga-core/sql/storage/insert_sarif_artifact.sql diff --git a/gems/lineage/sql/storage/insert_sarif_artifact_2.sql b/gems/gigasail/giga-core/sql/storage/insert_sarif_artifact_2.sql similarity index 100% rename from gems/lineage/sql/storage/insert_sarif_artifact_2.sql rename to gems/gigasail/giga-core/sql/storage/insert_sarif_artifact_2.sql diff --git a/gems/lineage/sql/storage/insert_sarif_finding.sql b/gems/gigasail/giga-core/sql/storage/insert_sarif_finding.sql similarity index 100% rename from gems/lineage/sql/storage/insert_sarif_finding.sql rename to gems/gigasail/giga-core/sql/storage/insert_sarif_finding.sql diff --git a/gems/lineage/sql/storage/insert_test_exposure_event.sql b/gems/gigasail/giga-core/sql/storage/insert_test_exposure_event.sql similarity index 100% rename from gems/lineage/sql/storage/insert_test_exposure_event.sql rename to gems/gigasail/giga-core/sql/storage/insert_test_exposure_event.sql diff --git a/gems/lineage/sql/storage/insert_unit_hotness.sql b/gems/gigasail/giga-core/sql/storage/insert_unit_hotness.sql similarity index 100% rename from gems/lineage/sql/storage/insert_unit_hotness.sql rename to gems/gigasail/giga-core/sql/storage/insert_unit_hotness.sql diff --git a/gems/lineage/sql/storage/record_coverage_line_with_details.sql b/gems/gigasail/giga-core/sql/storage/record_coverage_line_with_details.sql similarity index 100% rename from gems/lineage/sql/storage/record_coverage_line_with_details.sql rename to gems/gigasail/giga-core/sql/storage/record_coverage_line_with_details.sql diff --git a/gems/lineage/sql/storage/record_quality_metric.sql b/gems/gigasail/giga-core/sql/storage/record_quality_metric.sql similarity index 100% rename from gems/lineage/sql/storage/record_quality_metric.sql rename to gems/gigasail/giga-core/sql/storage/record_quality_metric.sql diff --git a/gems/lineage/sql/storage/record_quality_metric_2.sql b/gems/gigasail/giga-core/sql/storage/record_quality_metric_2.sql similarity index 100% rename from gems/lineage/sql/storage/record_quality_metric_2.sql rename to gems/gigasail/giga-core/sql/storage/record_quality_metric_2.sql diff --git a/gems/lineage/sql/storage/refresh_current_quality_metrics.sql b/gems/gigasail/giga-core/sql/storage/refresh_current_quality_metrics.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_current_quality_metrics.sql rename to gems/gigasail/giga-core/sql/storage/refresh_current_quality_metrics.sql diff --git a/gems/lineage/sql/storage/refresh_current_quality_metrics_2.sql b/gems/gigasail/giga-core/sql/storage/refresh_current_quality_metrics_2.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_current_quality_metrics_2.sql rename to gems/gigasail/giga-core/sql/storage/refresh_current_quality_metrics_2.sql diff --git a/gems/lineage/sql/storage/refresh_current_sarif_findings_view.sql b/gems/gigasail/giga-core/sql/storage/refresh_current_sarif_findings_view.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_current_sarif_findings_view.sql rename to gems/gigasail/giga-core/sql/storage/refresh_current_sarif_findings_view.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_2.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_2.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary_2.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_2.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_3.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_3.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary_3.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_3.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_4.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_4.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary_4.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_4.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_5.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_5.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary_5.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_5.sql diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_6.sql b/gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_6.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_test_exposure_summary_6.sql rename to gems/gigasail/giga-core/sql/storage/refresh_test_exposure_summary_6.sql diff --git a/gems/lineage/sql/storage/refresh_ui_summaries.sql b/gems/gigasail/giga-core/sql/storage/refresh_ui_summaries.sql similarity index 100% rename from gems/lineage/sql/storage/refresh_ui_summaries.sql rename to gems/gigasail/giga-core/sql/storage/refresh_ui_summaries.sql diff --git a/gems/lineage/sql/storage/resolve_current_path.sql b/gems/gigasail/giga-core/sql/storage/resolve_current_path.sql similarity index 100% rename from gems/lineage/sql/storage/resolve_current_path.sql rename to gems/gigasail/giga-core/sql/storage/resolve_current_path.sql diff --git a/gems/lineage/sql/storage/resolve_current_path_2.sql b/gems/gigasail/giga-core/sql/storage/resolve_current_path_2.sql similarity index 100% rename from gems/lineage/sql/storage/resolve_current_path_2.sql rename to gems/gigasail/giga-core/sql/storage/resolve_current_path_2.sql diff --git a/gems/lineage/sql/storage/resolve_unit_id.sql b/gems/gigasail/giga-core/sql/storage/resolve_unit_id.sql similarity index 100% rename from gems/lineage/sql/storage/resolve_unit_id.sql rename to gems/gigasail/giga-core/sql/storage/resolve_unit_id.sql diff --git a/gems/lineage/sql/storage/sarif_finding_counts_by_file.sql b/gems/gigasail/giga-core/sql/storage/sarif_finding_counts_by_file.sql similarity index 100% rename from gems/lineage/sql/storage/sarif_finding_counts_by_file.sql rename to gems/gigasail/giga-core/sql/storage/sarif_finding_counts_by_file.sql diff --git a/gems/lineage/sql/storage/sarif_findings_for_path.sql b/gems/gigasail/giga-core/sql/storage/sarif_findings_for_path.sql similarity index 100% rename from gems/lineage/sql/storage/sarif_findings_for_path.sql rename to gems/gigasail/giga-core/sql/storage/sarif_findings_for_path.sql diff --git a/gems/lineage/sql/storage/sarif_lifecycle_summary.sql b/gems/gigasail/giga-core/sql/storage/sarif_lifecycle_summary.sql similarity index 100% rename from gems/lineage/sql/storage/sarif_lifecycle_summary.sql rename to gems/gigasail/giga-core/sql/storage/sarif_lifecycle_summary.sql diff --git a/gems/lineage/sql/storage/unit_ids_for_current_path.sql b/gems/gigasail/giga-core/sql/storage/unit_ids_for_current_path.sql similarity index 100% rename from gems/lineage/sql/storage/unit_ids_for_current_path.sql rename to gems/gigasail/giga-core/sql/storage/unit_ids_for_current_path.sql diff --git a/gems/lineage/sql/storage/upsert_logical_unit.sql b/gems/gigasail/giga-core/sql/storage/upsert_logical_unit.sql similarity index 100% rename from gems/lineage/sql/storage/upsert_logical_unit.sql rename to gems/gigasail/giga-core/sql/storage/upsert_logical_unit.sql diff --git a/gems/lineage/src/db/architecture.rs b/gems/gigasail/giga-core/src/db/architecture.rs similarity index 59% rename from gems/lineage/src/db/architecture.rs rename to gems/gigasail/giga-core/src/db/architecture.rs index ce2d8eecb..b000eb271 100644 --- a/gems/lineage/src/db/architecture.rs +++ b/gems/gigasail/giga-core/src/db/architecture.rs @@ -14,8 +14,42 @@ const INSERT_NODE_SQL: &str = include_str!("../../sql/architecture/insert_node.s const INSERT_EDGE_SQL: &str = include_str!("../../sql/architecture/insert_edge.sql"); const INSERT_EDGE_SPAN_SQL: &str = include_str!("../../sql/architecture/insert_edge_span.sql"); const INSERT_PRESSURE_SQL: &str = include_str!("../../sql/architecture/insert_pressure.sql"); -const RECONCILE_LOGICAL_UNIT_SQL: &str = - include_str!("../../sql/architecture/reconcile_logical_unit.sql"); + +/// One-shot: current (latest-event) location per unit, into an indexed temp +/// table, so per-node reconciliation is an indexed probe rather than a repeated +/// full latest-event join. +// Refresh contents in place (DELETE + INSERT), never DROP: a cached reconcile +// statement from a prior ingest on the same connection still references this +// table, and DROP would fail with "database table is locked". +const BUILD_RECONCILE_TEMP_SQL: &str = "\ +CREATE TEMP TABLE IF NOT EXISTS arch_reconcile ( + unit_id TEXT, path TEXT, start_line INTEGER, name TEXT, type TEXT +); +CREATE INDEX IF NOT EXISTS arch_reconcile_idx ON arch_reconcile(path, type, name); +DELETE FROM arch_reconcile; +INSERT INTO arch_reconcile +SELECT u.id, + COALESCE(le.path, u.original_path), + COALESCE(le.start_line, u.start_line, 1), + u.name, + u.type +FROM logical_units u +LEFT JOIN ( + SELECT e.unit_id AS unit_id, e.path AS path, e.start_line AS start_line + FROM events e + WHERE e.id = ( + SELECT x.id FROM events x WHERE x.unit_id = e.unit_id + ORDER BY x.timestamp DESC, x.id DESC LIMIT 1 + ) +) le ON le.unit_id = u.id;"; + +/// Per-node reconcile against the materialized temp table (params identical to +/// the original `reconcile_logical_unit.sql`). +const RECONCILE_TEMP_SQL: &str = "\ +SELECT unit_id FROM arch_reconcile +WHERE path = ?1 AND (name = ?2 OR name LIKE ?3) AND type IN (?4, ?5) +ORDER BY ABS(start_line - ?6), unit_id +LIMIT 1;"; const SEARCH_SQL: &str = include_str!("../../sql/architecture/search.sql"); const LATEST_ARTIFACT_SQL: &str = include_str!("../../sql/architecture/latest_artifact.sql"); const ARTIFACT_HEALTH_SQL: &str = include_str!("../../sql/architecture/artifact_health.sql"); @@ -40,6 +74,8 @@ pub fn ingest_architecture_json( if document.get("kind").and_then(Value::as_str) != Some("espalier.architecture.v1") { bail!("unsupported architecture artifact kind"); } + // Self-heal the Big-O columns for pre-existing databases before nodes update them. + storage.ensure_big_o_columns()?; let schema_version = document .get("schema_version") .and_then(Value::as_i64) @@ -72,7 +108,14 @@ pub fn ingest_architecture_json( .and_then(Value::as_str) .unwrap_or(""); - let tx = storage.connection().unchecked_transaction()?; + // Nest safely inside the sync ingest's outer transaction; own one only when + // called standalone (`ingest-architecture`). + let owns_transaction = !storage.transaction_active(); + if owns_transaction { + storage.begin_transaction()?; + } + let result = (|| -> Result { + let tx = storage.connection(); tx.execute(DELETE_SNAPSHOT_SQL, params![analyzer, commit])?; tx.execute( INSERT_ARTIFACT_SQL, @@ -84,7 +127,10 @@ pub fn ingest_architecture_json( root, complete as i64, generated_at, - payload + // The full graph is decomposed into the nodes/edges/spans tables and + // this column is never read back (the gzipped run-store artifact is + // the durable copy), so storing it would only bloat the DB. + "" ], )?; let artifact_id = tx.last_insert_rowid(); @@ -93,6 +139,13 @@ pub fn ingest_architecture_json( ..ArchitectureIngestStats::default() }; + // Reconciling each architecture node to its logical unit needs each unit's + // *current* path/start-line (from its latest event). Computing that latest- + // event join per node re-scans the events table ~N times (60s for a real + // graph). Materialize it once into an indexed temp table; the per-node + // lookup is then a single indexed probe. + tx.execute_batch(BUILD_RECONCILE_TEMP_SQL)?; + for node in document .get("nodes") .and_then(Value::as_array) @@ -109,16 +162,16 @@ pub fn ingest_architecture_json( } else { reconcile_logical_unit(&tx, path.as_deref(), &name, &kind, start_line)? }; - if logical_unit_id.is_some() { + if let Some(unit_id) = &logical_unit_id { stats.reconciled_units += 1; + apply_node_big_o(&tx, node, unit_id)?; } let metadata = node.get("metadata").cloned().unwrap_or_else(|| json!({})); let confidence = metadata .get("confidence") .and_then(Value::as_str) .unwrap_or("high"); - tx.execute( - INSERT_NODE_SQL, + tx.prepare_cached(INSERT_NODE_SQL)?.execute( params![ artifact_id, id, @@ -147,8 +200,7 @@ pub fn ingest_architecture_json( .flatten() { let edge_id = text(edge, "id"); - tx.execute( - INSERT_EDGE_SQL, + tx.prepare_cached(INSERT_EDGE_SQL)?.execute( params![ artifact_id, edge_id, @@ -171,8 +223,7 @@ pub fn ingest_architecture_json( .into_iter() .flatten() { - tx.execute( - INSERT_EDGE_SPAN_SQL, + tx.prepare_cached(INSERT_EDGE_SPAN_SQL)?.execute( params![ artifact_id, edge_id, @@ -197,8 +248,7 @@ pub fn ingest_architecture_json( .get("components") .cloned() .unwrap_or_else(|| json!({})); - tx.execute( - INSERT_PRESSURE_SQL, + tx.prepare_cached(INSERT_PRESSURE_SQL)?.execute( params![ artifact_id, text(pressure, "node_id"), @@ -356,13 +406,63 @@ pub fn ingest_architecture_json( })?; } - tx.commit()?; + Ok(stats) + })(); + match result { + Ok(stats) => { + if owns_transaction { + storage.commit_transaction()?; + } + Ok(stats) + } + Err(error) => { + if owns_transaction { + let _ = storage.rollback_transaction(); + } + Err(error) + } + } +} - Ok(stats) +/// Store a function node's Big-O time/space complexity on its logical unit. +/// espalier emits `big_o_time`/`big_o_space` (the O(...) strings) plus +/// `time_complete`/`space_complete` bools. Status maps to: complete when the +/// bound is proven complete, partial when a bound is known but not complete, +/// unknown when absent. Nodes without any Big-O are left untouched. +fn apply_node_big_o(tx: &rusqlite::Connection, node: &Value, unit_id: &str) -> Result<()> { + let time = optional_text(node, "big_o_time"); + let space = optional_text(node, "big_o_space"); + if time.is_none() && space.is_none() { + return Ok(()); + } + // The analyzer may return "unknown" as the bound itself (couldn't determine + // one); treat that - and an absent bound - as unknown, a proven bound as + // complete, and a known-but-unproven bound as partial. + let status = |val: &Option, complete_key: &str| -> &'static str { + match val.as_deref() { + None | Some("") | Some("unknown") | Some("Unknown") => "unknown", + Some(_) if node.get(complete_key).and_then(Value::as_bool).unwrap_or(false) => { + "complete" + } + Some(_) => "partial", + } + }; + tx.execute( + "UPDATE logical_units SET big_o_time = ?2, big_o_time_status = ?3, \ + big_o_space = ?4, big_o_space_status = ?5 WHERE id = ?1", + params![ + unit_id, + time.clone().unwrap_or_default(), + status(&time, "time_complete"), + space.clone().unwrap_or_default(), + status(&space, "space_complete"), + ], + )?; + Ok(()) } fn reconcile_logical_unit( - tx: &rusqlite::Transaction<'_>, + tx: &rusqlite::Connection, path: Option<&str>, name: &str, kind: &str, @@ -374,7 +474,8 @@ fn reconcile_logical_unit( } else { vec![kind] }; - let mut stmt = tx.prepare(RECONCILE_LOGICAL_UNIT_SQL)?; + // Cached probe against the materialized temp table (built once per ingest). + let mut stmt = tx.prepare_cached(RECONCILE_TEMP_SQL)?; let suffix = format!("%{name}"); Ok(stmt .query_row( @@ -498,6 +599,158 @@ fn latest_artifact_id(storage: &Storage) -> Result { .context("no architecture artifact has been ingested") } +/// What an architecture fact site represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FactKind { + /// A resolved call/collaboration (`Owner#name`), attributed to a unit. + Call, + /// A state read/write (`read:field` / `write:field`), attributed to a unit. + State, + /// A source import/require (the module string), attributed to a file. + Import, +} + +/// A single call, state-access, or import site from the architecture graph, +/// tagged with the source location where it occurs. The diff service intersects +/// these with a diff's added lines to surface *new* facts per unit or file. +#[derive(Debug, Clone)] +pub struct ArchitectureFactSite { + pub path: String, + pub line: u32, + /// `Owner#name` for a call, `read:field`/`write:field` for state, or the + /// module string for an import. + pub label: String, + pub kind: FactKind, +} + +/// Every call and state-access site from the architecture graph ingested for +/// `commit_hash`. Returns an empty vec when no graph was ingested for it. +pub fn architecture_fact_sites_for_commit( + storage: &Storage, + commit_hash: &str, +) -> Result> { + let Some(artifact_id) = artifact_id_for_commit(storage, commit_hash)? else { + return Ok(Vec::new()); + }; + // node id -> (name, owner) + let mut nodes = HashMap::)>::new(); + { + let mut stmt = storage.connection().prepare( + "SELECT analyzer_node_id, name, owner FROM architecture_nodes WHERE artifact_id = ?1", + )?; + let rows = stmt.query_map(params![artifact_id], |row| { + Ok(( + row.get::<_, String>(0)?, + (row.get::<_, String>(1)?, row.get::<_, Option>(2)?), + )) + })?; + for row in rows { + let (id, value) = row?; + nodes.insert(id, value); + } + } + let mut sites = Vec::new(); + let mut stmt = storage.connection().prepare( + "SELECT e.source_node_id, e.target_node_id, e.kind, s.path, s.start_line \ + FROM architecture_edges e \ + JOIN architecture_edge_spans s \ + ON s.artifact_id = e.artifact_id AND s.edge_id = e.edge_id \ + WHERE e.artifact_id = ?1", + )?; + let rows = stmt.query_map(params![artifact_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + })?; + for row in rows { + let (source, target, kind, path, line) = row?; + let line = line.max(0) as u32; + match kind.as_str() { + // `writes` is fn -> state; the state field is the edge target. + "writes" => { + if let Some((name, _)) = nodes.get(&target) { + sites.push(ArchitectureFactSite { + path, + line, + label: format!("write:{name}"), + kind: FactKind::State, + }); + } + } + // `reads` is deliberately reversed: state -> fn, so the field is the source. + "reads" => { + if let Some((name, _)) = nodes.get(&source) { + sites.push(ArchitectureFactSite { + path, + line, + label: format!("read:{name}"), + kind: FactKind::State, + }); + } + } + // An import/require: the module string is the target external node's name. + "imports" => { + if let Some((name, _)) = nodes.get(&target) { + sites.push(ArchitectureFactSite { + path, + line, + label: name.clone(), + kind: FactKind::Import, + }); + } + } + // Only calls that resolve to a real unit in the corpus become + // dependencies. `external_call` (stdlib/builtins) and + // `unresolved_call` (method on an untyped local) are dropped: they + // are bare, unqualified names (`append`, `len`, `filepath`, a local + // variable) rather than a `Owner#name` collaboration. + "calls" | "internal_call" | "resolved_call" | "delegation" => { + if let Some((name, owner)) = nodes.get(&target) { + // A same-class call is not a *new dependency*: only a call to + // a different owner (class) is a collaboration dependency. + // Compare the caller's owner (source node) with the callee's. + let caller_owner = nodes.get(&source).and_then(|(_, o)| o.as_deref()); + let same_class = matches!( + (caller_owner, owner.as_deref()), + (Some(a), Some(b)) if !a.is_empty() && a == b + ); + if same_class { + continue; + } + let label = match owner { + Some(owner) if !owner.is_empty() => format!("{owner}#{name}"), + _ => name.clone(), + }; + sites.push(ArchitectureFactSite { + path, + line, + label, + kind: FactKind::Call, + }); + } + } + _ => {} + } + } + Ok(sites) +} + +fn artifact_id_for_commit(storage: &Storage, commit_hash: &str) -> Result> { + storage + .connection() + .query_row( + "SELECT id FROM architecture_artifacts WHERE commit_hash = ?1 ORDER BY id DESC LIMIT 1", + params![commit_hash], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + fn artifact_health(storage: &Storage, artifact_id: i64) -> Result { storage.connection().query_row( ARTIFACT_HEALTH_SQL, @@ -523,7 +776,7 @@ fn load_nodes_for_owner(storage: &Storage, artifact_id: i64, owner_id: &str) -> value["pressure"] = json!({"score": row.get::<_, Option>(14)?.unwrap_or(0.0), "band": row.get::<_, Option>(15)?.unwrap_or_else(|| "ordinary".into()), "explanation": parse_json(row.get::<_, Option>(16)?.unwrap_or_else(|| "{}".into()))}); value["incoming"] = json!(row.get::<_, i64>(17)?); value["outgoing"] = json!(row.get::<_, i64>(18)?); - value["lineage"] = json!({"hazards": row.get::<_, i64>(19)?, "changes": row.get::<_, i64>(20)?, + value["gigasail"] = json!({"hazards": row.get::<_, i64>(19)?, "changes": row.get::<_, i64>(20)?, "fixes": row.get::<_, i64>(21)?, "distinct_tests": row.get::<_, i64>(22)?, "line_coverage": row.get::<_, f64>(23)?, "mutant_coverage": row.get::<_, f64>(24)?}); Ok(value) @@ -587,7 +840,6 @@ mod tests { INSERT_EDGE_SQL, INSERT_EDGE_SPAN_SQL, INSERT_PRESSURE_SQL, - RECONCILE_LOGICAL_UNIT_SQL, SEARCH_SQL, LATEST_ARTIFACT_SQL, ARTIFACT_HEALTH_SQL, @@ -602,6 +854,159 @@ mod tests { } } + #[test] + fn arch_ingest_stores_node_big_o_on_the_reconciled_unit() { + let storage = Storage::open_memory().unwrap(); + let unit = LogicalUnit::new( + "run", + crate::model::UnitKind::Function, + "demo.rb", + 0, + 2, + 5, + "def run", + "def run\n@v=1\nend", + ); + storage.upsert_logical_unit(&unit, 10).unwrap(); + let payload = json!({ + "schema_version": 1, "kind": "espalier.architecture.v1", + "analyzer": {"name": "espalier", "version": "t"}, + "generated_at": "2026-07-11T00:00:00Z", + "corpus": {"commit": "abc", "root": ".", "complete": true, "languages": ["ruby"]}, + "nodes": [{ + "id": "fn:1", "kind": "function", "name": "run", "path": "demo.rb", + "start_line": 2, "end_line": 5, + "big_o_time": "O(n log n)", "time_complete": true, + "big_o_space": "O(n)", "space_complete": false, + "metadata": {"confidence": "high"} + }], + "edges": [], "pressure": [], "hazards": [] + }) + .to_string(); + ingest_architecture_json(&storage, &payload).unwrap(); + // time is proven complete, space is a known-but-partial bound. + assert_eq!( + storage.logical_unit_big_o(&unit.id).unwrap(), + ( + "O(n log n)".to_string(), + "complete".to_string(), + "O(n)".to_string(), + "partial".to_string() + ) + ); + // A unit whose node carries no Big-O stays unknown. + let plain = LogicalUnit::new( + "plain", + crate::model::UnitKind::Function, + "demo.rb", + 1, + 10, + 12, + "def plain", + "def plain\n1\nend", + ); + storage.upsert_logical_unit(&plain, 10).unwrap(); + assert_eq!(storage.logical_unit_big_o(&plain.id).unwrap().1, "unknown"); + + // An "unknown" bound string (analyzer couldn't determine one) is unknown, + // not "partial". + let loopy = LogicalUnit::new( + "loopy", + crate::model::UnitKind::Function, + "demo.rb", + 2, + 20, + 25, + "def loopy", + "def loopy\nx\nend", + ); + storage.upsert_logical_unit(&loopy, 10).unwrap(); + let payload2 = json!({ + "schema_version": 1, "kind": "espalier.architecture.v1", + "analyzer": {"name": "espalier", "version": "t"}, + "generated_at": "2026-07-11T00:00:00Z", + "corpus": {"commit": "abc", "root": ".", "complete": true, "languages": ["ruby"]}, + "nodes": [{ + "id": "fn:2", "kind": "function", "name": "loopy", "path": "demo.rb", + "start_line": 20, "end_line": 25, + "big_o_time": "unknown", "time_complete": false, + "metadata": {"confidence": "high"} + }], + "edges": [], "pressure": [], "hazards": [] + }) + .to_string(); + ingest_architecture_json(&storage, &payload2).unwrap(); + assert_eq!(storage.logical_unit_big_o(&loopy.id).unwrap().1, "unknown"); + } + + #[test] + fn fact_sites_classify_calls_and_state_by_span() { + let storage = Storage::open_memory().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let payload = json!({ + "schema_version": 1, + "kind": "espalier.architecture.v1", + "analyzer": {"name": "espalier", "version": "test"}, + "generated_at": "2026-07-11T00:00:00Z", + "corpus": {"commit": "deadbeef", "root": dir.path().to_str().unwrap(), "complete": true, "languages": ["ruby"]}, + "nodes": [ + {"id":"owner:1","kind":"owner","name":"Demo","owner":"Demo","path":"demo.rb","start_line":1,"start_column":0,"end_line":9,"end_column":3,"metadata":{}}, + {"id":"fn:run","kind":"function","name":"run","owner":"Demo","owner_id":"owner:1","path":"demo.rb","start_line":2,"start_column":0,"end_line":6,"end_column":3,"metadata":{}}, + {"id":"fn:help","kind":"function","name":"help","owner":"Demo","owner_id":"owner:1","path":"demo.rb","start_line":7,"start_column":0,"end_line":8,"end_column":3,"metadata":{}}, + {"id":"state:v","kind":"state","name":"@value","owner":"Demo","owner_id":"owner:1","path":"demo.rb","start_line":3,"start_column":2,"end_line":3,"end_column":8,"metadata":{}}, + {"id":"owner:2","kind":"owner","name":"Widget","owner":"Widget","path":"widget.rb","start_line":1,"start_column":0,"end_line":3,"end_column":3,"metadata":{}}, + {"id":"fn:paint","kind":"function","name":"paint","owner":"Widget","owner_id":"owner:2","path":"widget.rb","start_line":2,"start_column":0,"end_line":3,"end_column":3,"metadata":{}}, + {"id":"external:import:s","kind":"external","name":"strings","owner":null,"language":"ruby","path":null,"start_line":0,"start_column":0,"end_line":0,"end_column":0,"metadata":{"import":true}} + ], + "edges": [ + {"id":"e:import","source":"file:demo.rb","target":"external:import:s","kind":"imports","conditional":false,"weight":1,"confidence":"high","metadata":{"module":"strings"},"spans":[{"path":"demo.rb","start_line":1,"start_column":0,"end_line":1,"end_column":0}]}, + {"id":"e:call","source":"fn:run","target":"fn:paint","kind":"calls","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":4,"start_column":4,"end_line":4,"end_column":10}]}, + {"id":"e:call_same","source":"fn:run","target":"fn:help","kind":"calls","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":6,"start_column":4,"end_line":6,"end_column":10}]}, + {"id":"e:write","source":"fn:run","target":"state:v","kind":"writes","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":3,"start_column":2,"end_line":3,"end_column":8}]}, + {"id":"e:read","source":"state:v","target":"fn:help","kind":"reads","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":7,"start_column":2,"end_line":7,"end_column":8}]}, + {"id":"e:ext","source":"fn:run","target":"external:puts","kind":"external_call","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":5,"start_column":4,"end_line":5,"end_column":8}]} + ], + "pressure": [], + "hazards": [] + }).to_string(); + ingest_architecture_json(&storage, &payload).unwrap(); + + let sites = architecture_fact_sites_for_commit(&storage, "deadbeef").unwrap(); + // A cross-class call resolves to `Owner#name`; the external call is + // dropped, and a same-class call (Demo -> Demo#help) is NOT a dependency. + let call = sites + .iter() + .find(|s| s.kind == FactKind::Call && s.line == 4) + .unwrap(); + assert_eq!(call.label, "Widget#paint"); + assert!(sites.iter().all(|s| s.label != "puts"), "external calls dropped"); + assert!( + !sites.iter().any(|s| s.kind == FactKind::Call && s.line == 6), + "same-class call is not a new dependency" + ); + // Write names the state target; read names the state source (reversed). + let write = sites + .iter() + .find(|s| s.kind == FactKind::State && s.line == 3) + .unwrap(); + assert_eq!(write.label, "write:@value"); + let read = sites + .iter() + .find(|s| s.kind == FactKind::State && s.line == 7) + .unwrap(); + assert_eq!(read.label, "read:@value"); + // The import target names the module and is tagged as an import. + let import = sites + .iter() + .find(|s| s.kind == FactKind::Import) + .unwrap(); + assert_eq!(import.label, "strings"); + assert_eq!(import.line, 1); + + // An unknown commit yields nothing rather than erroring. + assert!(architecture_fact_sites_for_commit(&storage, "cafe").unwrap().is_empty()); + } + #[test] fn ingests_and_queries_focused_architecture() { let storage = Storage::open_memory().unwrap(); diff --git a/gems/lineage/src/db/engine.rs b/gems/gigasail/giga-core/src/db/engine.rs similarity index 99% rename from gems/lineage/src/db/engine.rs rename to gems/gigasail/giga-core/src/db/engine.rs index df5cb2900..dbd1a85b1 100644 --- a/gems/lineage/src/db/engine.rs +++ b/gems/gigasail/giga-core/src/db/engine.rs @@ -59,7 +59,7 @@ where /// Indexes the first-parent history through one immutable revision. This /// deliberately includes the revision's predecessors: logical-unit IDs - /// and rename lineage depend on their historical state. + /// and rename gigasail depend on their historical state. pub fn run_through_revision(&mut self, revision: &str) -> Result { self.storage.begin_transaction()?; match self.run_inner(None, Some(revision)) { @@ -629,7 +629,7 @@ mod tests { } let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("lineage.db"); + let db = dir.path().join("gigasail.db"); let provider = MemoryProvider { commits, files }; let storage = Storage::open(&db).unwrap(); let mut engine = LineageEngine::new( @@ -1105,7 +1105,7 @@ mod tests { ); let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("lineage.db"); + let db_path = dir.path().join("gigasail.db"); // Run 1: process c1 { diff --git a/gems/lineage/src/db/extract.rs b/gems/gigasail/giga-core/src/db/extract.rs similarity index 99% rename from gems/lineage/src/db/extract.rs rename to gems/gigasail/giga-core/src/db/extract.rs index 42ea8573a..5870bc3be 100644 --- a/gems/lineage/src/db/extract.rs +++ b/gems/gigasail/giga-core/src/db/extract.rs @@ -10,7 +10,7 @@ pub const DEFAULT_CODE_EXTENSIONS: &[&str] = &[ ]; const DEFAULT_IGNORED_COMPONENTS: &[&str] = &[ ".git", - ".lineage", + ".giga", ".zig-cache", ".clear-cache", ".clear-transpile-cache", @@ -946,7 +946,7 @@ mod tests { #[test] fn extracts_standalone_sql_query_as_a_logical_unit() { let file = BlobFile { - path: "gems/lineage/sql/demo.sql".into(), + path: "gems/gigasail/sql/demo.sql".into(), contents: "-- query-id: demo.lookup.v1\nSELECT value FROM demo WHERE value <> 0;\n" .into(), }; @@ -1032,7 +1032,7 @@ export const safeParse: (value: unknown) => Result = (value) => { #[test] fn extracts_rust_symbols_with_tree_sitter() { let file = BlobFile { - path: "gems/lineage/src/demo.rs".into(), + path: "gems/gigasail/src/demo.rs".into(), contents: r#" pub mod storage { pub struct Store { @@ -1227,10 +1227,10 @@ export class Worker { assert!(filter.supports_path("src/Main.java")); assert!(filter.supports_path("Sources/App.swift")); assert!(filter.supports_path("src/main.kt")); - assert!(filter.supports_path("gems/lineage/src/ui.rs")); + assert!(filter.supports_path("gems/gigasail/src/ui.rs")); assert!(filter.supports_path("script/tool.lua")); assert!(!filter.supports_path("benchmarks/x/bench.profile/transpiled.zig")); - assert!(!filter.supports_path("gems/lineage/target/debug/build.rs")); + assert!(!filter.supports_path("gems/gigasail/target/debug/build.rs")); assert!(!filter.supports_path("gems/nil-kill/vendor/example.rb")); assert!(!filter.supports_path("README.md")); assert!(!filter.supports_path("gems/x/x.gemspec")); @@ -1262,7 +1262,7 @@ export class Worker { assert!(is_production_source_path( "gems/decomplex/lib/decomplex/report.rb" )); - assert!(is_production_source_path("gems/lineage/src/ui.rs")); + assert!(is_production_source_path("gems/gigasail/src/ui.rs")); assert!(is_production_source_path("zig/lib/atomic.zig")); } diff --git a/gems/lineage/src/db/git.rs b/gems/gigasail/giga-core/src/db/git.rs similarity index 87% rename from gems/lineage/src/db/git.rs rename to gems/gigasail/giga-core/src/db/git.rs index fda86d547..eac9cebab 100644 --- a/gems/lineage/src/db/git.rs +++ b/gems/gigasail/giga-core/src/db/git.rs @@ -119,22 +119,42 @@ impl GitProvider { Ok(commit.id().to_string()) } + /// Number of commits reachable from `head` but not `base` (i.e. how many + /// commits the diff range spans). `WORKTREE` head counts as its parent's + /// range since it carries no commit of its own. + pub fn commit_count(&self, base_revision: &str, head_revision: &str) -> Result { + let head_revision = if head_revision == WORKTREE_REVISION { + "HEAD" + } else { + head_revision + }; + let base_oid = git2::Oid::from_str(&self.resolve_commit(base_revision)?)?; + let head_oid = git2::Oid::from_str(&self.resolve_commit(head_revision)?)?; + let repo = Repository::open(&self.path)?; + let mut walk = repo.revwalk()?; + walk.push(head_oid)?; + walk.hide(base_oid)?; + Ok(walk.count()) + } + pub fn diff_plan(&self, base_revision: &str, head_revision: &str) -> Result { let base_oid = self.resolve_commit(base_revision)?; let repo = Repository::open(&self.path)?; let base_tree = repo.find_commit(git2::Oid::from_str(&base_oid)?)?.tree()?; - let (head_oid, base, head, renames, override_contents) = + let (head_oid, base, head, renames, override_contents, binaries) = if head_revision == WORKTREE_REVISION { let mut diff = repo.diff_tree_to_workdir_with_index(Some(&base_tree), None)?; let (base, mut head, renames) = self.changed_snapshots(&repo, &base_tree, None, &mut diff)?; self.add_untracked_worktree_files(&repo, &mut head)?; + let binaries = self.collect_added_binaries(&repo, &diff); ( WORKTREE_REVISION.to_string(), base, head, renames, - self.file_contents_in_worktree(".lineage/diff.toml")?, + self.file_contents_in_worktree(".giga/diff.toml")?, + binaries, ) } else { let head_oid = self.resolve_commit(head_revision)?; @@ -142,18 +162,54 @@ impl GitProvider { let mut diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)?; let (base, head, renames) = self.changed_snapshots(&repo, &base_tree, Some(&head_tree), &mut diff)?; + let binaries = self.collect_added_binaries(&repo, &diff); ( head_oid.clone(), base, head, renames, - self.file_contents_at_commit(&head_oid, ".lineage/diff.toml")?, + self.file_contents_at_commit(&head_oid, ".giga/diff.toml")?, + binaries, ) }; let overrides = classification_overrides(override_contents.as_deref()); - Ok(build_diff_plan_with_renames_and_overrides( + let mut plan = build_diff_plan_with_renames_and_overrides( base_oid, head_oid, base, head, renames, overrides, - )) + ); + plan.inventory.binary_added = binaries; + Ok(plan) + } + + /// Binary files newly added by a diff, with their byte sizes, so the review + /// can warn about them. Reads each added delta's blob to classify it. + fn collect_added_binaries( + &self, + repo: &Repository, + diff: &git2::Diff, + ) -> Vec { + let mut out = Vec::new(); + for delta in diff.deltas() { + if delta.status() != git2::Delta::Added { + continue; + } + let new_file = delta.new_file(); + let Some(path) = new_file.path().and_then(|p| p.to_str()) else { + continue; + }; + let Some(path) = self.scoped_path(path) else { + continue; + }; + if let Ok(blob) = repo.find_blob(new_file.id()) { + if blob.is_binary() { + out.push(crate::diff::BinaryFile { + path, + bytes: blob.size() as u64, + }); + } + } + } + out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path))); + out } fn in_scope(&self, path: &str) -> bool { @@ -195,22 +251,24 @@ impl GitProvider { ) -> Result<(String, String)> { let head_revision = head_revision.filter(|revision| !revision.trim().is_empty()); let base_revision = base_revision.filter(|revision| !revision.trim().is_empty()); - if head_revision.is_some_and(|revision| revision == WORKTREE_REVISION) { - let base = match base_revision { - Some(base) => self.resolve_commit(base)?, - None => self.resolve_commit("HEAD")?, - }; - return Ok((base, WORKTREE_REVISION.into())); - } // The interactive default is intentionally a working-tree review: it // includes staged, unstaged, and non-ignored untracked files. Passing // an explicit head continues to request an immutable commit review. - if head_revision.is_none() { + if head_revision.is_none() || head_revision == Some(WORKTREE_REVISION) { let base = match base_revision { Some(base) => self.resolve_commit(base)?, None => self.resolve_commit("HEAD")?, }; - return Ok((base, WORKTREE_REVISION.into())); + // A clean worktree is byte-identical to HEAD, so pin the head to the + // HEAD commit: the diff is unchanged but commit-scoped evidence + // (coverage, mutation, SARIF) now attaches. Only fall back to the + // synthetic WORKTREE revision when there really are local changes. + let head = if self.worktree_is_clean()? { + self.resolve_commit("HEAD")? + } else { + WORKTREE_REVISION.into() + }; + return Ok((base, head)); } let head_oid = self.resolve_commit(head_revision.unwrap_or("HEAD"))?; let base_oid = match base_revision { @@ -220,6 +278,58 @@ impl GitProvider { Ok((base_oid, head_oid)) } + /// Whether the worktree matches HEAD exactly: no staged, unstaged, or + /// non-ignored untracked changes. Ignored paths (e.g. `.giga/`) do not count. + fn worktree_is_clean(&self) -> Result { + let repo = Repository::open(&self.path)?; + if repo.head().is_err() { + // Unborn HEAD (no commits): nothing to compare against. + return Ok(false); + } + let mut opts = git2::StatusOptions::new(); + opts.include_ignored(false) + .include_untracked(true) + .recurse_untracked_dirs(true); + let statuses = repo.statuses(Some(&mut opts))?; + Ok(statuses.is_empty()) + } + + /// Repo-relative paths changed between `base` and the working tree (staged + + /// unstaged). Drives `giga test`'s change detection: which packages, hence + /// which test producers, a change affects. + pub fn changed_paths(&self, base: &str) -> Result> { + let repo = Repository::open(&self.path)?; + let base_oid = git2::Oid::from_str(&self.resolve_commit(base)?)?; + let base_tree = repo.find_commit(base_oid)?.tree()?; + let mut opts = git2::DiffOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + let diff = repo.diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut opts))?; + let mut paths = std::collections::BTreeSet::new(); + diff.foreach( + &mut |delta, _| { + for file in [delta.new_file(), delta.old_file()] { + if let Some(path) = file.path().and_then(|p| p.to_str()) { + paths.insert(path.to_string()); + } + } + true + }, + None, + None, + None, + )?; + Ok(paths.into_iter().collect()) + } + + /// The merge base of two revisions (their common ancestor). Backs the + /// `giga_premerge` review range: `merge_base(branch, target)..branch`. + pub fn merge_base(&self, a: &str, b: &str) -> Result { + let a_oid = git2::Oid::from_str(&self.resolve_commit(a)?)?; + let b_oid = git2::Oid::from_str(&self.resolve_commit(b)?)?; + let repo = Repository::open(&self.path)?; + Ok(repo.merge_base(a_oid, b_oid)?.to_string()) + } + fn default_diff_base(&self, head_oid: &str) -> Result { let repo = Repository::open(&self.path)?; let head = repo.find_commit(git2::Oid::from_str(head_oid)?)?; @@ -823,16 +933,22 @@ mod tests { } #[test] - fn default_diff_pair_uses_head_against_the_working_tree() -> Result<()> { + fn default_diff_pair_pins_head_when_the_worktree_is_clean() -> Result<()> { let dir = tempdir()?; let repo = Repository::init(dir.path())?; let base = create_commit(&repo, "base", &[("app.rb", "puts :base\n")])?; let head = create_commit(&repo, "head", &[("app.rb", "puts :head\n")])?; let provider = GitProvider::open(dir.path())?; + // Clean worktree: the default head pins to the HEAD commit (not the + // synthetic WORKTREE revision) so commit-scoped evidence attaches. assert_eq!( provider.diff_revisions(None, None)?, - (head.clone(), WORKTREE_REVISION.into()) + (head.clone(), head.clone()) + ); + assert_eq!( + provider.diff_revisions(Some(&base), None)?, + (base.clone(), head.clone()) ); assert_eq!( provider.diff_revisions(Some(&base), Some("HEAD"))?, @@ -1017,6 +1133,19 @@ mod tests { Ok(()) } + #[test] + fn commit_count_spans_the_range() -> Result<()> { + let dir = tempdir()?; + let repo = Repository::init(dir.path())?; + let base = create_commit(&repo, "base", &[("a.txt", "1\n")])?; + create_commit(&repo, "second", &[("a.txt", "2\n")])?; + let head = create_commit(&repo, "third", &[("a.txt", "3\n")])?; + let provider = GitProvider::open(dir.path())?; + assert_eq!(provider.commit_count(&base, &head)?, 2); + assert_eq!(provider.commit_count(&base, &base)?, 0); + Ok(()) + } + #[test] fn diff_plan_preserves_git_detected_renames_with_edits() -> Result<()> { let dir = tempdir()?; @@ -1079,7 +1208,7 @@ mod tests { &[ ("spec/value_spec.rb", "describe :value do\n value\nend\n"), ( - ".lineage/diff.toml", + ".giga/diff.toml", "[[overrides]]\nprefix = \"spec/\"\nrole = \"production\"\n", ), ], @@ -1112,7 +1241,7 @@ mod tests { "describe :value do\n value\nend\n", ), ( - "crate/.lineage/diff.toml", + "crate/.giga/diff.toml", "[[overrides]]\nprefix = \"spec/\"\nrole = \"production\"\n", ), ], @@ -1128,7 +1257,7 @@ mod tests { } #[test] - fn test_lineage_engine_with_git_provider() -> Result<()> { + fn test_gigasail_engine_with_git_provider() -> Result<()> { let dir = tempdir()?; let repo = Repository::init(dir.path())?; diff --git a/gems/lineage/src/db/hazard.rs b/gems/gigasail/giga-core/src/db/hazard.rs similarity index 97% rename from gems/lineage/src/db/hazard.rs rename to gems/gigasail/giga-core/src/db/hazard.rs index f72641d89..3043c1d3c 100644 --- a/gems/lineage/src/db/hazard.rs +++ b/gems/gigasail/giga-core/src/db/hazard.rs @@ -16,12 +16,12 @@ pub struct HazardIngestStats { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct HazardSite { - pub(crate) path: String, - pub(crate) line: u32, - pub(crate) source: String, - pub(crate) hazard_type: String, - pub(crate) required_evidence: String, +pub struct HazardSite { + pub path: String, + pub line: u32, + pub source: String, + pub hazard_type: String, + pub required_evidence: String, } pub fn ingest_hazards( @@ -399,7 +399,7 @@ fn excluded_zig_file(path: &str) -> bool { ) } -// Both scanners consume the same contract resolver. The old Lineage copy +// Both scanners consume the same contract resolver. The old Gigasail copy // classified hazard names independently and could turn `unsafe_block` into a // race because it contains the substring "lock". const GO_HAZARDS: &str = hazard_contract::GO_HAZARDS; @@ -628,7 +628,7 @@ fn query_hazards( unique_sites } -pub(crate) fn scan_zig_sites(path: &str, contents: &str) -> Vec { +pub fn scan_zig_sites(path: &str, contents: &str) -> Vec { let sites = query_hazards( path, contents, @@ -727,11 +727,11 @@ fn executable_zig_retry_line(line: &str) -> bool { && code != "} else {" } -pub(crate) fn scan_go_sites(path: &str, contents: &str) -> Vec { +pub fn scan_go_sites(path: &str, contents: &str) -> Vec { query_hazards(path, contents, tree_sitter_go::LANGUAGE.into(), GO_HAZARDS) } -pub(crate) fn scan_rust_sites(path: &str, contents: &str) -> Vec { +pub fn scan_rust_sites(path: &str, contents: &str) -> Vec { query_hazards( path, contents, @@ -740,11 +740,11 @@ pub(crate) fn scan_rust_sites(path: &str, contents: &str) -> Vec { ) } -pub(crate) fn scan_c_sites(path: &str, contents: &str) -> Vec { +pub fn scan_c_sites(path: &str, contents: &str) -> Vec { query_hazards(path, contents, tree_sitter_c::LANGUAGE.into(), C_HAZARDS) } -pub(crate) fn scan_cpp_sites(path: &str, contents: &str) -> Vec { +pub fn scan_cpp_sites(path: &str, contents: &str) -> Vec { query_hazards( path, contents, @@ -753,10 +753,10 @@ pub(crate) fn scan_cpp_sites(path: &str, contents: &str) -> Vec { ) } -// C# reflection flow is owned by FactMine. Lineage keeps the same narrow site +// C# reflection flow is owned by FactMine. Gigasail keeps the same narrow site // shape for storage/UI consumers, but does not replay a second type/alias // analysis here. -pub(crate) fn scan_csharp_sites(path: &str, contents: &str) -> Vec { +pub fn scan_csharp_sites(path: &str, contents: &str) -> Vec { fact_mine_rust::syntax::hazards::extract_file_hazards( path, contents, @@ -932,7 +932,7 @@ mod tests { } #[test] - fn lineage_csharp_reflection_scan_uses_canonical_factmine_facts() { + fn gigasail_csharp_reflection_scan_uses_canonical_factmine_facts() { let source = r#" class Demo { void Run() { @@ -943,8 +943,8 @@ mod tests { } } "#; - let lineage_sites = scan_csharp_sites("Demo.cs", source); - let reflection: Vec<_> = lineage_sites + let gigasail_sites = scan_csharp_sites("Demo.cs", source); + let reflection: Vec<_> = gigasail_sites .iter() .filter(|site| site.hazard_type == "csharp_metaprogramming") .collect(); @@ -1399,7 +1399,7 @@ mod tests { #[test] fn vendored_hazard_queries_match_fact_mines_originals() { let originals_dir = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fact-mine/src/syntax"); + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fact-mine/src/syntax"); if !originals_dir.is_dir() { eprintln!("skipping: fact-mine sibling tree not present (not a monorepo checkout)"); return; @@ -1418,17 +1418,17 @@ mod tests { .join("src/db/hazards") .join(name), ) - .unwrap_or_else(|_| panic!("lineage generated query {name} is missing")); + .unwrap_or_else(|_| panic!("gigasail generated query {name} is missing")); assert_eq!( vendored_text, generated_copy, - "lineage generated {name} drifted from hazard-contract" + "gigasail generated {name} drifted from hazard-contract" ); let original = fs::read_to_string(originals_dir.join(name)) .unwrap_or_else(|_| panic!("fact-mine original {name} is missing")); assert_eq!( vendored_text, original, "{name} has drifted from fact-mine's original - re-copy it from \ - gems/fact-mine/src/syntax/{name} into gems/lineage/src/db/hazards/{name}" + gems/fact-mine/src/syntax/{name} into gems/gigasail/src/db/hazards/{name}" ); } } diff --git a/gems/lineage/src/db/hazards/c_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/c_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/c_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/c_hazards.scm diff --git a/gems/lineage/src/db/hazards/cpp_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/cpp_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/cpp_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/cpp_hazards.scm diff --git a/gems/lineage/src/db/hazards/csharp_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/csharp_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/csharp_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/csharp_hazards.scm diff --git a/gems/lineage/src/db/hazards/go_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/go_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/go_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/go_hazards.scm diff --git a/gems/lineage/src/db/hazards/rust_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/rust_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/rust_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/rust_hazards.scm diff --git a/gems/lineage/src/db/hazards/zig_hazards.scm b/gems/gigasail/giga-core/src/db/hazards/zig_hazards.scm similarity index 100% rename from gems/lineage/src/db/hazards/zig_hazards.scm rename to gems/gigasail/giga-core/src/db/hazards/zig_hazards.scm diff --git a/gems/lineage/src/db/hotness.rs b/gems/gigasail/giga-core/src/db/hotness.rs similarity index 99% rename from gems/lineage/src/db/hotness.rs rename to gems/gigasail/giga-core/src/db/hotness.rs index bbdf80b87..b0dcaea08 100644 --- a/gems/lineage/src/db/hotness.rs +++ b/gems/gigasail/giga-core/src/db/hotness.rs @@ -325,7 +325,7 @@ mod tests { use tempfile::tempdir; fn open_storage(dir: &std::path::Path) -> Storage { - Storage::open(&dir.join("lineage.db")).unwrap() + Storage::open(&dir.join("gigasail.db")).unwrap() } fn payload(entries: &str) -> String { diff --git a/gems/lineage/src/db/model.rs b/gems/gigasail/giga-core/src/db/model.rs similarity index 100% rename from gems/lineage/src/db/model.rs rename to gems/gigasail/giga-core/src/db/model.rs diff --git a/gems/lineage/src/db/mutant.rs b/gems/gigasail/giga-core/src/db/mutant.rs similarity index 74% rename from gems/lineage/src/db/mutant.rs rename to gems/gigasail/giga-core/src/db/mutant.rs index e657fcf14..4d97e9dfc 100644 --- a/gems/lineage/src/db/mutant.rs +++ b/gems/gigasail/giga-core/src/db/mutant.rs @@ -8,6 +8,247 @@ use anyhow::{Context, Result}; use serde_json::Value; use std::collections::{BTreeSet, HashMap, HashSet}; +/// One test's per-mutant attribution, distilled from an audit-capable +/// mutant-facts artifact (test-miser's normalized output). This is the +/// language-agnostic source for the diff "Tests" section's kill metrics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditTest { + pub id: String, + pub file: String, + pub line: Option, + /// Mutant ids this test killed. + pub killed: BTreeSet, + /// Whether this test covered any mutant (ran against mutated code). + pub covered: bool, + /// The test is skipped/pending (from a `pending`/`skipped` flag or a + /// `status` of "skipped"/"pending" on the inventory entry). + pub pending: bool, +} + +/// Extract per-test attribution from an audit-capable mutant-facts value, +/// accepting both the native `mutant-facts/v1` shape (top-level `tests` + +/// `mutants` with `covered_by`/`killed_by`) and the Mutation Testing Elements +/// shape (`files.*.mutants[coveredBy/killedBy]` + `testFiles.*.tests`). Returns +/// empty when the artifact carries no per-test attribution (subject-only facts). +pub fn collect_audit_tests(value: &Value) -> Vec { + use std::collections::BTreeMap; + // test_id -> (file, line, killed set, covered) + let mut tests: BTreeMap = BTreeMap::new(); + let entry = |tests: &mut BTreeMap, id: &str, file: &str, line: Option| { + tests.entry(id.to_string()).or_insert_with(|| AuditTest { + id: id.to_string(), + file: String::new(), + line: None, + killed: BTreeSet::new(), + covered: false, + pending: false, + }); + let t = tests.get_mut(id).unwrap(); + if t.file.is_empty() && !file.is_empty() { + t.file = file.to_string(); + } + if t.line.is_none() { + t.line = line; + } + }; + // Read a pending/skipped marker off an inventory entry (bool flag or a + // status string). Used by the tests[]/testFiles inventory readers below. + fn entry_pending(test: &Value) -> bool { + if ["pending", "skipped"] + .iter() + .any(|k| test.get(*k).and_then(Value::as_bool).unwrap_or(false)) + { + return true; + } + matches!( + test.get("status").and_then(Value::as_str), + Some("skipped") | Some("pending") | Some("skip") + ) + } + + // Native mutant-facts/v1: authoritative test inventory with file/line. + if let Some(arr) = value.get("tests").and_then(Value::as_array) { + for test in arr { + let Some(id) = test.get("id").and_then(Value::as_str) else { + continue; + }; + let file = test.get("file").and_then(Value::as_str).unwrap_or(""); + let line = test.get("line").and_then(Value::as_u64).map(|n| n as u32); + entry(&mut tests, id, file, line); + if entry_pending(test) { + tests.get_mut(id).unwrap().pending = true; + } + } + } + let apply_mutant = |tests: &mut BTreeMap, + mutant_id: &str, + covered_by: &Value, + killed_by: &Value| { + if let Some(cov) = covered_by.as_array() { + for t in cov.iter().filter_map(Value::as_str) { + entry(tests, t, "", None); + tests.get_mut(t).unwrap().covered = true; + } + } + if let Some(killed) = killed_by.as_array() { + for t in killed.iter().filter_map(Value::as_str) { + entry(tests, t, "", None); + let row = tests.get_mut(t).unwrap(); + row.covered = true; + row.killed.insert(mutant_id.to_string()); + } + } + }; + + if let Some(mutants) = value.get("mutants").and_then(Value::as_array) { + for mutant in mutants { + let id = mutant.get("id").and_then(Value::as_str).unwrap_or(""); + apply_mutant( + &mut tests, + id, + mutant.get("covered_by").unwrap_or(&Value::Null), + mutant.get("killed_by").unwrap_or(&Value::Null), + ); + } + } + + // Mutation Testing Elements shape (Stryker family). Mutant ids are qualified + // by their source path so they stay unique across files. + if let Some(files) = value.get("files").and_then(Value::as_object) { + for (path, file) in files { + if let Some(mutants) = file.get("mutants").and_then(Value::as_array) { + for mutant in mutants { + let raw = mutant.get("id").and_then(Value::as_str).unwrap_or(""); + let qualified = format!("{path}:{raw}"); + apply_mutant( + &mut tests, + &qualified, + mutant.get("coveredBy").unwrap_or(&Value::Null), + mutant.get("killedBy").unwrap_or(&Value::Null), + ); + } + } + } + } + if let Some(test_files) = value.get("testFiles").and_then(Value::as_object) { + for (path, tf) in test_files { + if let Some(arr) = tf.get("tests").and_then(Value::as_array) { + for test in arr { + if let Some(id) = test.get("id").and_then(Value::as_str) { + let line = test + .get("location") + .and_then(|l| l.get("start")) + .and_then(|s| s.get("line")) + .and_then(Value::as_u64) + .map(|n| n as u32); + entry(&mut tests, id, path, line); + if entry_pending(test) { + tests.get_mut(id).unwrap().pending = true; + } + } + } + } + } + } + + tests.into_values().collect() +} + +/// Ingest per-test attribution from an audit-capable mutant-facts artifact into +/// `test_exposure_events`, so the diff "Tests" section can report kill metrics. +/// Independent of the subject-summary ingest and of any production-unit +/// resolution (tests key on a synthetic unit id). Returns the number of tests +/// recorded; zero when the artifact carries no attribution. +pub fn ingest_audit_test_attribution( + storage: &Storage, + input: &str, + commit_hash: &str, + timestamp: Option, + test_type: &str, +) -> Result { + if !storage.commit_exists(commit_hash)? { + anyhow::bail!("commit {commit_hash} is not present in gigasail metadata"); + } + let value: Value = serde_json::from_str(input).context("parse mutant-facts JSON")?; + let tests = collect_audit_tests(&value); + if tests.is_empty() { + return Ok(0); + } + let ts = timestamp + .or(storage.commit_timestamp(commit_hash)?) + .unwrap_or_default(); + let tag = normalize_test_type(test_type); + let owns_transaction = !storage.transaction_active(); + if owns_transaction { + storage.begin_transaction()?; + } + let result = (|| -> Result { + for test in &tests { + let killed_any = !test.killed.is_empty(); + let line = test.line.unwrap_or(1); + let path = if test.file.is_empty() { + format!("test:{}", test.id) + } else { + test.file.clone() + }; + // Each test is a logical unit (a test method) so the exposure event's + // FK resolves; test units carry no production risk of their own. + let unit = LogicalUnit::new( + test.id.clone(), + crate::model::UnitKind::Function, + path.clone(), + 0, + line, + line, + test.id.clone(), + &test.id, + ); + storage.upsert_logical_unit(&unit, ts)?; + let payload = serde_json::json!({ + "test_path": test.file, + "test_start_line": test.line, + "test_end_line": test.line, + "pending": test.pending, + "killed_mutant_ids": test.killed.iter().collect::>(), + }) + .to_string(); + storage.insert_test_exposure_event(&TestExposureEvent { + unit_id: unit.id.clone(), + commit_hash: commit_hash.to_string(), + timestamp: ts, + path, + function: None, + line: test.line, + branch_id: None, + test_id: test.id.clone(), + test_type: tag.clone(), + mutation_status: Some(if killed_any { "killed" } else { "alive" }.to_string()), + mutation_kind: Some("stochastic".to_string()), + mutation_corpus: String::new(), + is_mutation_verified: true, + is_mutation_killed: killed_any, + is_verified: true, + payload_json: payload, + })?; + } + Ok(tests.len()) + })(); + match result { + Ok(count) => { + if owns_transaction { + storage.commit_transaction()?; + } + Ok(count) + } + Err(error) => { + if owns_transaction { + let _ = storage.rollback_transaction(); + } + Err(error) + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct MutantFact { pub file: String, @@ -97,7 +338,7 @@ where E: BoundaryExtractor, { if !storage.commit_exists(commit_hash)? { - anyhow::bail!("commit {commit_hash} is not present in lineage metadata"); + anyhow::bail!("commit {commit_hash} is not present in gigasail metadata"); } if let Some(scope) = &options.evidence_scope { if scope.revision != commit_hash { @@ -111,6 +352,11 @@ where } } + // Record per-test attribution when the artifact is audit-capable (test-miser's + // tests[] + killed_by, or MTE). No-op for subject-only facts, so every mutant + // ingest path picks it up without changing subject-summary behavior. + ingest_audit_test_attribution(storage, input, commit_hash, timestamp, test_type)?; + let facts = parse_mutant_facts(input)?; let timestamp = timestamp .or(storage.commit_timestamp(commit_hash)?) @@ -654,6 +900,95 @@ fn u32_at(value: &Value, keys: &[&str]) -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn collect_audit_tests_reads_native_and_mte_shapes() { + // Native mutant-facts/v1: tests inventory + per-mutant killed_by. + let native = serde_json::json!({ + "schema": "mutant-facts/v1", + "tests": [ + {"id": "t:a", "name": "A", "file": "spec/a_spec.rb", "line": 5}, + {"id": "t:b", "name": "B", "file": "spec/a_spec.rb", "line": 12} + ], + "mutants": [ + {"id": "m1", "covered_by": ["t:a", "t:b"], "killed_by": ["t:a"]}, + {"id": "m2", "covered_by": ["t:b"], "killed_by": ["t:b"]} + ] + }); + let mut tests = collect_audit_tests(&native); + tests.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(tests.len(), 2); + assert_eq!(tests[0].id, "t:a"); + assert_eq!(tests[0].file, "spec/a_spec.rb"); + assert_eq!(tests[0].line, Some(5)); + assert_eq!(tests[0].killed, ["m1".to_string()].into_iter().collect()); + assert!(tests[0].covered); + assert_eq!(tests[1].killed, ["m2".to_string()].into_iter().collect()); + + // MTE shape: mutant ids qualified by file; test file from testFiles key. + let mte = serde_json::json!({ + "schemaVersion": "2.0", + "files": {"lib/x.rb": {"mutants": [ + {"id": "1", "coveredBy": ["t:a", "t:b"], "killedBy": ["t:a"]} + ]}}, + "testFiles": {"test/x_test.rb": {"tests": [ + {"id": "t:a", "name": "A"}, {"id": "t:b", "name": "B", "status": "skipped"} + ]}} + }); + let mut tests = collect_audit_tests(&mte); + tests.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(tests[0].file, "test/x_test.rb"); + assert_eq!(tests[0].killed, ["lib/x.rb:1".to_string()].into_iter().collect()); + assert!(!tests[0].pending); + assert!( + tests[1].covered && tests[1].killed.is_empty(), + "t:b covered but killed nothing" + ); + assert!(tests[1].pending, "t:b marked skipped"); + + // Subject-only facts carry no per-test attribution. + assert!(collect_audit_tests(&serde_json::json!({"subjects": []})).is_empty()); + } + + #[test] + fn audit_attribution_ingest_populates_test_inventory() { + let storage = Storage::open_memory().unwrap(); + storage + .insert_metadata(&CommitMetadata { + hash: "abc".into(), + message: "m".into(), + timestamp: 10, + }) + .unwrap(); + let facts = json!({ + "schema": "mutant-facts/v1", + "tests": [ + {"id": "t:a", "name": "A", "file": "spec/a_spec.rb", "line": 5}, + {"id": "t:b", "name": "B", "file": "spec/a_spec.rb", "line": 12} + ], + "mutants": [ + {"id": "m1", "covered_by": ["t:a", "t:b"], "killed_by": ["t:a"]}, + {"id": "m2", "covered_by": ["t:b"], "killed_by": []} + ] + }) + .to_string(); + + let n = ingest_audit_test_attribution(&storage, &facts, "abc", Some(10), "unit").unwrap(); + assert_eq!(n, 2); + + let mut inv = storage.test_inventory_for_commit("abc").unwrap(); + inv.sort_by(|a, b| a.test_id.cmp(&b.test_id)); + assert_eq!(inv.len(), 2); + assert_eq!(inv[0].test_id, "t:a"); + assert_eq!(inv[0].test_set, "unit"); + assert_eq!(inv[0].test_path, "spec/a_spec.rb"); + assert_eq!(inv[0].start_line, 5); + assert_eq!(inv[0].killed_mutants, ["m1".to_string()].into_iter().collect()); + assert!(inv[0].had_mutation); + // t:b covered a mutant but killed none -> a "kills no mutants" candidate. + assert!(inv[1].killed_mutants.is_empty()); + assert!(inv[1].had_mutation); + } use crate::extract::HeuristicExtractor; use crate::model::{BlobFile, CommitMetadata}; use crate::stack_trace::RepoPathNormalizer; @@ -694,7 +1029,7 @@ mod tests { fn parses_mutant_facts() { let payload = json!({ "schema": "mutant-facts/v1", - "source": "gems/lineage/tools/mutant-converters/ruby_mutant.rb", + "source": "gems/gigasail/tools/mutant-converters/ruby_mutant.rb", "language": "ruby", "mutation_kind": "ruby-mutant", "subjects": [{ @@ -714,7 +1049,7 @@ mod tests { assert_eq!(facts[0].method, "Worker#run"); assert_eq!( facts[0].source, - "gems/lineage/tools/mutant-converters/ruby_mutant.rb" + "gems/gigasail/tools/mutant-converters/ruby_mutant.rb" ); assert_eq!(facts[0].language, "ruby"); assert_eq!(facts[0].mutation_kind, "stochastic"); diff --git a/gems/lineage/src/db/quality.rs b/gems/gigasail/giga-core/src/db/quality.rs similarity index 99% rename from gems/lineage/src/db/quality.rs rename to gems/gigasail/giga-core/src/db/quality.rs index daa610436..23c1f10fe 100644 --- a/gems/lineage/src/db/quality.rs +++ b/gems/gigasail/giga-core/src/db/quality.rs @@ -90,7 +90,7 @@ pub fn ingest_coverage_json_with_options( options: &CoverageIngestOptions, ) -> Result { if !storage.commit_exists(commit_hash)? { - anyhow::bail!("commit {commit_hash} is not present in lineage metadata"); + anyhow::bail!("commit {commit_hash} is not present in gigasail metadata"); } let records = parse_coverage_input(input, format)?; @@ -785,7 +785,7 @@ mod tests { #[test] fn parses_sqlcov_branch_states_as_partial_line_coverage() { let payload = serde_json::json!({ - "format": "sql-cov/v1", "file_path": "gems/lineage/sql/demo.sql", + "format": "sql-cov/v1", "file_path": "gems/gigasail/sql/demo.sql", "statements": [{ "start_line": 2, "end_line": 3, "hit_count": 1 }], "metrics": [{ "measurable": true, "span": { "start_line": 2, "nullable": true }, @@ -797,7 +797,7 @@ mod tests { }); let records = parse_coverage_records(&payload, "sqlcov").unwrap(); assert_eq!(records.len(), 1); - assert_eq!(records[0].path, "gems/lineage/sql/demo.sql"); + assert_eq!(records[0].path, "gems/gigasail/sql/demo.sql"); assert!((records[0].line_coverage.unwrap() - 250.0 / 3.0).abs() < 0.000_001); assert_eq!(records[0].integration_coverage, Some(200.0 / 3.0)); assert_eq!( diff --git a/gems/lineage/src/db/queries/c/tags.scm b/gems/gigasail/giga-core/src/db/queries/c/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/c/tags.scm rename to gems/gigasail/giga-core/src/db/queries/c/tags.scm diff --git a/gems/lineage/src/db/queries/cpp/tags.scm b/gems/gigasail/giga-core/src/db/queries/cpp/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/cpp/tags.scm rename to gems/gigasail/giga-core/src/db/queries/cpp/tags.scm diff --git a/gems/lineage/src/db/queries/csharp/tags.scm b/gems/gigasail/giga-core/src/db/queries/csharp/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/csharp/tags.scm rename to gems/gigasail/giga-core/src/db/queries/csharp/tags.scm diff --git a/gems/lineage/src/db/queries/go/tags.scm b/gems/gigasail/giga-core/src/db/queries/go/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/go/tags.scm rename to gems/gigasail/giga-core/src/db/queries/go/tags.scm diff --git a/gems/lineage/src/db/queries/java/tags.scm b/gems/gigasail/giga-core/src/db/queries/java/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/java/tags.scm rename to gems/gigasail/giga-core/src/db/queries/java/tags.scm diff --git a/gems/lineage/src/db/queries/javascript/tags.scm b/gems/gigasail/giga-core/src/db/queries/javascript/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/javascript/tags.scm rename to gems/gigasail/giga-core/src/db/queries/javascript/tags.scm diff --git a/gems/lineage/src/db/queries/kotlin/tags.scm b/gems/gigasail/giga-core/src/db/queries/kotlin/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/kotlin/tags.scm rename to gems/gigasail/giga-core/src/db/queries/kotlin/tags.scm diff --git a/gems/lineage/src/db/queries/lua/tags.scm b/gems/gigasail/giga-core/src/db/queries/lua/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/lua/tags.scm rename to gems/gigasail/giga-core/src/db/queries/lua/tags.scm diff --git a/gems/lineage/src/db/queries/php/tags.scm b/gems/gigasail/giga-core/src/db/queries/php/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/php/tags.scm rename to gems/gigasail/giga-core/src/db/queries/php/tags.scm diff --git a/gems/lineage/src/db/queries/python/tags.scm b/gems/gigasail/giga-core/src/db/queries/python/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/python/tags.scm rename to gems/gigasail/giga-core/src/db/queries/python/tags.scm diff --git a/gems/lineage/src/db/queries/ruby/tags.scm b/gems/gigasail/giga-core/src/db/queries/ruby/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/ruby/tags.scm rename to gems/gigasail/giga-core/src/db/queries/ruby/tags.scm diff --git a/gems/lineage/src/db/queries/rust/tags.scm b/gems/gigasail/giga-core/src/db/queries/rust/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/rust/tags.scm rename to gems/gigasail/giga-core/src/db/queries/rust/tags.scm diff --git a/gems/lineage/src/db/queries/swift/tags.scm b/gems/gigasail/giga-core/src/db/queries/swift/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/swift/tags.scm rename to gems/gigasail/giga-core/src/db/queries/swift/tags.scm diff --git a/gems/lineage/src/db/queries/typescript/tags.scm b/gems/gigasail/giga-core/src/db/queries/typescript/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/typescript/tags.scm rename to gems/gigasail/giga-core/src/db/queries/typescript/tags.scm diff --git a/gems/lineage/src/db/queries/zig/tags.scm b/gems/gigasail/giga-core/src/db/queries/zig/tags.scm similarity index 100% rename from gems/lineage/src/db/queries/zig/tags.scm rename to gems/gigasail/giga-core/src/db/queries/zig/tags.scm diff --git a/gems/lineage/src/db/sarif.rs b/gems/gigasail/giga-core/src/db/sarif.rs similarity index 99% rename from gems/lineage/src/db/sarif.rs rename to gems/gigasail/giga-core/src/db/sarif.rs index d9619d7ab..57867bb8d 100644 --- a/gems/lineage/src/db/sarif.rs +++ b/gems/gigasail/giga-core/src/db/sarif.rs @@ -16,7 +16,7 @@ pub struct SarifIngestStats { pub skipped_results: usize, } -/// A single physical SARIF finding after applying Lineage's shared path, +/// A single physical SARIF finding after applying Gigasail's shared path, /// fingerprint, category, and provenance normalization. Both persisted SARIF /// ingestion and ephemeral diff overlays consume this representation. #[derive(Debug, Clone, PartialEq)] @@ -404,7 +404,7 @@ fn normalize_sarif_result( .unwrap_or_else(|| short_hash(&format!("{rule_id}\0{message}\0{properties_json}"))); let provenance = string_properties(&properties); let proof_boundary: Vec = properties - .get("lineage.proof_boundary") + .get("gigasail.proof_boundary") .and_then(Value::as_array) .map(|values| { values @@ -747,7 +747,7 @@ mod tests { let properties = serde_json::json!({ "format": "espalier.report.sarif.v1", "tier": 1, - "lineage.proof_boundary": ["bounded input"], + "gigasail.proof_boundary": ["bounded input"], "espalier.manifest": large_manifest, }); @@ -756,7 +756,7 @@ mod tests { assert_eq!(persisted["format"], "espalier.report.sarif.v1"); assert_eq!(persisted["tier"], 1); assert_eq!( - persisted["lineage.proof_boundary"], + persisted["gigasail.proof_boundary"], serde_json::json!(["bounded input"]) ); assert!(persisted.get("espalier.manifest").is_none()); @@ -800,7 +800,7 @@ mod tests { "properties": { "format": "fact-mine.report.sarif.v1", "tier": 1, - "lineage.proof_boundary": ["bounded hazard scan"] + "gigasail.proof_boundary": ["bounded hazard scan"] }, "results": [{ "ruleId": "fact-mine.hazard", diff --git a/gems/lineage/src/db/stack_trace.rs b/gems/gigasail/giga-core/src/db/stack_trace.rs similarity index 99% rename from gems/lineage/src/db/stack_trace.rs rename to gems/gigasail/giga-core/src/db/stack_trace.rs index 01262835d..d1346cca1 100644 --- a/gems/lineage/src/db/stack_trace.rs +++ b/gems/gigasail/giga-core/src/db/stack_trace.rs @@ -151,7 +151,7 @@ where for payload in payloads { if !storage.commit_exists(&payload.commit_hash)? { anyhow::bail!( - "commit {} is not present in lineage metadata", + "commit {} is not present in gigasail metadata", payload.commit_hash ); } @@ -433,7 +433,7 @@ mod tests { } }); - // 1. Should fail because commit is not in lineage storage metadata + // 1. Should fail because commit is not in gigasail storage metadata let err = ingest_stack_traces( &storage, &SentryProvider, diff --git a/gems/lineage/src/db/storage.rs b/gems/gigasail/giga-core/src/db/storage.rs similarity index 85% rename from gems/lineage/src/db/storage.rs rename to gems/gigasail/giga-core/src/db/storage.rs index 1ed8d8e8f..8374e26f4 100644 --- a/gems/lineage/src/db/storage.rs +++ b/gems/gigasail/giga-core/src/db/storage.rs @@ -11,6 +11,14 @@ use rusqlite::{params, Connection, OptionalExtension}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; +/// The hotness overlay query, shared by core summary materialization and the +/// giga-ui runtime overlay. Exposed as a const so consumers across the crate +/// boundary reuse the single source instead of a cross-crate `include_str!`. +pub const APPLY_HOTNESS_SQL: &str = include_str!("../../sql/core/apply_hotness.sql"); +/// Per-line active-hazard overlay. Shared by the web UI, the LSP, and the MCP +/// `giga_unit_context` tool — it is a Storage runtime query, so it lives here. +pub const APPLY_HAZARDS_SQL: &str = include_str!("../../sql/core/apply_hazards.sql"); + pub struct Storage { conn: Connection, } @@ -87,6 +95,12 @@ pub struct UnitSummary { pub verification_stale_seconds: i64, pub verification_staleness_score: f64, pub reopened_count: i64, + /// Big-O time/space complexity from the architecture graph, with status + /// complete | partial | unknown (unknown = no analysis available). + pub big_o_time: String, + pub big_o_time_status: String, + pub big_o_space: String, + pub big_o_space_status: String, } impl Storage { @@ -97,6 +111,13 @@ impl Storage { .filter(|parent| !parent.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; + // Make the state directory self-ignoring so the database, its WAL/SHM + // sidecars, run artifacts, and the coordination lock never surface as + // Git changes (in `giga diff`, the clean-worktree gate, or `git status`). + let ignore = parent.join(".gitignore"); + if !ignore.exists() { + let _ = std::fs::write(&ignore, "*\n"); + } } let conn = Connection::open(path)?; configure_connection(&conn)?; @@ -157,6 +178,7 @@ impl Storage { self.ensure_logical_unit_column("current_mutant_verified_tests", "INTEGER DEFAULT 0")?; self.ensure_logical_unit_column("current_mutant_killed_tests", "INTEGER DEFAULT 0")?; self.ensure_logical_unit_column("last_test_exposure_at", "INTEGER DEFAULT 0")?; + self.ensure_big_o_columns()?; self.ensure_column( "test_exposure_events", "mutation_kind", @@ -227,7 +249,7 @@ impl Storage { Ok(()) } - pub(crate) fn connection(&self) -> &Connection { + pub fn connection(&self) -> &Connection { &self.conn } @@ -853,7 +875,10 @@ impl Storage { artifact.artifact_sha256, artifact.commit_hash, artifact.timestamp, - artifact.payload_json + // Never read back (findings are normalized into sarif_findings; + // the gzipped run-store artifact is the durable raw copy), so we + // do not persist the full document text here. + "" ], )?; let id = self.conn.query_row( @@ -1805,6 +1830,286 @@ impl Storage { Ok(changed > 0) } + /// Self-heal the per-stage new-test timing history table (idempotent). + fn ensure_test_stage_timings_table(&self) -> Result<()> { + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS test_stage_timings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + commit_hash TEXT NOT NULL, + stage TEXT NOT NULL, + test_set TEXT NOT NULL, + elapsed_ms REAL NOT NULL, + stddev_ms REAL NOT NULL DEFAULT 0, + n_samples INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER NOT NULL DEFAULT 0, + UNIQUE(commit_hash, stage, test_set) + ); + CREATE INDEX IF NOT EXISTS idx_test_stage_timings_lookup + ON test_stage_timings(stage, test_set, timestamp);", + )?; + self.ensure_column("test_stage_timings", "stddev_ms", "REAL NOT NULL DEFAULT 0")?; + Ok(()) + } + + /// Record the measured new-test time for a stage at a commit (upsert). + /// `n_samples` is how many repeat runs `elapsed_ms` averages, feeding the + /// confidence interval later. + pub fn record_stage_timing( + &self, + commit_hash: &str, + stage: &str, + test_set: &str, + elapsed_ms: f64, + stddev_ms: f64, + n_samples: i64, + timestamp: i64, + ) -> Result<()> { + self.ensure_test_stage_timings_table()?; + self.conn.execute( + "INSERT INTO test_stage_timings \ + (commit_hash, stage, test_set, elapsed_ms, stddev_ms, n_samples, timestamp) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(commit_hash, stage, test_set) DO UPDATE SET \ + elapsed_ms = ?4, stddev_ms = ?5, n_samples = ?6, timestamp = ?7", + params![commit_hash, stage, test_set, elapsed_ms, stddev_ms, n_samples, timestamp], + )?; + Ok(()) + } + + /// The last `window` per-commit stage times for a (stage, test_set), + /// excluding `exclude_commit` - the baseline the current run compares against. + pub fn stage_timing_history( + &self, + stage: &str, + test_set: &str, + window: usize, + exclude_commit: &str, + ) -> Result> { + self.ensure_test_stage_timings_table()?; + let mut stmt = self.conn.prepare( + "SELECT elapsed_ms FROM test_stage_timings \ + WHERE stage = ?1 AND test_set = ?2 AND commit_hash <> ?3 \ + ORDER BY timestamp DESC, id DESC LIMIT ?4", + )?; + let rows = stmt.query_map( + params![stage, test_set, exclude_commit, window as i64], + |row| row.get::<_, f64>(0), + )?; + Ok(rows.collect::, _>>()?) + } + + /// The recorded new-test time for a specific commit/stage/test_set, if any. + /// The recorded new-test measurement `(mean_ms, stddev_ms, n_samples)` for a + /// commit/stage/test_set, if any. + pub fn stage_timing_for_commit( + &self, + commit_hash: &str, + stage: &str, + test_set: &str, + ) -> Result> { + self.ensure_test_stage_timings_table()?; + Ok(self + .conn + .query_row( + "SELECT elapsed_ms, stddev_ms, n_samples FROM test_stage_timings \ + WHERE commit_hash = ?1 AND stage = ?2 AND test_set = ?3", + params![commit_hash, stage, test_set], + |row| Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, i64>(2)?)), + ) + .optional()?) + } + + /// Self-heal the Big-O columns on `logical_units`. `Storage::open` only + /// initializes brand-new files, so existing databases need this on every + /// Big-O read/write path (idempotent). Status is complete | partial | + /// unknown (unknown = no analysis). + pub fn ensure_big_o_columns(&self) -> Result<()> { + self.ensure_logical_unit_column("big_o_time", "TEXT DEFAULT ''")?; + self.ensure_logical_unit_column("big_o_time_status", "TEXT DEFAULT 'unknown'")?; + self.ensure_logical_unit_column("big_o_space", "TEXT DEFAULT ''")?; + self.ensure_logical_unit_column("big_o_space_status", "TEXT DEFAULT 'unknown'")?; + Ok(()) + } + + /// Record a function's Big-O time/space complexity (from the architecture + /// graph) on its logical unit. `status` is complete | partial | unknown. + pub fn update_logical_unit_big_o( + &self, + unit_id: &str, + time: &str, + time_status: &str, + space: &str, + space_status: &str, + ) -> Result<()> { + self.ensure_big_o_columns()?; + self.conn.execute( + "UPDATE logical_units SET big_o_time = ?2, big_o_time_status = ?3, \ + big_o_space = ?4, big_o_space_status = ?5 WHERE id = ?1", + params![unit_id, time, time_status, space, space_status], + )?; + Ok(()) + } + + /// The Big-O complexity recorded for a logical unit: (time, time_status, + /// space, space_status). `unknown` status means no analysis is available. + pub fn logical_unit_big_o(&self, unit_id: &str) -> Result<(String, String, String, String)> { + Ok(self + .conn + .query_row( + "SELECT big_o_time, big_o_time_status, big_o_space, big_o_space_status \ + FROM logical_units WHERE id = ?1", + params![unit_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()? + .unwrap_or_else(|| { + ( + String::new(), + "unknown".into(), + String::new(), + "unknown".into(), + ) + })) + } + + /// Big-O for a function identified by its current file path and name, for + /// the diff function box. Returns unknown when no unit matches or none has + /// analysis. Matches on the unit's latest-event path (or original path). + pub fn function_big_o( + &self, + path: &str, + name: &str, + ) -> Result<(String, String, String, String)> { + self.ensure_big_o_columns()?; + // The diff's group name and the stored unit name may differ in + // qualification (`constant` vs `Calc.constant`/`Calc#constant`), so match + // the leaf with a suffix LIKE as the reconciler does. + let leaf = name.rsplit(['.', ':', '#']).next().unwrap_or(name); + let suffix = format!("%{leaf}"); + Ok(self + .conn + .query_row( + "SELECT u.big_o_time, u.big_o_time_status, u.big_o_space, u.big_o_space_status \ + FROM logical_units u \ + LEFT JOIN (SELECT unit_id, path, \ + ROW_NUMBER() OVER (PARTITION BY unit_id ORDER BY timestamp DESC, id DESC) rk \ + FROM events) e ON e.unit_id = u.id AND e.rk = 1 \ + WHERE (u.name = ?2 OR u.name = ?4 OR u.name LIKE ?3) \ + AND COALESCE(e.path, u.original_path) = ?1 \ + AND (u.big_o_time_status <> 'unknown' OR u.big_o_space_status <> 'unknown') \ + LIMIT 1", + params![path, name, suffix, leaf], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()? + .unwrap_or_else(|| { + ( + String::new(), + "unknown".into(), + String::new(), + "unknown".into(), + ) + })) + } + + /// Aggregate `test_exposure_events` for one commit into a per-test inventory + /// for the diff "Tests" section. Test-level attributes (the test's own file + /// and definition span, pending status, and the set of mutants it killed) + /// ride in `payload_json` — runners that don't emit them degrade gracefully + /// (no def-span → never "changed"; no killed ids → falls back to the killed + /// coverage lines as a coarse mutant identity for redundancy). + pub fn test_inventory_for_commit( + &self, + commit_hash: &str, + ) -> Result> { + use std::collections::{BTreeMap, BTreeSet}; + let mut stmt = self.conn.prepare( + "SELECT test_id, test_type, path, line, is_mutation_verified, \ + mutation_status, is_mutation_killed, payload_json \ + FROM test_exposure_events WHERE commit_hash = ?1", + )?; + struct Agg { + test_type: String, + lines: BTreeSet<(String, i64)>, + had_mutation: bool, + killed: BTreeSet, + payload: String, + } + let mut map: BTreeMap = BTreeMap::new(); + let rows = stmt.query_map([commit_hash], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, i64>(6)?, + r.get::<_, String>(7)?, + )) + })?; + for row in rows { + let (test_id, test_type, path, line, verified, status, killed, payload) = row?; + let agg = map.entry(test_id).or_insert_with(|| Agg { + test_type, + lines: BTreeSet::new(), + had_mutation: false, + killed: BTreeSet::new(), + payload: "{}".to_string(), + }); + if let Some(l) = line { + agg.lines.insert((path.clone(), l)); + } + if verified == 1 || status.is_some() { + agg.had_mutation = true; + } + // Coarse fallback mutant identity when the runner gives no id: the + // killed line. Overwritten below if payload carries explicit ids. + if killed == 1 { + if let Some(l) = line { + agg.killed.insert(format!("{path}:{l}")); + } + } + if payload.len() > agg.payload.len() { + agg.payload = payload; + } + } + Ok(map + .into_iter() + .map(|(test_id, agg)| { + let meta = crate::test_summary::TestPayloadMeta::parse(&agg.payload); + let killed = if meta.killed_mutants.is_empty() { + agg.killed + } else { + meta.killed_mutants + }; + // Fall back to a covered file's language when the runner did not + // emit the test's own path. + let language = if meta.language == "unknown" { + agg.lines + .iter() + .next() + .map(|(p, _)| crate::test_summary::language_from_path(p)) + .unwrap_or_else(|| "unknown".to_string()) + } else { + meta.language + }; + crate::test_summary::TestInventoryRow { + test_id, + test_set: agg.test_type, + language, + test_path: meta.test_path, + start_line: meta.start_line, + end_line: meta.end_line, + pending: meta.pending, + covered_lines: agg.lines.len(), + had_mutation: agg.had_mutation, + killed_mutants: killed, + } + }) + .collect()) + } + /// unit_hotness postdates many deployed databases and Storage::open only /// initializes brand-new files, so every hotness path self-heals the /// table (idempotent, matching the ensure_column migration style). @@ -1889,7 +2194,7 @@ impl Storage { self.ensure_unit_hotness_table()?; let mut stmt = self .conn - .prepare(include_str!("../../sql/ui/runtime/top_hotness.sql"))?; + .prepare(include_str!("../../sql/core/top_hotness.sql"))?; let rows = stmt .query_map([], |row| { Ok(crate::model::HotnessRow { @@ -1910,7 +2215,7 @@ impl Storage { self.ensure_unit_hotness_table()?; let mut stmt = self .conn - .prepare(include_str!("../../sql/ui/runtime/apply_hotness.sql"))?; + .prepare(APPLY_HOTNESS_SQL)?; let path_owned = path.to_string(); let rows = stmt .query_map(params![path], move |row| { @@ -2159,6 +2464,10 @@ impl Storage { verification_staleness_score: verification_stale_seconds as f64 / 86_400.0, reopened_count: row.get(21)?, risk_score: 0.0, + big_o_time: String::new(), + big_o_time_status: "unknown".into(), + big_o_space: String::new(), + big_o_space_status: "unknown".into(), }) })?; rows.collect::, _>>()? @@ -2267,6 +2576,10 @@ impl Storage { verification_staleness_score: verification_stale_seconds as f64 / 86_400.0, reopened_count: row.get(21)?, risk_score: 0.0, + big_o_time: String::new(), + big_o_time_status: "unknown".into(), + big_o_space: String::new(), + big_o_space_status: "unknown".into(), }) })?; rows.collect::, _>>()? @@ -2297,6 +2610,15 @@ impl Storage { .then_with(|| left.name.cmp(&right.name)) }); out.truncate(limit); + // Enrich the surfaced units with their Big-O complexity (cheap: only the + // truncated top-N). Left as unknown/empty when no analysis is recorded. + for summary in &mut out { + let (time, time_status, space, space_status) = self.logical_unit_big_o(&summary.id)?; + summary.big_o_time = time; + summary.big_o_time_status = time_status; + summary.big_o_space = space; + summary.big_o_space_status = space_status; + } Ok(out) } } @@ -2363,6 +2685,7 @@ fn checked_table(table: &str) -> Result<&str> { | "quality_events" | "crash_events" | "test_exposure_events" + | "test_stage_timings" | "unit_hazards" | "unit_hotness" | "coverage_line_events" @@ -2396,14 +2719,16 @@ mod tests { } #[test] - fn extracted_storage_and_ui_queries_prepare_against_the_real_schema() { + fn extracted_storage_queries_prepare_against_the_real_schema() { let storage = Storage::open_memory().unwrap(); let sql_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("sql"); - let files = [sql_root.join("storage"), sql_root.join("ui/runtime")] + // UI runtime queries live in the giga-ui crate and are validated there + // against this same schema (see giga-ui's schema test). + let files = [sql_root.join("storage"), sql_root.join("core")] .into_iter() .flat_map(|root| collected_sql_files(&root)) .collect::>(); - assert!(files.len() >= 70); + assert!(files.len() >= 45); for path in files { let sql = fs::read_to_string(&path) .unwrap() @@ -2777,6 +3102,96 @@ mod tests { assert_eq!(summary, (2, "integration,unit".into(), 1, 1, 10)); } + #[test] + fn stage_timings_record_history_and_current() { + let storage = Storage::open_memory().unwrap(); + storage.record_stage_timing("c1", "precommit", "unit", 100.0, 2.0, 1, 10).unwrap(); + storage.record_stage_timing("c2", "precommit", "unit", 102.0, 2.0, 1, 20).unwrap(); + storage.record_stage_timing("c3", "precommit", "unit", 98.0, 1.5, 3, 30).unwrap(); + // Baseline is the history excluding the current commit. + let hist = storage.stage_timing_history("precommit", "unit", 10, "c3").unwrap(); + assert_eq!(hist.len(), 2); + assert!(hist.contains(&100.0) && hist.contains(&102.0)); + // The current commit's own measurement (mean, stddev, n) is retrievable. + assert_eq!( + storage.stage_timing_for_commit("c3", "precommit", "unit").unwrap(), + Some((98.0, 1.5, 3)) + ); + // Re-recording upserts. + storage.record_stage_timing("c3", "precommit", "unit", 95.0, 0.5, 4, 31).unwrap(); + assert_eq!( + storage.stage_timing_for_commit("c3", "precommit", "unit").unwrap(), + Some((95.0, 0.5, 4)) + ); + // A different test_set is isolated. + assert!(storage + .stage_timing_for_commit("c3", "precommit", "integration") + .unwrap() + .is_none()); + } + + #[test] + fn test_inventory_aggregates_payload_and_kills_per_test() { + let storage = Storage::open_memory().unwrap(); + let unit = LogicalUnit::new( + "run", + UnitKind::Function, + "src/a.rb", + 1, + 1, + 3, + "def run", + "def run\n1\nend", + ); + storage.upsert_logical_unit(&unit, 10).unwrap(); + let event = |test_id: &str, line: u32, killed: bool, payload: &str| TestExposureEvent { + unit_id: unit.id.clone(), + commit_hash: "abc".into(), + timestamp: 10, + path: "src/a.rb".into(), + function: Some("run".into()), + line: Some(line), + branch_id: Some(format!("{test_id}:{line}")), + test_id: test_id.into(), + test_type: "unit".into(), + mutation_status: Some(if killed { "killed" } else { "alive" }.into()), + mutation_kind: Some("stochastic".into()), + mutation_corpus: String::new(), + is_mutation_verified: true, + is_mutation_killed: killed, + is_verified: true, + payload_json: payload.into(), + }; + // One test covering two lines, with explicit payload metadata + kills. + let meta = r#"{"test_path":"spec/a_spec.rb","test_start_line":4,"test_end_line":9,"pending":false,"killed_mutant_ids":["m1","m2"]}"#; + storage.insert_test_exposure_event(&event("spec/a_spec.rb:killer", 2, true, meta)).unwrap(); + storage.insert_test_exposure_event(&event("spec/a_spec.rb:killer", 3, true, meta)).unwrap(); + // A pending test with no coverage lines and no payload kill ids. + let pmeta = r#"{"test_path":"spec/a_spec.rb","test_start_line":11,"test_end_line":13,"pending":true}"#; + let mut pending = event("spec/a_spec.rb:pending", 2, false, pmeta); + pending.line = None; // no covered line + storage.insert_test_exposure_event(&pending).unwrap(); + + let mut inv = storage.test_inventory_for_commit("abc").unwrap(); + inv.sort_by(|a, b| a.test_id.cmp(&b.test_id)); + assert_eq!(inv.len(), 2); + let killer = &inv[0]; + assert_eq!(killer.test_id, "spec/a_spec.rb:killer"); + assert_eq!(killer.language, "ruby"); + assert_eq!(killer.test_path, "spec/a_spec.rb"); + assert_eq!((killer.start_line, killer.end_line), (4, 9)); + assert_eq!(killer.covered_lines, 2, "two distinct covered lines"); + assert!(killer.had_mutation); + assert_eq!( + killer.killed_mutants, + ["m1".to_string(), "m2".to_string()].into_iter().collect() + ); + let pending = &inv[1]; + assert!(pending.pending); + assert_eq!(pending.covered_lines, 0); + assert!(pending.killed_mutants.is_empty()); + } + #[test] fn top_units_include_test_exposure_hardening_fields() { let storage = Storage::open_memory().unwrap(); diff --git a/gems/lineage/src/db/test_exposure.rs b/gems/gigasail/giga-core/src/db/test_exposure.rs similarity index 99% rename from gems/lineage/src/db/test_exposure.rs rename to gems/gigasail/giga-core/src/db/test_exposure.rs index fecd53e72..b1fd1561e 100644 --- a/gems/lineage/src/db/test_exposure.rs +++ b/gems/gigasail/giga-core/src/db/test_exposure.rs @@ -44,7 +44,7 @@ where E: BoundaryExtractor, { if !storage.commit_exists(commit_hash)? { - anyhow::bail!("commit {commit_hash} is not present in lineage metadata"); + anyhow::bail!("commit {commit_hash} is not present in gigasail metadata"); } let value: Value = serde_json::from_str(input).context("parse test exposure JSON")?; diff --git a/gems/lineage/src/db/vcs.rs b/gems/gigasail/giga-core/src/db/vcs.rs similarity index 100% rename from gems/lineage/src/db/vcs.rs rename to gems/gigasail/giga-core/src/db/vcs.rs diff --git a/gems/lineage/src/diff.rs b/gems/gigasail/giga-core/src/diff.rs similarity index 96% rename from gems/lineage/src/diff.rs rename to gems/gigasail/giga-core/src/diff.rs index 78c62deaa..7d9e9fd57 100644 --- a/gems/lineage/src/diff.rs +++ b/gems/gigasail/giga-core/src/diff.rs @@ -18,7 +18,7 @@ pub struct RevisionFile { } /// Repository-local source-role overrides, parsed from the immutable head -/// revision's `.lineage/diff.toml`. They are intentionally limited to exact +/// revision's `.giga/diff.toml`. They are intentionally limited to exact /// paths and directory prefixes so classification remains auditable and does /// not need a second glob language. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -49,6 +49,11 @@ pub struct DiffPlan { pub inventory: ChangeInventory, pub dependency_changes: Vec, pub language_summaries: Vec, + /// Per `language:test_set` test-suite churn + quality, for the "Tests" + /// section. Only groups the change touched appear. Empty when no test files + /// changed or no test evidence is ingested. + #[serde(default)] + pub test_summaries: Vec, pub evidence: EvidenceAvailability, pub resolved_sarif_findings: Vec, pub files: Vec, @@ -63,6 +68,14 @@ pub struct ChangeInventory { pub deleted_files: usize, pub renamed_files: usize, pub by_role: BTreeMap, + /// Binary files newly added by this change (path, byte size). Surfaced as a + /// red warning - added binaries in a source diff are usually a mistake. + #[serde(default)] + pub binary_added: Vec, + /// Third-party packages newly imported by this change, keyed by language. + /// Excludes stdlib and first-party (same-repo) imports. Sorted, unique. + #[serde(default)] + pub new_packages: BTreeMap>, pub configuration_paths: Vec, pub documentation_paths: Vec, pub generated_paths: Vec, @@ -75,6 +88,29 @@ pub struct ConfigFile { pub kind: String, } +/// A binary file added by the change, with its byte size. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +pub struct BinaryFile { + pub path: String, + pub bytes: u64, +} + +/// Human-readable byte size: `12 B`, `3.4 KB`, `1.2 MB`, `5.0 GB` (base 1024). +pub fn fmt_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -129,6 +165,9 @@ pub struct DiffFile { pub line_annotations: Vec, pub residual_lines: AddedLines, pub groups: Vec, + /// Modules imported/required on this file's added lines, from the ingested + /// architecture graph. Empty when no graph is available. + pub added_imports: Vec, /// Commit-matching SARIF observations. They are intentionally kept out of /// risk scoring until their artifact scope can prove completeness. pub sarif_findings: Vec, @@ -138,6 +177,14 @@ pub struct DiffFile { line_verification: BTreeMap, } +impl DiffFile { + /// New-side code line numbers this diff introduced (added source lines). + /// Used to decide which architecture facts are *newly* added. + pub fn added_line_numbers(&self) -> BTreeSet { + self.line_verification.keys().copied().collect() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] pub struct EvidenceAvailability { pub coverage: EvidenceState, @@ -291,6 +338,23 @@ pub struct DiffGroup { pub added_lines: AddedLines, pub verification: VerificationSlices, pub sarif_findings: Vec, + /// Collaboration targets (`Owner#name`, or a bare name for externals) whose + /// call site first appears on this group's added lines. Sourced from the + /// ingested architecture graph; empty when none is available. + pub added_dependencies: Vec, + /// State accesses (`read:field` / `write:field`) whose site first appears on + /// this group's added lines. Sourced from the ingested architecture graph. + pub added_state: Vec, + /// Big-O time/space complexity of this function + status (complete | partial + /// | unknown), from the architecture graph. Empty/unknown when unavailable. + #[serde(default)] + pub big_o_time: String, + #[serde(default)] + pub big_o_time_status: String, + #[serde(default)] + pub big_o_space: String, + #[serde(default)] + pub big_o_space_status: String, pub risk: RiskSummary, } @@ -403,6 +467,7 @@ pub fn build_diff_plan_with_renames_and_overrides( inventory, dependency_changes, language_summaries, + test_summaries: Vec::new(), evidence: unavailable_evidence(), resolved_sarif_findings: Vec::new(), files, @@ -512,6 +577,7 @@ fn plan_file( verification, line_annotations: Vec::new(), groups, + added_imports: Vec::new(), sarif_findings: Vec::new(), base_source, head_source, @@ -1145,6 +1211,12 @@ fn semantic_groups( risk, verification, sarif_findings: Vec::new(), + added_dependencies: Vec::new(), + added_state: Vec::new(), + big_o_time: String::new(), + big_o_time_status: "unknown".to_string(), + big_o_space: String::new(), + big_o_space_status: "unknown".to_string(), added_lines, } }) @@ -1874,7 +1946,7 @@ impl ClassificationOverrides { } } -/// Parses `.lineage/diff.toml` from the selected head revision. Invalid, +/// Parses `.giga/diff.toml` from the selected head revision. Invalid, /// absolute, and traversal paths are ignored rather than applying a broad /// classification to an unintended file. pub fn classification_overrides(contents: Option<&str>) -> ClassificationOverrides { @@ -1980,8 +2052,8 @@ fn is_lockfile(path: &str) -> bool { fn config_kind(path: &str) -> Option<&'static str> { let file = path.rsplit('/').next().unwrap_or(path); - if path == ".lineage/diff.toml" { - return Some("lineage"); + if path == ".giga/diff.toml" { + return Some("gigasail"); } if path.starts_with(".github/workflows/") && (file.ends_with(".yml") || file.ends_with(".yaml")) { @@ -2119,7 +2191,7 @@ fn is_manifest_path(path: &str) -> bool { ) } -fn language_for_path(path: &str) -> Option { +pub(crate) fn language_for_path(path: &str) -> Option { let extension = path.rsplit('.').next()?.to_ascii_lowercase(); let language = match extension.as_str() { "c" | "h" => "c", @@ -2146,6 +2218,16 @@ fn language_for_path(path: &str) -> Option { mod tests { use super::*; + #[test] + fn fmt_bytes_scales_by_1024_and_keeps_bytes_exact() { + assert_eq!(fmt_bytes(0), "0 B"); + assert_eq!(fmt_bytes(512), "512 B"); + assert_eq!(fmt_bytes(1024), "1.0 KB"); + assert_eq!(fmt_bytes(1536), "1.5 KB"); + assert_eq!(fmt_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(fmt_bytes(3 * 1024 * 1024 * 1024), "3.0 GB"); + } + fn file(path: &str, contents: &str) -> RevisionFile { RevisionFile { path: path.to_string(), @@ -2259,11 +2341,11 @@ mod tests { "base", "head", vec![file( - "gems/lineage/Cargo.toml", + "gems/gigasail/Cargo.toml", "[dependencies]\nserde = \"1\"\n[dev-dependencies]\ntempfile = \"3\"\n", )], vec![file( - "gems/lineage/Cargo.toml", + "gems/gigasail/Cargo.toml", "[dependencies]\nserde = { version = \"1.0\" }\ntoml = \"0.8\"\n", )], ); @@ -3362,7 +3444,7 @@ mod tests { SourceRole::Production ); assert_eq!( - source_role_with_overrides(".lineage/diff.toml", &overrides), + source_role_with_overrides(".giga/diff.toml", &overrides), SourceRole::Configuration ); } diff --git a/gems/lineage/src/diff_render.rs b/gems/gigasail/giga-core/src/diff_render.rs similarity index 71% rename from gems/lineage/src/diff_render.rs rename to gems/gigasail/giga-core/src/diff_render.rs index 172b2cc07..a2fcd91ac 100644 --- a/gems/lineage/src/diff_render.rs +++ b/gems/gigasail/giga-core/src/diff_render.rs @@ -1,10 +1,10 @@ -//! Stable presentation formats for structured Lineage diffs. +//! Stable presentation formats for structured Gigasail diffs. use crate::diff::{DiffGroup, DiffPlan, EvidenceState, FileChangeKind, SourceRole, Visibility}; use serde::Serialize; use std::fmt::Write; -pub const STRUCTURED_DIFF_FORMAT_VERSION: &str = "lineage-diff/v1"; +pub const STRUCTURED_DIFF_FORMAT_VERSION: &str = "gigasail-diff/v1"; /// Versioned JSON envelope for CI and editor integrations. #[derive(Debug, Serialize)] @@ -28,7 +28,7 @@ pub fn render_structured_diff_text(plan: &DiffPlan, full: bool) -> String { let mut output = String::new(); let _ = writeln!( output, - "Lineage diff {}..{} ({})", + "Gigasail diff {}..{} ({})", plan.scope.base_oid, plan.scope.head_oid, plan.scope.policy_version ); let _ = writeln!( @@ -84,6 +84,51 @@ pub fn render_structured_diff_text(plan: &DiffPlan, full: bool) -> String { .map_or_else(|| "unavailable".to_string(), |count| count.to_string()), ); } + for test in &plan.test_summaries { + let mut churn: Vec = Vec::new(); + if test.inventory_available { + churn.push(format!("+{} added", test.added)); + churn.push(format!("-{} deleted", test.deleted)); + churn.push(format!("{} changed", test.changed)); + } + churn.push(format!("{} pending", test.pending)); + let mut quality: Vec = Vec::new(); + if test.mutation_available { + quality.push(format!("{} kill no mutants", test.kill_no_mutants)); + quality.push(format!("{} kill no distinct mutants", test.kill_no_distinct)); + } + quality.push(format!("{} add no coverage", test.no_coverage)); + let mut line = format!( + "Tests {}:{}: {}", + test.language, + test.test_set, + churn.join(", ") + ); + line.push_str(&format!("; {}", quality.join(", "))); + if !test.inventory_available { + line.push_str(" (churn n/a: no base test evidence)"); + } + if let Some(timing) = &test.timing { + if timing.pending { + line.push_str("; time [ PENDING ]"); + } else if timing.processing { + line.push_str("; time [ PROCESSING ]"); + } else if timing.baseline_n == 0 { + line.push_str(&format!( + "; time {} (n={}, baseline building)", + crate::test_timing::fmt_ms(timing.new_ms), + timing.samples + )); + } else { + let sign = if timing.pct >= 0.0 { "+" } else { "" }; + line.push_str(&format!( + "; time {sign}{:.1}% ±{:.1}% (n={})", + timing.pct, timing.ci_pct, timing.samples + )); + } + } + let _ = writeln!(output, "{line}"); + } for dependency in &plan.dependency_changes { let detail = if dependency.status == crate::diff::DependencyStatus::Exact { format!("{} declared changes", dependency.entries.len()) @@ -217,6 +262,75 @@ mod tests { assert_eq!(json["plan"]["files"][0]["path"], "lib/app.rb"); } + #[test] + fn renders_the_tests_section_per_language_and_tag() { + let mut plan = sample_plan(); + plan.test_summaries.push(crate::test_summary::TestSummary { + language: "ruby".into(), + test_set: "unit".into(), + added: 3, + deleted: 1, + changed: 2, + pending: 1, + kill_no_mutants: 2, + no_coverage: 1, + kill_no_distinct: 4, + inventory_available: true, + mutation_available: true, + timing: None, + }); + // A group with no base evidence: churn is suppressed, not fabricated. + plan.test_summaries.push(crate::test_summary::TestSummary { + language: "go".into(), + test_set: "integration".into(), + pending: 1, + no_coverage: 2, + inventory_available: false, + mutation_available: false, + ..Default::default() + }); + // A measured timing delta and a pending timing. + plan.test_summaries.push(crate::test_summary::TestSummary { + language: "rust".into(), + test_set: "unit".into(), + added: 2, + inventory_available: true, + timing: Some(crate::test_summary::TestTiming { + pending: false, + pct: 2.1, + ci_pct: 0.8, + samples: 4, + baseline_n: 5, + ..Default::default() + }), + ..Default::default() + }); + plan.test_summaries.push(crate::test_summary::TestSummary { + language: "zig".into(), + test_set: "unit".into(), + added: 1, + inventory_available: true, + timing: Some(crate::test_summary::TestTiming { + pending: true, + ..Default::default() + }), + ..Default::default() + }); + + let text = render_structured_diff_text(&plan, false); + assert!(text.contains( + "Tests ruby:unit: +3 added, -1 deleted, 2 changed, 1 pending; \ + 2 kill no mutants, 4 kill no distinct mutants, 1 add no coverage" + )); + // No base evidence -> only head-derived counts, with the caveat note. + assert!(text.contains( + "Tests go:integration: 1 pending; 2 add no coverage (churn n/a: no base test evidence)" + )); + // Timing: measured delta with the ± sign, and the pending caption. + assert!(text.contains("time +2.1% ±0.8% (n=4)"), "measured timing: {text}"); + assert!(text.contains("Tests zig:unit:") && text.contains("time [ PENDING ]")); + } + #[test] fn renders_risk_and_full_group_detail_for_humans() { let mut plan = sample_plan(); @@ -240,7 +354,7 @@ mod tests { let concise = render_structured_diff_text(&plan, false); let detailed = render_structured_diff_text(&plan, true); - assert!(concise.contains("Lineage diff base..head")); + assert!(concise.contains("Gigasail diff base..head")); assert!(concise.contains("lib/app.rb")); assert!(!concise.contains("function run risk=")); assert!(detailed.contains("function run risk=")); @@ -267,6 +381,12 @@ mod tests { added_lines: Default::default(), verification: Default::default(), sarif_findings: Vec::new(), + added_dependencies: Vec::new(), + added_state: Vec::new(), + big_o_time: String::new(), + big_o_time_status: "unknown".into(), + big_o_space: String::new(), + big_o_space_status: "unknown".into(), risk: Default::default(), }); diff --git a/gems/gigasail/giga-core/src/diff_service.rs b/gems/gigasail/giga-core/src/diff_service.rs new file mode 100644 index 000000000..ff307aed5 --- /dev/null +++ b/gems/gigasail/giga-core/src/diff_service.rs @@ -0,0 +1,424 @@ +//! Read-only structured-diff assembly shared by the UI and command line. +//! +//! `DiffPlan` deliberately stays render-independent. This module owns the +//! repository and evidence-ledger joins required to turn a revision request +//! into the same structured plan for every presentation surface. + +use crate::diff::{ + apply_exact_sarif_findings, apply_head_only_sarif_findings, apply_partial_coverage, + apply_partial_mutation_kills, apply_partial_sarif_findings, apply_scoped_coverage, + apply_scoped_mutation_kills, DiffPlan, EvidenceScopeFingerprint, +}; +use crate::{GitProvider, Storage}; +use anyhow::Result; + +/// Read-only revision and evidence selection for one structured diff. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiffRequest { + pub base_revision: Option, + pub head_revision: Option, + pub coverage_source: Option, + pub sarif_source: Option, + pub selection: Option, + pub mutant_corpus: Option, + pub test_set: Option, +} + +/// Builds the revision-pinned plan consumed by both the React UI and CLI. +/// +/// Passing no storage is intentional: Git-derived structure remains useful +/// when a repository has not yet ingested dynamic evidence. +pub fn build_structured_diff( + provider: &GitProvider, + storage: Option<&Storage>, + request: &DiffRequest, +) -> Result { + let (base, head) = provider.diff_revisions( + request.base_revision.as_deref(), + request.head_revision.as_deref(), + )?; + let mut plan = provider.diff_plan(&base, &head)?; + bind_requested_evidence_scope(&mut plan, request)?; + if let Some(storage) = storage { + apply_known_coverage(storage, &mut plan, request.coverage_source.as_deref())?; + apply_known_mutation_kills(storage, &mut plan)?; + apply_known_sarif(storage, &mut plan, request.sarif_source.as_deref())?; + apply_known_architecture(storage, &mut plan)?; + apply_new_packages(provider, &mut plan)?; + apply_function_big_o(storage, &mut plan)?; + apply_test_summaries(storage, &mut plan)?; + } + Ok(plan) +} + +/// Attach each changed function's Big-O time/space complexity (from the +/// architecture graph, stored on its logical unit) to its group, for the diff +/// function box. Groups with no analysis keep the unknown default. +fn apply_function_big_o(storage: &Storage, plan: &mut DiffPlan) -> Result<()> { + for file in &mut plan.files { + for group in &mut file.groups { + if group.kind != "function" { + continue; + } + let (time, time_status, space, space_status) = + storage.function_big_o(&file.path, &group.name)?; + group.big_o_time = time; + group.big_o_time_status = time_status; + group.big_o_space = space; + group.big_o_space_status = space_status; + } + } + Ok(()) +} + +/// Compute the "Tests" section: for each changed test file, aggregate the base- +/// and head-commit test inventories (from `test_exposure_events`) and diff them +/// per `language:test_set`. Scoped to tests whose own file changed in this diff - +/// tests without a known definition file are excluded (can't attribute them). +fn apply_test_summaries(storage: &Storage, plan: &mut DiffPlan) -> Result<()> { + use crate::diff::SourceRole; + use std::collections::{BTreeMap, BTreeSet}; + let changed_test_files: BTreeSet = plan + .files + .iter() + .filter(|f| f.role == SourceRole::Test) + .map(|f| f.path.clone()) + .collect(); + if changed_test_files.is_empty() { + return Ok(()); + } + let base_full = storage.test_inventory_for_commit(&plan.scope.base_oid)?; + let head_full = storage.test_inventory_for_commit(&plan.scope.head_oid)?; + let base_present = !base_full.is_empty(); + let scope = |rows: Vec| { + rows.into_iter() + .filter(|r| changed_test_files.contains(&r.test_path)) + .collect::>() + }; + let base = scope(base_full); + let head = scope(head_full); + // Lines the diff added inside each changed test file — a test whose span + // contains one of these is "changed". + let mut changed_lines: BTreeMap> = BTreeMap::new(); + for file in &plan.files { + if file.role == SourceRole::Test { + changed_lines.insert(file.path.clone(), file.added_line_numbers()); + } + } + plan.test_summaries = + crate::test_summary::test_summaries(&base, &head, &changed_lines, base_present); + + // Enrich each group that added tests with new-test timing: the recorded + // measurement for this commit compared to the recent per-stage baseline, or + // pending when the (background) measurement has not landed yet. + let stage = crate::test_timing::TIMING_STAGE; + for summary in &mut plan.test_summaries { + if summary.added == 0 { + continue; + } + summary.timing = match storage.stage_timing_for_commit( + &plan.scope.head_oid, + stage, + &summary.test_set, + )? { + // A processing sentinel (0 samples): the background runner is timing. + Some((_, _, 0)) => Some(crate::test_summary::TestTiming { + processing: true, + ..Default::default() + }), + Some((mean, stddev, n)) => { + let baseline = + storage.stage_timing_history(stage, &summary.test_set, 10, &plan.scope.head_oid)?; + Some( + match crate::test_timing::timing_delta_from_stats( + &baseline, + mean, + stddev, + n as usize, + ) { + Some(d) => crate::test_summary::TestTiming { + pending: false, + pct: d.pct, + ci_pct: d.ci_pct, + new_ms: mean, + samples: d.samples, + baseline_n: d.baseline_n, + ..Default::default() + }, + // Measured, but no baseline to compare against yet - + // surface the absolute time rather than nothing. + None => crate::test_summary::TestTiming { + pending: false, + new_ms: mean, + samples: n as usize, + baseline_n: 0, + ..Default::default() + }, + }, + ) + } + None => Some(crate::test_summary::TestTiming { + pending: true, + ..Default::default() + }), + }; + } + Ok(()) +} + +/// Aggregate the change's newly-added imports into third-party packages grouped +/// by language, for the summary's NEW PACKAGES section. Uses the per-file +/// `added_imports` already stamped by [`apply_known_architecture`], filtered to +/// external packages via the repository's own module roots (read from go.mod / +/// Cargo.toml at head). Stdlib and first-party (same-repo) imports are dropped. +fn apply_new_packages(provider: &GitProvider, plan: &mut DiffPlan) -> Result<()> { + use crate::new_packages::{display_import, first_party_roots, group_third_party}; + let head = &plan.scope.head_oid; + let go_mod = provider.file_contents_at_commit(head, "go.mod").ok().flatten(); + let cargo = provider.file_contents_at_commit(head, "Cargo.toml").ok().flatten(); + let roots = first_party_roots(go_mod.as_deref(), cargo.as_deref()); + + let entries: Vec<(String, &[String])> = plan + .files + .iter() + .filter_map(|file| { + crate::diff::language_for_path(&file.path) + .map(|lang| (lang, file.added_imports.as_slice())) + }) + .collect(); + plan.inventory.new_packages = group_third_party( + entries.iter().map(|(lang, imports)| (lang.as_str(), *imports)), + &roots, + ); + + // Rewrite each file's added-import labels to the short, origin-aware display + // form used in the New Dependencies list (`./internal/ui`, `repo:subpath`). + for file in &mut plan.files { + if let Some(language) = crate::diff::language_for_path(&file.path) { + for import in &mut file.added_imports { + *import = display_import(&language, import, &roots); + } + } + } + Ok(()) +} + +/// Attach newly-added collaboration targets and state accesses to each changed +/// group, sourced from the architecture graph ingested for the head commit. A +/// fact counts as *new* when its call/access site lands on an added line inside +/// the group's span. No graph ingested -> groups keep their empty defaults. +fn apply_known_architecture(storage: &Storage, plan: &mut DiffPlan) -> Result<()> { + let sites = + crate::architecture::architecture_fact_sites_for_commit(storage, &plan.scope.head_oid)?; + if sites.is_empty() { + return Ok(()); + } + let mut by_path: std::collections::HashMap<&str, Vec<&_>> = std::collections::HashMap::new(); + for site in &sites { + by_path.entry(site.path.as_str()).or_default().push(site); + } + use crate::architecture::FactKind; + for file in &mut plan.files { + let Some(file_sites) = by_path.get(file.path.as_str()) else { + continue; + }; + let added = file.added_line_numbers(); + // File-level imports: any import site on an added line. + let mut imports = std::collections::BTreeSet::new(); + for site in file_sites { + if site.kind == FactKind::Import && added.contains(&site.line) { + imports.insert(site.label.clone()); + } + } + file.added_imports = imports.into_iter().collect(); + // Unit-level calls and state: sites on an added line inside a group span. + for group in &mut file.groups { + let mut deps = std::collections::BTreeSet::new(); + let mut state = std::collections::BTreeSet::new(); + for site in file_sites { + if site.line < group.start_line + || site.line > group.end_line + || !added.contains(&site.line) + { + continue; + } + match site.kind { + FactKind::Call => { + deps.insert(site.label.clone()); + } + FactKind::State => { + state.insert(site.label.clone()); + } + FactKind::Import => {} + } + } + group.added_dependencies = deps.into_iter().collect(); + group.added_state = state.into_iter().collect(); + } + } + Ok(()) +} + +fn bind_requested_evidence_scope(plan: &mut DiffPlan, request: &DiffRequest) -> Result<()> { + let supplied = [ + request.selection.as_ref(), + request.mutant_corpus.as_ref(), + request.test_set.as_ref(), + ]; + if supplied.iter().any(|item| item.is_some()) && !supplied.iter().all(|item| item.is_some()) { + anyhow::bail!("selection, mutant corpus, and test set must be supplied together"); + } + let (Some(selection), Some(mutant_corpus), Some(test_set)) = + (supplied[0], supplied[1], supplied[2]) + else { + return Ok(()); + }; + if selection.trim().is_empty() || mutant_corpus.trim().is_empty() || test_set.trim().is_empty() + { + anyhow::bail!("selection, mutant corpus, and test set cannot be empty"); + } + plan.scope.evidence_scope = EvidenceScopeFingerprint { + revision: plan.scope.head_oid.clone(), + selection: selection.clone(), + mutant_corpus: mutant_corpus.clone(), + test_set: test_set.clone(), + }; + Ok(()) +} + +fn apply_known_coverage( + storage: &Storage, + plan: &mut DiffPlan, + source: Option<&str>, +) -> Result<()> { + let paths = changed_paths(plan); + let source = source.unwrap_or("coverage"); + if let Some(artifact) = + storage.scoped_coverage_artifact_common(source, &plan.scope.evidence_scope, &paths)? + { + apply_scoped_coverage(plan, &artifact); + return Ok(()); + } + let rows = storage.coverage_observations_for_commit_paths(&plan.scope.head_oid, &paths)?; + apply_partial_coverage(plan, &rows); + Ok(()) +} + +fn apply_known_mutation_kills(storage: &Storage, plan: &mut DiffPlan) -> Result<()> { + let paths = changed_paths(plan); + if let Some(artifact) = storage.scoped_mutation_artifact(&plan.scope.evidence_scope, &paths)? { + apply_scoped_mutation_kills(plan, &artifact); + return Ok(()); + } + let rows = storage.mutation_kill_observations_for_commit_paths(&plan.scope.head_oid, &paths)?; + apply_partial_mutation_kills(plan, &rows); + Ok(()) +} + +fn apply_known_sarif(storage: &Storage, plan: &mut DiffPlan, source: Option<&str>) -> Result<()> { + let paths = changed_paths(plan); + if let Some(source) = source { + if let Some(rows) = + storage.scoped_sarif_observations_common(source, &plan.scope.evidence_scope, &paths)? + { + let base_scope = EvidenceScopeFingerprint { + revision: plan.scope.base_oid.clone(), + selection: plan.scope.evidence_scope.selection.clone(), + mutant_corpus: "not-applicable".into(), + test_set: plan.scope.evidence_scope.test_set.clone(), + }; + let base_paths = plan + .files + .iter() + .map(|file| { + file.previous_path + .clone() + .unwrap_or_else(|| file.path.clone()) + }) + .collect::>(); + if let Some(base_rows) = + storage.scoped_sarif_observations_common(source, &base_scope, &base_paths)? + { + apply_exact_sarif_findings(plan, &rows, &base_rows); + } else { + apply_head_only_sarif_findings(plan, &rows); + } + return Ok(()); + } + if storage.has_scoped_sarif_source(source)? { + plan.evidence.sarif = crate::diff::EvidenceState::Stale; + plan.evidence.hazards = crate::diff::EvidenceState::Stale; + return Ok(()); + } + } + let rows = storage.sarif_observations_for_commit_paths(&plan.scope.head_oid, &paths)?; + apply_partial_sarif_findings(plan, &rows); + Ok(()) +} + +fn changed_paths(plan: &DiffPlan) -> Vec { + plan.files.iter().map(|file| file.path.clone()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn builds_a_revision_pinned_plan_without_a_database() { + let directory = tempdir().unwrap(); + let repository = git2::Repository::init(directory.path()).unwrap(); + let signature = git2::Signature::now("Gigasail", "gigasail@example.test").unwrap(); + let path = directory.path().join("app.rb"); + std::fs::write(&path, "puts :base\n").unwrap(); + let base = commit_file(&repository, &signature, None); + std::fs::write(&path, "puts :head\n").unwrap(); + let head = commit_file(&repository, &signature, Some(base)); + let provider = GitProvider::open(directory.path()).unwrap(); + + let plan = build_structured_diff( + &provider, + None, + &DiffRequest { + base_revision: Some(base.to_string()), + head_revision: Some(head.to_string()), + selection: Some("production".into()), + mutant_corpus: Some("mutants".into()), + test_set: Some("suite".into()), + ..DiffRequest::default() + }, + ) + .unwrap(); + + assert_eq!(plan.scope.base_oid, base.to_string()); + assert_eq!(plan.scope.head_oid, head.to_string()); + assert_eq!(plan.scope.evidence_scope.revision, head.to_string()); + assert_eq!(plan.scope.evidence_scope.selection, "production"); + assert_eq!(plan.files.len(), 1); + assert_eq!(plan.files[0].path, "app.rb"); + assert_eq!(plan.evidence.coverage, crate::diff::EvidenceState::Missing); + } + + fn commit_file( + repository: &git2::Repository, + signature: &git2::Signature<'_>, + parent: Option, + ) -> git2::Oid { + let mut index = repository.index().unwrap(); + index.add_path(std::path::Path::new("app.rb")).unwrap(); + let tree = repository.find_tree(index.write_tree().unwrap()).unwrap(); + let parent_commit = parent.map(|oid| repository.find_commit(oid).unwrap()); + let parents = parent_commit.iter().collect::>(); + repository + .commit( + Some("HEAD"), + signature, + signature, + "change app", + &tree, + &parents, + ) + .unwrap() + } +} diff --git a/gems/lineage/src/ingest_service.rs b/gems/gigasail/giga-core/src/ingest_service.rs similarity index 100% rename from gems/lineage/src/ingest_service.rs rename to gems/gigasail/giga-core/src/ingest_service.rs diff --git a/gems/lineage/src/lib.rs b/gems/gigasail/giga-core/src/lib.rs similarity index 84% rename from gems/lineage/src/lib.rs rename to gems/gigasail/giga-core/src/lib.rs index 5d990d15f..08c7af39f 100644 --- a/gems/lineage/src/lib.rs +++ b/gems/gigasail/giga-core/src/lib.rs @@ -1,9 +1,8 @@ -//! Logical-unit lineage tracking for historical risk scoring. +//! Logical-unit gigasail tracking for historical risk scoring. //! //! The crate is intentionally split around replaceable boundaries: //! VCS traversal, source boundary extraction, analysis, and storage. -pub mod application; #[path = "db/architecture.rs"] pub mod architecture; pub mod diff; @@ -20,27 +19,27 @@ pub mod hazard; #[path = "db/hotness.rs"] pub mod hotness; pub mod ingest_service; -#[path = "ui/lsp.rs"] -pub mod lsp; -#[path = "ui/mcp.rs"] -pub mod mcp; +pub mod lock; #[path = "db/model.rs"] pub mod model; + +pub mod new_packages; #[path = "db/mutant.rs"] pub mod mutant; pub mod pipeline; #[path = "db/quality.rs"] pub mod quality; +pub mod review; #[path = "db/sarif.rs"] pub mod sarif; #[path = "db/stack_trace.rs"] pub mod stack_trace; #[path = "db/storage.rs"] pub mod storage; +pub mod test_summary; +pub mod test_timing; #[path = "db/test_exposure.rs"] pub mod test_exposure; -#[path = "ui/ui.rs"] -pub mod ui; #[path = "db/vcs.rs"] pub mod vcs; pub use architecture::{ @@ -61,11 +60,6 @@ pub use extract::{BoundaryExtractor, HeuristicExtractor, SourceFilter}; pub use git::GitProvider; pub use hazard::{ingest_hazards, HazardIngestStats}; pub use hotness::{ingest_hotness_json, HotnessIngestStats}; -pub use lsp::{ - diagnostics_for_annotations, gutter_items_for_annotations, serve_lsp, GutterItem, - GutterUpdateParams, -}; -pub use mcp::serve_mcp; pub use model::{ BlobFile, CommitMetadata, CrashEvent, Event, EventType, HazardEvent, LogicalUnit, QualityEvent, QualityMetric, SarifArtifact, SarifFinding, TestExposureEvent, UnitKind, @@ -97,9 +91,4 @@ pub use test_exposure::{ ingest_test_exposure_json, parse_test_exposure_records, TestExposureIngestStats, TestExposureRecord, }; -pub use ui::{ - dashboard_summary, file_index, line_annotations, serve_ui, serve_ui_with_overlays, - source_payload, source_payload_with_overlays, UiBugEvent, UiDashboard, UiFile, - UiLineAnnotation, UiOverlays, UiSourcePayload, -}; pub use vcs::VcsProvider; diff --git a/gems/gigasail/giga-core/src/lock.rs b/gems/gigasail/giga-core/src/lock.rs new file mode 100644 index 000000000..f296bd87c --- /dev/null +++ b/gems/gigasail/giga-core/src/lock.rs @@ -0,0 +1,378 @@ +//! Cross-process coordination lock for `.giga/` analysis runs. +//! +//! A single PID-bearing lock file (`.giga/lock.json`) serializes the +//! analyse/ingest ("ci then sync") work so `giga watch` and an MCP server never +//! index the same database concurrently. Readers (`giga diff`, MCP queries) +//! consult [`GigaLock::current`] to decide whether to wait for an in-progress +//! run on their target commit or render what is already stored. +//! +//! Acquisition is race-free across processes: the payload is written to a +//! per-PID temp file first, then atomically `hard_link`ed into place. `link(2)` +//! fails with `EEXIST` when the lock is already held, so the visible lock file +//! always contains a fully written record — there is no window where a peer can +//! observe a half-written lock and wrongly reclaim it. A lock left behind by a +//! dead process is reclaimed automatically via a `kill(pid, 0)` liveness check. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const LOCK_FILE: &str = "lock.json"; + +/// Distinguishes concurrent temp files within one process (threads share a PID). +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + +/// The recorded holder of the `.giga/` lock. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LockInfo { + pub pid: u32, + /// The commit whose evidence the holder is producing. + pub commit: String, + /// What the holder is doing, e.g. `"analyse"` or `"ingest"`. + pub operation: String, + /// Unix seconds when the lock was taken. + pub started_at: u64, +} + +/// An acquired lock. Dropping it releases the lock file (if this process is +/// still the recorded owner). +#[derive(Debug)] +pub struct GigaLock { + path: PathBuf, + info: LockInfo, +} + +impl GigaLock { + /// The record this process wrote when it acquired the lock. + pub fn info(&self) -> &LockInfo { + &self.info + } + + /// Try to acquire the `.giga/` lock for `commit`/`operation`. + /// + /// Returns `Ok(None)` when a live process already holds it. A stale lock + /// left by a dead process is reclaimed and acquired. + pub fn try_acquire(giga_dir: &Path, commit: &str, operation: &str) -> Result> { + fs::create_dir_all(giga_dir) + .with_context(|| format!("create {}", giga_dir.display()))?; + let path = giga_dir.join(LOCK_FILE); + let info = LockInfo { + pid: std::process::id(), + commit: commit.to_string(), + operation: operation.to_string(), + started_at: now_unix(), + }; + let bytes = serde_json::to_vec(&info)?; + let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let tmp = giga_dir.join(format!(".{LOCK_FILE}.{}.{}.tmp", info.pid, seq)); + fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?; + + // Bounded retry to resolve the reclaim race with a peer process. + let outcome = (|| { + for _ in 0..8 { + match fs::hard_link(&tmp, &path) { + Ok(()) => { + return Ok(Some(GigaLock { + path: path.clone(), + info: info.clone(), + })) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + match read_lock(&path)? { + // A live peer owns it. + Some(existing) if pid_alive(existing.pid) => return Ok(None), + // A dead peer left it behind: reclaim and retry. + Some(_) => { + let _ = fs::remove_file(&path); + continue; + } + // File exists but is unreadable/corrupt, or a peer is + // mid-reclaim. Assume held rather than risk stealing. + None => return Ok(None), + } + } + Err(err) => { + return Err(err).with_context(|| format!("link {}", path.display())) + } + } + } + // A peer kept winning the reclaim race; treat as held. + Ok(None) + })(); + + let _ = fs::remove_file(&tmp); + outcome + } + + /// Read the current holder if a live process holds the lock. A stale lock + /// (dead PID) is removed and `None` is returned. + pub fn current(giga_dir: &Path) -> Result> { + let path = giga_dir.join(LOCK_FILE); + match read_lock(&path)? { + Some(info) if pid_alive(info.pid) => Ok(Some(info)), + Some(_) => { + let _ = fs::remove_file(&path); + Ok(None) + } + None => Ok(None), + } + } +} + +/// Block while a live process holds the `.giga/` lock for `commit` (an analysis +/// of exactly that commit is in flight), so a reader sees complete evidence +/// instead of a half-ingested database. Returns as soon as the lock is free or +/// held for a *different* commit (that reader's target is not being worked on, +/// so it should render what it already has). Gives up after `timeout` and lets +/// the caller proceed regardless. `on_wait` is invoked once per poll with the +/// current holder, e.g. to print a "waiting" line. +pub fn wait_while_locked_for( + giga_dir: &Path, + commit: &str, + timeout: std::time::Duration, + poll: std::time::Duration, + mut on_wait: impl FnMut(&LockInfo), +) -> Result<()> { + let start = std::time::Instant::now(); + loop { + match GigaLock::current(giga_dir)? { + Some(info) if info.commit == commit => { + if start.elapsed() >= timeout { + return Ok(()); + } + on_wait(&info); + std::thread::sleep(poll); + } + _ => return Ok(()), + } + } +} + +impl Drop for GigaLock { + fn drop(&mut self) { + // Only remove the lock if we are still the recorded owner, so a + // reclaimed-and-retaken lock held by another process is left intact. + if let Ok(Some(info)) = read_lock(&self.path) { + if info.pid == self.info.pid && info.started_at == self.info.started_at { + let _ = fs::remove_file(&self.path); + } + } + } +} + +/// Parse the lock file. `Ok(None)` means the file is absent OR present but +/// unparseable; callers treat "present but unparseable" as held (they only ever +/// see a fully written record once it is linked, so unparseable implies genuine +/// corruption, which must not be silently stolen). +fn read_lock(path: &Path) -> Result> { + match fs::read(path) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| format!("read {}", path.display())), + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Whether a process with `pid` is alive. `kill(pid, 0)` returns `0` (we may +/// signal it) or fails with `EPERM` (alive, not ours) for live processes, and +/// `ESRCH` for dead ones. +#[cfg(unix)] +fn pid_alive(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if result == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(not(unix))] +fn pid_alive(_pid: u32) -> bool { + // Without a portable liveness probe, never steal a lock. + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + fn dead_pid() -> u32 { + // A child that has exited and been reaped: its PID is dead (barring an + // immediate, unlikely reuse within the test window). + let child = Command::new("true").spawn().expect("spawn true"); + let pid = child.id(); + let mut child = child; + child.wait().expect("reap true"); + pid + } + + #[test] + fn acquire_then_release_on_drop() { + let dir = tempfile::tempdir().unwrap(); + { + let lock = GigaLock::try_acquire(dir.path(), "abc123", "analyse") + .unwrap() + .expect("first acquire succeeds"); + assert_eq!(lock.info().commit, "abc123"); + assert_eq!(lock.info().operation, "analyse"); + // Held: a second acquire from this same (live) process is refused. + assert!(GigaLock::try_acquire(dir.path(), "abc123", "analyse") + .unwrap() + .is_none()); + assert!(GigaLock::current(dir.path()).unwrap().is_some()); + } + // Dropped: the lock is released and re-acquirable. + assert!(GigaLock::current(dir.path()).unwrap().is_none()); + assert!(GigaLock::try_acquire(dir.path(), "def456", "ingest") + .unwrap() + .is_some()); + } + + #[test] + fn stale_lock_from_dead_pid_is_reclaimed() { + let dir = tempfile::tempdir().unwrap(); + let stale = LockInfo { + pid: dead_pid(), + commit: "old".into(), + operation: "analyse".into(), + started_at: 1, + }; + fs::write(dir.path().join(LOCK_FILE), serde_json::to_vec(&stale).unwrap()).unwrap(); + // current() reports no live holder and clears the stale file. + assert!(GigaLock::current(dir.path()).unwrap().is_none()); + // A fresh acquire reclaims it. + let lock = GigaLock::try_acquire(dir.path(), "new", "analyse") + .unwrap() + .expect("reclaim stale lock"); + assert_eq!(lock.info().commit, "new"); + } + + #[test] + fn live_lock_is_never_reclaimed() { + let dir = tempfile::tempdir().unwrap(); + // Our own PID is alive, so this record must be treated as held. + let live = LockInfo { + pid: std::process::id(), + commit: "busy".into(), + operation: "ingest".into(), + started_at: 1, + }; + fs::write(dir.path().join(LOCK_FILE), serde_json::to_vec(&live).unwrap()).unwrap(); + assert!(GigaLock::current(dir.path()).unwrap().is_some()); + assert!(GigaLock::try_acquire(dir.path(), "busy", "ingest") + .unwrap() + .is_none()); + } + + #[test] + fn wait_returns_immediately_when_the_lock_is_free() { + let dir = tempfile::tempdir().unwrap(); + let mut waits = 0; + wait_while_locked_for( + dir.path(), + "x", + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(10), + |_| waits += 1, + ) + .unwrap(); + assert_eq!(waits, 0); + } + + #[test] + fn wait_ignores_a_lock_held_for_a_different_commit() { + let dir = tempfile::tempdir().unwrap(); + // A peer is analysing "other"; a reader targeting "mine" should not wait. + let _held = GigaLock::try_acquire(dir.path(), "other", "analyse") + .unwrap() + .unwrap(); + let mut waits = 0; + wait_while_locked_for( + dir.path(), + "mine", + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(10), + |_| waits += 1, + ) + .unwrap(); + assert_eq!(waits, 0); + } + + #[test] + fn wait_blocks_until_timeout_while_the_same_commit_is_locked() { + let dir = tempfile::tempdir().unwrap(); + let _held = GigaLock::try_acquire(dir.path(), "busy", "analyse") + .unwrap() + .unwrap(); + let mut waits = 0; + let start = std::time::Instant::now(); + wait_while_locked_for( + dir.path(), + "busy", + std::time::Duration::from_millis(60), + std::time::Duration::from_millis(10), + |info| { + assert_eq!(info.commit, "busy"); + waits += 1; + }, + ) + .unwrap(); + assert!(waits >= 1, "should have polled at least once while locked"); + assert!(start.elapsed() >= std::time::Duration::from_millis(60)); + } + + #[test] + fn hammer_exactly_one_winner_per_round() { + // Oversubscribed threads race for the lock. Because acquisition is an + // atomic hard_link and every winner HOLDS its lock until the whole round + // has attempted, exactly one thread can win; the rest observe a live + // holder (this process) and back off. After the round, the retained lock + // is dropped and the file is free again. + let dir = Arc::new(tempfile::tempdir().unwrap()); + for _ in 0..64 { + let attempts = Arc::new(AtomicUsize::new(0)); + let held: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let barrier = Arc::new(std::sync::Barrier::new(16)); + let mut handles = Vec::new(); + for _ in 0..16 { + let dir = Arc::clone(&dir); + let attempts = Arc::clone(&attempts); + let held = Arc::clone(&held); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + attempts.fetch_add(1, Ordering::SeqCst); + if let Some(lock) = + GigaLock::try_acquire(dir.path(), "race", "analyse").unwrap() + { + // Retain the lock (do not drop) so peers see it held. + held.lock().unwrap().push(lock); + } + })); + } + for handle in handles { + handle.join().unwrap(); + } + assert_eq!(attempts.load(Ordering::SeqCst), 16); + let mut held = held.lock().unwrap(); + assert_eq!(held.len(), 1, "exactly one thread must win the lock"); + held.clear(); // drop the retained lock -> release + assert!( + GigaLock::current(dir.path()).unwrap().is_none(), + "lock must be free after the winner releases" + ); + } + } +} diff --git a/gems/gigasail/giga-core/src/new_packages.rs b/gems/gigasail/giga-core/src/new_packages.rs new file mode 100644 index 000000000..8b32885d3 --- /dev/null +++ b/gems/gigasail/giga-core/src/new_packages.rs @@ -0,0 +1,354 @@ +//! Classify a change's newly-added imports into third-party packages, grouped +//! by language, for the diff summary's `NEW PACKAGES` section. +//! +//! The pivot is the repository's own module identity (Go's `module` line, a +//! Rust crate name, ...): an import under that identity is *first-party* (it is +//! a sibling package, surfaced elsewhere as a class collaboration, not a new +//! external package); an import with no external namespace is *stdlib*; only a +//! genuinely external module is a *third-party package* worth flagging here. + +/// Where an import's target lives relative to the repository under review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportOrigin { + /// Language standard library / builtins (`fmt`, `std::fmt`, `os`). + Stdlib, + /// A sibling package in this same repository (module root match). + Internal, + /// An external dependency - the only kind `NEW PACKAGES` lists. + ThirdParty, +} + +/// Go standard-library imports have no dot in their first path segment (no +/// domain); third-party import paths start with a host like `github.com`. +fn go_origin(label: &str) -> ImportOrigin { + let first = label.split('/').next().unwrap_or(label); + if first.contains('.') { + ImportOrigin::ThirdParty + } else { + ImportOrigin::Stdlib + } +} + +const RUST_STDLIB: [&str; 5] = ["std", "core", "alloc", "proc_macro", "test"]; + +fn rust_origin(label: &str) -> ImportOrigin { + let head = label.split("::").next().unwrap_or(label); + match head { + "crate" | "self" | "super" => ImportOrigin::Internal, + h if RUST_STDLIB.contains(&h) => ImportOrigin::Stdlib, + _ => ImportOrigin::ThirdParty, + } +} + +const NODE_BUILTINS: [&str; 22] = [ + "assert", "buffer", "child_process", "crypto", "dns", "events", "fs", "http", + "https", "net", "os", "path", "process", "querystring", "stream", "string_decoder", + "tls", "url", "util", "v8", "vm", "zlib", +]; + +fn js_origin(label: &str) -> ImportOrigin { + if label.starts_with('.') || label.starts_with('/') { + return ImportOrigin::Internal; + } + let bare = label.strip_prefix("node:").unwrap_or(label); + let root = bare.split('/').next().unwrap_or(bare); + if NODE_BUILTINS.contains(&root) { + ImportOrigin::Stdlib + } else { + ImportOrigin::ThirdParty + } +} + +/// Classify one import label for a language, given the repository's first-party +/// module roots (e.g. `github.com/yahn/unslop`, a crate name). A first-party +/// match always wins - it is a sibling package, never a new external one. +pub fn classify_import(language: &str, raw: &str, first_party: &[String]) -> ImportOrigin { + let label = raw.trim().trim_matches('"'); + if first_party.iter().any(|root| { + !root.is_empty() + && (label == root + || label.starts_with(&format!("{root}/")) + || label.starts_with(&format!("{root}::"))) + }) { + return ImportOrigin::Internal; + } + match language { + "go" => go_origin(label), + "rust" => rust_origin(label), + "javascript" | "typescript" => js_origin(label), + // Languages without a namespace convention we model: anything that is + // not first-party is treated as an external package. Better to list a + // genuine dependency than to silently drop it. + _ => ImportOrigin::ThirdParty, + } +} + +/// The package identity to display for a third-party import: the import path is +/// the package in Go; the crate is the first `::` segment in Rust; the package +/// is the (optionally scoped) first path segment in JS/TS. +pub fn package_name(language: &str, raw: &str) -> String { + let label = raw.trim().trim_matches('"'); + match language { + "rust" => label.split("::").next().unwrap_or(label).to_string(), + "javascript" | "typescript" => { + let bare = label.strip_prefix("node:").unwrap_or(label); + if let Some(scoped) = bare.strip_prefix('@') { + // `@scope/pkg/sub` -> `@scope/pkg`. + let mut parts = scoped.splitn(3, '/'); + match (parts.next(), parts.next()) { + (Some(scope), Some(pkg)) => format!("@{scope}/{pkg}"), + _ => bare.to_string(), + } + } else { + bare.split('/').next().unwrap_or(bare).to_string() + } + } + // Go import paths and unknown languages: the label is the package. + _ => label.to_string(), + } +} + +/// Short, origin-aware display form for an import in the New Dependencies list: +/// - first-party (this repo): `./internal/ui` - a relative path, no module root. +/// - third-party: `unslop:internal/ui` - `repo:subpath`, domain/owner stripped. +/// - stdlib: unchanged (`fmt`, `os`). +pub fn display_import(language: &str, raw: &str, first_party: &[String]) -> String { + let label = raw.trim().trim_matches('"'); + match classify_import(language, label, first_party) { + ImportOrigin::Internal => { + // Rust's own crate roots are relative by keyword, not a module path. + for kw in ["crate", "self", "super"] { + if let Some(rest) = label.strip_prefix(&format!("{kw}::")) { + return format!("./{}", rest.replace("::", "/")); + } + } + for root in first_party { + if root.is_empty() { + continue; + } + if label == root { + return "./".to_string(); + } + if let Some(rest) = label.strip_prefix(&format!("{root}/")) { + return format!("./{rest}"); + } + if let Some(rest) = label.strip_prefix(&format!("{root}::")) { + return format!("./{}", rest.replace("::", "/")); + } + } + label.to_string() + } + ImportOrigin::Stdlib => label.to_string(), + ImportOrigin::ThirdParty => short_third_party(language, label), + } +} + +/// A third-party import shortened to `repo:subpath` (or just `repo`): the host +/// and owner are dropped, keeping the repository name and the path within it. +pub fn short_third_party(language: &str, raw: &str) -> String { + let label = raw.trim().trim_matches('"'); + match language { + "go" => { + let segs: Vec<&str> = label.split('/').collect(); + // A hosted module root is host/owner/repo; a bare host/repo is two. + let root_len = if segs[0].contains('.') { + segs.len().min(3) + } else { + 1 + }; + let repo = segs.get(root_len - 1).copied().unwrap_or(label); + if segs.len() > root_len { + format!("{repo}:{}", segs[root_len..].join("/")) + } else { + repo.to_string() + } + } + "rust" => { + let mut it = label.splitn(2, "::"); + let krate = it.next().unwrap_or(label); + match it.next().filter(|rest| !rest.is_empty()) { + Some(rest) => format!("{krate}:{}", rest.replace("::", "/")), + None => krate.to_string(), + } + } + "javascript" | "typescript" => { + let pkg = package_name(language, label); + let bare = label.strip_prefix("node:").unwrap_or(label); + match bare.strip_prefix(&format!("{pkg}/")) { + Some(sub) => format!("{pkg}:{sub}"), + None => pkg, + } + } + _ => label.to_string(), + } +} + +/// Group a change's per-file added imports into the third-party packages it +/// introduces, keyed by language, sorted and de-duplicated. `files` yields one +/// `(language, imports)` entry per changed file; stdlib and first-party imports +/// are dropped. This is the whole of the NEW PACKAGES computation minus the +/// manifest reads that supply `first_party`. +pub fn group_third_party<'a, I>( + files: I, + first_party: &[String], +) -> std::collections::BTreeMap> +where + I: IntoIterator, +{ + let mut by_lang: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (language, imports) in files { + for import in imports { + if classify_import(language, import, first_party) == ImportOrigin::ThirdParty { + by_lang + .entry(language.to_string()) + .or_default() + .insert(package_name(language, import)); + } + } + } + by_lang + .into_iter() + .map(|(lang, pkgs)| (lang, pkgs.into_iter().collect())) + .collect() +} + +/// The repository's own module roots, parsed from manifests at the head +/// revision. Imports under these are first-party (excluded from NEW PACKAGES). +pub fn first_party_roots(go_mod: Option<&str>, cargo_toml: Option<&str>) -> Vec { + let mut roots = Vec::new(); + if let Some(text) = go_mod { + for line in text.lines() { + if let Some(rest) = line.trim().strip_prefix("module ") { + roots.push(rest.trim().to_string()); + break; + } + } + } + if let Some(text) = cargo_toml { + // The [package] name; the crate's own `use ::` paths are first-party. + let mut in_package = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_package = trimmed == "[package]"; + continue; + } + if in_package { + if let Some(rest) = trimmed.strip_prefix("name") { + if let Some(eq) = rest.trim_start().strip_prefix('=') { + let name = eq.trim().trim_matches('"'); + // Cargo normalises `-` to `_` in import paths. + roots.push(name.replace('-', "_")); + break; + } + } + } + } + } + roots +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn go_splits_stdlib_internal_and_third_party() { + let fp = vec!["github.com/yahn/unslop".to_string()]; + assert_eq!(classify_import("go", "fmt", &fp), ImportOrigin::Stdlib); + assert_eq!(classify_import("go", "path/filepath", &fp), ImportOrigin::Stdlib); + assert_eq!( + classify_import("go", "github.com/yahn/unslop/internal/format", &fp), + ImportOrigin::Internal + ); + assert_eq!( + classify_import("go", "github.com/spf13/cobra", &fp), + ImportOrigin::ThirdParty + ); + } + + #[test] + fn rust_splits_by_crate_head() { + let fp = vec!["myapp".to_string()]; + assert_eq!(classify_import("rust", "std::fmt", &fp), ImportOrigin::Stdlib); + assert_eq!(classify_import("rust", "crate::foo", &fp), ImportOrigin::Internal); + assert_eq!(classify_import("rust", "myapp::bar", &fp), ImportOrigin::Internal); + assert_eq!(classify_import("rust", "serde::Deserialize", &fp), ImportOrigin::ThirdParty); + assert_eq!(package_name("rust", "serde::Deserialize"), "serde"); + } + + #[test] + fn js_relative_is_internal_builtin_is_stdlib_scoped_package_kept() { + assert_eq!(classify_import("typescript", "./util", &[]), ImportOrigin::Internal); + assert_eq!(classify_import("javascript", "node:fs", &[]), ImportOrigin::Stdlib); + assert_eq!(classify_import("javascript", "fs", &[]), ImportOrigin::Stdlib); + assert_eq!(classify_import("typescript", "@scope/pkg/sub", &[]), ImportOrigin::ThirdParty); + assert_eq!(package_name("typescript", "@scope/pkg/sub"), "@scope/pkg"); + assert_eq!(package_name("javascript", "lodash/fp"), "lodash"); + } + + #[test] + fn display_first_party_is_relative_third_party_is_repo_colon_subpath() { + let fp = vec!["github.com/yahn/unslop".to_string()]; + // First-party in this repo -> relative path, no module root. + assert_eq!( + display_import("go", "github.com/yahn/unslop/internal/ui", &fp), + "./internal/ui" + ); + // Stdlib is left as-is. + assert_eq!(display_import("go", "fmt", &fp), "fmt"); + assert_eq!(display_import("go", "path/filepath", &fp), "path/filepath"); + // A third-party repo -> repo:subpath (host + owner stripped). + assert_eq!( + display_import("go", "github.com/yahn/unslop/internal/ui", &[]), + "unslop:internal/ui" + ); + assert_eq!(display_import("go", "github.com/spf13/cobra", &[]), "cobra"); + assert_eq!( + display_import("go", "golang.org/x/sync/errgroup", &[]), + "sync:errgroup" + ); + // Rust: crate-local relative, third-party crate:subpath. + let rs = vec!["myapp".to_string()]; + assert_eq!(display_import("rust", "crate::foo::bar", &rs), "./foo/bar"); + assert_eq!(display_import("rust", "serde::de::Visitor", &rs), "serde:de/Visitor"); + } + + #[test] + fn group_keeps_third_party_by_language_and_drops_the_rest() { + let roots = vec!["github.com/yahn/unslop".to_string()]; + let go_writer: Vec = ["fmt", "os", "github.com/yahn/unslop/internal/format"] + .iter() + .map(|s| s.to_string()) + .collect(); + let go_cmd: Vec = ["github.com/spf13/cobra", "github.com/yahn/unslop/internal/ui"] + .iter() + .map(|s| s.to_string()) + .collect(); + let rs: Vec = ["std::fmt", "serde::Deserialize", "tokio::spawn"] + .iter() + .map(|s| s.to_string()) + .collect(); + let files: Vec<(&str, &[String])> = vec![ + ("go", go_writer.as_slice()), + ("go", go_cmd.as_slice()), + ("rust", rs.as_slice()), + ]; + let out = group_third_party(files, &roots); + assert_eq!(out.get("go").unwrap(), &vec!["github.com/spf13/cobra".to_string()]); + assert_eq!( + out.get("rust").unwrap(), + &vec!["serde".to_string(), "tokio".to_string()] + ); + } + + #[test] + fn first_party_roots_parse_go_module_and_cargo_name() { + let go = "module github.com/yahn/unslop\n\ngo 1.22\n"; + let cargo = "[package]\nname = \"my-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; + let roots = first_party_roots(Some(go), Some(cargo)); + assert!(roots.contains(&"github.com/yahn/unslop".to_string())); + assert!(roots.contains(&"my_app".to_string())); + } +} diff --git a/gems/lineage/src/pipeline.rs b/gems/gigasail/giga-core/src/pipeline.rs similarity index 93% rename from gems/lineage/src/pipeline.rs rename to gems/gigasail/giga-core/src/pipeline.rs index 7992fa015..ae7ca45d6 100644 --- a/gems/lineage/src/pipeline.rs +++ b/gems/gigasail/giga-core/src/pipeline.rs @@ -1,4 +1,4 @@ -//! Typed configuration and manifest contracts for the Lineage evidence pipeline. +//! Typed configuration and manifest contracts for the Gigasail evidence pipeline. use anyhow::{bail, Context, Result}; use flate2::Compression; @@ -16,12 +16,14 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -pub const CONFIG_FILE_NAME: &str = "lineage.yml"; -pub const CONFIG_JSON_FILE_NAME: &str = "lineage.json"; -pub const RUN_MANIFEST_VERSION: &str = "lineage-run/v1"; +pub const CONFIG_FILE_NAME: &str = "giga.yml"; +pub const CONFIG_JSON_FILE_NAME: &str = "giga.json"; +pub const RUN_MANIFEST_VERSION: &str = "gigasail-run/v1"; const MAX_DECOMPRESSED_ARTIFACT_BYTES: u64 = 128 * 1024 * 1024; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// No `Eq`: `review` carries f64 ranking weights/thresholds (§tuning-configs), +// which are `PartialEq` only. Nothing keys a set/map on `LineageConfig`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct LineageConfig { pub version: u32, @@ -31,6 +33,10 @@ pub struct LineageConfig { pub profiles: BTreeMap, #[serde(default)] pub producers: BTreeMap, + /// Review gates, metric weighting, and purity policy. See + /// `crate::review::ReviewConfig` and docs/agents/tuning-configs.md. + #[serde(default)] + pub review: crate::review::ReviewConfig, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -108,7 +114,7 @@ pub struct EvidenceProducer { #[serde(rename_all = "snake_case")] pub enum ProducerExecutor { Command, - Lineage, + Gigasail, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -142,11 +148,14 @@ pub struct DeclaredEvidenceScope { pub enum ArtifactKind { /// A producer-owned intermediate artifact. It is staged, hashed, and /// retained with the run so later producers can consume it, but it is not - /// evidence that Lineage imports into its database. + /// evidence that Gigasail imports into its database. Auxiliary, Coverage, Mutants, Sarif, + /// An espalier architecture graph (`espalier.architecture.v1`), ingested to + /// power New Dependencies / New State in the diff. + Architecture, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -274,7 +283,7 @@ fn ensure_artifact_store_root(repo: &Path, config: &LineageConfig) -> Result<()> match fs::symlink_metadata(¤t) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { bail!( - "Lineage artifact-store ancestor {} must be a non-symlink directory", + "Gigasail artifact-store ancestor {} must be a non-symlink directory", current.display() ); } @@ -282,7 +291,7 @@ fn ensure_artifact_store_root(repo: &Path, config: &LineageConfig) -> Result<()> Err(error) if error.kind() == std::io::ErrorKind::NotFound => { fs::create_dir(¤t).with_context(|| { format!( - "create Lineage artifact-store directory {}", + "create Gigasail artifact-store directory {}", current.display() ) })?; @@ -290,7 +299,7 @@ fn ensure_artifact_store_root(repo: &Path, config: &LineageConfig) -> Result<()> Err(error) => { return Err(error).with_context(|| { format!( - "inspect Lineage artifact-store directory {}", + "inspect Gigasail artifact-store directory {}", current.display() ) }); @@ -320,7 +329,7 @@ impl ExecutionLock { if let Err(error) = writeln!(file, "{identity}").and_then(|_| file.sync_all()) { let _ = fs::remove_file(&path); return Err(error).with_context(|| { - format!("write Lineage execution lock {}", path.display()) + format!("write Gigasail execution lock {}", path.display()) }); } return Ok(Self { path }); @@ -328,18 +337,18 @@ impl ExecutionLock { Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempt == 0 => { if stale_execution_lock(&path)? { fs::remove_file(&path).with_context(|| { - format!("remove stale Lineage execution lock {}", path.display()) + format!("remove stale Gigasail execution lock {}", path.display()) })?; continue; } bail!( - "acquire Lineage execution lock {}; another profile execution may be running", + "acquire Gigasail execution lock {}; another profile execution may be running", path.display() ); } Err(error) => { return Err(error).with_context(|| { - format!("acquire Lineage execution lock {}", path.display()) + format!("acquire Gigasail execution lock {}", path.display()) }) } } @@ -356,7 +365,7 @@ impl Drop for ExecutionLock { fn stale_execution_lock(path: &Path) -> Result { let contents = fs::read_to_string(path) - .with_context(|| format!("read Lineage execution lock {}", path.display()))?; + .with_context(|| format!("read Gigasail execution lock {}", path.display()))?; let mut fields = contents.split_whitespace(); let Some(pid) = fields.next().and_then(|pid| pid.parse::().ok()) else { return Ok(true); @@ -435,7 +444,7 @@ pub fn load_config(repo: &Path) -> Result { bail!("no {CONFIG_FILE_NAME} found in {}", repo.display()); }; let contents = fs::read_to_string(&path) - .with_context(|| format!("read Lineage configuration {}", path.display()))?; + .with_context(|| format!("read Gigasail configuration {}", path.display()))?; load_config_contents( &contents, path.extension().and_then(|extension| extension.to_str()), @@ -444,7 +453,7 @@ pub fn load_config(repo: &Path) -> Result { /// Parses a configuration from an already-trusted source snapshot. CI uses /// this for a reviewed base revision so a pull request cannot grant itself -/// arbitrary command execution merely by editing `lineage.yml`. +/// arbitrary command execution merely by editing `giga.yml`. pub fn load_config_contents(contents: &str, extension: Option<&str>) -> Result { let config: LineageConfig = if extension == Some("json") { serde_json::from_str(contents).map_err(anyhow::Error::from) @@ -457,7 +466,7 @@ pub fn load_config_contents(contents: &str, extension: Option<&str>) -> Result Result { if config.version != 1 { bail!( - "unsupported lineage.yml version {}; expected 1", + "unsupported giga.yml version {}; expected 1", config.version ); } @@ -477,11 +486,11 @@ pub fn validate_config(config: LineageConfig) -> Result { if producer.executor == ProducerExecutor::Command && producer.argv.is_empty() { bail!("command producer {name:?} requires argv"); } - if producer.executor == ProducerExecutor::Lineage + if producer.executor == ProducerExecutor::Gigasail && producer.argv.as_slice() != ["fact-mine-native"] { bail!( - "lineage producer {name:?} must use the allowlisted embedded provider argv: [fact-mine-native]; use executor: command for an explicitly trusted external command" + "gigasail producer {name:?} must use the allowlisted embedded provider argv: [fact-mine-native]; use executor: command for an explicitly trusted external command" ); } if producer.timeout_seconds == 0 || producer.max_output_bytes == 0 { @@ -653,7 +662,7 @@ pub fn validate_run_artifacts(run_directory: &Path, manifest: &RunManifest) -> R Ok(()) } -/// Validates the strict SARIF contract accepted for persisted Lineage +/// Validates the strict SARIF contract accepted for persisted Gigasail /// evidence. Direct ingestion and manifest ingestion intentionally share this /// gate so an unsupported document cannot be reported as complete. pub fn validate_sarif_document(bytes: &[u8]) -> Result<()> { @@ -739,7 +748,7 @@ fn remove_retained_run(run_directory: &Path) -> Result<()> { make_removable(run_directory)?; } fs::remove_dir_all(run_directory) - .with_context(|| format!("prune retained Lineage run {}", run_directory.display())) + .with_context(|| format!("prune retained Gigasail run {}", run_directory.display())) } /// Executes one configured profile and replaces the bounded `latest` run. @@ -755,7 +764,7 @@ fn execute_profile_unlocked( let profile = config .profiles .get(profile_name) - .with_context(|| format!("unknown Lineage profile {profile_name:?}"))?; + .with_context(|| format!("unknown Gigasail profile {profile_name:?}"))?; let declared_outputs = profile .producers .iter() @@ -840,7 +849,7 @@ fn execute_profile_unlocked( let observed = working_tree_fingerprint(repo, &config.artifacts.directory, &declared_outputs)?; if observed != expected { - bail!("working tree changed while Lineage analysis was running"); + bail!("working tree changed while Gigasail analysis was running"); } } write_manifest(&run_directory, &manifest)?; @@ -1155,32 +1164,32 @@ pub fn recover_workspace_transactions(repo: &Path, config: &LineageConfig) -> Re let artifact_root = checked_relative_directory( &repository_root, &config.artifacts.directory, - "Lineage artifact store", + "Gigasail artifact store", )?; let runs = artifact_root.join("runs"); if !runs.exists() { return Ok(()); } let runs_root = - checked_relative_directory(&artifact_root, Path::new("runs"), "Lineage run store")?; + checked_relative_directory(&artifact_root, Path::new("runs"), "Gigasail run store")?; let declared_outputs = declared_workspace_outputs(config)?; for entry in fs::read_dir(&runs)?.filter_map(std::result::Result::ok) { let run = entry.path(); let metadata = fs::symlink_metadata(&run)?; if metadata.file_type().is_symlink() || !metadata.is_dir() { bail!( - "Lineage run {} must be a non-symlink directory", + "Gigasail run {} must be a non-symlink directory", run.display() ); } let run_name = run .file_name() - .context("Lineage run directory has no file name")?; + .context("Gigasail run directory has no file name")?; let run_root = - checked_relative_directory(&runs_root, Path::new(run_name), "Lineage run directory")?; + checked_relative_directory(&runs_root, Path::new(run_name), "Gigasail run directory")?; if run_root.parent() != Some(runs_root.as_path()) { bail!( - "Lineage run {} escapes the configured run store", + "Gigasail run {} escapes the configured run store", run.display() ); } @@ -1445,7 +1454,7 @@ fn build_manifest( /// current file content for every non-ignored change, anchored to HEAD. fn working_tree_fingerprint( repo: &Path, - lineage_directory: &Path, + gigasail_directory: &Path, declared_outputs: &BTreeSet, ) -> Result { let repository = git2::Repository::discover(repo).with_context(|| { @@ -1478,7 +1487,7 @@ fn working_tree_fingerprint( .include_ignored(false) .renames_head_to_index(true) .renames_index_to_workdir(true); - let lineage_root = lineage_directory + let gigasail_root = gigasail_directory .components() .next() .map(|component| component.as_os_str().to_string_lossy().replace('\\', "/")) @@ -1505,7 +1514,7 @@ fn working_tree_fingerprint( .map(|path| (path.replace('\\', "/"), entry.status().bits())) }) .filter(|(path, _)| { - lineage_root + gigasail_root .as_ref() .is_none_or(|root| path != root && !path.starts_with(&format!("{root}/"))) }) @@ -1514,7 +1523,7 @@ fn working_tree_fingerprint( changes.sort(); let mut fingerprint = Sha256::new(); - fingerprint.update(b"lineage-worktree-fingerprint-v1\0"); + fingerprint.update(b"gigasail-worktree-fingerprint-v1\0"); fingerprint.update(head.as_bytes()); // Include the index object IDs as well as worktree bytes. Analysis reads // the worktree, but a staged-only change is still part of the developer's @@ -1581,7 +1590,7 @@ fn retain_failed_run(repo: &Path, config: &LineageConfig, staged: &Path) -> Resu .context("staged run directory has no valid file name")?; let failed = runs.join(format!("failed-{}", name.trim_start_matches(".staging-"))); fs::rename(staged, &failed) - .with_context(|| format!("retain failed Lineage run {}", staged.display()))?; + .with_context(|| format!("retain failed Gigasail run {}", staged.display()))?; prune_retained_runs(repo, config)?; Ok(()) } @@ -1626,7 +1635,7 @@ fn finalize_run( run_name.trim_start_matches(".staging-") )); fs::rename(staged, &completed) - .with_context(|| format!("finalize staged Lineage run {}", staged.display()))?; + .with_context(|| format!("finalize staged Gigasail run {}", staged.display()))?; if kind == ProfileRunKind::StandaloneAnalysis { prune_retained_runs(repo, config)?; } @@ -1644,7 +1653,18 @@ pub fn publish_run(repo: &Path, config: &LineageConfig, completed: &Path) -> Res .file_name() .and_then(|name| name.to_str()) .context("completed run directory has no valid file name")?; - if completed.parent() != Some(runs.as_path()) { + // The completed run must live directly in the configured run store. Compare + // canonically as well, so a relative `--repo` (which makes `runs` relative) + // still matches a run directory whose path was resolved to an absolute form + // upstream. + let in_store = completed.parent().is_some_and(|parent| { + parent == runs + || matches!( + (parent.canonicalize(), runs.canonicalize()), + (Ok(parent), Ok(runs)) if parent == runs + ) + }); + if !in_store { bail!( "completed run {} is not in the configured run store", completed.display() @@ -1654,7 +1674,7 @@ pub fn publish_run(repo: &Path, config: &LineageConfig, completed: &Path) -> Res let published_name = if let Some(name) = completed_name.strip_prefix("pending-") { let published = runs.join(format!("published-{name}")); fs::rename(completed, &published) - .with_context(|| format!("publish completed Lineage run {}", completed.display()))?; + .with_context(|| format!("publish completed Gigasail run {}", completed.display()))?; name } else if let Some(name) = completed_name.strip_prefix("published-") { name @@ -1683,18 +1703,18 @@ pub fn publish_run(repo: &Path, config: &LineageConfig, completed: &Path) -> Res // Windows does not permit the inexpensive symlink publication used on // Unix. Keep the completed run immutable and require a platform // specific publisher rather than destructively replacing `latest`. - bail!("atomic Lineage run publication is currently supported on Unix hosts"); + bail!("atomic Gigasail run publication is currently supported on Unix hosts"); } if let Ok(metadata) = fs::symlink_metadata(&latest) { if !metadata.file_type().is_symlink() { let legacy = runs.join(format!("legacy-{published_name}")); fs::rename(&latest, &legacy).with_context(|| { - format!("preserve legacy latest Lineage run {}", latest.display()) + format!("preserve legacy latest Gigasail run {}", latest.display()) })?; } } fs::rename(&temporary_link, &latest) - .with_context(|| format!("atomically publish latest Lineage run {}", latest.display()))?; + .with_context(|| format!("atomically publish latest Gigasail run {}", latest.display()))?; sync_parent_directory(&latest)?; prune_retained_runs(repo, config)?; Ok(()) @@ -1769,7 +1789,7 @@ fn prune_abandoned_runs(runs: &Path, stale_age: Duration) -> Result<()> { .is_some_and(|age| age >= stale_age); if stale { fs::remove_dir_all(entry.path()).with_context(|| { - format!("prune abandoned Lineage run {}", entry.path().display()) + format!("prune abandoned Gigasail run {}", entry.path().display()) })?; } } @@ -1782,8 +1802,8 @@ fn execute_producer( producer: &EvidenceProducer, run_directory: &Path, ) -> Result { - if producer.executor == ProducerExecutor::Lineage { - return execute_lineage_provider(repo, name, producer, run_directory); + if producer.executor == ProducerExecutor::Gigasail { + return execute_gigasail_provider(repo, name, producer, run_directory); } let working_directory = producer .working_directory @@ -1934,10 +1954,10 @@ fn execute_producer( }) } -/// Executes an embedded Lineage provider. The enum deliberately does *not* +/// Executes an embedded Gigasail provider. The enum deliberately does *not* /// resolve arbitrary executables from PATH: configuration is data, and a -/// `lineage` executor must remain an auditable, constrained capability. -fn execute_lineage_provider( +/// `gigasail` executor must remain an auditable, constrained capability. +fn execute_gigasail_provider( repo: &Path, name: &str, producer: &EvidenceProducer, @@ -2044,7 +2064,7 @@ struct FactMineRunStats { oversized_files: usize, } -/// The FactMine provider is linked into Lineage and is therefore safe to run +/// The FactMine provider is linked into Gigasail and is therefore safe to run /// in an unconfigured repository. It deliberately reports source-derived /// hazards as *partial* SARIF: static syntax can prove a site exists, never /// that a dynamic risk is absent. @@ -2066,13 +2086,13 @@ fn build_fact_mine_sarif(repo: &Path) -> Result<(FactMineRunStats, Vec)> { "runs": [{ "tool": {"driver": {"name": "FactMine", "informationUri": "https://github.com/cuzzo/clear"}}, "properties": { - "lineage.analysis_complete": false, - "lineage.provider_capability": "bounded-syntax-hazard-scan", - "lineage.proof_boundary": fact_mine_proof_boundary(&stats), - "lineage.scanned_files": stats.scanned_files, - "lineage.scanned_bytes": stats.scanned_bytes, - "lineage.unreadable_files": stats.unreadable_files, - "lineage.oversized_files": stats.oversized_files, + "gigasail.analysis_complete": false, + "gigasail.provider_capability": "bounded-syntax-hazard-scan", + "gigasail.proof_boundary": fact_mine_proof_boundary(&stats), + "gigasail.scanned_files": stats.scanned_files, + "gigasail.scanned_bytes": stats.scanned_bytes, + "gigasail.unreadable_files": stats.unreadable_files, + "gigasail.oversized_files": stats.oversized_files, }, "results": findings, }], @@ -2098,7 +2118,7 @@ fn write_fact_mine_sarif(repo: &Path, producer: &EvidenceProducer, document: &[u } fn embedded_fact_mine_version() -> String { - const FACT_MINE_MANIFEST: &str = include_str!("../../fact-mine/Cargo.toml"); + const FACT_MINE_MANIFEST: &str = include_str!("../../../fact-mine/Cargo.toml"); let version = FACT_MINE_MANIFEST .lines() .skip_while(|line| line.trim() != "[package]") @@ -2106,7 +2126,7 @@ fn embedded_fact_mine_version() -> String { .map(|value| value.trim_matches('"')) .unwrap_or("unknown"); format!( - "fact-mine-rust/{version};lineage/{}", + "fact-mine-rust/{version};gigasail/{}", env!("CARGO_PKG_VERSION") ) } @@ -2201,7 +2221,7 @@ fn collect_fact_mine_findings( hazard.hazard_type, if hazard.required_evidence.is_empty() { "review" } else { &hazard.required_evidence }, )}, - "partialFingerprints": {"lineage/v1": fingerprint}, + "partialFingerprints": {"gigasail/v1": fingerprint}, "properties": { "category": "static-hazard", "required_evidence": hazard.required_evidence, @@ -2413,10 +2433,10 @@ pub fn validate_relative_path(path: &Path, label: &str) -> Result<()> { fn validate_artifact_directory(path: &Path) -> Result<()> { validate_relative_path(path, "artifacts.directory")?; // A project may keep the database, run store, and declared producer - // outputs under one `.lineage/` root. Keep the historical - // `.lineage/artifacts/` default, but do not force a split that makes the + // outputs under one `.giga/` root. Keep the historical + // `.giga/artifacts/` default, but do not force a split that makes the // database look like a source-tree mutation during subproject CI. - let reserved = Path::new(".lineage"); + let reserved = Path::new(".giga"); if !path.starts_with(reserved) { bail!("artifacts.directory must be beneath {}", reserved.display()); } @@ -2449,7 +2469,7 @@ fn is_environment_key_continue(byte: u8) -> bool { } fn default_artifact_directory() -> PathBuf { - PathBuf::from(".lineage/artifacts") + PathBuf::from(".giga/artifacts") } fn default_compression() -> ArtifactCompression { @@ -2533,16 +2553,59 @@ mod tests { assert_eq!(config.artifacts.compression, ArtifactCompression::Gzip); assert_eq!( latest_run_directory(directory.path(), &config), - directory.path().join(".lineage/artifacts/latest") + directory.path().join(".giga/artifacts/latest") ); assert_eq!(config.profiles["ci"].producers, ["coverage"]); } + #[test] + fn loads_a_review_block_through_the_real_config_path() { + use crate::review::{PuritySource, Visibility}; + let directory = tempdir().unwrap(); + fs::write( + directory.path().join(CONFIG_FILE_NAME), + "version: 1\n\ + review:\n\ + \x20 metrics:\n\ + \x20 \"T3\": { policy: deprioritize, weight: 0.0 }\n\ + \x20 weights:\n\ + \x20 tier_two_finding: 5.0\n\ + \x20 purity:\n\ + \x20 source: effects\n\ + \x20 gates:\n\ + \x20 - id: uncovered-tier1\n\ + \x20 when: { tier: 1, on: added, coverage: uncovered }\n\ + \x20 severity: critical\n", + ) + .unwrap(); + + let config = load_config(directory.path()).unwrap(); + // The `review:` key round-trips through load_config (not just serde), + // so a project's config actually reaches the evaluator. + assert_eq!(config.review.weights.tier_two_finding, 5.0); + assert_eq!(config.review.metrics["T3"].policy, Visibility::Deprioritize); + assert_eq!(config.review.purity.source, PuritySource::Effects); + assert_eq!(config.review.gates[0].id, "uncovered-tier1"); + } + + #[test] + fn a_malformed_review_block_is_a_load_error_not_a_silent_default() { + let directory = tempdir().unwrap(); + // `wieght` is a typo; deny_unknown_fields must reject it so the mistake + // surfaces instead of silently falling back to default gating. + fs::write( + directory.path().join(CONFIG_FILE_NAME), + "version: 1\nreview:\n weights:\n wieght: 3.0\n", + ) + .unwrap(); + assert!(load_config(directory.path()).is_err()); + } + #[test] fn worktree_fingerprint_changes_with_dirty_source_and_untracked_files() { let directory = tempdir().unwrap(); let repository = git2::Repository::init(directory.path()).unwrap(); - let signature = git2::Signature::now("Lineage", "lineage@example.test").unwrap(); + let signature = git2::Signature::now("Gigasail", "gigasail@example.test").unwrap(); fs::write(directory.path().join("lib.rs"), "pub fn value() {}\n").unwrap(); let mut index = repository.index().unwrap(); index.add_path(Path::new("lib.rs")).unwrap(); @@ -2552,7 +2615,7 @@ mod tests { .commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[]) .unwrap(); - let artifact_directory = Path::new(".lineage/artifacts"); + let artifact_directory = Path::new(".giga/artifacts"); let clean = working_tree_fingerprint(directory.path(), artifact_directory, &BTreeSet::new()) .unwrap(); @@ -2574,19 +2637,19 @@ mod tests { let untracked = working_tree_fingerprint(directory.path(), artifact_directory, &BTreeSet::new()) .unwrap(); - fs::create_dir_all(directory.path().join(".lineage/artifacts")).unwrap(); + fs::create_dir_all(directory.path().join(".giga/artifacts")).unwrap(); fs::write( - directory.path().join(".lineage/artifacts/transient.json"), + directory.path().join(".giga/artifacts/transient.json"), "{}\n", ) .unwrap(); - let with_lineage_output = + let with_gigasail_output = working_tree_fingerprint(directory.path(), artifact_directory, &BTreeSet::new()) .unwrap(); assert_ne!(clean, modified); assert_ne!(modified, staged); assert_ne!(modified, untracked); - assert_eq!(untracked, with_lineage_output); + assert_eq!(untracked, with_gigasail_output); assert!(untracked.starts_with("worktree:")); } @@ -2607,7 +2670,7 @@ mod tests { assert!(load_config(directory.path()) .unwrap_err() .to_string() - .contains("both lineage.yml")); + .contains("both giga.yml")); } #[test] @@ -2639,6 +2702,7 @@ mod tests { }, )]), producers: BTreeMap::from([("coverage".into(), producer("coverage.json"))]), + review: Default::default(), }; assert!(validate_config(duplicate_profile) .unwrap_err() @@ -2653,6 +2717,7 @@ mod tests { ("coverage".into(), producer("result.json")), ("other".into(), producer("result.json")), ]), + review: Default::default(), }; assert!(validate_config(duplicate_output) .unwrap_err() @@ -2741,6 +2806,7 @@ mod tests { }, ), ]), + review: Default::default(), }) .unwrap(); @@ -2758,7 +2824,7 @@ mod tests { ); let failed = directory .path() - .join(".lineage/artifacts/runs") + .join(".giga/artifacts/runs") .read_dir() .unwrap() .map(|entry| entry.unwrap().path()) @@ -2816,6 +2882,7 @@ mod tests { }], }, )]), + review: Default::default(), }) .unwrap(); @@ -2874,6 +2941,7 @@ mod tests { .collect(), }, )]), + review: Default::default(), }; assert!(quarantine_profile_outputs( @@ -2929,11 +2997,12 @@ mod tests { }], }, )]), + review: Default::default(), }) .unwrap(); let run = directory .path() - .join(".lineage/artifacts/runs/.staging-interrupted"); + .join(".giga/artifacts/runs/.staging-interrupted"); fs::create_dir_all(&run).unwrap(); let transaction = quarantine_profile_outputs(directory.path(), &run, &config.profiles["ci"], &config) @@ -2980,11 +3049,12 @@ mod tests { }], }, )]), + review: Default::default(), }) .unwrap(); let run = directory .path() - .join(".lineage/artifacts/runs/.staging-interrupted"); + .join(".giga/artifacts/runs/.staging-interrupted"); fs::create_dir_all(&run).unwrap(); let transaction = quarantine_profile_outputs(directory.path(), &run, &config.profiles["ci"], &config) @@ -3049,6 +3119,7 @@ mod tests { }, ), ]), + review: Default::default(), }) .unwrap(); @@ -3102,7 +3173,7 @@ mod tests { } #[test] - fn lineage_executor_rejects_unallowlisted_path_commands() { + fn gigasail_executor_rejects_unallowlisted_path_commands() { let config = LineageConfig { version: 1, artifacts: ArtifactStoreConfig::default(), @@ -3110,7 +3181,7 @@ mod tests { producers: BTreeMap::from([( "unsafe".into(), EvidenceProducer { - executor: ProducerExecutor::Lineage, + executor: ProducerExecutor::Gigasail, argv: vec!["sh".into(), "-c".into(), "touch escaped".into()], working_directory: None, timeout_seconds: 1, @@ -3119,6 +3190,7 @@ mod tests { produces: Vec::new(), }, )]), + review: Default::default(), }; let error = validate_config(config).unwrap_err().to_string(); assert!(error.contains("allowlisted embedded provider"), "{error}"); @@ -3164,6 +3236,7 @@ mod tests { }], }, )]), + review: Default::default(), }) .unwrap(); for revision in ["one", "two", "three"] { @@ -3181,7 +3254,7 @@ mod tests { seal_published_run(&run.directory).unwrap(); } } - let runs = fs::read_dir(directory.path().join(".lineage/artifacts/runs")) + let runs = fs::read_dir(directory.path().join(".giga/artifacts/runs")) .unwrap() .filter_map(std::result::Result::ok) .filter(|entry| entry.file_name().to_string_lossy().starts_with("analysis-")) @@ -3227,6 +3300,7 @@ mod tests { }], }, )]), + review: Default::default(), }) .unwrap(); let error = ProfileExecutionSession::begin(directory.path(), &config) @@ -3236,7 +3310,7 @@ mod tests { .to_string(); assert!(error.contains("validate SARIF artifact"), "{error}"); assert!( - fs::read_dir(directory.path().join(".lineage/artifacts/runs")) + fs::read_dir(directory.path().join(".giga/artifacts/runs")) .unwrap() .filter_map(std::result::Result::ok) .all(|entry| !entry.file_name().to_string_lossy().starts_with("analysis-")) @@ -3263,6 +3337,7 @@ mod tests { artifacts: ArtifactStoreConfig::default(), profiles: BTreeMap::new(), producers: BTreeMap::new(), + review: Default::default(), }; let session = ProfileExecutionSession::begin(directory.path(), &config).unwrap(); let error = match ProfileExecutionSession::begin(directory.path(), &config) { @@ -3295,7 +3370,7 @@ mod tests { ) .unwrap(); let producer = EvidenceProducer { - executor: ProducerExecutor::Lineage, + executor: ProducerExecutor::Gigasail, argv: vec!["fact-mine-native".into()], working_directory: None, timeout_seconds: 1, @@ -3321,23 +3396,23 @@ mod tests { assert!(results.contains("z.rs")); assert!(!results.contains("ignored.rs")); assert_eq!( - document.pointer("/runs/0/properties/lineage.analysis_complete"), + document.pointer("/runs/0/properties/gigasail.analysis_complete"), Some(&serde_json::Value::Bool(false)) ); assert_eq!( - document.pointer("/runs/0/properties/lineage.provider_capability"), + document.pointer("/runs/0/properties/gigasail.provider_capability"), Some(&serde_json::Value::String( "bounded-syntax-hazard-scan".to_string() )) ); assert!(document - .pointer("/runs/0/properties/lineage.proof_boundary") + .pointer("/runs/0/properties/gigasail.proof_boundary") .and_then(serde_json::Value::as_array) .is_some()); let run = directory.path().join("run"); fs::create_dir_all(&run).unwrap(); let producer_run = - execute_lineage_provider(directory.path(), "fact-mine", &producer, &run).unwrap(); + execute_gigasail_provider(directory.path(), "fact-mine", &producer, &run).unwrap(); assert_eq!(producer_run.outcome, ProducerOutcome::Succeeded); assert!( producer_run.tool_version.starts_with("fact-mine-rust/"), diff --git a/gems/gigasail/giga-core/src/review.rs b/gems/gigasail/giga-core/src/review.rs new file mode 100644 index 000000000..8dae19853 --- /dev/null +++ b/gems/gigasail/giga-core/src/review.rs @@ -0,0 +1,1056 @@ +//! The review evaluator: turns a `DiffPlan` + the `review:` config into a +//! `ReviewReport` (verdict + gates + ranked findings). One evaluator, shared by +//! the MCP tools, CI, and the diff UI (the single-source-of-truth invariant in +//! docs/agents/tuning-configs.md §9). Pure logic — no I/O, no server deps. +//! +//! This is the first slice: SARIF findings by tier, coverage/mutation posture, +//! effect-derived purity, per-metric visibility/weight, configurable ranking +//! weights, and the gates that evaluate off data already in the plan +//! (`uncovered-tier1`, purity coverage gates). Reserved config (perf, tags, +//! test depth, hazard gates, branch-coverage requirements) parses and is +//! carried, but is not yet evaluated — each is flagged where it is skipped. + +use crate::diff::{DiffPlan, LineVerification, SarifFindingSummary}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +// ─────────────────────────────── config ──────────────────────────────────── + +/// The `review:` section of `giga.yml`. Every subsection defaults, so an absent +/// `review:` behaves sanely (show everything, default weights, gate only on +/// uncovered T1). Reserved fields (`tests`, `perf`, `tags`, `retain`) parse for +/// forward-compatibility but are not yet consumed by the evaluator. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ReviewConfig { + #[serde(default)] + pub metrics: BTreeMap, + #[serde(default)] + pub weights: RiskWeights, + #[serde(default)] + pub purity: PurityConfig, + #[serde(default)] + pub gates: Vec, + #[serde(default)] + pub report: ReportConfig, + /// Which test producers run at each review stage (precommit / premerge) and + /// whether mutation testing runs there. See `stage_tests`. + #[serde(default)] + pub tests: TestDepthConfig, + /// A project graph: which files belong to each package, which packages it + /// depends on, and the test producers to run for it. A change runs the + /// affected packages (changed + everything that transitively depends on + /// them). This is the "run these tests when these files change" model — not + /// a build system (see tuning-configs.md §12–§13). + #[serde(default)] + pub packages: BTreeMap, + /// Opt-in pre-test check gates. When true, `giga test` runs each affected + /// package's `checks` (lint/format gates) before its producers and stops + /// early if any fail. Off by default — checks only run when turned on (here + /// or via `giga test --checks`). See tuning-configs.md §14. + #[serde(default)] + pub checks_enabled: bool, + // Reserved (parsed, not yet evaluated) — see tuning-configs.md §3f–§3i. + #[serde(default)] + pub retain: Option, + #[serde(default)] + pub perf: Option, + #[serde(default)] + pub tags: BTreeMap, +} + +/// One node in the project graph. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Package { + /// Globs identifying this package's files (`gems/fact-mine/**`). A trailing + /// `/**` matches the directory subtree; other entries match exactly. + #[serde(default)] + pub paths: Vec, + /// Packages this one depends on. A change to a dependency runs this package + /// too (reverse-transitive closure). + #[serde(default)] + pub depends_on: Vec, + /// Test producers to run when this package is affected (precommit + premerge). + #[serde(default)] + pub producers: Vec, + /// Additional producers to run only at premerge (e.g. fuzz suites). + #[serde(default)] + pub premerge: Vec, + /// Pre-test check gates for this package (lint/format). Each entry is either + /// a `contrib::` reference to a bundled recommended script, + /// or a repo-relative script path. Only run when checks are enabled. + #[serde(default)] + pub checks: Vec, +} + +impl ReviewConfig { + /// The producers to run for a set of changed paths at a stage: the union + /// over every affected package (a package whose files changed, plus every + /// package that transitively depends on it). Empty when no `packages` graph + /// is configured — callers then fall back to the stage's profiles. + pub fn affected_producers(&self, changed_paths: &[String], mode: ReviewMode) -> Vec { + let mut producers: BTreeSet = BTreeSet::new(); + for name in self.affected_packages(changed_paths) { + if let Some(pkg) = self.packages.get(name) { + producers.extend(pkg.producers.iter().cloned()); + if mode == ReviewMode::Premerge { + producers.extend(pkg.premerge.iter().cloned()); + } + } + } + producers.into_iter().collect() + } + + /// The pre-test check refs for a set of changed paths: the union of `checks` + /// over every affected package. Same affected-package set as + /// `affected_producers`; checks do not vary by stage. + pub fn affected_checks(&self, changed_paths: &[String]) -> Vec { + let mut checks: Vec = Vec::new(); + for name in self.affected_packages(changed_paths) { + if let Some(pkg) = self.packages.get(name) { + for c in &pkg.checks { + if !checks.contains(c) { + checks.push(c.clone()); + } + } + } + } + checks + } + + /// The names of the packages affected by a change (see `affected_packages`), + /// as owned strings for reporting / JSON output. + pub fn affected_package_names(&self, changed_paths: &[String]) -> Vec { + self.affected_packages(changed_paths) + .into_iter() + .map(String::from) + .collect() + } + + /// The set of packages affected by a change: every package whose files + /// changed, plus every package that transitively depends on one of those + /// (reverse-transitive closure). Empty when no `packages` graph is + /// configured. Ordering is deterministic (BTreeSet over package names). + fn affected_packages(&self, changed_paths: &[String]) -> BTreeSet<&str> { + let mut affected: BTreeSet<&str> = BTreeSet::new(); + if self.packages.is_empty() { + return affected; + } + // Directly-changed packages. + for (name, pkg) in &self.packages { + if changed_paths + .iter() + .any(|p| pkg.paths.iter().any(|glob| path_matches(glob, p))) + { + affected.insert(name.as_str()); + } + } + // Reverse edges: dependency -> dependent. Close over them so a change to + // a dependency pulls in its dependents. + let mut queue: Vec<&str> = affected.iter().copied().collect(); + while let Some(dep) = queue.pop() { + for (name, pkg) in &self.packages { + if pkg.depends_on.iter().any(|d| d == dep) && affected.insert(name.as_str()) { + queue.push(name.as_str()); + } + } + } + affected + } +} + +/// A minimal glob: a trailing `/**` matches the directory subtree; otherwise an +/// exact path match. Sufficient for package-root globs like `gems/fact-mine/**`. +fn path_matches(glob: &str, path: &str) -> bool { + if let Some(prefix) = glob.strip_suffix("/**") { + path == prefix || path.starts_with(&format!("{prefix}/")) + } else { + path == glob + } +} + +/// Per-stage test selection. `giga_precommit` runs the fast set (no mutation by +/// default); `giga_premerge` runs the exhaustive set (mutation on by default). +/// Turning mutation off at a stage forfeits the "covered but not killed" +/// signal — a test that executes a line without asserting anything about it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TestDepthConfig { + #[serde(default = "precommit_default")] + pub precommit: StageTests, + #[serde(default = "premerge_default")] + pub premerge: StageTests, +} + +impl Default for TestDepthConfig { + fn default() -> Self { + Self { + precommit: precommit_default(), + premerge: premerge_default(), + } + } +} + +/// The producers (by giga.yml profile) and whether mutation runs, for one stage. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct StageTests { + /// giga.yml profiles whose producers run at this stage. A profile groups + /// producers by `test_type` tag (unit / integration / fuzz), so a stage can + /// mix "run unit + integration coverage" without naming each producer. + #[serde(default)] + pub profiles: Vec, + /// Whether mutation testing (test-miser / the mutant runner) runs here. + #[serde(default)] + pub mutation: bool, +} + +fn precommit_default() -> StageTests { + StageTests { + profiles: vec!["ci".into()], + mutation: false, + } +} +fn premerge_default() -> StageTests { + StageTests { + profiles: vec!["ci".into(), "analyse".into()], + mutation: true, + } +} + +/// The resolved test run for a stage, after applying the mutation-requirement +/// coupling. `mutation_forced` is true when a gate/purity kill-rate requirement +/// turned mutation on despite the stage default being off — surfaced so the +/// runner (and the agent) can see *why* mutants are running at precommit. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ResolvedStage { + pub profiles: Vec, + pub mutation: bool, + pub mutation_forced: bool, +} + +impl ReviewConfig { + /// Whether any active gate or purity bucket demands a mutant kill rate. + /// If so, mutation MUST run wherever those gates apply — otherwise the + /// verdict is permanently `critical` ("no mutants killed") no matter how + /// good the tests are, and an agent can never satisfy it. + pub fn requires_mutation(&self) -> bool { + let gate_kill = self.gates.iter().any(|g| { + g.require + .as_ref() + .and_then(|r| r.mutation_kill_rate) + .is_some() + }); + gate_kill + || self.purity.pure.mutation_kill_rate.is_some() + || self.purity.stateful.mutation_kill_rate.is_some() + } + + /// The test run for a stage. Mutation is on when the stage default asks for + /// it OR a kill-rate requirement forces it (`mutation_forced`). + pub fn stage_tests(&self, mode: ReviewMode) -> ResolvedStage { + let base = match mode { + ReviewMode::Precommit => &self.tests.precommit, + ReviewMode::Premerge => &self.tests.premerge, + }; + let forced = !base.mutation && self.requires_mutation(); + ResolvedStage { + profiles: base.profiles.clone(), + mutation: base.mutation || forced, + mutation_forced: forced, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MetricPolicy { + pub policy: Visibility, + /// Ranking weight when shown/deprioritized. `deprioritize` implies 0. + #[serde(default)] + pub weight: Option, + /// A finding whose metric value is below this is dropped entirely. + #[serde(default)] + pub threshold: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + Show, + Deprioritize, + Ignore, +} + +/// Ranking weights that replace the hardcoded risk formula. Defaults reproduce +/// today's `apply_tier_one_hazards` behavior (T2/T3 weightless, T1/hazard = 8). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RiskWeights { + #[serde(default = "one")] + pub not_covered: f64, + #[serde(default = "half")] + pub partially_covered: f64, + #[serde(default = "two")] + pub added_complexity: f64, + #[serde(default = "eight")] + pub tier_one_finding: f64, + #[serde(default = "three")] + pub tier_two_finding: f64, + #[serde(default = "zero")] + pub tier_three_finding: f64, + #[serde(default = "eight")] + pub unverified_hazard: f64, + #[serde(default = "four")] + pub uncovered_mutant: f64, +} + +impl Default for RiskWeights { + fn default() -> Self { + Self { + not_covered: 1.0, + partially_covered: 0.5, + added_complexity: 2.0, + tier_one_finding: 8.0, + tier_two_finding: 3.0, + tier_three_finding: 0.0, + unverified_hazard: 8.0, + uncovered_mutant: 4.0, + } + } +} + +impl RiskWeights { + fn tier_weight(&self, tier: Option) -> f64 { + match tier { + Some(1) => self.tier_one_finding, + Some(2) => self.tier_two_finding, + Some(3) => self.tier_three_finding, + _ => 0.0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct PurityConfig { + #[serde(default)] + pub source: PuritySource, + #[serde(default)] + pub pure: CoverageRequire, + #[serde(default)] + pub stateful: CoverageRequire, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PuritySource { + #[default] + Effects, + Sarif, + Off, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct CoverageRequire { + #[serde(default)] + pub line_coverage: Option, + #[serde(default)] + pub branch_coverage: Option, + #[serde(default)] + pub mutation_kill_rate: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gate { + pub id: String, + pub when: GateWhen, + #[serde(default)] + pub require: Option, + #[serde(default)] + pub unless_evidence: Vec, + #[serde(default = "critical_severity")] + pub severity: Severity, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct GateWhen { + #[serde(default)] + pub tier: Option, + #[serde(default)] + pub coverage: Option, + #[serde(default)] + pub hazard: Option, + #[serde(default)] + pub verified: Option, + #[serde(default)] + pub mutant_killed: Option, + #[serde(default)] + pub purity: Option, + #[serde(default)] + pub tag: Option, + #[serde(default)] + pub on: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + Critical, + Warn, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReportConfig { + #[serde(default)] + pub include_resolved: bool, + #[serde(default = "default_max_findings")] + pub max_findings_per_tier: usize, + #[serde(default = "default_group_by")] + pub group_by: String, +} + +impl Default for ReportConfig { + fn default() -> Self { + Self { + include_resolved: false, + max_findings_per_tier: 25, + group_by: "tier".into(), + } + } +} + +fn zero() -> f64 { + 0.0 +} +fn half() -> f64 { + 0.5 +} +fn one() -> f64 { + 1.0 +} +fn two() -> f64 { + 2.0 +} +fn three() -> f64 { + 3.0 +} +fn four() -> f64 { + 4.0 +} +fn eight() -> f64 { + 8.0 +} +fn critical_severity() -> Severity { + Severity::Critical +} +fn default_max_findings() -> usize { + 25 +} +fn default_group_by() -> String { + "tier".into() +} + +/// The default gate set when `review.gates` is empty: block on a new, +/// on-added-lines, uncovered T1 finding. +pub fn default_gates() -> Vec { + vec![Gate { + id: "uncovered-tier1".into(), + when: GateWhen { + tier: Some(1), + coverage: Some("uncovered".into()), + on: Some("added".into()), + ..GateWhen::default() + }, + require: None, + unless_evidence: Vec::new(), + severity: Severity::Critical, + }] +} + +// ─────────────────────────────── report ──────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewMode { + Precommit, + Premerge, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Pass, + NeedsReview, + Critical, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReviewReport { + pub mode: ReviewMode, + pub range: ReviewRange, + pub verdict: Verdict, + pub gates_triggered: Vec, + pub summary: ReviewSummary, + /// The test depth resolved for this stage (which profiles, whether mutation + /// ran, and whether a kill-rate requirement forced it on). + pub tests: ResolvedStage, + pub findings: Vec, + pub deprioritized: usize, + pub ignored: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReviewRange { + pub base: String, + pub head: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct GateHit { + pub id: String, + pub severity: Severity, + pub count: usize, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReviewSummary { + pub findings: TierCounts, + pub coverage: CoveragePosture, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +pub struct TierCounts { + pub t1: usize, + pub t2: usize, + pub t3: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct CoveragePosture { + pub line: f64, + pub mutation_kill: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReviewFinding { + pub tier: Option, + pub rule_id: String, + pub tool: String, + pub file: String, + pub line: u32, + pub message: String, + pub coverage: String, + pub weight: f64, + pub deprioritized: bool, + pub proof_boundary: Vec, +} + +// ─────────────────────────────── evaluator ────────────────────────────────── + +/// A normalized new-SARIF candidate, decoupled from the plan so the policy/gate +/// logic is unit-testable without constructing a full `DiffPlan`. +#[derive(Debug, Clone)] +struct Candidate { + file: String, + finding: SarifFindingSummary, + /// Per-line coverage of the finding's line (from the diff's line annotations). + coverage: LineVerification, + /// Whether the finding's line is an added line in the diff. + on_added: bool, +} + +pub fn evaluate(plan: &DiffPlan, config: &ReviewConfig, mode: ReviewMode) -> ReviewReport { + let candidates = collect_candidates(plan); + let gates = if config.gates.is_empty() { + default_gates() + } else { + config.gates.clone() + }; + + let mut findings = Vec::new(); + let mut counts = TierCounts::default(); + let mut deprioritized = 0usize; + let mut ignored = 0usize; + + for cand in &candidates { + match classify(config, cand) { + Decision::Ignore => ignored += 1, + Decision::Keep { weight, deprio } => { + if deprio { + deprioritized += 1; + } else { + match cand.finding.tier { + Some(1) => counts.t1 += 1, + Some(2) => counts.t2 += 1, + Some(3) => counts.t3 += 1, + _ => {} + } + } + findings.push(ReviewFinding { + tier: cand.finding.tier, + rule_id: cand.finding.rule_id.clone(), + tool: cand.finding.tool.clone(), + file: cand.file.clone(), + line: cand.finding.start_line, + message: cand.finding.message.clone(), + coverage: coverage_label(cand.coverage).into(), + weight, + deprioritized: deprio, + proof_boundary: cand.finding.proof_boundary.clone(), + }); + } + } + } + + // Ranked: highest weight first, then T1 before T2/T3, then by location. + findings.sort_by(|a, b| { + b.weight + .total_cmp(&a.weight) + .then(a.tier.unwrap_or(9).cmp(&b.tier.unwrap_or(9))) + .then(a.file.cmp(&b.file)) + .then(a.line.cmp(&b.line)) + }); + cap_per_tier(&mut findings, config.report.max_findings_per_tier); + + let gates_triggered = evaluate_gates(&gates, &candidates, config); + let verdict = verdict_from(&gates_triggered); + + ReviewReport { + mode, + range: ReviewRange { + base: plan.scope.base_oid.clone(), + head: plan.scope.head_oid.clone(), + }, + verdict, + gates_triggered, + summary: ReviewSummary { + findings: counts, + coverage: coverage_posture(plan), + }, + tests: config.stage_tests(mode), + findings, + deprioritized, + ignored, + } +} + +enum Decision { + Ignore, + Keep { weight: f64, deprio: bool }, +} + +/// Apply the per-metric policy (§3a) + ranking weight (§3b) to one candidate. +/// Only `new` findings are review candidates; `resolved`/others are dropped. +fn classify(config: &ReviewConfig, cand: &Candidate) -> Decision { + if cand.finding.status != "new" { + return Decision::Ignore; + } + let policy = metric_policy(config, &cand.finding); + let base_weight = config.weights.tier_weight(cand.finding.tier); + match policy { + Some(p) => match p.policy { + Visibility::Ignore => Decision::Ignore, + Visibility::Deprioritize => Decision::Keep { + weight: p.weight.unwrap_or(0.0), + deprio: true, + }, + Visibility::Show => Decision::Keep { + weight: p.weight.unwrap_or(base_weight), + deprio: false, + }, + }, + None => Decision::Keep { + weight: base_weight, + deprio: false, + }, + } +} + +/// Most-specific policy wins: an exact `rule_id` key over a bare `T` key. +fn metric_policy<'a>( + config: &'a ReviewConfig, + finding: &SarifFindingSummary, +) -> Option<&'a MetricPolicy> { + config.metrics.get(&finding.rule_id).or_else(|| { + finding + .tier + .and_then(|t| config.metrics.get(&format!("T{t}"))) + }) +} + +fn collect_candidates(plan: &DiffPlan) -> Vec { + let mut out = Vec::new(); + for file in &plan.files { + let added = file.added_line_numbers(); + let cov: BTreeMap = file + .line_annotations + .iter() + .map(|a| (a.line, a.verification)) + .collect(); + // A file's findings live both directly and on its groups; dedupe by + // (rule_id, line) so a finding attached at both levels counts once. + let mut seen = BTreeSet::new(); + let group_findings = file.groups.iter().flat_map(|g| g.sarif_findings.iter()); + for finding in file.sarif_findings.iter().chain(group_findings) { + if !seen.insert((finding.rule_id.clone(), finding.start_line)) { + continue; + } + out.push(Candidate { + file: file.path.clone(), + coverage: cov + .get(&finding.start_line) + .copied() + .unwrap_or(LineVerification::Unknown), + on_added: added.contains(&finding.start_line), + finding: finding.clone(), + }); + } + } + out +} + +/// Evaluate the gates that operate off plan data today: tier + coverage + +/// on-added selectors. Hazard, purity-`require`, and branch-coverage gates are +/// reserved (their inputs are not in the plan yet) and are skipped here — see +/// tuning-configs.md §7 items 3–4. +fn evaluate_gates(gates: &[Gate], candidates: &[Candidate], _config: &ReviewConfig) -> Vec { + let mut hits = Vec::new(); + for gate in gates { + // Only finding-selector gates are wired in this slice. + if gate.when.hazard.is_some() + || gate.when.purity.is_some() + || gate.when.tag.is_some() + || gate.require.is_some() + { + continue; + } + let matched: Vec<&Candidate> = candidates + .iter() + .filter(|c| c.finding.status == "new") + .filter(|c| gate.when.tier.is_none() || gate.when.tier == c.finding.tier) + .filter(|c| { + gate.when.on.as_deref() != Some("added") || c.on_added + }) + .filter(|c| match gate.when.coverage.as_deref() { + Some("uncovered") => matches!(c.coverage, LineVerification::NotCovered), + Some("partial") => matches!(c.coverage, LineVerification::PartiallyCovered), + Some(_) | None => true, + }) + .collect(); + if !matched.is_empty() { + hits.push(GateHit { + id: gate.id.clone(), + severity: gate.severity, + count: matched.len(), + reason: format!("{} finding(s) matched gate {}", matched.len(), gate.id), + }); + } + } + hits +} + +fn verdict_from(gates: &[GateHit]) -> Verdict { + if gates.iter().any(|g| g.severity == Severity::Critical) { + Verdict::Critical + } else if gates.is_empty() { + Verdict::Pass + } else { + Verdict::NeedsReview + } +} + +fn coverage_posture(plan: &DiffPlan) -> CoveragePosture { + let mut covered_killed = 0usize; + let mut measured = 0usize; + let mut killed = 0usize; + for file in &plan.files { + let v = &file.verification; + covered_killed += v.covered_and_killed + v.covered; + killed += v.covered_and_killed; + measured += v.covered_and_killed + v.covered + v.partially_covered + v.not_covered; + } + let ratio = |n: usize| if measured == 0 { 0.0 } else { n as f64 / measured as f64 }; + CoveragePosture { + line: ratio(covered_killed), + mutation_kill: ratio(killed), + } +} + +fn coverage_label(v: LineVerification) -> &'static str { + match v { + LineVerification::CoveredAndKilled => "covered+killed", + LineVerification::Covered => "covered", + LineVerification::PartiallyCovered => "partial", + LineVerification::NotCovered => "uncovered", + LineVerification::Unknown => "unknown", + } +} + +fn cap_per_tier(findings: &mut Vec, max: usize) { + if max == 0 { + return; + } + let mut per: BTreeMap, usize> = BTreeMap::new(); + findings.retain(|f| { + let n = per.entry(f.tier).or_insert(0); + *n += 1; + *n <= max + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(yaml: &str) -> ReviewConfig { + serde_yaml::from_str(yaml).expect("parse review config") + } + + fn finding(rule: &str, tier: u8, status: &str) -> SarifFindingSummary { + SarifFindingSummary { + source: "espalier".into(), + tool: "nil-kill".into(), + rule_id: rule.into(), + level: "warning".into(), + category: "safety".into(), + message: "m".into(), + fingerprint: "fp".into(), + tier: Some(tier), + tier_one: tier == 1, + status: status.into(), + provenance: Default::default(), + proof_boundary: Vec::new(), + start_line: 10, + end_line: 10, + } + } + + fn cand(rule: &str, tier: u8, status: &str, cov: LineVerification, added: bool) -> Candidate { + Candidate { + file: "a.go".into(), + finding: finding(rule, tier, status), + coverage: cov, + on_added: added, + } + } + + #[test] + fn defaults_parse_and_reproduce_the_hardcoded_weights() { + let c = ReviewConfig::default(); + assert_eq!(c.weights.tier_one_finding, 8.0); + assert_eq!(c.weights.tier_two_finding, 3.0); + assert_eq!(c.weights.tier_three_finding, 0.0); + // An empty config gates only on uncovered T1. + assert_eq!(default_gates()[0].id, "uncovered-tier1"); + } + + #[test] + fn full_review_block_parses_with_reserved_fields() { + let c = cfg(r#" +metrics: + "T3": { policy: deprioritize, weight: 0.0 } + "test-miser.redundant": { policy: ignore } +weights: + tier_two_finding: 5.0 +purity: + source: effects + stateful: { line_coverage: 1.0, branch_coverage: 1.0 } +gates: + - id: uncovered-tier1 + when: { tier: 1, on: added, coverage: uncovered } + severity: critical +perf: { enabled: false } +tags: + critical: { match: { paths: ["internal/auth/**"] } } +"#); + assert_eq!(c.weights.tier_two_finding, 5.0); + assert_eq!(c.metrics["T3"].policy, Visibility::Deprioritize); + assert_eq!(c.metrics["test-miser.redundant"].policy, Visibility::Ignore); + assert!(c.perf.is_some()); + assert!(c.tags.contains_key("critical")); + } + + #[test] + fn metric_policy_ignore_deprioritize_and_specificity() { + let c = cfg(r#" +metrics: + "T3": { policy: deprioritize, weight: 0.0 } + "espalier.nil": { policy: ignore } +"#); + // rule_id key wins over the tier key. + assert!(matches!( + classify(&c, &cand("espalier.nil", 1, "new", LineVerification::NotCovered, true)), + Decision::Ignore + )); + // A bare T3 is deprioritized (kept, weight 0). + match classify(&c, &cand("other", 3, "new", LineVerification::Covered, true)) { + Decision::Keep { weight, deprio } => { + assert_eq!(weight, 0.0); + assert!(deprio); + } + _ => panic!("expected kept+deprioritized"), + } + // Resolved findings are never candidates. + assert!(matches!( + classify(&c, &cand("other", 1, "resolved", LineVerification::NotCovered, true)), + Decision::Ignore + )); + } + + #[test] + fn uncovered_tier1_gate_fires_and_sets_critical() { + let c = ReviewConfig::default(); + let gates = default_gates(); + // A new T1 on an uncovered added line → gate fires → critical. + let hits = evaluate_gates( + &gates, + &[cand("r", 1, "new", LineVerification::NotCovered, true)], + &c, + ); + assert_eq!(hits.len(), 1); + assert_eq!(verdict_from(&hits), Verdict::Critical); + + // A covered T1 does not fire. + let hits = evaluate_gates( + &gates, + &[cand("r", 1, "new", LineVerification::Covered, true)], + &c, + ); + assert!(hits.is_empty()); + assert_eq!(verdict_from(&hits), Verdict::Pass); + + // An uncovered T1 that is NOT on an added line does not fire. + let hits = evaluate_gates( + &gates, + &[cand("r", 1, "new", LineVerification::NotCovered, false)], + &c, + ); + assert!(hits.is_empty()); + } + + #[test] + fn stage_defaults_precommit_skips_mutation_premerge_runs_it() { + let c = ReviewConfig::default(); + let pre = c.stage_tests(ReviewMode::Precommit); + assert!(!pre.mutation && !pre.mutation_forced); + assert_eq!(pre.profiles, ["ci"]); + let merge = c.stage_tests(ReviewMode::Premerge); + assert!(merge.mutation && !merge.mutation_forced); + } + + #[test] + fn a_kill_rate_requirement_forces_mutation_at_precommit() { + // A gate that requires a mutant kill rate must pull mutation into the + // precommit run, or the verdict is stuck critical ("no mutants killed"). + let c = cfg(r#" +gates: + - id: pure-kill + when: { purity: pure } + require: { mutation_kill_rate: 0.8 } + severity: critical +"#); + assert!(c.requires_mutation()); + let pre = c.stage_tests(ReviewMode::Precommit); + assert!(pre.mutation, "mutation forced on at precommit"); + assert!(pre.mutation_forced, "and flagged as forced, not defaulted"); + } + + #[test] + fn purity_kill_rate_also_forces_mutation() { + let c = cfg(r#" +purity: + pure: { line_coverage: 0.95, mutation_kill_rate: 0.8 } +"#); + assert!(c.requires_mutation()); + assert!(c.stage_tests(ReviewMode::Precommit).mutation_forced); + } + + #[test] + fn custom_stage_config_parses_and_overrides_defaults() { + let c = cfg(r#" +tests: + precommit: { profiles: [unit], mutation: false } + premerge: { profiles: [unit, integration, fuzz], mutation: true } +"#); + assert_eq!(c.tests.precommit.profiles, ["unit"]); + assert_eq!(c.tests.premerge.profiles, ["unit", "integration", "fuzz"]); + assert!(c.tests.premerge.mutation); + } + + #[test] + fn affected_producers_follows_the_reverse_dependency_closure() { + let c = cfg(r#" +packages: + fact-mine: { paths: ["gems/fact-mine/**"], producers: [fact-mine-test] } + giga-core: { paths: ["gems/gigasail/giga-core/**"], producers: [giga-core-test] } + boobytrap: { paths: ["gems/boobytrap/**"], producers: [boobytrap-test], depends_on: [giga-core, fact-mine] } + slopcop: { paths: ["gems/slopcop/**"], producers: [slopcop-test], depends_on: [boobytrap] } + compiler: { paths: ["compiler/ruby/**"], producers: [compiler-spec, transpile], premerge: [fuzz-compiler] } + zig: { paths: ["zig/**"], producers: [zig-test, transpile], premerge: [fuzz-zig] } +"#); + // A fact-mine change reaches boobytrap (deps on it) and slopcop (deps on boobytrap). + let p = c.affected_producers(&["gems/fact-mine/src/x.rs".into()], ReviewMode::Precommit); + assert_eq!(p, ["boobytrap-test", "fact-mine-test", "slopcop-test"]); + // giga-core -> boobytrap -> slopcop too. + let p = c.affected_producers(&["gems/gigasail/giga-core/src/diff.rs".into()], ReviewMode::Precommit); + assert_eq!(p, ["boobytrap-test", "giga-core-test", "slopcop-test"]); + // A compiler change: precommit runs spec+transpile; premerge adds fuzz. + let pre = c.affected_producers(&["compiler/ruby/ast/parser.rb".into()], ReviewMode::Precommit); + assert_eq!(pre, ["compiler-spec", "transpile"]); + let merge = c.affected_producers(&["compiler/ruby/ast/parser.rb".into()], ReviewMode::Premerge); + assert_eq!(merge, ["compiler-spec", "fuzz-compiler", "transpile"]); + // A zig change doesn't drag in the compiler graph. + let p = c.affected_producers(&["zig/runtime/switch.zig".into()], ReviewMode::Precommit); + assert_eq!(p, ["transpile", "zig-test"]); + // No packages -> empty (callers fall back to stage profiles). + assert!(ReviewConfig::default() + .affected_producers(&["x".into()], ReviewMode::Precommit) + .is_empty()); + } + + #[test] + fn affected_checks_unions_over_the_reverse_dependency_closure() { + let c = cfg(r#" +checks_enabled: true +packages: + fact-mine: { paths: ["gems/fact-mine/**"], checks: ["contrib:lint:rust"] } + slopcop: { paths: ["gems/slopcop/**"], checks: ["contrib:lint:ruby"], depends_on: [fact-mine] } + compiler: { paths: ["compiler/ruby/**"], checks: ["contrib:lint:ruby", "tools/custom.rb"] } +"#); + assert!(c.checks_enabled); + // A fact-mine change pulls slopcop's checks in too (dedup keeps one ruby lint). + let checks = c.affected_checks(&["gems/fact-mine/src/x.rs".into()]); + assert_eq!(checks, ["contrib:lint:rust", "contrib:lint:ruby"]); + // A compiler change runs only the compiler's checks. + let checks = c.affected_checks(&["compiler/ruby/ast/parser.rb".into()]); + assert_eq!(checks, ["contrib:lint:ruby", "tools/custom.rb"]); + // No match / no packages -> no checks. + assert!(c.affected_checks(&["docs/README.md".into()]).is_empty()); + assert!(ReviewConfig::default().affected_checks(&["x".into()]).is_empty()); + } + + #[test] + fn hazard_and_require_gates_are_reserved_not_evaluated() { + let c = ReviewConfig::default(); + let gates = vec![Gate { + id: "unverified-hazard".into(), + when: GateWhen { + hazard: Some("any".into()), + verified: Some(false), + ..GateWhen::default() + }, + require: None, + unless_evidence: Vec::new(), + severity: Severity::Critical, + }]; + // Skipped in this slice → no hits (documented reservation). + assert!(evaluate_gates(&gates, &[cand("r", 1, "new", LineVerification::NotCovered, true)], &c).is_empty()); + } +} diff --git a/gems/gigasail/giga-core/src/test_summary.rs b/gems/gigasail/giga-core/src/test_summary.rs new file mode 100644 index 000000000..5bf282f92 --- /dev/null +++ b/gems/gigasail/giga-core/src/test_summary.rs @@ -0,0 +1,374 @@ +//! The diff "Tests" section: per `language:test_set` (tag) counts of how a change +//! affected the test suite. Split into structural churn (added/deleted/changed/ +//! pending tests) and quality signals (tests that kill no mutants, add no +//! coverage, or kill no *distinct* mutants — i.e. are redundant for kills). +//! +//! This module is pure: it takes the base- and head-commit test inventories +//! (built from `test_exposure_events`) plus the diff's changed test-file lines, +//! and returns one [`TestSummary`] per `(language, test_set)` that changed. The +//! DB query that materializes the inventories lives in storage; keeping the +//! logic here makes every metric unit-testable without a database. + +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use ts_rs::TS; + +/// Test-level attributes a runner may attach to a test-exposure record's +/// `payload_json`: the test's own file and definition span (for "changed"), +/// whether it is pending/skipped, and the set of mutant ids it killed (for +/// "kills no distinct mutants"). All optional — absent fields degrade. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TestPayloadMeta { + pub language: String, + pub test_path: String, + pub start_line: u32, + pub end_line: u32, + pub pending: bool, + pub killed_mutants: BTreeSet, +} + +impl TestPayloadMeta { + pub fn parse(payload: &str) -> Self { + let v: Value = serde_json::from_str(payload).unwrap_or(Value::Null); + let str_at = |keys: &[&str]| -> String { + keys.iter() + .find_map(|k| v.get(*k).and_then(Value::as_str)) + .unwrap_or_default() + .to_string() + }; + let u32_at = |keys: &[&str]| -> Option { + keys.iter() + .find_map(|k| v.get(*k).and_then(Value::as_u64)) + .map(|n| n as u32) + }; + let test_path = str_at(&["test_path", "test_file"]); + let start_line = u32_at(&["test_start_line", "start_line"]).unwrap_or(0); + let end_line = u32_at(&["test_end_line", "end_line"]).unwrap_or(start_line); + let pending = ["pending", "skipped"] + .iter() + .any(|k| v.get(*k).and_then(Value::as_bool).unwrap_or(false)); + let killed_mutants = v + .get("killed_mutant_ids") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + Self { + language: language_from_path(&test_path), + test_path, + start_line, + end_line, + pending, + killed_mutants, + } + } +} + +/// Best-effort language of a source path, by extension. +pub fn language_from_path(path: &str) -> String { + let ext = path.rsplit('.').next().unwrap_or(""); + match ext { + "rb" => "ruby", + "go" => "go", + "zig" => "zig", + "rs" => "rust", + "py" => "python", + "js" | "jsx" | "mjs" => "javascript", + "ts" | "tsx" => "typescript", + _ => "unknown", + } + .to_string() +} + +/// One test's aggregated evidence at a single commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestInventoryRow { + /// Stable test identity (e.g. `spec/foo_spec.rb:BarTest#baz`). + pub test_id: String, + /// Test tag / suite: `unit` | `integration` | `fuzz` (from `test_type`). + pub test_set: String, + /// Language of the test file (e.g. `ruby`, `go`), from the test path. + pub language: String, + /// The test's own definition file (not the production file it covers). + pub test_path: String, + /// Definition line span, used to decide whether the diff changed this test. + pub start_line: u32, + pub end_line: u32, + /// The test is skipped/pending (rspec `pending`, Go `t.Skip`, minitest skip). + pub pending: bool, + /// Count of distinct production lines this test exercised. + pub covered_lines: usize, + /// Whether this test was exercised under mutation at all (so an empty kill + /// set is meaningful — "ran mutants, killed none" vs "never ran mutants"). + pub had_mutation: bool, + /// The set of mutant ids this test killed. + pub killed_mutants: BTreeSet, +} + +/// Per `(language, test_set)` test-churn + quality summary. Only groups with at +/// least one nonzero count are emitted (the "only show tags that changed" rule). +/// New-test timing for a group: how the change's new tests compare to the +/// recent per-stage baseline. `pending` means a measurement was expected but has +/// not landed yet (the background runner fills it in). +#[derive(Debug, Clone, Default, PartialEq, Serialize, TS)] +pub struct TestTiming { + pub pending: bool, + /// A measurement is in flight (the background runner is timing the new tests). + pub processing: bool, + /// Percent change vs the baseline (+ is slower). + pub pct: f64, + /// Confidence half-width in percentage points (the `±`). + pub ci_pct: f64, + /// The measured new-test time in milliseconds (shown when there is no + /// baseline to compute a delta against yet). + pub new_ms: f64, + /// Repeat measurements behind the estimate. + pub samples: usize, + /// Historical commits behind the baseline; 0 means measured-but-no-baseline + /// yet (the delta is not meaningful, the baseline is still building). + pub baseline_n: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, TS)] +pub struct TestSummary { + pub language: String, + pub test_set: String, + // Structural churn (require base+head inventories; see `inventory_available`). + pub added: usize, + pub deleted: usize, + pub changed: usize, + pub pending: usize, + // Quality signals (head-only; require mutation/coverage evidence). + pub kill_no_mutants: usize, + pub no_coverage: usize, + pub kill_no_distinct: usize, + /// True when the base commit had test evidence, so added/deleted/changed are + /// trustworthy (otherwise everything would look "added"). + pub inventory_available: bool, + /// True when head tests carried mutation evidence, so `kill_no_mutants` / + /// `kill_no_distinct` reflect real kill data rather than absence. + pub mutation_available: bool, + /// New-test timing (delta ± CI), or pending, when this group added tests. + #[serde(default)] + pub timing: Option, +} + +impl TestSummary { + fn any_change(&self) -> bool { + self.added + + self.deleted + + self.changed + + self.pending + + self.kill_no_mutants + + self.no_coverage + + self.kill_no_distinct + > 0 + } +} + +/// Compute the per-`(language, test_set)` summaries. +/// +/// - `base` / `head`: test inventories at each commit, already scoped to the +/// tests that live in the diff's changed test files. +/// - `changed_lines`: per test-file, the set of line numbers the diff touched +/// (added or removed). A test whose span intersects these is "changed". +/// - `base_present`: whether the base commit had *any* test evidence; when +/// false, added/deleted/changed are suppressed (not fabricated) and +/// `inventory_available` is false on every emitted group. +pub fn test_summaries( + base: &[TestInventoryRow], + head: &[TestInventoryRow], + changed_lines: &BTreeMap>, + base_present: bool, +) -> Vec { + // Redundancy is a suite-wide property: a test "kills no distinct mutants" if + // every mutant it kills is also killed by some *other* head test. Compute the + // union once, then test each row against the union of the others. + let redundant: BTreeSet<&str> = redundant_test_ids(head); + + let base_ids: BTreeSet<&str> = base.iter().map(|r| r.test_id.as_str()).collect(); + let head_by_id: BTreeMap<&str, &TestInventoryRow> = + head.iter().map(|r| (r.test_id.as_str(), r)).collect(); + + let mut groups: BTreeMap<(String, String), TestSummary> = BTreeMap::new(); + + // Head-side: added, changed, pending, and every quality signal. + for row in head { + let g = group_mut(&mut groups, &row.language, &row.test_set, base_present); + if base_present && !base_ids.contains(row.test_id.as_str()) { + g.added += 1; + } else if base_present { + // Present in both commits: "changed" if the diff touched its span. + if let Some(lines) = changed_lines.get(&row.test_path) { + if lines.iter().any(|l| *l >= row.start_line && *l <= row.end_line) { + g.changed += 1; + } + } + } + if row.pending { + g.pending += 1; + } + if row.had_mutation { + g.mutation_available = true; + if row.killed_mutants.is_empty() { + g.kill_no_mutants += 1; + } else if redundant.contains(row.test_id.as_str()) { + g.kill_no_distinct += 1; + } + } + if row.covered_lines == 0 { + g.no_coverage += 1; + } + } + + // Base-side: deleted tests (present at base, gone at head). + if base_present { + for row in base { + if !head_by_id.contains_key(row.test_id.as_str()) { + group_mut(&mut groups, &row.language, &row.test_set, base_present).deleted += 1; + } + } + } + + groups + .into_values() + .filter(TestSummary::any_change) + .collect() +} + +fn group_mut<'a>( + groups: &'a mut BTreeMap<(String, String), TestSummary>, + lang: &str, + set: &str, + base_present: bool, +) -> &'a mut TestSummary { + groups + .entry((lang.to_string(), set.to_string())) + .or_insert_with(|| TestSummary { + language: lang.to_string(), + test_set: set.to_string(), + inventory_available: base_present, + ..TestSummary::default() + }) +} + +/// Test ids whose (non-empty) killed-mutant set is fully covered by the union of +/// the *other* tests' kills — they kill nothing distinct. +fn redundant_test_ids(head: &[TestInventoryRow]) -> BTreeSet<&str> { + let mut redundant = BTreeSet::new(); + for row in head { + if row.killed_mutants.is_empty() { + continue; + } + let others_union: BTreeSet<&str> = head + .iter() + .filter(|o| o.test_id != row.test_id) + .flat_map(|o| o.killed_mutants.iter().map(String::as_str)) + .collect(); + if row + .killed_mutants + .iter() + .all(|m| others_union.contains(m.as_str())) + { + redundant.insert(row.test_id.as_str()); + } + } + redundant +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(id: &str, set: &str, path: &str, start: u32, end: u32) -> TestInventoryRow { + TestInventoryRow { + test_id: id.into(), + test_set: set.into(), + language: "ruby".into(), + test_path: path.into(), + start_line: start, + end_line: end, + pending: false, + covered_lines: 1, + had_mutation: false, + killed_mutants: BTreeSet::new(), + } + } + + fn get<'a>(v: &'a [TestSummary], lang: &str, set: &str) -> &'a TestSummary { + v.iter() + .find(|s| s.language == lang && s.test_set == set) + .unwrap_or_else(|| panic!("no summary for {lang}:{set}")) + } + + #[test] + fn added_deleted_changed_split_by_tag_only_report_touched_groups() { + let base = vec![ + row("t_a", "unit", "spec/a_spec.rb", 1, 5), + row("t_gone", "unit", "spec/a_spec.rb", 10, 15), + row("t_int", "integration", "spec/i_spec.rb", 1, 4), + ]; + let head = vec![ + row("t_a", "unit", "spec/a_spec.rb", 1, 7), // present in both; changed below + row("t_new", "unit", "spec/a_spec.rb", 20, 25), // added + row("t_int", "integration", "spec/i_spec.rb", 1, 4), // untouched + ]; + // The diff touched lines 6 and 22 of a_spec.rb (inside t_a and t_new). + let mut changed = BTreeMap::new(); + changed.insert("spec/a_spec.rb".to_string(), BTreeSet::from([6u32, 22])); + + let summaries = test_summaries(&base, &head, &changed, true); + let unit = get(&summaries, "ruby", "unit"); + assert_eq!(unit.added, 1, "t_new"); + assert_eq!(unit.deleted, 1, "t_gone"); + assert_eq!(unit.changed, 1, "t_a span intersects line 6"); + // integration group had no churn -> not emitted at all. + assert!(summaries.iter().all(|s| s.test_set != "integration")); + } + + #[test] + fn pending_and_no_coverage_are_counted_head_only() { + let mut pending = row("t_skip", "unit", "spec/a_spec.rb", 1, 3); + pending.pending = true; + let mut uncovered = row("t_empty", "unit", "spec/a_spec.rb", 5, 7); + uncovered.covered_lines = 0; + let head = vec![pending, uncovered]; + let summaries = test_summaries(&[], &head, &BTreeMap::new(), false); + let unit = get(&summaries, "ruby", "unit"); + assert_eq!(unit.pending, 1); + assert_eq!(unit.no_coverage, 1); + // No base evidence -> structural churn suppressed, not fabricated. + assert!(!unit.inventory_available); + assert_eq!(unit.added, 0); + } + + #[test] + fn kill_no_mutants_versus_kill_no_distinct() { + // t_kills uniquely kills m3; t_redundant only kills m1/m2 which t_kills + // and t_other also kill; t_silent ran mutants but killed none. + let mut t_kills = row("t_kills", "unit", "spec/a_spec.rb", 1, 3); + t_kills.had_mutation = true; + t_kills.killed_mutants = BTreeSet::from(["m1".into(), "m2".into(), "m3".into()]); + let mut t_other = row("t_other", "unit", "spec/a_spec.rb", 5, 7); + t_other.had_mutation = true; + t_other.killed_mutants = BTreeSet::from(["m1".into(), "m2".into()]); + let mut t_redundant = row("t_redundant", "unit", "spec/a_spec.rb", 9, 11); + t_redundant.had_mutation = true; + t_redundant.killed_mutants = BTreeSet::from(["m1".into()]); + let mut t_silent = row("t_silent", "unit", "spec/a_spec.rb", 13, 15); + t_silent.had_mutation = true; // ran mutants, killed nothing + + let head = vec![t_kills, t_other, t_redundant, t_silent]; + let summaries = test_summaries(&[], &head, &BTreeMap::new(), false); + let unit = get(&summaries, "ruby", "unit"); + assert!(unit.mutation_available); + assert_eq!(unit.kill_no_mutants, 1, "t_silent"); + // t_other kills {m1,m2} both covered by t_kills; t_redundant {m1} covered. + // t_kills is NOT redundant (m3 is unique to it). + assert_eq!(unit.kill_no_distinct, 2, "t_other and t_redundant"); + } +} diff --git a/gems/gigasail/giga-core/src/test_timing.rs b/gems/gigasail/giga-core/src/test_timing.rs new file mode 100644 index 000000000..6fe73d64a --- /dev/null +++ b/gems/gigasail/giga-core/src/test_timing.rs @@ -0,0 +1,234 @@ +//! New-test timing for the diff "Tests" section: measure how long a change's +//! new tests take and compare against the recent per-stage baseline, reporting a +//! delta with a confidence interval that tightens as more repeat measurements +//! are taken. This module is the pure statistics; storage of the per-commit +//! history and the (background) measurement runner live elsewhere. +//! +//! Baseline uses the **median** of recent per-commit stage times - robust to the +//! occasional slow CI run that a mean would let skew the comparison. The CI +//! half-width comes from the repeat measurements of the new tests (Student-t +//! standard error for n>=2); with a single measurement it falls back to the +//! historical coefficient of variation, honestly reflecting that one run can't +//! bound its own noise. + +/// The stage label new-test timings are stored under; the diff looks them up by +/// this label + test_set. Distinct from review stages: it identifies the "new +/// tests" measurement, independent of which review triggered it. +pub const TIMING_STAGE: &str = "new-tests"; + +#[derive(Debug, Clone, PartialEq)] +pub struct TimingDelta { + /// Robust baseline (median of recent per-commit stage times), milliseconds. + pub baseline_ms: f64, + /// Measured new-test time (mean of the repeat samples), milliseconds. + pub new_ms: f64, + /// Percent change of new vs baseline (+ is slower). + pub pct: f64, + /// Confidence half-width in percentage points (the `+/-`). + pub ci_pct: f64, + /// Number of repeat measurements taken of the new tests. + pub samples: usize, + /// Number of historical commits behind the baseline. + pub baseline_n: usize, +} + +/// Human time: `7.3s` for a second or more, else `123ms`. +pub fn fmt_ms(ms: f64) -> String { + if ms >= 1000.0 { + format!("{:.1}s", ms / 1000.0) + } else { + format!("{ms:.0}ms") + } +} + +/// Median of a sample. `None` for an empty input. +pub fn median(mut xs: Vec) -> Option { + if xs.is_empty() { + return None; + } + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = xs.len() / 2; + Some(if xs.len() % 2 == 0 { + (xs[mid - 1] + xs[mid]) / 2.0 + } else { + xs[mid] + }) +} + +/// Two-sided 95% Student-t factor for `n-1` degrees of freedom, small-n table +/// with a large-n normal fallback (1.96). Used to widen the CI for few repeats. +fn t95(n: usize) -> f64 { + match n { + 0 | 1 => 12.71, // df=1 (a lone pair would be unusual; conservative) + 2 => 12.71, // df=1 + 3 => 4.30, // df=2 + 4 => 3.18, // df=3 + 5 => 2.78, + 6 => 2.57, + 7 => 2.45, + 8 => 2.36, + 9 => 2.31, + 10 => 2.26, + 11..=16 => 2.13, + 17..=31 => 2.04, + _ => 1.96, + } +} + +/// Coefficient of variation (stddev / median) of the baseline history. Used as +/// the uncertainty when only one new-test measurement exists. +fn historical_cv(baseline: &[f64]) -> f64 { + let Some(m) = median(baseline.to_vec()) else { + return 0.0; + }; + if m <= 0.0 || baseline.len() < 2 { + return 0.0; + } + let mean = baseline.iter().sum::() / baseline.len() as f64; + let var = + baseline.iter().map(|x| (x - mean).powi(2)).sum::() / (baseline.len() as f64 - 1.0); + var.sqrt() / m +} + +/// Compute the timing delta of the new-test measurements against the historical +/// per-stage baseline. `None` when there is no usable baseline or no samples. +pub fn timing_delta(baseline: &[f64], new_samples: &[f64]) -> Option { + let baseline_ms = median(baseline.to_vec())?; + if baseline_ms <= 0.0 || new_samples.is_empty() { + return None; + } + let n = new_samples.len(); + let new_ms = new_samples.iter().sum::() / n as f64; + let pct = (new_ms - baseline_ms) / baseline_ms * 100.0; + let ci_pct = if n >= 2 { + // Standard error of the new-sample mean, as a percent of baseline. + let var = new_samples.iter().map(|x| (x - new_ms).powi(2)).sum::() / (n as f64 - 1.0); + let se = var.sqrt() / (n as f64).sqrt(); + t95(n) * se / baseline_ms * 100.0 + } else { + // One measurement can't bound its own noise; use the history's spread. + historical_cv(baseline) * 100.0 + }; + Some(TimingDelta { + baseline_ms, + new_ms, + pct, + ci_pct, + samples: n, + baseline_n: baseline.len(), + }) +} + +/// Like [`timing_delta`] but from the stored summary statistics (mean + stddev + +/// sample count) rather than raw samples - what a recorded measurement carries. +pub fn timing_delta_from_stats( + baseline: &[f64], + new_mean: f64, + new_stddev: f64, + n: usize, +) -> Option { + let baseline_ms = median(baseline.to_vec())?; + if baseline_ms <= 0.0 || n == 0 { + return None; + } + let pct = (new_mean - baseline_ms) / baseline_ms * 100.0; + let ci_pct = if n >= 2 { + let se = new_stddev / (n as f64).sqrt(); + t95(n) * se / baseline_ms * 100.0 + } else { + historical_cv(baseline) * 100.0 + }; + Some(TimingDelta { + baseline_ms, + new_ms: new_mean, + pct, + ci_pct, + samples: n, + baseline_n: baseline.len(), + }) +} + +/// Sample mean and (population-corrected) standard deviation of measurements. +/// The runner stores these; `timing_delta_from_stats` consumes them. +pub fn mean_stddev(samples: &[f64]) -> (f64, f64) { + let n = samples.len(); + if n == 0 { + return (0.0, 0.0); + } + let mean = samples.iter().sum::() / n as f64; + if n < 2 { + return (mean, 0.0); + } + let var = samples.iter().map(|x| (x - mean).powi(2)).sum::() / (n as f64 - 1.0); + (mean, var.sqrt()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f64, b: f64, eps: f64) -> bool { + (a - b).abs() < eps + } + + #[test] + fn median_is_robust_to_a_slow_outlier() { + // A single slow run must not drag the baseline the way a mean would. + assert_eq!(median(vec![100.0, 102.0, 98.0, 500.0]), Some(101.0)); + assert_eq!(median(vec![100.0]), Some(100.0)); + assert_eq!(median(vec![]), None); + } + + #[test] + fn delta_is_percent_over_the_median_baseline() { + // baseline median 100ms (a slow 500ms run is ignored); new ~102ms -> +2%. + let d = timing_delta(&[100.0, 98.0, 102.0, 100.0, 500.0], &[102.0, 102.0, 102.0]).unwrap(); + assert!(approx(d.baseline_ms, 100.0, 0.001)); + assert!(approx(d.pct, 2.0, 0.001)); + assert_eq!(d.samples, 3); + } + + #[test] + fn ci_tightens_with_more_repeat_measurements() { + // Same spread of samples, more repeats -> a narrower confidence band. + let few = timing_delta(&[100.0, 100.0, 100.0], &[100.0, 110.0]).unwrap(); + let many = timing_delta( + &[100.0, 100.0, 100.0], + &[100.0, 110.0, 100.0, 110.0, 100.0, 110.0], + ) + .unwrap(); + assert!( + many.ci_pct < few.ci_pct, + "more samples should narrow CI: few={} many={}", + few.ci_pct, + many.ci_pct + ); + } + + #[test] + fn single_measurement_uses_historical_variability() { + // With n=1 the CI reflects how noisy the history is, not zero. + let noisy = timing_delta(&[80.0, 120.0, 90.0, 110.0], &[100.0]).unwrap(); + let steady = timing_delta(&[100.0, 100.0, 100.0, 100.0], &[100.0]).unwrap(); + assert_eq!(noisy.samples, 1); + assert!(noisy.ci_pct > steady.ci_pct); + assert!(approx(steady.ci_pct, 0.0, 0.001), "steady history -> tiny CI"); + } + + #[test] + fn delta_from_stats_matches_raw_samples() { + let baseline = [100.0, 100.0, 100.0]; + let samples = [98.0, 102.0, 100.0, 104.0]; + let (mean, sd) = mean_stddev(&samples); + let raw = timing_delta(&baseline, &samples).unwrap(); + let stats = timing_delta_from_stats(&baseline, mean, sd, samples.len()).unwrap(); + assert!(approx(raw.pct, stats.pct, 1e-9)); + assert!(approx(raw.ci_pct, stats.ci_pct, 1e-9)); + } + + #[test] + fn no_baseline_or_no_samples_is_none() { + assert!(timing_delta(&[], &[100.0]).is_none()); + assert!(timing_delta(&[100.0], &[]).is_none()); + } +} diff --git a/gems/gigasail/giga-ui/Cargo.toml b/gems/gigasail/giga-ui/Cargo.toml new file mode 100644 index 000000000..568ad310b --- /dev/null +++ b/gems/gigasail/giga-ui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "giga-ui" +version = "0.0.1" +edition = "2021" +description = "Gigasail web UI, language server, and MCP surfaces" +license = "PolyForm-Noncommercial-1.0.0" + +[lib] +name = "giga_ui" +path = "src/lib.rs" + +[[bin]] +name = "giga-ui" +path = "src/main.rs" + +[dependencies] +giga-core = { path = "../giga-core", version = "0.0.1" } +anyhow = "1.0" +askama = "0.12" +axum = "0.7" +clap = { version = "=4.4.18", features = ["derive"] } +git2 = "0.18" +rayon = "1.8" +rusqlite = { version = "0.30", features = ["bundled"] } +rust-embed = "8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["io-std", "macros", "net", "rt", "rt-multi-thread"] } +tower-http = { version = "0.5", features = ["set-header", "trace"] } +tower-lsp = "0.20" +rmcp = { version = "2.2.0", features = ["server", "transport-io"] } +url = "2.5" + +[dev-dependencies] +tempfile = "=3.10.1" diff --git a/gems/lineage/askama.toml b/gems/gigasail/giga-ui/askama.toml similarity index 100% rename from gems/lineage/askama.toml rename to gems/gigasail/giga-ui/askama.toml diff --git a/gems/lineage/sql/ui/architecture_owner_by_name.sql b/gems/gigasail/giga-ui/sql/ui/architecture_owner_by_name.sql similarity index 100% rename from gems/lineage/sql/ui/architecture_owner_by_name.sql rename to gems/gigasail/giga-ui/sql/ui/architecture_owner_by_name.sql diff --git a/gems/lineage/sql/ui/architecture_symbols_for_path.sql b/gems/gigasail/giga-ui/sql/ui/architecture_symbols_for_path.sql similarity index 100% rename from gems/lineage/sql/ui/architecture_symbols_for_path.sql rename to gems/gigasail/giga-ui/sql/ui/architecture_symbols_for_path.sql diff --git a/gems/lineage/sql/ui/runtime/analyzer_health.sql b/gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/analyzer_health.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health.sql diff --git a/gems/lineage/sql/ui/runtime/analyzer_health_2.sql b/gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health_2.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/analyzer_health_2.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health_2.sql diff --git a/gems/lineage/sql/ui/runtime/analyzer_health_3.sql b/gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health_3.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/analyzer_health_3.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/analyzer_health_3.sql diff --git a/gems/lineage/sql/ui/runtime/apply_crash_history.sql b/gems/gigasail/giga-ui/sql/ui/runtime/apply_crash_history.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_crash_history.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/apply_crash_history.sql diff --git a/gems/lineage/sql/ui/runtime/apply_line_coverage.sql b/gems/gigasail/giga-ui/sql/ui/runtime/apply_line_coverage.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_line_coverage.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/apply_line_coverage.sql diff --git a/gems/lineage/sql/ui/runtime/apply_semantic_churn.sql b/gems/gigasail/giga-ui/sql/ui/runtime/apply_semantic_churn.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_semantic_churn.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/apply_semantic_churn.sql diff --git a/gems/lineage/sql/ui/runtime/apply_test_exposure.sql b/gems/gigasail/giga-ui/sql/ui/runtime/apply_test_exposure.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_test_exposure.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/apply_test_exposure.sql diff --git a/gems/lineage/sql/ui/runtime/apply_unit_quality.sql b/gems/gigasail/giga-ui/sql/ui/runtime/apply_unit_quality.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/apply_unit_quality.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/apply_unit_quality.sql diff --git a/gems/lineage/sql/ui/runtime/dashboard_coverage_line_counts.sql b/gems/gigasail/giga-ui/sql/ui/runtime/dashboard_coverage_line_counts.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/dashboard_coverage_line_counts.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/dashboard_coverage_line_counts.sql diff --git a/gems/lineage/sql/ui/runtime/dashboard_hazard_counts.sql b/gems/gigasail/giga-ui/sql/ui/runtime/dashboard_hazard_counts.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/dashboard_hazard_counts.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/dashboard_hazard_counts.sql diff --git a/gems/lineage/sql/ui/runtime/dashboard_line_counts.sql b/gems/gigasail/giga-ui/sql/ui/runtime/dashboard_line_counts.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/dashboard_line_counts.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/dashboard_line_counts.sql diff --git a/gems/lineage/sql/ui/runtime/decay_bounds.sql b/gems/gigasail/giga-ui/sql/ui/runtime/decay_bounds.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/decay_bounds.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/decay_bounds.sql diff --git a/gems/lineage/sql/ui/runtime/file_index_with_scope.sql b/gems/gigasail/giga-ui/sql/ui/runtime/file_index_with_scope.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/file_index_with_scope.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/file_index_with_scope.sql diff --git a/gems/lineage/sql/ui/runtime/file_versions.sql b/gems/gigasail/giga-ui/sql/ui/runtime/file_versions.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/file_versions.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/file_versions.sql diff --git a/gems/lineage/sql/ui/runtime/fix_decay_bounds.sql b/gems/gigasail/giga-ui/sql/ui/runtime/fix_decay_bounds.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/fix_decay_bounds.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/fix_decay_bounds.sql diff --git a/gems/lineage/sql/ui/runtime/has_multicommit_quality_history.sql b/gems/gigasail/giga-ui/sql/ui/runtime/has_multicommit_quality_history.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/has_multicommit_quality_history.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/has_multicommit_quality_history.sql diff --git a/gems/lineage/sql/ui/runtime/line_coverage_by_file.sql b/gems/gigasail/giga-ui/sql/ui/runtime/line_coverage_by_file.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/line_coverage_by_file.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/line_coverage_by_file.sql diff --git a/gems/lineage/sql/ui/runtime/persisted_source_symbols.sql b/gems/gigasail/giga-ui/sql/ui/runtime/persisted_source_symbols.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/persisted_source_symbols.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/persisted_source_symbols.sql diff --git a/gems/lineage/sql/ui/runtime/read_model_file_index_with_scope.sql b/gems/gigasail/giga-ui/sql/ui/runtime/read_model_file_index_with_scope.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/read_model_file_index_with_scope.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/read_model_file_index_with_scope.sql diff --git a/gems/lineage/sql/ui/runtime/review_next_items.sql b/gems/gigasail/giga-ui/sql/ui/runtime/review_next_items.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/review_next_items.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/review_next_items.sql diff --git a/gems/lineage/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql b/gems/gigasail/giga-ui/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql diff --git a/gems/lineage/sql/ui/runtime/top_architecture_risks.sql b/gems/gigasail/giga-ui/sql/ui/runtime/top_architecture_risks.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/top_architecture_risks.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/top_architecture_risks.sql diff --git a/gems/lineage/sql/ui/runtime/top_complexity_functions.sql b/gems/gigasail/giga-ui/sql/ui/runtime/top_complexity_functions.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/top_complexity_functions.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/top_complexity_functions.sql diff --git a/gems/lineage/sql/ui/runtime/unit_signal_counts.sql b/gems/gigasail/giga-ui/sql/ui/runtime/unit_signal_counts.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/unit_signal_counts.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/unit_signal_counts.sql diff --git a/gems/lineage/sql/ui/runtime/unit_signal_counts_2.sql b/gems/gigasail/giga-ui/sql/ui/runtime/unit_signal_counts_2.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/unit_signal_counts_2.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/unit_signal_counts_2.sql diff --git a/gems/lineage/sql/ui/runtime/unit_test_profiles.sql b/gems/gigasail/giga-ui/sql/ui/runtime/unit_test_profiles.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/unit_test_profiles.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/unit_test_profiles.sql diff --git a/gems/lineage/sql/ui/runtime/warning_units.sql b/gems/gigasail/giga-ui/sql/ui/runtime/warning_units.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/warning_units.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/warning_units.sql diff --git a/gems/lineage/sql/ui/runtime/warning_units_2.sql b/gems/gigasail/giga-ui/sql/ui/runtime/warning_units_2.sql similarity index 100% rename from gems/lineage/sql/ui/runtime/warning_units_2.sql rename to gems/gigasail/giga-ui/sql/ui/runtime/warning_units_2.sql diff --git a/gems/gigasail/giga-ui/src/lib.rs b/gems/gigasail/giga-ui/src/lib.rs new file mode 100644 index 000000000..22b05d211 --- /dev/null +++ b/gems/gigasail/giga-ui/src/lib.rs @@ -0,0 +1,23 @@ +//! Gigasail web UI and language server surfaces. +//! +//! Re-exports the giga-core modules the UI code references via `crate::` so +//! the moved source keeps its internal paths. (The MCP server moved to the +//! `giga` CLI crate — it is a protocol adapter over giga-core, not UI.) + +pub use giga_core::*; +pub use giga_core::{architecture, diff, extract, git, hazard, model, storage, vcs}; + +#[path = "ui/ui.rs"] +pub mod ui; +#[path = "ui/lsp.rs"] +pub mod lsp; + +pub use lsp::{ + diagnostics_for_annotations, gutter_items_for_annotations, serve_lsp, GutterItem, + GutterUpdateParams, +}; +pub use ui::{ + dashboard_summary, file_index, line_annotations, serve_ui, serve_ui_with_overlays, + source_payload, source_payload_with_overlays, UiBugEvent, UiDashboard, UiFile, + UiLineAnnotation, UiOverlays, UiSourcePayload, +}; diff --git a/gems/gigasail/giga-ui/src/main.rs b/gems/gigasail/giga-ui/src/main.rs new file mode 100644 index 000000000..1350905c2 --- /dev/null +++ b/gems/gigasail/giga-ui/src/main.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use giga_ui::{serve_lsp, serve_ui_with_overlays}; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(name = "giga-ui")] +#[command(about = "Gigasail web UI and language server (MCP moved to `giga mcp`)")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Serve the local Gigasail source and verification UI. + Serve { + #[arg(long, default_value = ".giga/gigasail.db")] + db: PathBuf, + #[arg(long, default_value = ".")] + repo: PathBuf, + #[arg(long, default_value = "127.0.0.1")] + host: String, + #[arg(long, default_value_t = 8080)] + port: u16, + #[arg(long = "overlay")] + overlays: Vec, + }, + /// Run the Gigasail language server over stdio. + Lsp { + #[arg(long, default_value = ".giga/gigasail.db")] + db: PathBuf, + #[arg(long, default_value = ".")] + repo: PathBuf, + #[arg(long = "overlay")] + overlays: Vec, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Serve { + db, + repo, + host, + port, + overlays, + } => { + serve_ui_with_overlays(db, repo, &host, port, &overlays)?; + } + Command::Lsp { db, repo, overlays } => { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(serve_lsp(db, repo, &overlays))?; + } + } + Ok(()) +} diff --git a/gems/lineage/src/ui/assets/app.css b/gems/gigasail/giga-ui/src/ui/assets/app.css similarity index 100% rename from gems/lineage/src/ui/assets/app.css rename to gems/gigasail/giga-ui/src/ui/assets/app.css diff --git a/gems/lineage/src/ui/assets/app.js b/gems/gigasail/giga-ui/src/ui/assets/app.js similarity index 100% rename from gems/lineage/src/ui/assets/app.js rename to gems/gigasail/giga-ui/src/ui/assets/app.js diff --git a/gems/lineage/src/ui/assets/diff/.vite/manifest.json b/gems/gigasail/giga-ui/src/ui/assets/diff/.vite/manifest.json similarity index 100% rename from gems/lineage/src/ui/assets/diff/.vite/manifest.json rename to gems/gigasail/giga-ui/src/ui/assets/diff/.vite/manifest.json diff --git a/gems/lineage/src/ui/assets/diff/assets/index--_D05yx7.js b/gems/gigasail/giga-ui/src/ui/assets/diff/assets/index--_D05yx7.js similarity index 93% rename from gems/lineage/src/ui/assets/diff/assets/index--_D05yx7.js rename to gems/gigasail/giga-ui/src/ui/assets/diff/assets/index--_D05yx7.js index 9b3611f9c..d2f0748e5 100644 --- a/gems/lineage/src/ui/assets/diff/assets/index--_D05yx7.js +++ b/gems/gigasail/giga-ui/src/ui/assets/diff/assets/index--_D05yx7.js @@ -56,6 +56,6 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho monaco.config({ paths: { vs: '...' } }) For more please check the link https://github.com/suren-atoyan/monaco-loader#config - `},Zd=qy(Gy)(Kd),Xy={config:Yy},Qy=function(){for(var r=arguments.length,o=new Array(r),s=0;s{s.current=!1}:i,r)}var de=cm;function ju(){}function Oa(i,r,o,s){return fm(i,s)||rm(i,r,o,s)}function fm(i,r){return i.editor.getModel(Id(i,r))}function rm(i,r,o,s){return i.editor.createModel(r,o,s?Id(i,s):void 0)}function Id(i,r){return i.Uri.parse(r)}function sm({original:i,modified:r,language:o,originalLanguage:s,modifiedLanguage:E,originalModelPath:z,modifiedModelPath:H,keepCurrentOriginalModel:X=!1,keepCurrentModifiedModel:U=!1,theme:_="light",loading:R="Loading...",options:J={},height:et="100%",width:pt="100%",className:_t,wrapperProps:xt={},beforeMount:lt=ju,onMount:Y=ju}){let[K,ft]=B.useState(!1),[Xt,Z]=B.useState(!0),st=B.useRef(null),nt=B.useRef(null),Nt=B.useRef(null),St=B.useRef(Y),at=B.useRef(lt),Vt=B.useRef(!1);Pd(()=>{let k=Wd.init();return k.then(p=>(nt.current=p)&&Z(!1)).catch(p=>p?.type!=="cancelation"&&console.error("Monaco initialization: error:",p)),()=>st.current?ee():k.cancel()}),de(()=>{if(st.current&&nt.current){let k=st.current.getOriginalEditor(),p=Oa(nt.current,i||"",s||o||"text",z||"");p!==k.getModel()&&k.setModel(p)}},[z],K),de(()=>{if(st.current&&nt.current){let k=st.current.getModifiedEditor(),p=Oa(nt.current,r||"",E||o||"text",H||"");p!==k.getModel()&&k.setModel(p)}},[H],K),de(()=>{let k=st.current.getModifiedEditor();k.getOption(nt.current.editor.EditorOption.readOnly)?k.setValue(r||""):r!==k.getValue()&&(k.executeEdits("",[{range:k.getModel().getFullModelRange(),text:r||"",forceMoveMarkers:!0}]),k.pushUndoStop())},[r],K),de(()=>{st.current?.getModel()?.original.setValue(i||"")},[i],K),de(()=>{let{original:k,modified:p}=st.current.getModel();nt.current.editor.setModelLanguage(k,s||o||"text"),nt.current.editor.setModelLanguage(p,E||o||"text")},[o,s,E],K),de(()=>{nt.current?.editor.setTheme(_)},[_],K),de(()=>{st.current?.updateOptions(J)},[J],K);let zt=B.useCallback(()=>{if(!nt.current)return;at.current(nt.current);let k=Oa(nt.current,i||"",s||o||"text",z||""),p=Oa(nt.current,r||"",E||o||"text",H||"");st.current?.setModel({original:k,modified:p})},[o,r,E,i,s,z,H]),ve=B.useCallback(()=>{!Vt.current&&Nt.current&&(st.current=nt.current.editor.createDiffEditor(Nt.current,{automaticLayout:!0,...J}),zt(),nt.current?.editor.setTheme(_),ft(!0),Vt.current=!0)},[J,_,zt]);B.useEffect(()=>{K&&St.current(st.current,nt.current)},[K]),B.useEffect(()=>{!Xt&&!K&&ve()},[Xt,K,ve]);function ee(){let k=st.current?.getModel();X||k?.original?.dispose(),U||k?.modified?.dispose(),st.current?.dispose()}return za.createElement(Fd,{width:pt,height:et,isEditorReady:K,loading:R,_ref:Nt,className:_t,wrapperProps:xt})}var om=sm,dm=B.memo(om);function hm(i){let r=B.useRef();return B.useEffect(()=>{r.current=i},[i]),r.current}var vm=hm,Pn=new Map;function ym({defaultValue:i,defaultLanguage:r,defaultPath:o,value:s,language:E,path:z,theme:H="light",line:X,loading:U="Loading...",options:_={},overrideServices:R={},saveViewState:J=!0,keepCurrentModel:et=!1,width:pt="100%",height:_t="100%",className:xt,wrapperProps:lt={},beforeMount:Y=ju,onMount:K=ju,onChange:ft,onValidate:Xt=ju}){let[Z,st]=B.useState(!1),[nt,Nt]=B.useState(!0),St=B.useRef(null),at=B.useRef(null),Vt=B.useRef(null),zt=B.useRef(K),ve=B.useRef(Y),ee=B.useRef(),k=B.useRef(s),p=vm(z),N=B.useRef(!1),Q=B.useRef(!1);Pd(()=>{let A=Wd.init();return A.then(j=>(St.current=j)&&Nt(!1)).catch(j=>j?.type!=="cancelation"&&console.error("Monaco initialization: error:",j)),()=>at.current?h():A.cancel()}),de(()=>{let A=Oa(St.current,i||s||"",r||E||"",z||o||"");A!==at.current?.getModel()&&(J&&Pn.set(p,at.current?.saveViewState()),at.current?.setModel(A),J&&at.current?.restoreViewState(Pn.get(z)))},[z],Z),de(()=>{at.current?.updateOptions(_)},[_],Z),de(()=>{!at.current||s===void 0||(at.current.getOption(St.current.editor.EditorOption.readOnly)?at.current.setValue(s):s!==at.current.getValue()&&(Q.current=!0,at.current.executeEdits("",[{range:at.current.getModel().getFullModelRange(),text:s,forceMoveMarkers:!0}]),at.current.pushUndoStop(),Q.current=!1))},[s],Z),de(()=>{let A=at.current?.getModel();A&&E&&St.current?.editor.setModelLanguage(A,E)},[E],Z),de(()=>{X!==void 0&&at.current?.revealLine(X)},[X],Z),de(()=>{St.current?.editor.setTheme(H)},[H],Z);let ot=B.useCallback(()=>{if(!(!Vt.current||!St.current)&&!N.current){ve.current(St.current);let A=z||o,j=Oa(St.current,s||i||"",r||E||"",A||"");at.current=St.current?.editor.create(Vt.current,{model:j,automaticLayout:!0,..._},R),J&&at.current.restoreViewState(Pn.get(A)),St.current.editor.setTheme(H),X!==void 0&&at.current.revealLine(X),st(!0),N.current=!0}},[i,r,o,s,E,z,_,R,J,H,X]);B.useEffect(()=>{Z&&zt.current(at.current,St.current)},[Z]),B.useEffect(()=>{!nt&&!Z&&ot()},[nt,Z,ot]),k.current=s,B.useEffect(()=>{Z&&ft&&(ee.current?.dispose(),ee.current=at.current?.onDidChangeModelContent(A=>{Q.current||ft(at.current.getValue(),A)}))},[Z,ft]),B.useEffect(()=>{if(Z){let A=St.current.editor.onDidChangeMarkers(j=>{let x=at.current.getModel()?.uri;if(x&&j.find(G=>G.path===x.path)){let G=St.current.editor.getModelMarkers({resource:x});Xt?.(G)}});return()=>{A?.dispose()}}return()=>{}},[Z,Xt]);function h(){ee.current?.dispose(),et?J&&Pn.set(z,at.current.saveViewState()):at.current.getModel()?.dispose(),at.current.dispose()}return za.createElement(Fd,{width:pt,height:_t,isEditorReady:Z,loading:U,_ref:Vt,className:xt,wrapperProps:lt})}var mm=ym;B.memo(mm);function th({language:i,modified:r,original:o,sideBySide:s=!0,highlights:E=[],annotations:z=[]}){const H=E.length===0&&z.length===0?void 0:(X,U)=>{X.getModifiedEditor().createDecorationsCollection([...gm(E,U),...Sm(z,U)])};return D.jsx(dm,{height:"18rem",language:i,modified:r,onMount:H,options:{automaticLayout:!0,glyphMargin:z.length>0,minimap:{enabled:!1},readOnly:!0,renderSideBySide:s},original:o,theme:"vs-dark"})}function gm(i,r){return i.map(o=>({options:{className:"lineage-sarif-line",hoverMessage:{value:o.title},isWholeLine:!0,overviewRuler:{color:"#d29922",position:r.editor.OverviewRulerLane.Right}},range:new r.Range(Math.max(1,o.startLine),1,Math.max(1,o.endLine),1)}))}function Sm(i,r){return i.map(o=>({options:{className:`lineage-verification-${o.verification}`,glyphMarginClassName:`lineage-verification-glyph-${o.verification}`,hoverMessage:{value:bm(o.verification)},isWholeLine:!0,overviewRuler:{color:pm(o.verification),position:r.editor.OverviewRulerLane.Right}},range:new r.Range(Math.max(1,o.line),1,Math.max(1,o.line),1)}))}function bm(i){return`Lineage verification: ${i.replaceAll("_"," ")}`}function pm(i){return{covered_and_killed:"#3fb950",covered:"#58a6ff",partially_covered:"#d29922",not_covered:"#f85149",unknown:"#8b949e"}[i]}const Ma=50;function _m(){const[i,r]=B.useState(window.location.search),o=Q0(i),s=new URLSearchParams(i),E=s.get("layout"),z=E==="inline"||E==="split"?E:window.localStorage.getItem("lineage.diff.layout")==="inline"||window.innerWidth<900?"inline":"split",[H,X]=B.useState(null),[U,_]=B.useState(null);return B.useEffect(()=>{o&&L0(o).then(X).catch(R=>{_(R instanceof ti?R.message:"Unable to load diff plan")})},[o?.base,o?.head,o?.coverage_source,o?.sarif_source,o?.selection,o?.mutant_corpus,o?.test_set,o?.page,o?.path]),B.useEffect(()=>{const R=()=>r(window.location.search);return window.addEventListener("popstate",R),()=>window.removeEventListener("popstate",R)},[]),D.jsxs("main",{className:"app-shell",children:[D.jsxs("header",{children:[D.jsx("p",{className:"eyebrow",children:"Lineage"}),D.jsx("h1",{children:"Revision-aware diff review"}),D.jsx("p",{children:"Revision-pinned inventory and raw source review."})]}),D.jsx(Om,{revisions:o}),!o&&D.jsx("p",{role:"status",children:"Add immutable base and head revisions to the URL to begin review."}),U&&D.jsx("p",{role:"alert",children:U}),H&&D.jsx(Em,{initialLayout:z,page:Tm(i),plan:H,rawPath:s.get("presentation")==="raw"?s.get("path"):null,selectedGroup:s.get("group")})]})}function Em({initialLayout:i,page:r,plan:o,rawPath:s,selectedGroup:E}){const[z,H]=B.useState(()=>i==="split"),X=s?o.files.find(Y=>Y.path===s):void 0,U=Math.max(1,Math.ceil(o.files.length/Ma)),_=E===null?-1:o.files.findIndex(Y=>Y.groups.some(K=>li(Y,K)===E)),R=_>=0?Math.floor(_/Ma)+1:Math.min(r,U),J=o.files.slice((R-1)*Ma,R*Ma);B.useEffect(()=>H(i==="split"),[i]);const et=Y=>{const K=new URLSearchParams(window.location.search);K.set("layout",Y?"split":"inline"),window.history.replaceState({},"",`${window.location.pathname}?${K}`),window.localStorage.setItem("lineage.diff.layout",Y?"split":"inline"),H(Y)},pt=Y=>{const K=new URLSearchParams(window.location.search);K.set("presentation","raw"),K.set("path",Y),K.set("focus","residual"),K.delete("group"),window.history.replaceState({},"",`${window.location.pathname}?${K}`),window.dispatchEvent(new PopStateEvent("popstate"))},_t=()=>{const Y=new URLSearchParams(window.location.search);Y.delete("presentation"),Y.delete("path"),Y.delete("focus"),window.history.replaceState({},"",`${window.location.pathname}?${Y}`),window.dispatchEvent(new PopStateEvent("popstate"))},xt=Y=>{const K=new URLSearchParams(window.location.search);Y===1?K.delete("page"):K.set("page",String(Y)),K.delete("group"),K.delete("path"),window.history.pushState({},"",`${window.location.pathname}?${K}`),window.dispatchEvent(new PopStateEvent("popstate"))},lt=(Y,K)=>{const ft=new URLSearchParams(window.location.search);K===null?(ft.delete("group"),ft.delete("path")):(ft.set("group",li(Y,K)),ft.set("path",Y.path)),window.history.pushState({},"",`${window.location.pathname}?${ft}`),window.dispatchEvent(new PopStateEvent("popstate"))};return D.jsxs("section",{"aria-label":"Diff inventory",className:"preview-card",children:[D.jsxs("p",{children:[o.inventory.changed_files," files in ",o.inventory.changed_directories," directories"]}),D.jsxs("p",{children:[o.inventory.added_files," added · ",o.inventory.modified_files," modified · ",o.inventory.deleted_files," deleted · ",o.inventory.renamed_files," renamed"]}),D.jsxs("p",{children:["Base ",o.scope.base_oid," · Head ",o.scope.head_oid]}),D.jsxs("p",{children:["Evidence scope: ",o.scope.evidence_scope.selection," · ",o.scope.evidence_scope.mutant_corpus," · ",o.scope.evidence_scope.test_set]}),D.jsxs("p",{children:["Evidence: coverage ",o.evidence.coverage," · mutation ",o.evidence.mutation," · hazards ",o.evidence.hazards," · SARIF ",o.evidence.sarif]}),o.resolved_sarif_findings.length>0&&D.jsxs("p",{children:["Resolved SARIF findings: ",o.resolved_sarif_findings.map(Y=>`${Y.path} ${uh(Y.finding)}`).join(" · ")]}),D.jsx(In,{label:"Configuration",paths:o.inventory.configuration_paths.map(Y=>`${Y.path} (${Y.kind})`)}),D.jsx(In,{label:"Documentation",paths:o.inventory.documentation_paths}),D.jsx(In,{label:"Generated",paths:o.inventory.generated_paths}),D.jsx(In,{label:"Lockfiles",paths:o.inventory.lockfile_paths}),o.dependency_changes.map(Y=>D.jsx(Mm,{change:Y},Y.manifest_path)),o.language_summaries.map(Y=>D.jsx(zm,{summary:Y},Y.language)),D.jsxs("fieldset",{children:[D.jsx("legend",{children:"Diff layout"}),D.jsxs("label",{children:[D.jsx("input",{checked:z,name:"layout",onChange:()=>et(!0),type:"radio"}),"Side by side"]}),D.jsxs("label",{children:[D.jsx("input",{checked:!z,name:"layout",onChange:()=>et(!1),type:"radio"}),"Inline"]})]}),X?D.jsx(Um,{file:X,headOid:o.scope.head_oid,onBack:_t,sideBySide:z}):D.jsxs(D.Fragment,{children:[D.jsx(Am,{onPage:xt,page:R,pageCount:U,totalFiles:o.files.length}),J.map(Y=>D.jsx(Dm,{file:Y,headOid:o.scope.head_oid,onGroupChange:lt,onRaw:pt,selectedGroup:E,sideBySide:z},Y.path))]})]})}function Tm(i){const r=Number(new URLSearchParams(i).get("page"));return Number.isSafeInteger(r)&&r>1?r:1}function Am({onPage:i,page:r,pageCount:o,totalFiles:s}){if(o<=1)return null;const E=(r-1)*Ma+1,z=Math.min(s,r*Ma);return D.jsxs("nav",{"aria-label":"Diff file pages",children:[D.jsxs("p",{children:["Showing files ",E,"–",z," of ",s]}),D.jsx("button",{disabled:r===1,onClick:()=>i(r-1),children:"Previous files"}),D.jsx("button",{disabled:r===o,onClick:()=>i(r+1),children:"Next files"})]})}function Om({revisions:i}){const[r,o]=B.useState(i?.base??""),[s,E]=B.useState(i?.head??""),[z,H]=B.useState(i?.coverage_source??""),[X,U]=B.useState(i?.sarif_source??""),[_,R]=B.useState(i?.selection??""),[J,et]=B.useState(i?.mutant_corpus??""),[pt,_t]=B.useState(i?.test_set??"");B.useEffect(()=>{o(i?.base??""),E(i?.head??""),H(i?.coverage_source??""),U(i?.sarif_source??""),R(i?.selection??""),et(i?.mutant_corpus??""),_t(i?.test_set??"")},[i?.base,i?.head,i?.coverage_source,i?.sarif_source,i?.selection,i?.mutant_corpus,i?.test_set]);const xt=lt=>{if(lt.preventDefault(),!r.trim()||!s.trim())return;const Y=new URLSearchParams(window.location.search);Y.set("base",r.trim()),Y.set("head",s.trim());for(const[K,ft]of[["coverage_source",z],["sarif_source",X],["selection",_],["mutant_corpus",J],["test_set",pt]])ft.trim()?Y.set(K,ft.trim()):Y.delete(K);Y.delete("presentation"),Y.delete("path"),Y.delete("group"),window.history.pushState({},"",`${window.location.pathname}?${Y}`),window.dispatchEvent(new PopStateEvent("popstate"))};return D.jsxs("form",{"aria-label":"Revision comparison",className:"revision-controls",onSubmit:xt,children:[D.jsxs("label",{children:["Base revision ",D.jsx("input",{"aria-label":"Base revision",onChange:lt=>o(lt.target.value),required:!0,value:r})]}),D.jsxs("label",{children:["Head revision ",D.jsx("input",{"aria-label":"Head revision",onChange:lt=>E(lt.target.value),required:!0,value:s})]}),D.jsxs("details",{children:[D.jsx("summary",{children:"Evidence selection"}),D.jsxs("label",{children:["Coverage source ",D.jsx("input",{"aria-label":"Coverage source",onChange:lt=>H(lt.target.value),value:z})]}),D.jsxs("label",{children:["SARIF source ",D.jsx("input",{"aria-label":"SARIF source",onChange:lt=>U(lt.target.value),value:X})]}),D.jsxs("label",{children:["Selection ",D.jsx("input",{"aria-label":"Evidence selection",onChange:lt=>R(lt.target.value),value:_})]}),D.jsxs("label",{children:["Mutant corpus ",D.jsx("input",{"aria-label":"Mutant corpus",onChange:lt=>et(lt.target.value),value:J})]}),D.jsxs("label",{children:["Test set ",D.jsx("input",{"aria-label":"Test set",onChange:lt=>_t(lt.target.value),value:pt})]})]}),D.jsx("button",{type:"submit",children:"Compare revisions"})]})}function Mm({change:i}){return i.status!=="exact"?D.jsxs("p",{children:[i.manifest_path,": unknown package-file change"]}):i.entries.length===0?D.jsxs("p",{children:[i.manifest_path,": no declared dependency changes"]}):D.jsxs("section",{"aria-label":`Dependency changes for ${i.manifest_path}`,children:[D.jsxs("p",{children:[i.manifest_path,": declared dependency changes"]}),D.jsx("ul",{children:i.entries.map(r=>D.jsxs("li",{children:[r.name," (",r.scope,"): ",r.before??"not declared"," → ",r.after??"not declared"]},`${r.scope}:${r.name}`))})]})}function zm({summary:i}){const r=i.production_by_visibility;return D.jsxs("p",{children:[i.language,": ",i.production.code," production code lines · ",i.production.comments," production comments · public ",Uf(r.public)," · private ",Uf(r.private)," · unknown visibility ",Uf(r.unknown)," · ",D.jsx(Hf,{verification:i.production_verification})," · ",i.test.code," test code lines · ",i.test.comments," test comments · assertions ",i.test_assertions??"unavailable"]})}function In({label:i,paths:r}){return r.length>0?D.jsxs("p",{children:[i,": ",r.join(", ")]}):null}function Dm({file:i,headOid:r,onGroupChange:o,onRaw:s,selectedGroup:E,sideBySide:z}){const[H,X]=B.useState(!1),U=i.groups.filter(R=>R.visibility!=="private"),_=i.groups.filter(R=>R.visibility==="private");return B.useEffect(()=>{E!==null&&i.groups.some(R=>li(i,R)===E)&&X(!0)},[i,E]),D.jsxs("article",{className:"file-review",children:[D.jsxs("button",{"aria-expanded":H,className:"disclosure",onClick:()=>X(!H),children:[i.path," · risk ",i.risk.score," · ",i.role," · ",i.change," · ",i.added_lines.code," code lines"]})," ",D.jsx("a",{href:ch(i.path,r),children:"Open source"}),H&&D.jsxs("div",{className:"file-body",children:[!i.semantic_classification_available&&D.jsx("p",{className:"metrics",children:"Semantic classification unavailable; use the raw source-ordered diff."}),D.jsx(lh,{risk:i.risk,verification:i.verification}),D.jsx(ah,{findings:i.sarif_findings}),i.semantic_classification_available&&U.sort(nh).map(R=>D.jsx(eh,{file:i,group:R,onGroupChange:o,selectedGroup:E,sideBySide:z},`${R.kind}:${R.name}:${R.start_line}`)),D.jsxs("p",{children:["Other changed lines: ",i.residual_lines.code," code, ",i.residual_lines.comments," comments. ",D.jsx("button",{onClick:()=>s(i.path),children:"Open raw file diff"})]}),(i.removed_lines.code>0||i.removed_lines.comments>0)&&D.jsxs("details",{children:[D.jsx("summary",{children:"Removals"}),D.jsxs("p",{children:[i.removed_lines.code," code lines and ",i.removed_lines.comments," comments removed; review in the raw diff."]})]}),i.semantic_classification_available&&_.length>0&&D.jsx(Rm,{file:i,groups:_,onGroupChange:o,selectedGroup:E,sideBySide:z}),(i.base_source===null||i.head_source===null)&&D.jsx("p",{children:"Binary or one-sided change; open the raw file view for details."})]})]})}function Rm({file:i,groups:r,onGroupChange:o,selectedGroup:s,sideBySide:E}){const[z,H]=B.useState(!1),X=r.reduce(Nm,jm()),U=r.reduce(Hm,xm()),_=r.reduce((R,J)=>R+J.risk.tier_one_hazards,0);return D.jsxs("section",{children:[D.jsxs("button",{"aria-expanded":z,className:"disclosure",onClick:()=>H(!z),children:["Private changes (",r.length," functions, ",X.code," code, +",r.reduce((R,J)=>R+J.risk.added_complexity,0)," complexity, +",_," tier-1 hazards, ",D.jsx(Hf,{verification:U}),")"]}),z&&r.slice().sort(nh).map(R=>D.jsx(eh,{file:i,group:R,onGroupChange:o,selectedGroup:s,sideBySide:E},`${R.kind}:${R.name}:${R.start_line}`))]})}function eh({file:i,group:r,onGroupChange:o,selectedGroup:s,sideBySide:E}){const z=li(i,r),[H,X]=B.useState(()=>s===z);B.useEffect(()=>X(s===z),[z,s]);const U=()=>{const _=!H;X(_),o(i,_?r:null)};return D.jsxs("section",{className:"group-review",children:[D.jsxs("button",{"aria-expanded":H,className:"disclosure",onClick:U,children:[r.kind," ",r.name," · ",r.added_lines.code," code lines · ",r.added_lines.comments," comments"]}),D.jsx(lh,{risk:r.risk,verification:r.verification}),D.jsx(ah,{findings:r.sarif_findings}),H&&D.jsx(th,{annotations:Bm(i.line_annotations,r.start_line,r.end_line),highlights:qm(r),language:i.language??"plaintext",modified:wd(i.head_source,r.start_line,r.end_line),original:wd(i.base_source,r.base_start_line,r.base_end_line),sideBySide:E})]})}function Um({file:i,headOid:r,onBack:o,sideBySide:s}){return D.jsxs("article",{className:"file-review",children:[D.jsx("button",{onClick:o,children:"Back to semantic review"}),D.jsxs("h2",{children:["Raw diff: ",i.path]}),D.jsx("a",{href:ch(i.path,r),children:"Open source"}),i.base_source!==null&&i.head_source!==null?D.jsx(th,{annotations:i.line_annotations,highlights:i.sarif_findings.map(ih),language:i.language??"plaintext",modified:i.head_source,original:i.base_source,sideBySide:s}):D.jsx("p",{children:"Binary or one-sided change."})]})}function lh({risk:i,verification:r}){return D.jsxs("p",{className:"metrics",children:[D.jsx(Hf,{verification:r})," · +",i.added_complexity," complexity · +",i.tier_one_hazards," tier-1 hazards"]})}function ah({findings:i}){return i.length===0?null:D.jsxs("p",{className:"metrics",children:["SARIF findings: ",i.map(uh).join(" · ")]})}function uh(i){return`${i.status} ${i.level}/${i.category} ${i.tier===null?"unclassified tier":`tier-${i.tier}`} ${i.source}:${i.tool}/${i.rule_id} line ${i.start_line}: ${i.message}`}function wd(i,r,o){return i===null||r===null||o===null?"":i.split(` + `},Zd=qy(Gy)(Kd),Xy={config:Yy},Qy=function(){for(var r=arguments.length,o=new Array(r),s=0;s{s.current=!1}:i,r)}var de=cm;function ju(){}function Oa(i,r,o,s){return fm(i,s)||rm(i,r,o,s)}function fm(i,r){return i.editor.getModel(Id(i,r))}function rm(i,r,o,s){return i.editor.createModel(r,o,s?Id(i,s):void 0)}function Id(i,r){return i.Uri.parse(r)}function sm({original:i,modified:r,language:o,originalLanguage:s,modifiedLanguage:E,originalModelPath:z,modifiedModelPath:H,keepCurrentOriginalModel:X=!1,keepCurrentModifiedModel:U=!1,theme:_="light",loading:R="Loading...",options:J={},height:et="100%",width:pt="100%",className:_t,wrapperProps:xt={},beforeMount:lt=ju,onMount:Y=ju}){let[K,ft]=B.useState(!1),[Xt,Z]=B.useState(!0),st=B.useRef(null),nt=B.useRef(null),Nt=B.useRef(null),St=B.useRef(Y),at=B.useRef(lt),Vt=B.useRef(!1);Pd(()=>{let k=Wd.init();return k.then(p=>(nt.current=p)&&Z(!1)).catch(p=>p?.type!=="cancelation"&&console.error("Monaco initialization: error:",p)),()=>st.current?ee():k.cancel()}),de(()=>{if(st.current&&nt.current){let k=st.current.getOriginalEditor(),p=Oa(nt.current,i||"",s||o||"text",z||"");p!==k.getModel()&&k.setModel(p)}},[z],K),de(()=>{if(st.current&&nt.current){let k=st.current.getModifiedEditor(),p=Oa(nt.current,r||"",E||o||"text",H||"");p!==k.getModel()&&k.setModel(p)}},[H],K),de(()=>{let k=st.current.getModifiedEditor();k.getOption(nt.current.editor.EditorOption.readOnly)?k.setValue(r||""):r!==k.getValue()&&(k.executeEdits("",[{range:k.getModel().getFullModelRange(),text:r||"",forceMoveMarkers:!0}]),k.pushUndoStop())},[r],K),de(()=>{st.current?.getModel()?.original.setValue(i||"")},[i],K),de(()=>{let{original:k,modified:p}=st.current.getModel();nt.current.editor.setModelLanguage(k,s||o||"text"),nt.current.editor.setModelLanguage(p,E||o||"text")},[o,s,E],K),de(()=>{nt.current?.editor.setTheme(_)},[_],K),de(()=>{st.current?.updateOptions(J)},[J],K);let zt=B.useCallback(()=>{if(!nt.current)return;at.current(nt.current);let k=Oa(nt.current,i||"",s||o||"text",z||""),p=Oa(nt.current,r||"",E||o||"text",H||"");st.current?.setModel({original:k,modified:p})},[o,r,E,i,s,z,H]),ve=B.useCallback(()=>{!Vt.current&&Nt.current&&(st.current=nt.current.editor.createDiffEditor(Nt.current,{automaticLayout:!0,...J}),zt(),nt.current?.editor.setTheme(_),ft(!0),Vt.current=!0)},[J,_,zt]);B.useEffect(()=>{K&&St.current(st.current,nt.current)},[K]),B.useEffect(()=>{!Xt&&!K&&ve()},[Xt,K,ve]);function ee(){let k=st.current?.getModel();X||k?.original?.dispose(),U||k?.modified?.dispose(),st.current?.dispose()}return za.createElement(Fd,{width:pt,height:et,isEditorReady:K,loading:R,_ref:Nt,className:_t,wrapperProps:xt})}var om=sm,dm=B.memo(om);function hm(i){let r=B.useRef();return B.useEffect(()=>{r.current=i},[i]),r.current}var vm=hm,Pn=new Map;function ym({defaultValue:i,defaultLanguage:r,defaultPath:o,value:s,language:E,path:z,theme:H="light",line:X,loading:U="Loading...",options:_={},overrideServices:R={},saveViewState:J=!0,keepCurrentModel:et=!1,width:pt="100%",height:_t="100%",className:xt,wrapperProps:lt={},beforeMount:Y=ju,onMount:K=ju,onChange:ft,onValidate:Xt=ju}){let[Z,st]=B.useState(!1),[nt,Nt]=B.useState(!0),St=B.useRef(null),at=B.useRef(null),Vt=B.useRef(null),zt=B.useRef(K),ve=B.useRef(Y),ee=B.useRef(),k=B.useRef(s),p=vm(z),N=B.useRef(!1),Q=B.useRef(!1);Pd(()=>{let A=Wd.init();return A.then(j=>(St.current=j)&&Nt(!1)).catch(j=>j?.type!=="cancelation"&&console.error("Monaco initialization: error:",j)),()=>at.current?h():A.cancel()}),de(()=>{let A=Oa(St.current,i||s||"",r||E||"",z||o||"");A!==at.current?.getModel()&&(J&&Pn.set(p,at.current?.saveViewState()),at.current?.setModel(A),J&&at.current?.restoreViewState(Pn.get(z)))},[z],Z),de(()=>{at.current?.updateOptions(_)},[_],Z),de(()=>{!at.current||s===void 0||(at.current.getOption(St.current.editor.EditorOption.readOnly)?at.current.setValue(s):s!==at.current.getValue()&&(Q.current=!0,at.current.executeEdits("",[{range:at.current.getModel().getFullModelRange(),text:s,forceMoveMarkers:!0}]),at.current.pushUndoStop(),Q.current=!1))},[s],Z),de(()=>{let A=at.current?.getModel();A&&E&&St.current?.editor.setModelLanguage(A,E)},[E],Z),de(()=>{X!==void 0&&at.current?.revealLine(X)},[X],Z),de(()=>{St.current?.editor.setTheme(H)},[H],Z);let ot=B.useCallback(()=>{if(!(!Vt.current||!St.current)&&!N.current){ve.current(St.current);let A=z||o,j=Oa(St.current,s||i||"",r||E||"",A||"");at.current=St.current?.editor.create(Vt.current,{model:j,automaticLayout:!0,..._},R),J&&at.current.restoreViewState(Pn.get(A)),St.current.editor.setTheme(H),X!==void 0&&at.current.revealLine(X),st(!0),N.current=!0}},[i,r,o,s,E,z,_,R,J,H,X]);B.useEffect(()=>{Z&&zt.current(at.current,St.current)},[Z]),B.useEffect(()=>{!nt&&!Z&&ot()},[nt,Z,ot]),k.current=s,B.useEffect(()=>{Z&&ft&&(ee.current?.dispose(),ee.current=at.current?.onDidChangeModelContent(A=>{Q.current||ft(at.current.getValue(),A)}))},[Z,ft]),B.useEffect(()=>{if(Z){let A=St.current.editor.onDidChangeMarkers(j=>{let x=at.current.getModel()?.uri;if(x&&j.find(G=>G.path===x.path)){let G=St.current.editor.getModelMarkers({resource:x});Xt?.(G)}});return()=>{A?.dispose()}}return()=>{}},[Z,Xt]);function h(){ee.current?.dispose(),et?J&&Pn.set(z,at.current.saveViewState()):at.current.getModel()?.dispose(),at.current.dispose()}return za.createElement(Fd,{width:pt,height:_t,isEditorReady:Z,loading:U,_ref:Vt,className:xt,wrapperProps:lt})}var mm=ym;B.memo(mm);function th({language:i,modified:r,original:o,sideBySide:s=!0,highlights:E=[],annotations:z=[]}){const H=E.length===0&&z.length===0?void 0:(X,U)=>{X.getModifiedEditor().createDecorationsCollection([...gm(E,U),...Sm(z,U)])};return D.jsx(dm,{height:"18rem",language:i,modified:r,onMount:H,options:{automaticLayout:!0,glyphMargin:z.length>0,minimap:{enabled:!1},readOnly:!0,renderSideBySide:s},original:o,theme:"vs-dark"})}function gm(i,r){return i.map(o=>({options:{className:"gigasail-sarif-line",hoverMessage:{value:o.title},isWholeLine:!0,overviewRuler:{color:"#d29922",position:r.editor.OverviewRulerLane.Right}},range:new r.Range(Math.max(1,o.startLine),1,Math.max(1,o.endLine),1)}))}function Sm(i,r){return i.map(o=>({options:{className:`gigasail-verification-${o.verification}`,glyphMarginClassName:`gigasail-verification-glyph-${o.verification}`,hoverMessage:{value:bm(o.verification)},isWholeLine:!0,overviewRuler:{color:pm(o.verification),position:r.editor.OverviewRulerLane.Right}},range:new r.Range(Math.max(1,o.line),1,Math.max(1,o.line),1)}))}function bm(i){return`Gigasail verification: ${i.replaceAll("_"," ")}`}function pm(i){return{covered_and_killed:"#3fb950",covered:"#58a6ff",partially_covered:"#d29922",not_covered:"#f85149",unknown:"#8b949e"}[i]}const Ma=50;function _m(){const[i,r]=B.useState(window.location.search),o=Q0(i),s=new URLSearchParams(i),E=s.get("layout"),z=E==="inline"||E==="split"?E:window.localStorage.getItem("gigasail.diff.layout")==="inline"||window.innerWidth<900?"inline":"split",[H,X]=B.useState(null),[U,_]=B.useState(null);return B.useEffect(()=>{o&&L0(o).then(X).catch(R=>{_(R instanceof ti?R.message:"Unable to load diff plan")})},[o?.base,o?.head,o?.coverage_source,o?.sarif_source,o?.selection,o?.mutant_corpus,o?.test_set,o?.page,o?.path]),B.useEffect(()=>{const R=()=>r(window.location.search);return window.addEventListener("popstate",R),()=>window.removeEventListener("popstate",R)},[]),D.jsxs("main",{className:"app-shell",children:[D.jsxs("header",{children:[D.jsx("p",{className:"eyebrow",children:"Gigasail"}),D.jsx("h1",{children:"Revision-aware diff review"}),D.jsx("p",{children:"Revision-pinned inventory and raw source review."})]}),D.jsx(Om,{revisions:o}),!o&&D.jsx("p",{role:"status",children:"Add immutable base and head revisions to the URL to begin review."}),U&&D.jsx("p",{role:"alert",children:U}),H&&D.jsx(Em,{initialLayout:z,page:Tm(i),plan:H,rawPath:s.get("presentation")==="raw"?s.get("path"):null,selectedGroup:s.get("group")})]})}function Em({initialLayout:i,page:r,plan:o,rawPath:s,selectedGroup:E}){const[z,H]=B.useState(()=>i==="split"),X=s?o.files.find(Y=>Y.path===s):void 0,U=Math.max(1,Math.ceil(o.files.length/Ma)),_=E===null?-1:o.files.findIndex(Y=>Y.groups.some(K=>li(Y,K)===E)),R=_>=0?Math.floor(_/Ma)+1:Math.min(r,U),J=o.files.slice((R-1)*Ma,R*Ma);B.useEffect(()=>H(i==="split"),[i]);const et=Y=>{const K=new URLSearchParams(window.location.search);K.set("layout",Y?"split":"inline"),window.history.replaceState({},"",`${window.location.pathname}?${K}`),window.localStorage.setItem("gigasail.diff.layout",Y?"split":"inline"),H(Y)},pt=Y=>{const K=new URLSearchParams(window.location.search);K.set("presentation","raw"),K.set("path",Y),K.set("focus","residual"),K.delete("group"),window.history.replaceState({},"",`${window.location.pathname}?${K}`),window.dispatchEvent(new PopStateEvent("popstate"))},_t=()=>{const Y=new URLSearchParams(window.location.search);Y.delete("presentation"),Y.delete("path"),Y.delete("focus"),window.history.replaceState({},"",`${window.location.pathname}?${Y}`),window.dispatchEvent(new PopStateEvent("popstate"))},xt=Y=>{const K=new URLSearchParams(window.location.search);Y===1?K.delete("page"):K.set("page",String(Y)),K.delete("group"),K.delete("path"),window.history.pushState({},"",`${window.location.pathname}?${K}`),window.dispatchEvent(new PopStateEvent("popstate"))},lt=(Y,K)=>{const ft=new URLSearchParams(window.location.search);K===null?(ft.delete("group"),ft.delete("path")):(ft.set("group",li(Y,K)),ft.set("path",Y.path)),window.history.pushState({},"",`${window.location.pathname}?${ft}`),window.dispatchEvent(new PopStateEvent("popstate"))};return D.jsxs("section",{"aria-label":"Diff inventory",className:"preview-card",children:[D.jsxs("p",{children:[o.inventory.changed_files," files in ",o.inventory.changed_directories," directories"]}),D.jsxs("p",{children:[o.inventory.added_files," added · ",o.inventory.modified_files," modified · ",o.inventory.deleted_files," deleted · ",o.inventory.renamed_files," renamed"]}),D.jsxs("p",{children:["Base ",o.scope.base_oid," · Head ",o.scope.head_oid]}),D.jsxs("p",{children:["Evidence scope: ",o.scope.evidence_scope.selection," · ",o.scope.evidence_scope.mutant_corpus," · ",o.scope.evidence_scope.test_set]}),D.jsxs("p",{children:["Evidence: coverage ",o.evidence.coverage," · mutation ",o.evidence.mutation," · hazards ",o.evidence.hazards," · SARIF ",o.evidence.sarif]}),o.resolved_sarif_findings.length>0&&D.jsxs("p",{children:["Resolved SARIF findings: ",o.resolved_sarif_findings.map(Y=>`${Y.path} ${uh(Y.finding)}`).join(" · ")]}),D.jsx(In,{label:"Configuration",paths:o.inventory.configuration_paths.map(Y=>`${Y.path} (${Y.kind})`)}),D.jsx(In,{label:"Documentation",paths:o.inventory.documentation_paths}),D.jsx(In,{label:"Generated",paths:o.inventory.generated_paths}),D.jsx(In,{label:"Lockfiles",paths:o.inventory.lockfile_paths}),o.dependency_changes.map(Y=>D.jsx(Mm,{change:Y},Y.manifest_path)),o.language_summaries.map(Y=>D.jsx(zm,{summary:Y},Y.language)),D.jsxs("fieldset",{children:[D.jsx("legend",{children:"Diff layout"}),D.jsxs("label",{children:[D.jsx("input",{checked:z,name:"layout",onChange:()=>et(!0),type:"radio"}),"Side by side"]}),D.jsxs("label",{children:[D.jsx("input",{checked:!z,name:"layout",onChange:()=>et(!1),type:"radio"}),"Inline"]})]}),X?D.jsx(Um,{file:X,headOid:o.scope.head_oid,onBack:_t,sideBySide:z}):D.jsxs(D.Fragment,{children:[D.jsx(Am,{onPage:xt,page:R,pageCount:U,totalFiles:o.files.length}),J.map(Y=>D.jsx(Dm,{file:Y,headOid:o.scope.head_oid,onGroupChange:lt,onRaw:pt,selectedGroup:E,sideBySide:z},Y.path))]})]})}function Tm(i){const r=Number(new URLSearchParams(i).get("page"));return Number.isSafeInteger(r)&&r>1?r:1}function Am({onPage:i,page:r,pageCount:o,totalFiles:s}){if(o<=1)return null;const E=(r-1)*Ma+1,z=Math.min(s,r*Ma);return D.jsxs("nav",{"aria-label":"Diff file pages",children:[D.jsxs("p",{children:["Showing files ",E,"–",z," of ",s]}),D.jsx("button",{disabled:r===1,onClick:()=>i(r-1),children:"Previous files"}),D.jsx("button",{disabled:r===o,onClick:()=>i(r+1),children:"Next files"})]})}function Om({revisions:i}){const[r,o]=B.useState(i?.base??""),[s,E]=B.useState(i?.head??""),[z,H]=B.useState(i?.coverage_source??""),[X,U]=B.useState(i?.sarif_source??""),[_,R]=B.useState(i?.selection??""),[J,et]=B.useState(i?.mutant_corpus??""),[pt,_t]=B.useState(i?.test_set??"");B.useEffect(()=>{o(i?.base??""),E(i?.head??""),H(i?.coverage_source??""),U(i?.sarif_source??""),R(i?.selection??""),et(i?.mutant_corpus??""),_t(i?.test_set??"")},[i?.base,i?.head,i?.coverage_source,i?.sarif_source,i?.selection,i?.mutant_corpus,i?.test_set]);const xt=lt=>{if(lt.preventDefault(),!r.trim()||!s.trim())return;const Y=new URLSearchParams(window.location.search);Y.set("base",r.trim()),Y.set("head",s.trim());for(const[K,ft]of[["coverage_source",z],["sarif_source",X],["selection",_],["mutant_corpus",J],["test_set",pt]])ft.trim()?Y.set(K,ft.trim()):Y.delete(K);Y.delete("presentation"),Y.delete("path"),Y.delete("group"),window.history.pushState({},"",`${window.location.pathname}?${Y}`),window.dispatchEvent(new PopStateEvent("popstate"))};return D.jsxs("form",{"aria-label":"Revision comparison",className:"revision-controls",onSubmit:xt,children:[D.jsxs("label",{children:["Base revision ",D.jsx("input",{"aria-label":"Base revision",onChange:lt=>o(lt.target.value),required:!0,value:r})]}),D.jsxs("label",{children:["Head revision ",D.jsx("input",{"aria-label":"Head revision",onChange:lt=>E(lt.target.value),required:!0,value:s})]}),D.jsxs("details",{children:[D.jsx("summary",{children:"Evidence selection"}),D.jsxs("label",{children:["Coverage source ",D.jsx("input",{"aria-label":"Coverage source",onChange:lt=>H(lt.target.value),value:z})]}),D.jsxs("label",{children:["SARIF source ",D.jsx("input",{"aria-label":"SARIF source",onChange:lt=>U(lt.target.value),value:X})]}),D.jsxs("label",{children:["Selection ",D.jsx("input",{"aria-label":"Evidence selection",onChange:lt=>R(lt.target.value),value:_})]}),D.jsxs("label",{children:["Mutant corpus ",D.jsx("input",{"aria-label":"Mutant corpus",onChange:lt=>et(lt.target.value),value:J})]}),D.jsxs("label",{children:["Test set ",D.jsx("input",{"aria-label":"Test set",onChange:lt=>_t(lt.target.value),value:pt})]})]}),D.jsx("button",{type:"submit",children:"Compare revisions"})]})}function Mm({change:i}){return i.status!=="exact"?D.jsxs("p",{children:[i.manifest_path,": unknown package-file change"]}):i.entries.length===0?D.jsxs("p",{children:[i.manifest_path,": no declared dependency changes"]}):D.jsxs("section",{"aria-label":`Dependency changes for ${i.manifest_path}`,children:[D.jsxs("p",{children:[i.manifest_path,": declared dependency changes"]}),D.jsx("ul",{children:i.entries.map(r=>D.jsxs("li",{children:[r.name," (",r.scope,"): ",r.before??"not declared"," → ",r.after??"not declared"]},`${r.scope}:${r.name}`))})]})}function zm({summary:i}){const r=i.production_by_visibility;return D.jsxs("p",{children:[i.language,": ",i.production.code," production code lines · ",i.production.comments," production comments · public ",Uf(r.public)," · private ",Uf(r.private)," · unknown visibility ",Uf(r.unknown)," · ",D.jsx(Hf,{verification:i.production_verification})," · ",i.test.code," test code lines · ",i.test.comments," test comments · assertions ",i.test_assertions??"unavailable"]})}function In({label:i,paths:r}){return r.length>0?D.jsxs("p",{children:[i,": ",r.join(", ")]}):null}function Dm({file:i,headOid:r,onGroupChange:o,onRaw:s,selectedGroup:E,sideBySide:z}){const[H,X]=B.useState(!1),U=i.groups.filter(R=>R.visibility!=="private"),_=i.groups.filter(R=>R.visibility==="private");return B.useEffect(()=>{E!==null&&i.groups.some(R=>li(i,R)===E)&&X(!0)},[i,E]),D.jsxs("article",{className:"file-review",children:[D.jsxs("button",{"aria-expanded":H,className:"disclosure",onClick:()=>X(!H),children:[i.path," · risk ",i.risk.score," · ",i.role," · ",i.change," · ",i.added_lines.code," code lines"]})," ",D.jsx("a",{href:ch(i.path,r),children:"Open source"}),H&&D.jsxs("div",{className:"file-body",children:[!i.semantic_classification_available&&D.jsx("p",{className:"metrics",children:"Semantic classification unavailable; use the raw source-ordered diff."}),D.jsx(lh,{risk:i.risk,verification:i.verification}),D.jsx(ah,{findings:i.sarif_findings}),i.semantic_classification_available&&U.sort(nh).map(R=>D.jsx(eh,{file:i,group:R,onGroupChange:o,selectedGroup:E,sideBySide:z},`${R.kind}:${R.name}:${R.start_line}`)),D.jsxs("p",{children:["Other changed lines: ",i.residual_lines.code," code, ",i.residual_lines.comments," comments. ",D.jsx("button",{onClick:()=>s(i.path),children:"Open raw file diff"})]}),(i.removed_lines.code>0||i.removed_lines.comments>0)&&D.jsxs("details",{children:[D.jsx("summary",{children:"Removals"}),D.jsxs("p",{children:[i.removed_lines.code," code lines and ",i.removed_lines.comments," comments removed; review in the raw diff."]})]}),i.semantic_classification_available&&_.length>0&&D.jsx(Rm,{file:i,groups:_,onGroupChange:o,selectedGroup:E,sideBySide:z}),(i.base_source===null||i.head_source===null)&&D.jsx("p",{children:"Binary or one-sided change; open the raw file view for details."})]})]})}function Rm({file:i,groups:r,onGroupChange:o,selectedGroup:s,sideBySide:E}){const[z,H]=B.useState(!1),X=r.reduce(Nm,jm()),U=r.reduce(Hm,xm()),_=r.reduce((R,J)=>R+J.risk.tier_one_hazards,0);return D.jsxs("section",{children:[D.jsxs("button",{"aria-expanded":z,className:"disclosure",onClick:()=>H(!z),children:["Private changes (",r.length," functions, ",X.code," code, +",r.reduce((R,J)=>R+J.risk.added_complexity,0)," complexity, +",_," tier-1 hazards, ",D.jsx(Hf,{verification:U}),")"]}),z&&r.slice().sort(nh).map(R=>D.jsx(eh,{file:i,group:R,onGroupChange:o,selectedGroup:s,sideBySide:E},`${R.kind}:${R.name}:${R.start_line}`))]})}function eh({file:i,group:r,onGroupChange:o,selectedGroup:s,sideBySide:E}){const z=li(i,r),[H,X]=B.useState(()=>s===z);B.useEffect(()=>X(s===z),[z,s]);const U=()=>{const _=!H;X(_),o(i,_?r:null)};return D.jsxs("section",{className:"group-review",children:[D.jsxs("button",{"aria-expanded":H,className:"disclosure",onClick:U,children:[r.kind," ",r.name," · ",r.added_lines.code," code lines · ",r.added_lines.comments," comments"]}),D.jsx(lh,{risk:r.risk,verification:r.verification}),D.jsx(ah,{findings:r.sarif_findings}),H&&D.jsx(th,{annotations:Bm(i.line_annotations,r.start_line,r.end_line),highlights:qm(r),language:i.language??"plaintext",modified:wd(i.head_source,r.start_line,r.end_line),original:wd(i.base_source,r.base_start_line,r.base_end_line),sideBySide:E})]})}function Um({file:i,headOid:r,onBack:o,sideBySide:s}){return D.jsxs("article",{className:"file-review",children:[D.jsx("button",{onClick:o,children:"Back to semantic review"}),D.jsxs("h2",{children:["Raw diff: ",i.path]}),D.jsx("a",{href:ch(i.path,r),children:"Open source"}),i.base_source!==null&&i.head_source!==null?D.jsx(th,{annotations:i.line_annotations,highlights:i.sarif_findings.map(ih),language:i.language??"plaintext",modified:i.head_source,original:i.base_source,sideBySide:s}):D.jsx("p",{children:"Binary or one-sided change."})]})}function lh({risk:i,verification:r}){return D.jsxs("p",{className:"metrics",children:[D.jsx(Hf,{verification:r})," · +",i.added_complexity," complexity · +",i.tier_one_hazards," tier-1 hazards"]})}function ah({findings:i}){return i.length===0?null:D.jsxs("p",{className:"metrics",children:["SARIF findings: ",i.map(uh).join(" · ")]})}function uh(i){return`${i.status} ${i.level}/${i.category} ${i.tier===null?"unclassified tier":`tier-${i.tier}`} ${i.source}:${i.tool}/${i.rule_id} line ${i.start_line}: ${i.message}`}function wd(i,r,o){return i===null||r===null||o===null?"":i.split(` `).slice(r-1,o).join(` -`)}function nh(i,r){return r.risk.score-i.risk.score||r.risk.tier_one_hazards-i.risk.tier_one_hazards||r.risk.not_covered-i.risk.not_covered||r.added_lines.code-i.added_lines.code||i.name.localeCompare(r.name)}function jm(){return{code:0,comments:0,other:0}}function xm(){return{covered_and_killed:0,covered:0,partially_covered:0,not_covered:0,unknown:0}}function Nm(i,r){return{code:i.code+r.added_lines.code,comments:i.comments+r.added_lines.comments,other:i.other+r.added_lines.other}}function Hm(i,r){return{covered_and_killed:i.covered_and_killed+r.verification.covered_and_killed,covered:i.covered+r.verification.covered,partially_covered:i.partially_covered+r.verification.partially_covered,not_covered:i.not_covered+r.verification.not_covered,unknown:i.unknown+r.verification.unknown}}function Uf(i){return i.covered_and_killed+i.covered+i.partially_covered+i.not_covered+i.unknown}function Hf({verification:i}){return D.jsxs(D.Fragment,{children:[i.covered_and_killed," covered+killed · ",i.covered," covered · ",i.partially_covered," partial · ",i.not_covered," not covered · ",i.unknown," unknown"]})}function qm(i){return i.sarif_findings.map(r=>({...ih(r),startLine:Math.max(1,r.start_line-i.start_line+1),endLine:Math.max(1,r.end_line-i.start_line+1)}))}function Bm(i,r,o){return i.filter(s=>s.line>=r&&s.line<=o).map(s=>({...s,line:s.line-r+1}))}function ih(i){return{startLine:i.start_line,endLine:i.end_line,title:`${i.tool}/${i.rule_id}: ${i.message}`}}function ch(i,r){return`/?${new URLSearchParams({path:i,commit:r})}#L1`}function li(i,r){return`${i.path}:${r.kind}:${r.name}:${r.start_line}`}function Ym(i){if(!(i instanceof HTMLElement))throw new Error("Lineage UI requires an HTML root element");X0.createRoot(i).render(D.jsx(B.StrictMode,{children:D.jsx(_m,{})}))}Ym(document.getElementById("root")); +`)}function nh(i,r){return r.risk.score-i.risk.score||r.risk.tier_one_hazards-i.risk.tier_one_hazards||r.risk.not_covered-i.risk.not_covered||r.added_lines.code-i.added_lines.code||i.name.localeCompare(r.name)}function jm(){return{code:0,comments:0,other:0}}function xm(){return{covered_and_killed:0,covered:0,partially_covered:0,not_covered:0,unknown:0}}function Nm(i,r){return{code:i.code+r.added_lines.code,comments:i.comments+r.added_lines.comments,other:i.other+r.added_lines.other}}function Hm(i,r){return{covered_and_killed:i.covered_and_killed+r.verification.covered_and_killed,covered:i.covered+r.verification.covered,partially_covered:i.partially_covered+r.verification.partially_covered,not_covered:i.not_covered+r.verification.not_covered,unknown:i.unknown+r.verification.unknown}}function Uf(i){return i.covered_and_killed+i.covered+i.partially_covered+i.not_covered+i.unknown}function Hf({verification:i}){return D.jsxs(D.Fragment,{children:[i.covered_and_killed," covered+killed · ",i.covered," covered · ",i.partially_covered," partial · ",i.not_covered," not covered · ",i.unknown," unknown"]})}function qm(i){return i.sarif_findings.map(r=>({...ih(r),startLine:Math.max(1,r.start_line-i.start_line+1),endLine:Math.max(1,r.end_line-i.start_line+1)}))}function Bm(i,r,o){return i.filter(s=>s.line>=r&&s.line<=o).map(s=>({...s,line:s.line-r+1}))}function ih(i){return{startLine:i.start_line,endLine:i.end_line,title:`${i.tool}/${i.rule_id}: ${i.message}`}}function ch(i,r){return`/?${new URLSearchParams({path:i,commit:r})}#L1`}function li(i,r){return`${i.path}:${r.kind}:${r.name}:${r.start_line}`}function Ym(i){if(!(i instanceof HTMLElement))throw new Error("Gigasail UI requires an HTML root element");X0.createRoot(i).render(D.jsx(B.StrictMode,{children:D.jsx(_m,{})}))}Ym(document.getElementById("root")); diff --git a/gems/gigasail/giga-ui/src/ui/assets/diff/assets/index-BZcXXTiw.css b/gems/gigasail/giga-ui/src/ui/assets/diff/assets/index-BZcXXTiw.css new file mode 100644 index 000000000..0cf910ca1 --- /dev/null +++ b/gems/gigasail/giga-ui/src/ui/assets/diff/assets/index-BZcXXTiw.css @@ -0,0 +1 @@ +:root{background:#10151c;color:#e7edf5;font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0}.app-shell{margin:0 auto;max-width:72rem;padding:3rem 1.5rem}.eyebrow{color:#80cbc4;font-size:.78rem;font-weight:700;letter-spacing:.12em;margin:0 0 .45rem;text-transform:uppercase}h1,h2,p{margin-top:0}.preview-card{background:#18212d;border:1px solid #2b3b4f;border-radius:.75rem;margin-top:2rem;padding:1.25rem}.gigasail-sarif-line{background:color-mix(in srgb,#d29922 22%,transparent)}.gigasail-verification-covered-and-killed{background:color-mix(in srgb,#3fb950 18%,transparent)}.gigasail-verification-covered{background:color-mix(in srgb,#58a6ff 16%,transparent)}.gigasail-verification-partially-covered{background:color-mix(in srgb,#d29922 20%,transparent)}.gigasail-verification-not-covered{background:color-mix(in srgb,#f85149 20%,transparent)}.gigasail-verification-unknown{background:color-mix(in srgb,#8b949e 14%,transparent)}.gigasail-verification-glyph-covered-and-killed{background:#3fb950;border-radius:50%}.gigasail-verification-glyph-covered{background:#58a6ff;border-radius:50%}.gigasail-verification-glyph-partially-covered{background:#d29922;border-radius:50%}.gigasail-verification-glyph-not-covered{background:#f85149;border-radius:50%}.gigasail-verification-glyph-unknown{background:#8b949e;border-radius:50%} diff --git a/gems/lineage/src/ui/assets/diff/index.html b/gems/gigasail/giga-ui/src/ui/assets/diff/index.html similarity index 92% rename from gems/lineage/src/ui/assets/diff/index.html rename to gems/gigasail/giga-ui/src/ui/assets/diff/index.html index 3802cde9d..c79e9d2b3 100644 --- a/gems/lineage/src/ui/assets/diff/index.html +++ b/gems/gigasail/giga-ui/src/ui/assets/diff/index.html @@ -3,7 +3,7 @@ - Lineage Diff + Gigasail Diff diff --git a/gems/lineage/src/ui/controllers/architecture.rs b/gems/gigasail/giga-ui/src/ui/controllers/architecture.rs similarity index 99% rename from gems/lineage/src/ui/controllers/architecture.rs rename to gems/gigasail/giga-ui/src/ui/controllers/architecture.rs index 87cc08210..d5f34fcb7 100644 --- a/gems/lineage/src/ui/controllers/architecture.rs +++ b/gems/gigasail/giga-ui/src/ui/controllers/architecture.rs @@ -179,7 +179,7 @@ fn render_architecture_page(inventory: &Value, neighborhood: &Value, lens: &str) out.push_str(&html_escape(owner_name)); out.push_str(" architecture"); out.push_str("
"); - out.push_str("
← Lineage

"); + out.push_str("
← Gigasail

"); out.push_str(&html_escape(owner_name)); out.push_str("

"); out.push_str(&html_escape( diff --git a/gems/lineage/src/ui/controllers/assets.rs b/gems/gigasail/giga-ui/src/ui/controllers/assets.rs similarity index 100% rename from gems/lineage/src/ui/controllers/assets.rs rename to gems/gigasail/giga-ui/src/ui/controllers/assets.rs diff --git a/gems/lineage/src/ui/controllers/diff.rs b/gems/gigasail/giga-ui/src/ui/controllers/diff.rs similarity index 99% rename from gems/lineage/src/ui/controllers/diff.rs rename to gems/gigasail/giga-ui/src/ui/controllers/diff.rs index edb226ce8..9050f97ae 100644 --- a/gems/lineage/src/ui/controllers/diff.rs +++ b/gems/gigasail/giga-ui/src/ui/controllers/diff.rs @@ -240,7 +240,7 @@ mod tests { fn test_state() -> (tempfile::TempDir, UiServerState, String, String) { let dir = tempdir().unwrap(); let repo = git2::Repository::init(dir.path()).unwrap(); - let signature = git2::Signature::now("Lineage", "lineage@example.test").unwrap(); + let signature = git2::Signature::now("Gigasail", "gigasail@example.test").unwrap(); std::fs::write(dir.path().join("app.rb"), "puts :base\n").unwrap(); let mut index = repo.index().unwrap(); index.add_path(std::path::Path::new("app.rb")).unwrap(); @@ -266,7 +266,7 @@ mod tests { .unwrap() .to_string(); let state = UiServerState { - db: Arc::new(dir.path().join("lineage.db")), + db: Arc::new(dir.path().join("gigasail.db")), repo: Arc::new(dir.path().to_path_buf()), overlays: Arc::new(UiOverlays::default()), }; @@ -276,7 +276,7 @@ mod tests { fn large_test_state() -> (tempfile::TempDir, UiServerState, String, String) { let dir = tempdir().unwrap(); let repo = git2::Repository::init(dir.path()).unwrap(); - let signature = git2::Signature::now("Lineage", "lineage@example.test").unwrap(); + let signature = git2::Signature::now("Gigasail", "gigasail@example.test").unwrap(); let mut index = repo.index().unwrap(); for number in 0..=crate::diff::DIFF_FILE_PAGE_SIZE { let path = format!("app-{number:03}.rb"); @@ -308,7 +308,7 @@ mod tests { .unwrap() .to_string(); let state = UiServerState { - db: Arc::new(dir.path().join("lineage.db")), + db: Arc::new(dir.path().join("gigasail.db")), repo: Arc::new(dir.path().to_path_buf()), overlays: Arc::new(UiOverlays::default()), }; diff --git a/gems/lineage/src/ui/controllers/index.rs b/gems/gigasail/giga-ui/src/ui/controllers/index.rs similarity index 100% rename from gems/lineage/src/ui/controllers/index.rs rename to gems/gigasail/giga-ui/src/ui/controllers/index.rs diff --git a/gems/lineage/src/ui/controllers/mod.rs b/gems/gigasail/giga-ui/src/ui/controllers/mod.rs similarity index 100% rename from gems/lineage/src/ui/controllers/mod.rs rename to gems/gigasail/giga-ui/src/ui/controllers/mod.rs diff --git a/gems/lineage/src/ui/controllers/source.rs b/gems/gigasail/giga-ui/src/ui/controllers/source.rs similarity index 100% rename from gems/lineage/src/ui/controllers/source.rs rename to gems/gigasail/giga-ui/src/ui/controllers/source.rs diff --git a/gems/lineage/src/ui/lsp.rs b/gems/gigasail/giga-ui/src/ui/lsp.rs similarity index 97% rename from gems/lineage/src/ui/lsp.rs rename to gems/gigasail/giga-ui/src/ui/lsp.rs index 8b9c0be15..707b3e622 100644 --- a/gems/lineage/src/ui/lsp.rs +++ b/gems/gigasail/giga-ui/src/ui/lsp.rs @@ -79,7 +79,7 @@ enum GutterUpdate {} impl Notification for GutterUpdate { type Params = GutterUpdateParams; - const METHOD: &'static str = "lineage/gutterUpdate"; + const METHOD: &'static str = "gigasail/gutterUpdate"; } pub async fn serve_lsp( @@ -125,7 +125,7 @@ impl LanguageServer for LineageLsp { async fn initialized(&self, _: InitializedParams) { self.client - .log_message(MessageType::INFO, "Lineage LSP initialized") + .log_message(MessageType::INFO, "Gigasail LSP initialized") .await; } @@ -167,7 +167,7 @@ impl LanguageServer for LineageLsp { Ok(Some(facts)) => Ok(hover_for_line(&facts, line)), Ok(None) => Ok(None), Err(error) => { - self.log_error(format!("lineage hover failed: {error:#}")) + self.log_error(format!("gigasail hover failed: {error:#}")) .await; Ok(None) } @@ -179,7 +179,7 @@ impl LanguageServer for LineageLsp { Ok(Some(facts)) => Ok(Some(code_lenses_for_units(&facts))), Ok(None) => Ok(Some(Vec::new())), Err(error) => { - self.log_error(format!("lineage codeLens failed: {error:#}")) + self.log_error(format!("gigasail codeLens failed: {error:#}")) .await; Ok(Some(Vec::new())) } @@ -199,7 +199,7 @@ impl LanguageServer for LineageLsp { Ok(Some(path)) => path, Ok(None) => return Ok(None), Err(error) => { - self.log_error(format!("lineage definition failed: {error:#}")) + self.log_error(format!("gigasail definition failed: {error:#}")) .await; return Ok(None); } @@ -207,7 +207,7 @@ impl LanguageServer for LineageLsp { let storage = match Storage::open_existing(&self.db) { Ok(storage) => storage, Err(error) => { - self.log_error(format!("lineage definition failed: {error:#}")) + self.log_error(format!("gigasail definition failed: {error:#}")) .await; return Ok(None); } @@ -215,7 +215,7 @@ impl LanguageServer for LineageLsp { let definitions = match storage.find_definitions(&word, None, Some(¤t_path)) { Ok(definitions) => definitions, Err(error) => { - self.log_error(format!("lineage definition failed: {error:#}")) + self.log_error(format!("gigasail definition failed: {error:#}")) .await; return Ok(None); } @@ -255,7 +255,7 @@ impl LineageLsp { } Ok(None) => {} Err(error) => { - self.log_error(format!("lineage diagnostics failed: {error:#}")) + self.log_error(format!("gigasail diagnostics failed: {error:#}")) .await; } } @@ -476,9 +476,9 @@ pub fn diagnostics_for_annotations(annotations: &[UiLineAnnotation]) -> Vec Vec Vec Option { } let mut lines = Vec::new(); - lines.push("### Lineage".to_string()); + lines.push("### Gigasail".to_string()); lines.push(format!("`{}` line {}", facts.path, line)); if let Some(unit) = unit { lines.push(format!( @@ -712,7 +712,7 @@ fn code_lenses_for_units(facts: &FileFacts) -> Vec { .iter() .map(|unit| { let mut title = format!( - "Lineage: risk {:.1} | fixes {} | tests {} | mutant {}/{} | stale {} | reopened {}", + "Gigasail: risk {:.1} | fixes {} | tests {} | mutant {}/{} | stale {} | reopened {}", unit.risk_score, unit.fixes, unit.current_distinct_tests, @@ -731,7 +731,7 @@ fn code_lenses_for_units(facts: &FileFacts) -> Vec { range: range_for_line(unit.start_line), command: Some(Command { title, - command: "lineage.showUnit".to_string(), + command: "gigasail.showUnit".to_string(), arguments: Some(vec![serde_json::json!({ "path": facts.path, "unit_id": unit.id, diff --git a/gems/lineage/src/ui/templates/app.html b/gems/gigasail/giga-ui/src/ui/templates/app.html similarity index 91% rename from gems/lineage/src/ui/templates/app.html rename to gems/gigasail/giga-ui/src/ui/templates/app.html index 1469eb052..9abcb57be 100644 --- a/gems/lineage/src/ui/templates/app.html +++ b/gems/gigasail/giga-ui/src/ui/templates/app.html @@ -1,5 +1,5 @@

- +