diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 490116b..5fead69 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -10,7 +10,7 @@ permissions: jobs: audition: - name: audition audits audition + name: Audition audits itself runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 68c0024..930def9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ Gemfile.lock .DS_Store /doc/api/ +/.yardoc/ diff --git a/.yardoc/checksums b/.yardoc/checksums deleted file mode 100644 index 9837058..0000000 --- a/.yardoc/checksums +++ /dev/null @@ -1,25 +0,0 @@ -lib/audition.rb fffcc8c0ef406b910a2d818ba1e2c74154084be3 -lib/audition/cli.rb 9535b5b2b5916f0e3d2020d643b468372de68848 -lib/audition/fixer.rb 442b6a8d2f2c4334489883f5c5e4e853f517ff27 -lib/audition/config.rb 1e47c602d6541b80fd2d79440f2a2b988b9c3755 -lib/audition/report.rb 166bc8d4bc00734deb6ed64eabde9186aaea30da -lib/audition/target.rb 754dd75ebda7effe94792f048c0ec9f7776a9bb7 -lib/audition/finding.rb f54540fbf59b18a3133c80c8523a12e969028446 -lib/audition/version.rb 7c2cfcef5de6d89622aa767b63c366c41ea242c1 -lib/audition/baseline.rb ea5a9291b5b4fc765d0397691b777d41225dbff8 -lib/audition/rewriters.rb ddbdac282297621d0856d4be12cbeddfc6a672b2 -lib/audition/directives.rb b381dc9212b76aa295d48d59c0ae16b0fb6d3e7b -lib/audition/bundle_sweep.rb 0c605c06ba1cb4b4b50d1ffeb347747f23ec5f4b -lib/audition/static/checks.rb 6b19aaebb00f32d3ad85d12a9405d3ada1495dfc -lib/audition/dynamic/prober.rb 8581edcdb200c8e4dfbab242d0ef47fbab907d1d -lib/audition/dynamic/harness.rb 14170146c441c6077795bcf38c16b6003de8ff71 -lib/audition/static/analyzer.rb 4c4d5956a3eb4177d5e7f16053f60d0f28fef207 -lib/audition/static/checks/base.rb 6b6fbdd384da7009bbfad879f29f43d45c983764 -lib/audition/static/graph_audit.rb 9fcb7c8b6742f1cb6a7dd3e01109bb7547dc3faa -lib/audition/static/source_file.rb a793eef1f586ae4f01ef4a274613688ebbf58f91 -lib/audition/static/literal_classifier.rb b6ee1ba0267c1621c0f3350bc238ac98b8866e68 -lib/audition/static/checks/unsafe_calls.rb bd1aeda2049f361e8f33d55f2da605eb16f500d5 -lib/audition/static/checks/runtime_require.rb c52e6c71bf211baed188ec35a35d6f7456b9927b -lib/audition/static/checks/global_variables.rb 0f2c57e93ab2364f79c795ec1d8271be36ca9011 -lib/audition/static/checks/ractor_isolation.rb 2f5f0f49e67363cfa631a9ee606e21f95c577ca3 -lib/audition/static/checks/mutable_constants.rb d14f0ad56d9c421f76ad43ee747dfb167ca73e4b diff --git a/.yardoc/complete b/.yardoc/complete deleted file mode 100644 index e69de29..0000000 diff --git a/.yardoc/object_types b/.yardoc/object_types deleted file mode 100644 index 53f4474..0000000 Binary files a/.yardoc/object_types and /dev/null differ diff --git a/.yardoc/objects/root.dat b/.yardoc/objects/root.dat deleted file mode 100644 index a636ee4..0000000 Binary files a/.yardoc/objects/root.dat and /dev/null differ diff --git a/.yardoc/proxy_types b/.yardoc/proxy_types deleted file mode 100644 index beefda1..0000000 Binary files a/.yardoc/proxy_types and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index a43a7d7..42b20fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,160 @@ # Changelog +## [Unreleased] + +- Progress narration on stderr, stdout left pipeable: + `◆ Audition checking 50/919 5% (0.2s, on 8 ractors)`, + rewritten in place on a terminal, one line per phase off one. + A phase running in Ractors says how many, next to the clock + rather than the counts. On above 200 files and for every + bundle sweep, off for `--format json` and `--format github`; + `--progress` / `--no-progress` force either way. +- Files are dealt to scan Ractors largest-first, onto whichever + worker is carrying least (longest-processing-time-first). + Contiguous slices put neighbouring files—alike in size—on one + worker, which left a single Ractor still running well after + the others had finished: 2,500 files went 2.3s to 1.1s on 8 + workers. +- Worker count is now every core the Ractor pool can run, + `Etc.nprocessors` capped by `RUBY_MAX_CPU` (default 8), where + it was one less than the core count. Ractors past that cap add + no parallelism; `-j` / `--workers` overrides. +- The graph audit's four whole-tree walks now run in the same + Ractors, above 100 files. Each worker parses its slice once + and keeps the trees: one round reports the class and module + names its slice declares, and once those are merged a second + round walks the same trees against them. 3,400 files went + 2.4s to 1.2s on 8 workers, the whole phase 3.8s to 2.6s. +- New check `static-scan`, for the static pass's own blind + spots. rubydex reports every expression it could not resolve; + where the shape could hide what the class-state and constant + walks look for, the hole is now a finding instead of a clean + line: a singleton opened on a runtime receiver that writes + class-level state (`class << pick_target` around + `@cache = {}`), a runtime superclass or mixin argument + (`class Widget < base`, `include Object.const_get(name)`), + and a constant assigned through an unresolved path + (`TABLE = mod::Lookup`, info). Only the class and module + bodies are read, so an `include` matcher in a spec stays + silent, and a line another check already reported keeps its + own finding. +- New `.audition.yml` key `test_dirs`, for a project that files + its tests somewhere other than `test`, `spec` or `features`. + Findings under those directories are tagged `test` and + counted apart from production code. The list replaces the + default rather than adding to it; the `_test.rb` and + `_spec.rb` suffixes count whatever it says. +- Color on every output surface, not just the findings list. + The bundle sweep gained a titled table: severity glyphs on + the verdicts, one color per row, blank cells where a count is + zero. Usage text, the baseline line, fix and dry-run output, + `--compare` deltas and `--capabilities` too. `--plain`, + `NO_COLOR` and `TERM=dumb` strip all of it. +- New check `native-gem-calls`, for calls into a C extension + that never declared `rb_ext_ractor_safe`: they raise + `Ractor::UnsafeError`, and the extension is outside the tree, + so the call site is what gets flagged. Which gems count comes + from the target's own `Gemfile.lock`, its installed extension + binaries and its generated type stubs—never a gem list—and a + platform build Audition cannot inspect is reported as + unverified. Taint then follows the values: chained calls, + locals and ivars, `sig`/`T.let`/`T.cast` types, `case`/`when` + and `is_a?` narrowing, block element parameters, arguments + into other methods, reopened classes, mixin bodies, and + across files until nothing new is learned. +- Rails targets scan the whole root, not just `app`, `lib` and + `config`—local gems, engines and tests boot into the same + process. Vendored and scratch directories stay excluded. + Indexing another call's result (`Registry.by_name["KEY"]`) is + an unprovable call result. +- New `shallow_opaque` finding: `X.new.freeze`, + `compute.freeze`, and frozen containers of fresh instances or + unprovable call results freeze the wrapper, not the value. A + later bare `NAME.freeze` counts the same, and one unknown + element no longer excuses a provably fresh sibling, so + `T.let({KEY => Widget.new(...)}.freeze, ...)` now reports. +- Class-level state finds three more shapes: `attr_accessor` or + `attr_writer` on a singleton class (the declaration itself, + plus every assignment through one), ivars assigned by + instance methods of a module something `extend`s, and + `instance_variable_set("@x", v)` or + `remove_instance_variable(:@x)` where the receiver is + provably a class. Computed names, unprovable receivers, + `instance_variable_get`, `instance_variable_defined?` and + `instance_variables` stay quiet. +- Class-level state no longer waits to be shown the `extend`. A + concern puts its companion module on the class from inside + whatever library defines the pattern, so nothing in the target + ever spells `extend ClassMethods`, and a nested `ClassMethods` + module or a `class_methods do` block went unread. Both are now + walked as class-level scope. Which trees reported them used to + turn on an unrelated file—one literal `extend ClassMethods` + anywhere in a scan seeded the name for all of it—so the same + shape was an error in one library and silent in its neighbour. + `extend const_get(:Name)` on self and a module mixed into a + `singleton_class` now count as extend sites, and a mixin + argument that resolves this way stops being reported as one + the graph could not read. +- New check `dependency-class-state`: a dependency holding its + configuration in a class-level ivar has no source in the + tree, so the call site is flagged; the target's own type + stubs say which calls are attribute reads. Reads warn, writes + error. +- New check `derived-constants`: an alias (`DEFAULT = PRIMARY`) + or a frozen container of references (`ALL = [A, B].freeze`) + inherits the referent's finding. `mutable-constants` findings + seed a graph pass that propagates along constant references, + transitively and across files, at the source's severity. +- New check `unshareable-reads`: reading a gem constant that + holds an unshareable object warns at the read site, `sig` + blocks included—Sorbet's `T::Boolean` is an unfrozen, lazily + memoizing `TypeAlias`. +- Test findings are counted apart. Anything under `test/`, + `spec/` or `features/`, or in a `_test.rb`/`_spec.rb` file, + is marked `(tests)` in text and `"test"` in JSON and never + moves the verdict or the exit code. +- `T.let`, `T.cast` and `T.must` are unwrapped everywhere: + classification, the reported container type (no more "mutable + T literal"), depth checks and autofixes all see the value + inside. The safe fix is now `T.let({...}.freeze, ...)`, not a + freeze on the cast. +- Dynamic findings pin the failing line. The harness ships a + backtrace with every error, so script, rack, Rails boot and + load failures land on the deepest frame inside the target + instead of `line: nil` on the entry file. +- Constant sweep limit 5,000 → 200,000, and hitting it is a + `runtime-scan` warning naming the count and the limit instead + of silent truncation; probes take `max_constants`. A failed + Rails boot keeps the sweep of everything loaded before it, + and the rails probe realpaths the environment file so a + symlinked root (macOS `/var`) stops turning the app's own + findings into dependency findings. +- The sweep catches state planted on classes that already + existed—a cache ivar on `String`, a class variable on a + stdlib module. Every pre-boot module's ivars and class + variables are snapshotted (without forcing autoloads) and + diffed after the boot; RubyGems, Bundler and VM internals are + excluded as probe machinery. +- Rack targets get the full sweep, not just the boot-and-serve + verdict: the same constant, class-state, class-variable and + native-extension passes require and Rails targets already + got. +- Unshareable constants name their blocker: "just not frozen" + where freezing the value fixes it, "blocked by unfrozen + String inside" or "blocked by Proc inside" where it does not. +- `begin ... end` is classified by its last statement, so a + constant built in a block is reported and fixed instead of + reading as opaque; an inline cast around it is peeled in + either order. A block with `rescue`, `else` or `ensure` stays + opaque. +- More call shapes classified: a `Set` from an opaque source or + a mapping block is a mutable container whatever it holds; + indexing a constant (`Mime[:xml]`) is an unprovable call + result, with `ENV` and Sorbet's `T` constructors silent; and + `File.expand_path`, `join`, `dirname`, `basename`, + `absolute_path` and `realpath` return fresh Strings—mutable + bare, shareable frozen. + ## [0.3.0] - 2026-09-05 - Native extensions. `audition .` in a gem checkout, `audition @@ -64,7 +219,7 @@ singleton ivars plus delegate, capture-free boot procs and the boot-in-raise-mode gate, share-a-copy and freeze-in-the-setter, main-or-local singletons with per-Ractor rebuilds, subsystem - make_shareable! hooks, and boot-time loading hygiene. audition + make_shareable! hooks, and boot-time loading hygiene. Audition was run on both sides of the 43 files the PRs touch. - Fix knowledge base: third pass, the gem dialect. i18n PR 741 (the first full gem conversion out of the Rails ractorization @@ -72,7 +227,7 @@ docs/rails_core_best_practices.md: config class variables moving to singleton-class ivars behind delegators, the opt-in `/ractorize` entry point, and frozen caches degrading to - recompute-per-call. audition was run on both sides of the PR + recompute-per-call. Audition was run on both sides of the PR to verify its checks against the conversion; it confirmed the fixes, caught a `.freeze` lost in a rebase and a class variable read that still raises from workers (reproduced on @@ -96,7 +251,7 @@ argument was silently ignored. README gained a CI and git hooks section with copy-paste lefthook, pre-commit, and GitHub Actions configs. -- Dogfooding: this repository now runs audition on itself, on +- Dogfooding: this repository now runs Audition on itself, on every commit through lefthook (staged files, static) and on every push through a non-blocking CI self-audit with PR annotations and a job summary. @@ -106,13 +261,13 @@ deployment mode and Actions' bundler-cache put every gem in `vendor/bundle`) count as dependencies, not as the target's own code. Found by the very first CI self-audit, which - attributed the vendored gems to audition itself and flipped + attributed the vendored gems to Audition itself and flipped the verdict from blocked to not_ready. - Report rendering split into one class per format (`Report::Text`, `Report::Json`, `Report::Github`); the `Report` class keeps only the data, verdict, and counts. The `Report#to_text/to_json/to_github` methods are gone, an API - change for anyone driving audition programmatically. + change for anyone driving Audition programmatically. ## [0.2.4] - 2026-08-23 @@ -129,7 +284,7 @@ main: the 61 ractorization commits that landed after the first study (through 2026-08-20) were read in full and distilled into seven new patterns in docs/rails_core_best_practices.md, with - every claim that touches audition's behavior verified on Ruby + every claim that touches Audition's behavior verified on Ruby 4.0.6. The check advice, autofix recipes, README, and agent skill below follow from that refresh. - New mutable-constants finding, with a safe `.freeze` autofix, @@ -163,11 +318,11 @@ rerun, spraying Ractor backtraces on stderr first, whenever a scanned file sends the capture scanner after local variables. Its visit methods were generated with define_method, the exact - un-shareable-Proc pattern audition's own unsafe-calls check + un-shareable-Proc pattern Audition's own unsafe-calls check flags (and a pragma silenced), so the first dispatch from a worker Ractor raised. They are plain defs now, and the check is covered by an in-Ractor regression spec. Found while - verifying rubydex 0.3.0, which audition now runs on: findings + verifying rubydex 0.3.0, which Audition now runs on: findings are byte-identical to 0.2.9 on real gems and no API we consume changed, so the dependency floor stays at 0.2. diff --git a/README.md b/README.md index af21347..9bc03a7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Point it at a Ruby script, a gem, a Rack app, or a Rails root and it tells you whether that code can run inside Ractors, why it cannot, -and how to fix it. Unlike a linter, audition does not stop at +and how to fix it. Unlike a linter, Audition does not stop at pattern-matching your source: whole-program analysis is powered by [rubydex](https://github.com/Shopify/rubydex), Shopify's Ruby indexer, and the target is also loaded in a sandboxed subprocess @@ -47,7 +47,7 @@ core itself is being ractorized; see the state, so `globalid` is not blamed for ActiveSupport's state. - **Dogfooding.** The scanner for Ractor compatibility is built using Ractors: static analysis fans out across CPU cores on - Ractor workers, and audition passes its own audit. It runs on + Ractor workers, and Audition passes its own audit. It runs on itself on every commit (a lefthook pre-commit over the staged files) and on every push (a full self-audit in CI). - **Trained on Rails core.** Several checks and fix suggestions @@ -58,26 +58,24 @@ core itself is being ractorized; see the findings are documented in [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md). - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks; - `path:line` is clickable in supporting terminals. JSON output for - CI. + `path:line` is clickable in supporting terminals. Long scans + narrate their phases on stderr. JSON output for CI. ```console $ audition worker.rb -* audition 0.1.0 ruby 4.0.6 · script at . +* Audition 0.3.0 ruby 4.0.6 · script at . worker.rb - x raises inside a Ractor: Ractor::IsolationError: can not - access global variable $jobs from non-main Ractor - why: The script ran fine on the main Ractor but failed under - Ractor.new; the static findings usually pinpoint the line. - x worker.rb:1 write to global variable $jobs - why: Non-main Ractors cannot access global variables; this - raises Ractor::IsolationError the moment the line executes - in a Ractor (verified on Ruby 4.0). - fix: Pass the value into the Ractor explicitly - (Ractor.new(value) { |v| ... }) or over a Ractor::Port; for - per-Ractor state use Ractor.current[:key]. - x worker.rb:4 read of global variable $jobs + x worker.rb:1 write to global variable $jobs global-variables + why: Non-main Ractors cannot access global variables; this raises + Ractor::IsolationError the moment the line executes in a Ractor + (verified on Ruby 4.0). + fix: Pass the value into the Ractor explicitly (Ractor.new(value) { + |v| ... }) or over a Ractor::Port; for per-Ractor state use + Ractor.current[:key]; do one-time process setup on the main Ractor + before spawning. + x worker.rb:1 raises inside a Ractor: Ractor::IsolationError ... + x worker.rb:4 read of global variable $jobs global-variables ... dynamic probes @@ -93,16 +91,29 @@ And the whole-bundle view: ```console $ audition Gemfile.lock --static-only -╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮ -│ gem │ version │ verdict │ errors │ warnings │ fixable │ -├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤ -│ activesupport │ 8.1.0 │ not ready │ 157 │ 97 │ 77 │ -│ i18n │ 1.14.7 │ not ready │ 48 │ 40 │ 45 │ -│ mail │ 2.9.1 │ not ready │ 27 │ 4 │ 13 │ -│ rack │ 3.2.6 │ not ready │ 23 │ 45 │ 60 │ -│ ... │ │ │ │ │ │ -╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯ -0 of 11 gems ractor-ready +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│ Audition bundle sweep │ +├──────────┬──────────┬─────────────┬────────┬────────────┬──────────┬─────────┬────────┤ +│ gem │ version │ verdict │ errors │ dep errors │ warnings │ fixable │ status │ +├──────────┼──────────┼─────────────┼────────┼────────────┼──────────┼─────────┼────────┤ +│ rubocop │ 1.88.2 │ x not ready │ 194 │ - │ 203 │ 28 │ ok │ +│ parser │ 3.3.12.0 │ x not ready │ 154 │ - │ 26 │ 26 │ ok │ +│ rack │ 3.2.7 │ x not ready │ 31 │ - │ 61 │ 13 │ ok │ +│ rake │ 13.4.2 │ x not ready │ 30 │ - │ 24 │ 5 │ ok │ +│ prism │ 1.9.0 │ x not ready │ 16 │ - │ 88 │ 15 │ ok │ +│ json │ 2.21.2 │ x not ready │ 7 │ - │ 5 │ - │ ok │ +│ pastel │ 0.8.0 │ x not ready │ 2 │ - │ 1 │ - │ ok │ +│ tty-link │ 0.2.0 │ x not ready │ 1 │ - │ 1 │ - │ ok │ +╰──────────┴──────────┴─────────────┴────────┴────────────┴──────────┴─────────┴────────╯ + +x 0 of 8 gems ractor-ready · 8 not ready +``` + +Verdicts are colored by severity, clean counts are left blank, and +the sweep names each gem as it finishes: + +```console +◆ Audition sweep rubocop 8/8 (100%) 5.8s ``` **Requires Ruby 4.0 or newer**, strictly: the tool targets the modern @@ -172,6 +183,20 @@ Useful flags: | `--capabilities` | table of what this Ruby allows in Ractors | | `--timeout 60` | dynamic probe budget in seconds | | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) | +| `--progress` / `--no-progress` | force / suppress the stderr narration | +| `-j 4` / `--workers 4` | scan Ractors (default: cores, capped by `RUBY_MAX_CPU`) | + +Long runs narrate themselves on stderr, so stdout stays pipeable: +a rewriting status line on a terminal, one line per phase off it. + +``` +◆ Audition checking 50/919 5% (0.2s, on 8 ractors) +``` + +A phase running in Ractors says how many. The narration turns on +by itself for a large tree or a bundle sweep and stays off for +`--format json` and `--format github`; the flags force either +way. Exit codes: `0` clean, `1` findings at or above the `--fail-on` threshold (or a failed dynamic probe), `2` usage error. @@ -210,11 +235,20 @@ timeout: 60 exclude: - legacy/** - db/schema.rb +test_dirs: + - qa checks: disable: - at-exit ``` +`test_dirs` names the directories that hold tests rather than +code a production boot loads; findings in them are tagged +`test` and rated as test code. It defaults to `test`, `spec`, +`features`, and replaces that list rather than adding to it—the +`_test.rb` and `_spec.rb` suffixes always count, whatever it +says. + ## CI and git hooks **GitHub Actions.** `--format github` turns findings into @@ -260,7 +294,7 @@ With [pre-commit](https://pre-commit.com): - repo: local hooks: - id: audition - name: audition + name: Audition language: system entry: audition --static-only --plain types: [ruby] @@ -292,7 +326,7 @@ Static, with file:line precision: `@cache ||= {}` and `return @x if defined?(@x)` memoizations. - **Constants that are not deeply shareable**: bare mutable literals, interpolated strings, the subtle shallow freeze - (`[[1], [2]].freeze` still raises; audition explains why), and + (`[[1], [2]].freeze` still raises; Audition explains why), and call results the magic comment never covers (`X.tr(":", "")`, `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed last in its own ractorization. Honors `# frozen_string_literal:` @@ -317,11 +351,21 @@ Static, with file:line precision: scan of every compiled `.bundle`/`.so` for the `rb_ext_ractor_safe` import, which also covers precompiled platform gems that ship no sources; an unbuilt checkout is - scanned at the source level (`ext/**`, C, Rust, or Zig) instead. + scanned at the source level instead (C, Rust, or Zig sources + beside their `extconf.rb`, `Cargo.toml` or `build.zig`). A silent extension raises `Ractor::UnsafeError` on every call from a non-main Ractor, so it rates a warning; a declared one gets an info note, because the declaration is the maintainer's assertion, not a proof. +- **The static pass's own blind spots**: where rubydex reports an + expression it could not resolve and the shape could hide what + the checks above look for, the hole is reported rather than + read as a clean line — a singleton opened on a runtime receiver + that writes class-level state, a superclass or `include` + argument computed at runtime, a constant assigned through an + unresolved path. A clean report for such a class covers only + what the class itself declares; the dynamic probe reaches the + rest. Dynamic, on the live object graph: @@ -346,7 +390,7 @@ Dynamic, on the live object graph: ## Agent skill This repository ships a `ractor-readiness` skill that teaches -coding agents (Claude Code and friends) the full audition +coding agents (Claude Code and friends) the full Audition workflow: audit, fix tiers, suite-parity verification, and incremental adoption. It lives in [skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md). @@ -402,7 +446,7 @@ bundle exec exe/audition --capabilities ``` Static scanning is Ractor-parallel on large targets (one worker -per core, minus one for the main Ractor); audition's own `lib/` +per core, minus one for the main Ractor); Audition's own `lib/` passes `audition lib` clean. The design notes in `docs/design.md` include the empirically @@ -413,7 +457,7 @@ calibrated against. The whole-program checks stand on [rubydex](https://github.com/Shopify/rubydex), Shopify's -high-performance static analysis suite for Ruby: audition feeds +high-performance static analysis suite for Ruby: Audition feeds every file into its graph and reads state ownership back out. Thanks to its authors, in particular the top five contributors: [Alexandre Terrasa](https://github.com/Morriar), diff --git a/audition.gemspec b/audition.gemspec index 2d2b277..d4f5697 100644 --- a/audition.gemspec +++ b/audition.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "pastel", ">= 0.8" spec.add_dependency "prism", ">= 1.0" - spec.add_dependency "rubydex", ">= 0.2" + spec.add_dependency "rubydex", ">= 0.4" spec.add_dependency "table_tennis", ">= 1.0" spec.add_dependency "tty-link", ">= 0.2" end diff --git a/docs/design.md b/docs/design.md index db17f47..a9ff4ba 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,4 +1,4 @@ -# audition; design +# Audition; design Probe anything (script, gem, Rack app, Rails app, directory) for the ability to run under Ractors. Report **what** breaks, **why** it breaks, and **how to fix it**. @@ -11,12 +11,17 @@ ecosystem ships Ractor *utilities* (ractor_safe, ratomic, ractor-pool), not chec `Ractor.shareable?` is a primitive, not a diagnosis. Companion use case: verifying an app can run on Ractor-parallel web servers such as [kino](https://github.com/yaroslav/kino). -## Two probes, one report +## Three passes, one report -1. **Static analysis** (`Audition::Static`); Prism AST checks over every `.rb` file. - Finds code that *would* raise under a non-main Ractor, with file:line precision. - Zero execution, safe on any codebase. -2. **Dynamic probing** (`Audition::Dynamic`); a subprocess harness (JSON over stdout) +1. **Per-file static** (`Audition::Static::Analyzer`); one Prism visitor per check over + every `.rb` file. Expression-level: what a single file can prove alone. Zero + execution, safe on any codebase. +2. **Whole-program static** (`Audition::Static::GraphAudit`); semantic checks on the + rubydex graph, which resolves state to its true owner. An ivar written in the class + body, in `def self.x`, and inside `class << self`, across several files, unifies + into one declaration; per-file visitors cannot see that. The pass also reports where + the graph *fails* to resolve, so a hole does not read as a clean line. +3. **Dynamic probing** (`Audition::Dynamic`); a subprocess harness (JSON over stdout) that actually loads the target and interrogates it: runs scripts inside a Ractor, requires libraries and walks their namespaces checking `Ractor.shareable?` on every constant plus class-level state, calls Rack apps inside a Ractor, and micro-probes @@ -24,6 +29,8 @@ app can run on Ractor-parallel web servers such as [kino](https://github.com/yar Static finds latent hazards dynamic can't reach (code paths never executed during a probe); dynamic finds truth static can't see (metaprogramming, actual object graphs). +Neither closes the other's blind spots, so both report, and the check name says which +layer spoke (`runtime-*` and `dynamic-*` are the probe's). ## Ruby 4.0 Ractor semantics (empirically verified on 4.0.6, 2026-07-17) @@ -48,6 +55,43 @@ probe); dynamic finds truth static can't see (metaprogramming, actual object gra | Errors via `Ractor#value` | `Ractor::RemoteError`, real error in `#cause` | | `Ractor#take` / `Ractor.yield` | Removed; `Ractor::Port`, `Ractor#value` | +## Scanning in Ractors + +The scan is CPU-bound and embarrassingly parallel, so both static passes fan out: +`Etc.nprocessors` capped by `RUBY_MAX_CPU` (default 8—Ractors past the cap add no +parallelism), overridable with `-j`. `WorkSplit` deals files largest-first onto +whichever worker carries least (longest-processing-time-first, Graham 1969; byte size +stands in for parse cost). Contiguous slices would not do: neighbours in a tree are +alike in size, so one worker can draw a slice weighing several times the mean and +still be running once the rest have finished. + +The graph audit fans out differently, because parsed Prism trees cannot cross a Ractor +boundary. Its whole-tree walks may not emit until every name the scan declares is +known, so each worker parses its slice once and keeps the trees: round one reports the +names its slice declares, and once those are merged round two walks the same trees +against them. Below 100 files the spawn and source copy cost more than the walks they +divide, so it stays serial. Any `Ractor::Error` falls back to serial, and under `-w` +the fallback says why—a walk that is itself Ractor-hostile must not hide behind it. + +The bundle sweep is the exception: its per-gem static analysis runs in worker threads, +because the dynamic probes it interleaves are subprocesses and those parallelize +regardless of the GVL. + +**Audition's own code has to be Ractor-safe**, since it runs its own checks inside +Ractors. Both violations so far were found by running Audition on itself: a constant +holding a hash of unfrozen arrays, and a shallow-frozen singleton. + +### Mechanics the scanner itself depends on (verified on 4.0.6, 2026-09-13) + +| Fact | Consequence | +|---|---| +| A `Set` of compound elements cannot cross a boundary; `Marshal` can | Dedup sets travel as pair arrays, rebuilt in the worker | +| `Ractor::ClosedError` is a `StopIteration`, not a `Ractor::Error` | Rescue both, or a send to a dead worker kills the scan | +| `Ractor#close` from another Ractor raises | Workers can't be cancelled; a bailed fan-out is abandoned | +| Blocked Ractors hold no Ractor-pool native threads | Abandoning them costs no later parallelism | +| No penalty for running outside the main Ractor | Walks move into workers wholesale | +| A process's first parallel round pays warmup | The per-file pass absorbs it before the graph phase | + ## Components - `Audition::Finding`; `Data` value: check, severity (`:error/:warning/:info`), @@ -57,21 +101,54 @@ probe); dynamic finds truth static can't see (metaprogramming, actual object gra path or installed gem name; enumerates the Ruby files to scan and the dynamic entry. - `Audition::Static::SourceFile`; path + Prism parse result + magic-comment awareness (`frozen_string_literal`, `shareable_constant_value`). -- `Audition::Static::Checks::*`; one Prism visitor per check: - `GlobalVariables`, `ClassVariables`, `MutableConstants` (deep shareability, shallow - freeze detection), `ClassLevelState` (ivars on class objects incl. `@x ||=` - memoization), `RuntimeRequire` (require/autoload at runtime), `UnsafeCalls` - (knowledge-base driven: Rails class-level macros, sync primitives, removed APIs...). +- `Audition::Static::Analyzer`; runs the per-file checks over sources or paths, + serially or across Ractors, and reports unparseable files itself (`syntax`). +- `Audition::Static::Checks::*`; one Prism visitor per check: `GlobalVariables`, + `MutableConstants` (deep shareability, shallow freeze detection), `RactorIsolation` + (blocks capturing outer locals, off Prism's exact scope depths), `RuntimeRequire` + (require/autoload at runtime), `UnsafeCalls` (knowledge-base driven: Rails + class-level macros, sync primitives, removed APIs...), `UnshareableReads`, + `DependencyClassState`. Subclass `Base` and `Checks.register` to add one. +- `Audition::Static::LiteralClassifier`; classifies an expression node by shareability + (`:shareable`, `:mutable_container`, `:shallow_freeze`, `:proc`, `:default_proc`, + `:opaque_call`, `:shallow_opaque`...); the shared vocabulary under the constant + checks. +- `Audition::Static::GraphAudit`; the rubydex-graph pass: `class-variables`, + `class-level-state` (including singleton attributes, writes through them, and + the companion module a concern puts on the class), + `derived-constants` (a flagged value followed into the constants that capture it), + and `static-scan` (the graph's own unresolved expressions, where the shape could + hide what the other walks look for). +- `Audition::Static::NativeExtensions` / `GemCalls`; byte-scan every compiled + `.bundle`/`.so` for the `rb_ext_ractor_safe` import, then flag call sites into gems + that never declared it—the extension is outside the scanned tree, so the call site + is all that is left to report. Taint follows the values from there. +- `Audition::Static::WorkSplit`; worker count and largest-first work dealing. - `Audition::Dynamic::Harness`; standalone script run via `ruby harness.rb `, stdlib-only, one JSON document out. Modes: `script_main`, `script_ractor`, `require` (namespace walk), `rack`, `capabilities`. - `Audition::Dynamic::Prober`; spawns the harness per mode with timeout, parses JSON, converts results to findings. -- `Audition::Report`; text (grouped by file, colored) and JSON; verdict roll-up. +- `Audition::Fixer` / `Audition::Rewriters`; safe inline autofixes attached to + findings, applied bottom-up so offsets hold, plus unsafe-tier multi-site rewrites + planned from a parsed file and its findings. `--dry-run` previews either. +- `Audition::Baseline` / `Config` / `Directives`; the incremental-adoption ledger + (counts per `check|relative/path`, so line drift does not resurrect a finding), + `.audition.yml`, and `# audition:disable ` pragmas. +- `Audition::BundleSweep`; audits every gem in a `Gemfile.lock` and ranks the + results; the "can my whole app move to Ractors" view. +- `Audition::Progress`; narrates the scan phase by phase on stderr, stdout left + pipeable. Shareable, so a worker can hold one. +- `Audition::Report`; `Text` (grouped by file, colored), `JSON`, `GitHub` + (annotations plus job summary), `Sweep` (the per-gem table), `Style` (color and OSC 8 + hyperlinks, decided once and stripped for pipes, `NO_COLOR`, `TERM=dumb`); verdict + roll-up over all three passes. - `Audition::CLI` / `exe/audition`; OptionParser; exit 0 clean / 1 findings ≥ `--fail-on` threshold / 2 usage error. ## Out of scope (v1) -Scoped `shareable_constant_value` regions (treated file-wide), non-literal constant -values statically (dynamic probe covers them), auto-fixing. +Scoped `shareable_constant_value` regions (treated file-wide). Constant values that +depend on runtime state: the classifier names the shapes it can (`:opaque_call`, +`:shallow_opaque`) and taint-follows them, but proving what a call actually returns +stays the dynamic probe's job. diff --git a/docs/rails_core_best_practices.md b/docs/rails_core_best_practices.md index 92b1c85..5577334 100644 --- a/docs/rails_core_best_practices.md +++ b/docs/rails_core_best_practices.md @@ -227,7 +227,7 @@ rare path, db27b67b), and Uncountables stopped subclassing Array reverted wholesale after merging (261cec84). Freezing shared state can regress behavior; expect rollbacks. -## What this means for audition +## What this means for Audition Detection already aligned: mutable and shallow-frozen constants, class-level ivar state on the graph, proc constants, global @@ -255,7 +255,7 @@ Refreshed on 2026-08-22 against rails/rails `main` at 2a2db1e8d6 with the same selection over `cba6112015..origin/main`: 52 commits by message, 48 by pickaxe, 61 unique without merges, every one read in full (`git show --stat -p`) by four parallel -readers; claims that touch audition's own behavior were then +readers; claims that touch Audition's own behavior were then re-verified on Ruby 4.0.6. The volume sits in Active Record model schema, Action View templates, Active Support notifications and callbacks, and routing. Every pattern from the @@ -352,7 +352,7 @@ populated its nested hashes (4c30e8c6cd). Verified on Ruby 4.0.6: `Regexp.new`, `Regexp.union`, `String#tr`, `+`, `*`, `%`, `format`, `String.new`, and `Symbol#to_s` all return unfrozen, unshareable objects, and reading such a constant from a Ractor -raises. This is now an audition check (see below). +raises. This is now an Audition check (see below). ### 15. define_method with a shareable lambda @@ -415,7 +415,7 @@ that the application's entry point requires (c0db4bb1eb, `ActiveSupport::Ractors.store_if_absent` joined the shim (444c16102d) with a Mutex-guarded `Ractor.current[key] ||=` -fallback, and the mechanical rewrite is exactly what audition +fallback, and the mechanical rewrite is exactly what Audition emits (bf6b888a7b: `@cache = Concurrent::Map.new` to `def self.cache = store_if_absent(:key) { Concurrent::Map.new }`, `map[k] ||= v` to `compute_if_absent`). New shapes on top of it: @@ -451,7 +451,7 @@ per-algorithm methods from closures (upstream), and `_returning_columns_for_insert` stays on `on_main` because it needs a connection. -## What changed in audition after the second pass +## What changed in Audition after the second pass - Pattern 14 became a mutable-constants finding with a safe `.freeze` autofix: String-only methods on any receiver, @@ -473,7 +473,7 @@ Declined, deliberately: - A Rails dialect emitting `ActiveSupport::Ractors.*`. The module is `:nodoc:` and documented as disposable once old Rubies drop - off; audition keeps emitting plain Ruby. + off; Audition keeps emitting plain Ruby. - An `on_main` autofix. It needs the ractor-dispatch gem and is the last rung of Rails' own ladder. - Autofixes for deleting memos or hoisting them into constants. @@ -491,7 +491,7 @@ engineer leading the Rails ractorization (open, head 58aa1dc). The first full gem-side conversion out of that effort, and the fix for the residual recorded at the end of the second pass ("I18n keeps configuration in class variables"). Method: the -diff and every review comment read in full, audition run on +diff and every review comment read in full, Audition run on both sides of the PR (static and dynamic), and every Ruby semantics claim below re-verified on Ruby 4.0.6. @@ -531,7 +531,7 @@ becomes a frozen alias of the new state plus `deprecate_constant` (`RESERVED_KEYS = @reserved_keys`), the plain-Ruby version of pattern 8's DeprecatedObjectProxy. And per-Ractor caches get a portability guard for pre-3.0 rubies: `if defined?(Ractor)` uses -`Ractor.current[key] ||=` (the shape audition emits) and the +`Ractor.current[key] ||=` (the shape Audition emits) and the else branch keeps the old class variable. Statically that fallback scans as a class-variables error; dynamically the branch never runs on 4.0, so the class variable is never even @@ -559,7 +559,7 @@ author scrapped it himself ("This was wrong. We can't have default procs in the hash."). The default-proc rule bites even the people leading the Rails effort. -### What audition says about the PR (verified) +### What Audition says about the PR (verified) On pre-PR main (547917d), the static scan flags everything the PR fixes: RESERVED_KEYS mutable Array plus in-place `<<`, @@ -573,13 +573,13 @@ a lazy `||=` class-ivar memo that is only safe after the opt-in ractorize file runs, and no static scan can see a file the application must choose to require. -Three residuals audition catches are real, two worth reporting +Three residuals Audition catches are real, two worth reporting upstream: - INTERPOLATION_PATTERN lost its `.freeze` in a rebase (the PR body claims it is frozen; the first review thread shows the freeze existed before the scrapped approach took it away). - audition marks it fixable. + Audition marks it fixable. - `@@fallbacks` survives, and the reader's comment claims class variable reads are safe from workers. Reproduced: with the fallbacks backend loaded and ractorize applied, @@ -591,7 +591,7 @@ upstream: writes) remain, consistent with the PR's declared "most of what Rails needs" scope. -### What this means for audition (third pass) +### What this means for Audition (third pass) - Four checks confirmed against a conversion written by the people who set the patterns: mutable-constants (including the @@ -617,7 +617,7 @@ had read: 10 merged after the second pass cutoff and 18 inside its window whose commit messages never say ractor and whose diffs add no `Ractor` token, because a plain `.freeze`, a deleted memo, or an eager require needs neither. All 45 commits -and every PR discussion were read in full, audition was run on +and every PR discussion were read in full, Audition was run on both sides of the 43 files they touch, and every Ruby claim below was re-verified on Ruby 4.0.6. Numbered on from the catalog. @@ -644,7 +644,7 @@ delegate cache frozen once populated, and two `@x ||= new.freeze` memos turned into frozen private constants, the origin of the shape in pattern 12. Its review also records a limit: a memo on `QueryAttribute` "can't be eagerly memoized" -and was reverted. All of this is now an audition check (see +and was reverted. All of this is now an Audition check (see below). ### 22. Deleting a memo is a benchmark decision @@ -664,7 +664,7 @@ state but assigned eagerly in `included`, `inherited`, made lazy (`@name || @klass&.table_name`) so the object is cheap to build before the table name is known, 1.1x instead of 6x; `attribute_method_patterns_cache` assigned eagerly as a -`Concurrent::Map` in `included` and `inherited`. audition flags +`Concurrent::Map` in `included` and `inherited`. Audition flags that last one as class-level state on the post-PR file, and two months later 1a59302e64 (pattern 11) deleted it for a frozen index. A reviewer also had the justification comment on the @@ -715,7 +715,7 @@ writing its class's setting was always a bug global). The values still need sharing: the railtie merges user config into the rescue tables, `Hash#merge` returns an unfrozen hash (verified), and an `after_initialize` block makes them -shareable explicitly. audition confirms the conversion (all +shareable explicitly. Audition confirms the conversion (all twelve `mattr_accessor`/`cattr_accessor` errors vanish) and then reports the seven new module ivars as class-level state, the adoption gap the i18n pass recorded: the target shape of the @@ -747,7 +747,7 @@ inside the block through `self.class`, the late-binding anchor of pattern 15. A sibling fix: `Array(options[:on])` captured by the transaction callback condition lambdas needed `.freeze` (58653), because `Array()` returns a fresh unfrozen array -(verified). None of this is visible statically; audition +(verified). None of this is visible statically; Audition reports nothing on either side of these files. The boot gate is the detector. @@ -788,7 +788,7 @@ with the accepted consequence that subscribers registered on main are not seen by workers (the test asserts exactly that; shape verified). The same PR moved a `Concurrent::Map` constant (`PREFIXED_PARTIAL_NAMES`) behind a `store_if_absent` method, -pattern 17 applied to a constant; audition was silent on that +pattern 17 applied to a constant; Audition was silent on that constant and now flags it, since `Concurrent::Map` defines no `#freeze` at all. The larger rebuild is `SchemaContext` (58578, the follow-up to ae739c3854): the context is frozen and made @@ -854,7 +854,7 @@ the hot path touches before moving it per Ractor. A style note from 57859: `shareable_lambda { |x| ... }` in block form, not `shareable_lambda(&->(x) { ... })`. -### What audition says about the 28 PRs (verified) +### What Audition says about the 28 PRs (verified) Static scan of both sides of the 43 files these PRs touch, with the checks as they stood before this pass: 195 findings before, @@ -863,7 +863,7 @@ the checks as they stood before this pass: 195 findings before, `@empty ||= new.freeze` memos clear (`WhereClause`, `Type`); `JS_ESCAPE_MAP`'s rebuild and the metadata serializer constants clear; the `Concurrent::Map` that 57642 assigned eagerly is -flagged, and was deleted later. What audition missed, and what +flagged, and was deleted later. What Audition missed, and what changed because of it: - Nine `Object.new` sentinels, one `BasicObject.new`, three diff --git a/lib/audition.rb b/lib/audition.rb index 8810006..5288ab6 100644 --- a/lib/audition.rb +++ b/lib/audition.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true require_relative "audition/version" +require_relative "audition/progress" require_relative "audition/finding" require_relative "audition/target" require_relative "audition/static/source_file" require_relative "audition/static/literal_classifier" require_relative "audition/static/checks" require_relative "audition/static/graph_audit" +require_relative "audition/static/gem_calls" require_relative "audition/static/native_extensions" require_relative "audition/static/analyzer" require_relative "audition/dynamic/prober" diff --git a/lib/audition/bundle_sweep.rb b/lib/audition/bundle_sweep.rb index 7d2c937..149cef3 100644 --- a/lib/audition/bundle_sweep.rb +++ b/lib/audition/bundle_sweep.rb @@ -31,10 +31,10 @@ def initialize(lockfile:, static_only: false, timeout: 30, # Audits every locked gem and ranks the results, worst first. # - # @param progress [Proc, nil] called with (row, done, total) - # as each gem finishes + # @param progress [Progress] named per gem as each finishes, + # which is the only unit a sweep has # @return [Array] - def rows(progress: nil) + def rows(progress: Progress::SILENT) gems = locked_gems queue = Thread::Queue.new gems.each { |g| queue << g } @@ -42,17 +42,19 @@ def rows(progress: nil) collected = [] mutex = Thread::Mutex.new - @concurrency.times.map do - Thread.new do - while (name, version = queue.pop) - row = audit_gem(name, version) - mutex.synchronize do - collected << row - progress&.call(row, collected.size, gems.size) + progress.phase("sweep", total: gems.size, unit: "gems") do + @concurrency.times.map do + Thread.new do + while (name, version = queue.pop) + row = audit_gem(name, version) + mutex.synchronize do + collected << row + progress.item(row.name) + end end end - end - end.each(&:join) + end.each(&:join) + end collected.sort_by do |row| [VERDICT_ORDER.fetch(row.verdict), -row.errors, row.name] @@ -115,8 +117,10 @@ def gem_target(spec) ruby_files: spec.require_paths.flat_map do |rp| ruby_files_under(File.join(root, rp)) end, - entry: {mode: :require, feature: spec.name, root: root}, - compiled_files: Target.compiled_for(spec) + entry: {mode: :require, feature: spec.name, + load_paths: spec.full_require_paths, root: root}, + compiled_files: Target.compiled_for(spec), + stub_files: Target.stubs(root) ) end @@ -148,7 +152,12 @@ def static_findings(target, config) end per_file = Static::Analyzer.new .analyze_paths(files, workers: 1) - per_file + Static::GraphAudit.new.analyze_paths(files) + + gem_calls = Static::GemCalls + .new(root: target.root, stubs: target.stub_files) + .analyze_paths(files) + per_file + gem_calls + Static::GraphAudit.new + .analyze_paths(files, constant_findings: per_file + gem_calls, + workers: 1) + Static::NativeExtensions.new.analyze(target, compiled_files: compiled) end diff --git a/lib/audition/cli.rb b/lib/audition/cli.rb index c0420be..5465589 100644 --- a/lib/audition/cli.rb +++ b/lib/audition/cli.rb @@ -34,7 +34,7 @@ def run(argv) args = options[:args] if args.empty? - @stderr.puts(USAGE) + @stderr.puts(style(options, io: @stderr).dim(USAGE)) return 2 end @@ -53,7 +53,7 @@ def run(argv) audit(target, options) end rescue Error => e - @stderr.puts("audition: #{e.message}") + complain(e.message, options) 2 end @@ -65,7 +65,7 @@ def parse(argv) dynamic_only: false, fix: false, unsafe: false, dry_run: false, capabilities: false, plain: false, timeout: 30, write_baseline: false, no_baseline: false, - deps: false, explicit: [] + deps: false, progress: nil, workers: nil, explicit: [] } parser = OptionParser.new do |o| o.banner = USAGE @@ -129,9 +129,20 @@ def parse(argv) options[:timeout] = v options[:explicit] << :timeout end + o.on("-j", "--workers COUNT", Integer, + "scan Ractors (default: cores, capped by " \ + "RUBY_MAX_CPU)") do |v| + options[:workers] = v + options[:explicit] << :workers + end o.on("--plain", "disable colors and hyperlinks") do options[:plain] = true end + o.on("--[no-]progress", + "narrate scan phases on stderr (default: on for a " \ + "large tree on a terminal)") do |v| + options[:progress] = v + end o.on("-v", "--version") do @stdout.puts(VERSION) return 0 @@ -144,11 +155,19 @@ def parse(argv) options[:args] = parser.parse(argv) options rescue OptionParser::ParseError => e - @stderr.puts("audition: #{e.message}") - @stderr.puts(USAGE) + complain(e.message) + @stderr.puts(style(nil, io: @stderr).dim(USAGE)) 2 end + def complain(message, options = nil) + s = style(options, io: @stderr) + @stderr.puts( + "#{s.red(s.glyph(:error))} #{s.bold("Audition")}: " \ + "#{message}" + ) + end + def audit(target, options) # Read the comparison report up front: a missing or broken # file should be a fast usage error, not a post-audit crash. @@ -158,7 +177,7 @@ def audit(target, options) directives = Directives.new findings = [] unless options[:dynamic_only] - findings = filter(static_findings(target, config), + findings = filter(static_findings(target, config, options), directives, config) findings = run_fix(target, findings, options) if options[:fix] end @@ -175,9 +194,11 @@ def audit(target, options) if options[:write_baseline] recorded = Baseline.write(target.root, all) + s = style(options) @stdout.puts( - "baseline written: #{recorded} finding(s) recorded in " \ - "#{Baseline.path_for(target.root)}" + "#{s.green(s.glyph(:pass))} baseline written: " \ + "#{s.bold("#{recorded} finding(s)")} recorded in " \ + "#{s.cyan(Baseline.path_for(target.root))}" ) return 0 end @@ -239,7 +260,10 @@ def emit_comparison(old, findings, target, options) "#{s.red("#{introduced.size} introduced")}" ) introduced.each do |f| - @stdout.puts(" + #{f.message} (#{f.location})") + @stdout.puts( + " #{s.red("+")} #{f.message} " \ + "#{s.cyan("(#{f.location})")}" + ) end end @@ -275,17 +299,55 @@ def apply_baseline(findings, target, options) baseline.filter(findings, root: target.root) end - def static_findings(target, config) + def static_findings(target, config, options) files = target.ruby_files.reject do |file| config.excluded?(file.delete_prefix("#{target.root}/")) end compiled = target.compiled_files.reject do |file| config.excluded?(file.delete_prefix("#{target.root}/")) end - per_file = Static::Analyzer.new.analyze_paths(files) - per_file + Static::GraphAudit.new.analyze_paths(files) + + stubs = target.stub_files.reject do |file| + config.excluded?(file.delete_prefix("#{target.root}/")) + end + progress = Progress.for(units: files.size, + wanted: options[:progress], format: options[:format], + io: @stderr, style: options[:plain] ? plain_style : nil) + scan(target, files, stubs, compiled, progress, + workers: options[:workers]).map do |f| + if target.test_file?(f.path, dirs: config.test_dirs) + f.with(test: true) + else + f + end + end + end + + # Phases are named for what a waiting reader wants to know; + # progress shares stderr with fix chatter, for the same reason. + def scan(target, files, stubs, compiled, progress, workers: nil) + progress.phase("learning", total: files.size + stubs.size) do |p| + Static::Checks::UnshareableReads.learn(files + stubs, + progress: p) + Static::Checks::DependencyClassState.learn(stubs) + end + per_file = progress.phase("checking", total: files.size) do |p| + Static::Analyzer.new.analyze_paths(files, workers: workers, + progress: p) + end + gem_calls = progress.phase("gem calls") do |p| + Static::GemCalls.new(root: target.root, stubs: stubs) + .analyze_paths(files, progress: p) + end + graph = progress.phase("graph") do |p| + Static::GraphAudit.new.analyze_paths(files, + constant_findings: per_file + gem_calls, + workers: workers, progress: p) + end + per_file + gem_calls + graph + Static::NativeExtensions.new.analyze(target, compiled_files: compiled) + ensure + progress.finish end # Fix chatter goes to stderr: stdout carries the report, which @@ -299,16 +361,19 @@ def run_fix(target, findings, options) applied = fixer.apply(findings) total = applied.values.sum + s = style(options, io: @stderr) if total.zero? - @stderr.puts("nothing to fix") + @stderr.puts(s.dim("nothing to fix")) return findings end @stderr.puts( - "fixed #{total} finding(s) in #{applied.size} file(s)" + "#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \ + "finding(s)")} in #{s.bold("#{applied.size} file(s)")}" ) - filter(static_findings(target, Config.load(target.root)), - Directives.new, Config.load(target.root)) + config = Config.load(target.root) + filter(static_findings(target, config, options), + Directives.new, config) end def render_preview(previews, options) @@ -316,7 +381,7 @@ def render_preview(previews, options) previews.each do |preview| @stderr.puts(s.bold(preview[:path])) preview[:hunks].each do |hunk| - @stderr.puts(" @ line #{hunk[:line]}") + @stderr.puts(s.cyan(" @ line #{hunk[:line]}")) hunk[:old].each_line do |line| @stderr.puts(s.red(" - #{line.chomp}")) end @@ -325,7 +390,7 @@ def render_preview(previews, options) end end end - @stderr.puts("dry run: no files were changed") + @stderr.puts(s.dim("dry run: no files were changed")) end def prober(options) @@ -356,26 +421,32 @@ def append_step_summary(markdown) File.open(path, "a") { |f| f.puts(markdown) } rescue SystemCallError => e - @stderr.puts( - "audition: cannot write step summary: #{e.message}" - ) + complain("cannot write step summary: #{e.message}") end + # Diagnostics can precede option parsing, so --plain is + # honored only once there is something to honor it from. def style(options, io: @stdout) - if options[:plain] - Report::Style.new(color: false, hyperlinks: false) + if options&.fetch(:plain, false) + plain_style else Report::Style.detect(io: io) end end + def plain_style + Report::Style.new(color: false, hyperlinks: false) + end + def exit_code(report, options) return 0 if options[:fail_on] == :never threshold = SEVERITIES.fetch(options[:fail_on]) + # Test findings never load in a production boot; they are + # reported but do not fail the run, matching the verdict. failed = report.findings.any? do |f| - f.severity_rank >= threshold + f.severity_rank >= threshold && !f.test? end || report.dynamic_results.any? { |r| !r.passed } failed ? 1 : 0 end @@ -391,30 +462,41 @@ def deps_target(target) Target.detect(lockfile) end - VERDICT_CELLS = { - :not_ready => "not ready", :blocked => "blocked", - :risky => "risky", :ready => "ready", nil => "-" - }.freeze - def sweep(target, options) sweeper = BundleSweep.new( lockfile: target.entry[:lockfile], static_only: options[:static_only], timeout: options[:timeout] ) - rows = sweeper.rows(progress: sweep_progress) - - case options[:format] - when :json then emit_sweep_json(rows) - when :github then emit_sweep_github(rows) - else emit_sweep_table(rows, options) - end + rows = sweep_rows(sweeper, options) + emit_sweep(Report::Sweep.new(rows, style(options)), options) return 0 if options[:fail_on] == :never threshold = SEVERITIES.fetch(options[:fail_on]) (rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0 end + # No unit count: every gem is a scan of its own, so even a + # short lockfile runs long enough to narrate. + def sweep_rows(sweeper, options) + progress = Progress.for(wanted: options[:progress], + format: options[:format], io: @stderr, + style: options[:plain] ? plain_style : nil) + sweeper.rows(progress: progress) + ensure + progress.finish + end + + def emit_sweep(sweep, options) + case options[:format] + when :json then @stdout.puts(sweep.json) + when :github + @stdout.puts(sweep.annotations) + append_step_summary(sweep.markdown) + else @stdout.puts(sweep.render(**table_opts(options))) + end + end + # Same contract as a direct audit: a row fails when it carries # findings at or above --fail-on; not-ready rows always fail. def row_failed?(row, threshold) @@ -426,14 +508,6 @@ def row_failed?(row, threshold) hits.positive? end - def sweep_progress - return nil unless @stderr.respond_to?(:tty?) && @stderr.tty? - - lambda do |row, done, total| - @stderr.puts("audited #{row.name} (#{done}/#{total})") - end - end - # Cells arrive preformatted: coercion would render a version # like "3.2" as 3.200. Color follows the CLI's own detection # because the table gem reads the global $stdout and cannot @@ -446,116 +520,29 @@ def table_opts(options) } end - def emit_sweep_table(rows, options) - ready = rows.count { |r| r.verdict == :ready } - table = rows.map do |r| - { - "gem" => r.name, - "version" => r.version, - "verdict" => VERDICT_CELLS.fetch(r.verdict), - "errors" => r.errors, - "dep errors" => r.dep_errors, - "warnings" => r.warnings, - "fixable" => r.fixable, - "status" => r.status - } - end - @stdout.puts(TableTennis.new( - table, zebra: true, **table_opts(options) - ).to_s) - @stdout.puts( - "#{ready} of #{rows.size} gems ractor-ready" - ) - end - - # Sweep rows carry no file or line, so the annotations land on - # the run summary rather than a diff; the markdown table goes - # to the job summary page when Actions provides one. - def emit_sweep_github(rows) - ready = rows.count { |r| r.verdict == :ready } - rows.each do |row| - level = sweep_annotation_level(row) - next unless level - - @stdout.puts( - "::#{level} title=audition::gem #{row.name} " \ - "#{row.version}: #{row.errors} errors, " \ - "#{row.dep_errors} dependency errors, " \ - "#{row.warnings} warnings " \ - "(#{VERDICT_CELLS.fetch(row.verdict)})" - ) - end - @stdout.puts("#{ready} of #{rows.size} gems ractor-ready") - append_step_summary(sweep_summary_markdown(rows, ready)) - end - - def sweep_annotation_level(row) - if row.verdict == :not_ready || - (row.errors + row.dep_errors).positive? - "error" - elsif row.warnings.positive? - "warning" - end - end - - def sweep_summary_markdown(rows, ready) - lines = [ - "## audition bundle sweep", "", - "| gem | version | verdict | errors | dep errors " \ - "| warnings | fixable |", - "| --- | --- | --- | --- | --- | --- | --- |" - ] - rows.each do |r| - lines << "| #{r.name} | #{r.version} | " \ - "#{VERDICT_CELLS.fetch(r.verdict)} | #{r.errors} | " \ - "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |" - end - lines << "" - lines << "#{ready} of #{rows.size} gems ractor-ready" - lines.join("\n") - end - - def emit_sweep_json(rows) - @stdout.puts(JSON.pretty_generate( - "audition" => VERSION, - "ruby" => RUBY_VERSION, - "bundle" => rows.map do |r| - { - "gem" => r.name, - "version" => r.version, - "verdict" => r.verdict&.to_s, - "errors" => r.errors, - "dependency_errors" => r.dep_errors, - "warnings" => r.warnings, - "infos" => r.infos, - "fixable" => r.fixable, - "status" => r.status - } - end - )) - end - def print_capabilities(options) result = prober(options).probe(mode: :capabilities) caps = result.raw["capabilities"] unless caps - @stderr.puts( - "audition: capabilities probe failed: #{result.raw}" - ) + complain("capabilities probe failed: #{result.raw}", + options) return 2 end + s = style(options) rows = caps.map do |probe, info| { "works in Ractor" => probe, - "ok" => info["ok"] ? "yes" : "no", - "raises" => info["error"] || "-" + "ok" => s.glyph(info["ok"] ? :pass : :error), + "raises" => info["error"] } end - @stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}") @stdout.puts( - TableTennis.new(rows, **table_opts(options)).to_s + "#{s.glyph(:section)} #{s.bold("ruby #{RUBY_VERSION}")} " \ + "#{s.dim("at #{RbConfig.ruby}")}" ) + @stdout.puts(TableTennis.new(rows, + title: "Audition capabilities", **table_opts(options)).to_s) 0 end end diff --git a/lib/audition/config.rb b/lib/audition/config.rb index 5a46d58..96937cd 100644 --- a/lib/audition/config.rb +++ b/lib/audition/config.rb @@ -10,6 +10,8 @@ module Audition # exclude: # - legacy/** # - db/schema.rb + # test_dirs: + # - qa # checks: # disable: # - at-exit @@ -20,7 +22,7 @@ class Config EMPTY = Ractor.make_shareable( {fail_on: nil, timeout: nil, exclude: [], - disabled_checks: []} + disabled_checks: [], test_dirs: nil} ) FAIL_ON_LEVELS = %w[error warning info never].freeze @@ -42,7 +44,8 @@ def self.load(root) timeout: data["timeout"], exclude: Array(data["exclude"]).map(&:to_s), disabled_checks: - Array(data.dig("checks", "disable")).map(&:to_s) + Array(data.dig("checks", "disable")).map(&:to_s), + test_dirs: data["test_dirs"]&.map(&:to_s) ) rescue Psych::Exception => e raise Error, "#{path}: #{e.message}" @@ -54,6 +57,7 @@ def self.validate!(path, data) "#{path}: expected a YAML mapping, got #{data.class}" end + validate_test_dirs!(path, data["test_dirs"]) fail_on = data["fail_on"] return if fail_on.nil? || FAIL_ON_LEVELS.include?(fail_on.to_s) @@ -62,13 +66,35 @@ def self.validate!(path, data) "#{path}: fail_on must be one of error, warning, " \ "info, or never (got #{fail_on.inspect})" end - private_class_method :validate! - def initialize(fail_on:, timeout:, exclude:, disabled_checks:) + # Each name is matched against one path segment, so a nested + # path would silently match nothing. + def self.validate_test_dirs!(path, dirs) + return if dirs.nil? || (dirs.is_a?(Array) && + dirs.none? { |dir| dir.to_s.include?("/") }) + + raise Error, + "#{path}: test_dirs must be a list of directory names " \ + "without slashes (got #{dirs.inspect})" + end + private_class_method :validate!, :validate_test_dirs! + + def initialize(fail_on:, timeout:, exclude:, disabled_checks:, + test_dirs: nil) @fail_on = fail_on @timeout = timeout @exclude = exclude @disabled_checks = disabled_checks + @test_dirs = test_dirs + end + + # Directories whose findings are tagged as test code rather + # than code a production boot loads. An explicit empty list + # leaves only the `_test.rb`/`_spec.rb` suffixes. + # + # @return [Array] Target::TEST_DIRS unless configured + def test_dirs + @test_dirs || Target::TEST_DIRS end # Globs follow .gitignore-style expectations: `*` stays within diff --git a/lib/audition/dynamic/harness.rb b/lib/audition/dynamic/harness.rb index 8169a76..cf93e45 100644 --- a/lib/audition/dynamic/harness.rb +++ b/lib/audition/dynamic/harness.rb @@ -15,7 +15,11 @@ require "rbconfig" module AuditionHarness - MAX_CONSTS = 5000 + # High enough for the largest targets: the sweep is the backstop + # for everything static analysis cannot prove, so it must reach + # every constant the boot defined. A hit is reported as + # truncated, never silent. + MAX_CONSTS = 200_000 # Directories under the target root that are not the target's # own surface. Bundler's deployment mode (and bundler-cache in @@ -82,7 +86,18 @@ def main(mode, payload, out:) def describe_error(error) root = unwrap(error) {"class" => scrub(root.class.name.to_s), - "message" => scrub(root.message.to_s)[0, 500]} + "message" => scrub(root.message.to_s)[0, 500], + "backtrace" => backtrace_for(root)} + end + + # Enough frames to reach the target's code under framework + # wrappers; frames carry paths, which can carry arbitrary bytes. + def backtrace_for(error) + Array(error.backtrace).first(30).map do |frame| + scrub(frame.to_s)[0, 300] + end + rescue Exception + [] end def scrub(text) @@ -137,10 +152,15 @@ def library(payload) Array(payload["load_paths"]).each do |lp| $LOAD_PATH.unshift(lp) # audition:disable global-variables end + baseline = module_state_snapshot before = Object.constants features = $LOADED_FEATURES.dup # audition:disable global-variables - require_target(payload.fetch("feature"), payload["root"]) - scan(Object.constants - before, root: payload["root"]).merge( + require_target(payload.fetch("feature"), + Array(payload["load_paths"])) + merge_reopened( + scan(Object.constants - before, root: payload["root"], + limit: payload["max_constants"]), baseline + ).merge( "native_extensions" => native_extensions( features, payload["root"], payload["known_compiled"] ) @@ -152,17 +172,17 @@ def library(payload) # rspec/mocks), and squashed names ship snake_case files # (activesupport provides active_support). The second has no # rule to invert, so when the target ships exactly one top-level - # file under lib/, that file is the entry, required by absolute - # path. An absolute require keeps the path as given, and scan + # file on its load paths, that file is the entry, required by + # absolute path. An absolute require keeps the path as given, and scan # compares constant origins against the realpathed root, so the # candidate is built from the realpath too (a symlinked tmpdir, # macOS /var, otherwise turns own findings into dependency ones). # The error reported is the last one seen: a candidate that loads # but fails inside says more than "cannot load such file". - def require_target(feature, root) + def require_target(feature, load_paths) require feature # audition:disable runtime-require rescue LoadError => error - entry_candidates(feature, root).each do |candidate| + entry_candidates(feature, load_paths).each do |candidate| return require candidate # audition:disable runtime-require rescue LoadError => e error = e @@ -170,11 +190,13 @@ def require_target(feature, root) raise error end - def entry_candidates(feature, root) + def entry_candidates(feature, load_paths) candidates = [] slashed = feature.tr("-", "/") candidates << slashed if slashed != feature - files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : [] + files = load_paths.flat_map do |path| + Dir[File.join(realpath(path), "*.rb")] + end candidates << files.first.delete_suffix(".rb") if files.size == 1 candidates end @@ -184,23 +206,28 @@ def entry_candidates(feature, root) # are inspected for class-level ivars and class variables, then # descended into. const_get can raise (autoload failures) and # anything can lie; every step is rescued and counted. - def scan(root_names, root: nil) + def scan(root_names, root: nil, limit: nil) # Loaded features are realpathed by require; the target root # must be too, or symlinked paths (macOS /var vs /private/var) # break the own-vs-dependency comparison. root = realpath(root) + limit = (limit || MAX_CONSTS).to_i unshareable = [] class_state = [] class_vars = [] errors = 0 + truncated = false seen = {} queue = root_names.map { |name| [Object, name.to_s] } visited = 0 until queue.empty? + if visited >= limit + truncated = true + break + end owner, name = queue.shift visited += 1 - break if visited > MAX_CONSTS begin value = owner.const_get(name, false) @@ -223,8 +250,11 @@ def scan(root_names, root: nil) else begin unless Ractor.shareable?(value) + blocker, blocker_depth = blocker_for(value) unshareable << origin.merge( - "const" => full, "class" => value.class.name + "const" => full, "class" => value.class.name, + "blocker" => blocker, + "blocker_nested" => blocker_depth.positive? ) end rescue Exception @@ -237,6 +267,8 @@ def scan(root_names, root: nil) "class_state" => class_state, "class_variables" => class_vars, "scanned" => visited, + "truncated" => truncated, + "limit" => limit, "errors" => errors} end @@ -340,6 +372,125 @@ def safe_shareable?(value) false end + # Ivar/cvar state of every module defined before the boot. The + # new-constants sweep never revisits these, so state the boot + # plants on reopened core and stdlib classes is diffed against + # this snapshot instead. Autoload stubs are left untouched: + # forcing them here would load code behind the back of the + # before/after constant accounting. + def module_state_snapshot(limit = 50_000) + snap = {} + seen = {} + queue = Object.constants.map { |name| [Object, name.to_s] } + until queue.empty? || snap.size >= limit + owner, name = queue.shift + begin + next if owner.autoload?(name, false) + + value = owner.const_get(name, false) + rescue Exception + next + end + next unless value.is_a?(Module) + next if seen[value.object_id] + + seen[value.object_id] = true + begin + snap[value] = [value.instance_variables, + value.class_variables(false)] + rescue Exception + next + end + value.constants(false).each do |child| + queue << [value, child.to_s] + end + end + snap + end + + # RubyGems, Bundler, and the VM mutate their own module state on + # every require; that is probe machinery, not the target's doing. + MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/ + + # State the boot added to pre-existing modules, reported through + # the same channels as freshly defined class state. No source + # location exists for a reopen, and unknown origins count as own. + def merge_reopened(result, snapshot) + origin = {"path" => nil, "line" => nil, "own" => true} + snapshot.each do |mod, (ivars, cvars)| + new_ivars = mod.instance_variables - ivars + new_cvars = mod.class_variables(false) - cvars + next if new_ivars.empty? && new_cvars.empty? + + full = mod.name || mod.inspect + next if full.match?(MACHINERY) + if new_ivars.any? + unshareable = new_ivars.reject do |ivar| + safe_shareable?(mod.instance_variable_get(ivar)) + end + result["class_state"] << origin.merge( + "const" => full, + "ivars" => new_ivars.map(&:to_s), + "unshareable" => unshareable.map(&:to_s) + ) + end + if new_cvars.any? + result["class_variables"] << origin.merge( + "const" => full, "cvars" => new_cvars.map(&:to_s) + ) + end + rescue Exception + next + end + result + end + + # The innermost unshareable node of an unshareable value, with + # its depth: an unfrozen node with shareable contents just needs + # freezing, an inherently unshareable one needs replacing. + # Bounded, because shareability of each child is itself a + # recursive check. + def blocker_for(value, depth = 0) + return [describe_blocker(value), depth] if depth > 5 + + child = children_of(value).find { |c| !safe_shareable?(c) } + if child + blocker_for(child, depth + 1) + else + [describe_blocker(value), depth] + end + rescue Exception + [describe_blocker(value), depth] + end + + def children_of(value) + children = + case value + when Hash then value.keys + value.values + when Array then value + when Struct, Data then value.deconstruct + else + value.instance_variables.map do |ivar| + value.instance_variable_get(ivar) + end + end + children.first(1000) + end + + # "unfrozen X" only when freezing would actually flip the + # verdict; a Proc or IO stays unshareable frozen, and naming it + # unfrozen would send the reader to a fix that cannot work. + def describe_blocker(value) + return value.class.to_s if value.frozen? + + frozen_helps = begin + Ractor.shareable?(value.dup.freeze) + rescue Exception + false + end + frozen_helps ? "unfrozen #{value.class}" : value.class.to_s + end + # -- rack -------------------------------------------------------- # App objects built in config.ru are almost never shareable (the @@ -349,6 +500,8 @@ def safe_shareable?(value) # and serve one request entirely inside a Ractor. def rack(payload) config_ru = payload.fetch("config_ru") + root = payload["root"] || File.dirname(config_ru) + bundler_setup begin require "rack" # audition:disable runtime-require rescue LoadError @@ -356,6 +509,9 @@ def rack(payload) end out = {"rack_available" => true} + baseline = module_state_snapshot + before = Object.constants + features = $LOADED_FEATURES.dup # audition:disable global-variables begin app = Rack::Builder.parse_file(config_ru) app = app.first if app.is_a?(Array) @@ -364,6 +520,16 @@ def rack(payload) rescue Exception => e out["main_boot_error"] = describe_error(e) end + # The main-process boot defines the app's constant graph; + # sweeping it gives rack targets the same backstop as require + # and rails targets. A failed boot still sweeps what loaded. + out.merge!(merge_reopened( + scan(Object.constants - before, root: root, + limit: payload["max_constants"]), baseline + )) + out["native_extensions"] = native_extensions( + features, root, payload["known_compiled"] + ) out["ractor_boot_call"] = rack_in_ractor(config_ru) if out["ractor_boot_call"]["ok"] @@ -445,20 +611,53 @@ def rack_in_ractor(config_ru) # -- rails ------------------------------------------------------- + # The prober sets BUNDLE_GEMFILE to the target's Gemfile; the + # target's gems must resolve before its boot files load. A + # setup failure propagates: it is the true boot failure, and + # the standard reporting captures it. Bundler.setup is called + # directly because bundler/setup exits on failure, swallowing + # the message. + def bundler_setup + return unless ENV["BUNDLE_GEMFILE"] + + require "bundler" # audition:disable runtime-require + Bundler.ui.silence { Bundler.setup } + end + + # A boot failure does not abandon the sweep: everything defined + # before the failure is still scanned, so the probe reports what + # it could reach alongside the boot error. def rails(payload) environment = payload.fetch("environment") + bundler_setup + baseline = module_state_snapshot before = Object.constants features = $LOADED_FEATURES.dup # audition:disable global-variables started = Time.now - require environment # audition:disable runtime-require + boot_error = nil begin - Rails.application.eager_load! - rescue Exception - nil + # Absolute requires keep the path as given; realpathing it + # keeps own-vs-dependency attribution honest under symlinked + # roots (macOS /var). + require realpath(environment) # audition:disable runtime-require + begin + Rails.application.eager_load! + rescue Exception + nil + end + rescue Exception => e + boot_error = describe_error(e) end - boot = {"ok" => true, - "seconds" => (Time.now - started).round(1)} - scan(Object.constants - before, root: payload["root"]).merge( + boot = + if boot_error + {"ok" => false, "error" => boot_error} + else + {"ok" => true, "seconds" => (Time.now - started).round(1)} + end + merge_reopened( + scan(Object.constants - before, root: payload["root"], + limit: payload["max_constants"]), baseline + ).merge( "boot" => boot, "native_extensions" => native_extensions( features, payload["root"], payload["known_compiled"] diff --git a/lib/audition/dynamic/prober.rb b/lib/audition/dynamic/prober.rb index 4fae063..24f4893 100644 --- a/lib/audition/dynamic/prober.rb +++ b/lib/audition/dynamic/prober.rb @@ -22,7 +22,7 @@ module Dynamic # Spawns the harness subprocess per probe mode, parses its JSON, # and converts observations into findings. class Prober - HARNESS = File.expand_path("harness.rb", __dir__) + HARNESS = File.expand_path("harness.rb", __dir__).freeze RUNTIME_WHY = "Observed on the live object graph after loading the " \ @@ -60,13 +60,13 @@ def probe(entry) def probe_script(entry) path = entry[:path] - ractor = run("script_ractor", "path" => path) + ractor = run("script_ractor", {"path" => path}) if ractor["ok"] return Result.new(mode: :script, raw: ractor, findings: [], passed: true) end - main = run("script_main", "path" => path) + main = run("script_main", {"path" => path}) finding = script_finding(path, ractor, main) Result.new(mode: :script, raw: {"ractor" => ractor, "main" => main}, @@ -76,6 +76,7 @@ def probe_script(entry) def script_finding(path, ractor, main) if main["ok"] error = describe(ractor) + site = failure_site(ractor, prefix: "#{path}:") Finding.new( check: "dynamic-script", severity: :error, @@ -84,11 +85,12 @@ def script_finding(path, ractor, main) "failed under Ractor.new; the static findings " \ "usually pinpoint the exact line.", fix: "Fix the static findings for this file, then " \ - "re-audition.", + "re-run Audition.", path: path, - line: nil + line: site&.last ) else + site = failure_site(main, prefix: "#{path}:") Finding.new( check: "dynamic-script", severity: :error, @@ -97,7 +99,7 @@ def script_finding(path, ractor, main) "Ractor, so Ractor-readiness cannot be assessed.", fix: "Make the script run standalone first.", path: path, - line: nil + line: site&.last ) end end @@ -105,38 +107,51 @@ def script_finding(path, ractor, main) def probe_require(entry) feature = entry[:feature] raw = run("require", - "feature" => feature, - "load_paths" => Array(entry[:load_paths]), - "root" => entry[:root], - "known_compiled" => Array(entry[:compiled_files])) - findings = runtime_findings(raw, feature) + {"feature" => feature, + "load_paths" => Array(entry[:load_paths]), + "root" => entry[:root], + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}) + findings = runtime_findings(raw, feature, + root: entry[:root]) Result.new(mode: :require, raw: raw, findings: findings, passed: own_clean?(findings)) end def probe_rails(entry) raw = run("rails", - "environment" => entry[:environment], - "root" => entry[:root], - "known_compiled" => Array(entry[:compiled_files])) + {"environment" => entry[:environment], + "root" => entry[:root], + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}, + root: entry[:root]) boot = raw["boot"] + findings = runtime_findings(raw, entry[:environment], + root: entry[:root]) if boot && !boot["ok"] - finding = Finding.new( + # The sweep of whatever loaded before the failure stays: + # a partial dynamic result beats none. + why = "Ractor-readiness cannot be fully assessed " \ + "until the application boots." + if raw["scanned"].to_i.positive? + why += " Findings below cover what loaded before " \ + "the failure." + end + site = failure_site(boot, prefix: own_prefix(entry[:root])) + findings.unshift(Finding.new( check: "dynamic-rails", severity: :error, message: "Rails failed to boot: #{describe(boot)}", - why: "Ractor-readiness cannot be assessed until the " \ - "application boots.", + why: why, fix: "Boot the app (bin/rails runner 1) and fix " \ - "whatever breaks, then re-audition.", - path: entry[:environment], - line: nil - ) + "whatever breaks, then re-run Audition.", + path: site&.first || entry[:environment], + line: site&.last + )) return Result.new(mode: :rails, raw: raw, - findings: [finding], passed: false) + findings: findings, passed: false) end - findings = runtime_findings(raw, entry[:environment]) Result.new(mode: :rails, raw: raw, findings: findings, passed: own_clean?(findings)) end @@ -150,10 +165,18 @@ def own_clean?(findings) def probe_rack(entry) config_ru = entry[:config_ru] - raw = run("rack", "config_ru" => config_ru) - findings = rack_findings(raw, config_ru) + root = entry[:root] || File.dirname(config_ru) + raw = run("rack", + {"config_ru" => config_ru, + "root" => root, + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}, + root: root) + findings = rack_findings(raw, config_ru, root) + findings += runtime_findings(raw, config_ru, root: root) unless raw["error"] passed = raw.dig("ractor_boot_call", "ok") == true && - raw.dig("concurrency", "failures").to_i.zero? + raw.dig("concurrency", "failures").to_i.zero? && + own_clean?(findings) Result.new(mode: :rack, raw: raw, findings: findings, passed: passed) end @@ -166,19 +189,28 @@ def probe_capabilities # -- findings builders --------------------------------------- - def runtime_findings(raw, label) + def runtime_findings(raw, label, root: nil) if raw["error"] - return [load_failure_finding(raw, label)] + return [load_failure_finding(raw, label, root)] end findings = [] raw.fetch("unshareable_constants", []).each do |entry| + blocker = entry["blocker"] + detail = + if blocker.nil? || blocker == entry["class"] + "" + elsif entry["blocker_nested"] + " (blocked by #{blocker} inside)" + else + " (just not frozen)" + end findings << runtime_finding( entry, label, check: "runtime-unshareable-constant", severity: :error, message: "constant #{entry["const"]} holds an " \ - "unshareable #{entry["class"]}", + "unshareable #{entry["class"]}#{detail}", why: "Reading it from a non-main Ractor raises " \ "Ractor::IsolationError. #{RUNTIME_WHY}", fix: "Freeze it deeply at definition time " \ @@ -208,6 +240,22 @@ def runtime_findings(raw, label) findings << native_finding(entry, label) end + # A truncated sweep must never read as a clean one. + if raw["truncated"] + findings.unshift(Finding.new( + check: "runtime-scan", + severity: :warning, + message: "constant sweep truncated after " \ + "#{raw["scanned"]} constants " \ + "(limit #{raw["limit"]})", + why: "Constants beyond the limit were never probed, " \ + "so their absence from the findings proves " \ + "nothing.", + fix: "Raise the probe's max_constants and re-run.", + path: label, + line: nil + )) + end findings end @@ -292,7 +340,8 @@ def runtime_finding(entry, label, check:, severity:, ) end - def load_failure_finding(raw, label) + def load_failure_finding(raw, label, root = nil) + site = failure_site(raw, prefix: own_prefix(root)) Finding.new( check: "runtime-load", severity: :error, @@ -300,14 +349,14 @@ def load_failure_finding(raw, label) why: "Ractor-readiness cannot be assessed until the " \ "target loads.", fix: "Make `require` succeed on a bare Ruby first.", - path: label, - line: nil + path: site&.first || label, + line: site&.last ) end - def rack_findings(raw, config_ru) + def rack_findings(raw, config_ru, root = nil) if raw.dig("ractor_boot_call", "ok") - return concurrency_findings(raw, config_ru) + return concurrency_findings(raw, config_ru, root) end if raw["rack_available"] == false @@ -316,7 +365,7 @@ def rack_findings(raw, config_ru) severity: :warning, message: "rack gem not available in the probe process", why: "The rack probe boots the app via Rack::Builder.", - fix: "Install rack next to audition and re-run.", + fix: "Install rack next to Audition and re-run.", path: config_ru, line: nil )] @@ -332,6 +381,10 @@ def rack_findings(raw, config_ru) "booting config.ru and serving one GET / inside a " \ "Ractor failed." end + site = failure_site(raw["ractor_boot_call"], + prefix: own_prefix(root)) || + failure_site({"error" => raw["main_boot_error"]}, + prefix: own_prefix(root)) [Finding.new( check: "dynamic-rack", severity: :error, @@ -340,16 +393,18 @@ def rack_findings(raw, config_ru) fix: "Remove global/class-level state touched during " \ "boot and request handling; keep middleware config " \ "frozen; open connections per-Ractor.", - path: config_ru, - line: nil + path: site&.first || config_ru, + line: site&.last )] end - def concurrency_findings(raw, config_ru) + def concurrency_findings(raw, config_ru, root = nil) stats = raw["concurrency"] || {} failures = stats["failures"].to_i return [] if failures.zero? + site = failure_site({"error" => stats["first_error"]}, + prefix: own_prefix(root)) [Finding.new( check: "dynamic-rack-concurrency", severity: :error, @@ -361,11 +416,43 @@ def concurrency_findings(raw, config_ru) "shared state races. #{RUNTIME_WHY}", fix: "Look for process-global state touched during " \ "request handling and boot.", - path: config_ru, - line: nil + path: site&.first || config_ru, + line: site&.last )] end + # A backtrace frame inside the target names the failing + # line; frames outside it stay unattributed rather than + # pinning a finding to a dependency's file. + BACKTRACE_FRAME = /\A(.+?):(\d+):in / + + def failure_site(hash, prefix:) + prefixes = Array(prefix) + return nil if prefixes.empty? || !hash.is_a?(Hash) + + error = hash["error"].is_a?(Hash) ? hash["error"] : hash + frame = Array(error["backtrace"]).find do |f| + f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) } + end + match = frame&.match(BACKTRACE_FRAME) + match && [match[1], Integer(match[2], 10)] + end + + # require realpaths frames while eval keeps paths as given, + # so a symlinked root (macOS /var) must match both spellings. + def own_prefix(root) + return nil if root.nil? + + [root + File::SEPARATOR, + realpath(root) + File::SEPARATOR].uniq + end + + def realpath(path) + File.realpath(path) + rescue SystemCallError + path + end + def describe(hash) error = hash.is_a?(Hash) ? (hash["error"] || hash) : {} klass = error["class"] || "UnknownError" @@ -378,8 +465,8 @@ def describe(hash) # Harness output can carry arbitrary target bytes; force # valid UTF-8 before any string work or a binary exception # message crashes the whole run. - def run(mode, payload = {}) - out, err, timed_out = execute(mode, payload) + def run(mode, payload = {}, root: nil) + out, err, timed_out = execute(mode, payload, root: root) out = sanitize(out) err = sanitize(err) if timed_out @@ -405,9 +492,28 @@ def sanitize(text) # child the target spawned inherits our pipes and would # otherwise hold the read until it exits, defeating the # timeout and leaving orphans behind. - def execute(mode, payload) + def execute(mode, payload, root: nil) cmd = [@ruby, "-W0", HARNESS, mode] - Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait| + # An app boots against its own bundle: the subprocess runs + # from the target root with the target's Gemfile. When + # Audition itself runs under bundle exec, the inherited + # Bundler environment (RUBYOPT's -rbundler/setup above all) + # would activate Audition's bundle inside the child before + # the harness starts, so it is scrubbed first. + env = {} + opts = {pgroup: true} + if root && File.directory?(root) + opts[:chdir] = root + gemfile = File.join(root, "Gemfile") + if File.file?(gemfile) + ENV.each_key do |key| + env[key] = nil if key.start_with?("BUNDLE") || + %w[RUBYOPT RUBYLIB].include?(key) + end + env["BUNDLE_GEMFILE"] = gemfile + end + end + Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait| stdin.write(JSON.generate(payload)) stdin.close out_reader = reader(stdout) diff --git a/lib/audition/finding.rb b/lib/audition/finding.rb index eedab5a..13ee3fc 100644 --- a/lib/audition/finding.rb +++ b/lib/audition/finding.rb @@ -64,12 +64,14 @@ def unsafe? = safety == :unsafe # @return [Autofix, nil] machine-applicable correction # @!attribute [r] dependency # @return [Boolean] see {#dependency?} + # @!attribute [r] test + # @return [Boolean] see {#test?} Finding = Data.define( :check, :severity, :message, :why, :fix, - :path, :line, :source, :autofix, :dependency + :path, :line, :source, :autofix, :dependency, :test ) do def initialize(source: nil, autofix: nil, dependency: false, - **rest) + test: false, **rest) super end @@ -82,6 +84,12 @@ def error? = severity == :error # @return [Boolean] def dependency? = dependency + # True when the problem lives in the target's test or spec + # code: real, but never loaded by the production boot. + # + # @return [Boolean] + def test? = test + # @return [Boolean] whether an {Autofix} is attached def fixable? = !autofix.nil? diff --git a/lib/audition/progress.rb b/lib/audition/progress.rb new file mode 100644 index 0000000..9824682 --- /dev/null +++ b/lib/audition/progress.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +module Audition + # Narrates a scan phase by phase on stderr. This class tracks + # only where the scan has got to; what the narration looks + # like—a line rewritten in place, a line per phase, or nothing + # at all—belongs to the renderer. + class Progress + # Below this many files a scan ends before a reader could read + # the first redraw. + AUTO_THRESHOLD = 200 + + # Shared by the renderers: both write to a stream that may go + # away, both spell out counts and durations the same way, and + # both paint with the palette the report uses. + class Renderer + def self.terminal?(io) + io.respond_to?(:tty?) && io.tty? + rescue IOError + false + end + + # @param io [IO] + # @param style [Report::Style] defaults to whatever the + # stream supports, so a redirected run comes out plain + def initialize(io, style: nil) + @io = io + @style = style || Report::Style.detect(io: io) + @live = true + end + + def update(progress) = nil + + # A named unit is still just progress unless the renderer + # has a reason to treat it differently. + def item(progress) = update(progress) + + def phase_done(progress) = nil + + def clear = nil + + private + + def fraction(progress) + count = commas(progress.count) + total = progress.total + total ? "#{count}/#{commas(total)}" : count + end + + # Narration is cosmetic: a stream that has gone away ends it, + # never the scan. + def write(text) + return unless @live + + @io.write(text) + @io.flush if @io.respond_to?(:flush) + rescue IOError, SystemCallError + @live = false + end + + def seconds(value) + format("%.1fs", value) + end + + def commas(value) + value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse + end + + # Named rather than dynamic dispatch, so the check for unsafe + # sends needs no exception here. + def paint(text, color) + case color + when :bold then @style.bold(text) + when :dim then @style.dim(text) + when :cyan then @style.cyan(text) + when :green then @style.green(text) + when :magenta then @style.magenta(text) + else text + end + end + end + + # One status line, rewritten in place for the whole scan and + # erased when it ends. + class Line < Renderer + # Redraws closer together than this are invisible and cost a + # syscall on every file. + REDRAW_INTERVAL = 0.1 + + # Stands in for the counter while a stage runs work that + # cannot be counted, so the line still moves. + SPINNER = %w[| / - \\].freeze + + DEFAULT_WIDTH = 80 + MIN_WIDTH = 24 + + def self.width + columns = ENV["COLUMNS"].to_i + (columns >= MIN_WIDTH) ? columns : DEFAULT_WIDTH + end + + # @param interval [Float] seconds between redraws within one + # stage; zero draws every update + def initialize(io, style: nil, interval: REDRAW_INTERVAL) + super(io, style: style) + @interval = interval + @width = self.class.width + @drawn = 0 + @heading = nil + @redrawn_at = 0.0 + @spin = 0 + @mutex = Mutex.new + @heartbeat = nil + end + + def update(progress) + if progress.countable? + stop_heartbeat + else + start_heartbeat(progress) + end + draw(progress) + end + + def clear + stop_heartbeat + @mutex.synchronize { erase } + end + + private + + # Work with nothing to count would leave the line frozen, + # which reads as a hang, so a thread keeps the clock and the + # spinner moving until something countable starts. + def start_heartbeat(progress) + return if @heartbeat + + @heartbeat = Thread.new do + loop do + sleep(REDRAW_INTERVAL) + @spin += 1 + draw(progress) + end + end + nil + end + + # `Mutex#synchronize` releases through `ensure`, so killing a + # drawing thread cannot leave the lock held. + def stop_heartbeat + @heartbeat&.kill + @heartbeat = nil + end + + # A new phase or stage is drawn at once; redraws within one + # are throttled. The scan clock doubles as the throttle + # clock, so nothing here keeps time of its own. + def draw(progress) + @mutex.synchronize do + heading = heading(progress) + now = progress.elapsed + fresh = heading != @heading + next if !fresh && now - @redrawn_at < @interval + + @heading = heading + @redrawn_at = now + render(status(progress, now)) + end + end + + def heading(progress) + [progress.label, progress.stage_label].compact.join(" ") + end + + # The phase carries the weight, the stage and the clock are + # secondary, and the one moving number gets the accent color. + def status(progress, now) + [ + [@style.glyph(:section), :dim], + ["Audition", :bold], + [progress.label, :bold], + [progress.stage_label, :dim], + *measure(progress), + [aside(progress, now), :dim] + ].reject { |text, _| text.nil? || text.empty? } + end + + def measure(progress) + return [[spinner, :magenta]] unless progress.countable? + + cells = [[fraction(progress), :cyan]] + percent = progress.percent + cells << ["#{percent}%", :green] if percent + cells + end + + # How the run is going rather than what it is scanning, kept + # apart from the counts so neither reads as the other. + def aside(progress, now) + count = progress.ractors + on = count ? ", on #{count} ractors" : "" + "(#{seconds(now)}#{on})" + end + + def spinner + SPINNER[@spin % SPINNER.size] + end + + # Escape sequences make a painted string's own length useless, + # so the plain text is what gets measured: padded to the + # previous width so a shorter status leaves no tail behind, + # and one column short of the edge so the cursor never wraps. + # A line too long to fit is trimmed unpainted, since a cut + # through an escape sequence would corrupt the terminal. + def render(cells) + plain = cells.map(&:first).join(" ") + if plain.length > @width - 1 + plain = plain[0, @width - 1] + text = plain + else + text = cells.map { |cell| paint(*cell) }.join(" ") + end + write("\r#{text}#{" " * [@drawn - plain.length, 0].max}\r") + @drawn = plain.length + end + + def erase + return unless @drawn.positive? + + write("\r#{" " * @drawn}\r") + @drawn = 0 + end + end + + # One completion line per phase. A log is the only trace a + # non-interactive run leaves, and a rewritten line would fill + # it with control characters. + class Log < Renderer + # A unit worth naming is worth a line of its own, since + # nothing here rewrites what came before. + def item(progress) + write( + "#{paint("Audition", :bold)}: #{progress.label} " \ + "#{paint(progress.stage_label.to_s, :bold)} " \ + "#{paint("(#{fraction(progress)})", :dim)}\n" + ) + end + + # Phases that walk the tree more than once report a duration + # and no count: each of their stage counts is a fraction of + # the work. + def phase_done(progress) + total = progress.phase_total + scanned = total ? ": #{commas(total)} #{progress.unit}" : "" + count = progress.ractors + on = count ? " on #{count} ractors" : "" + write( + "#{paint("Audition", :bold)}: " \ + "#{paint(progress.label, :bold)}#{scanned} in " \ + "#{paint(seconds(progress.phase_elapsed), :dim)}#{on}\n" + ) + end + end + + class << self + # The one clock in play: the renderers throttle and animate + # against the scan's own elapsed time rather than keeping + # time of their own. + def now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + # @param units [Integer, nil] work items ahead; nil for work + # slow enough to narrate whatever its size, such as a + # sweep, where every unit is a scan of its own + # @param wanted [Boolean, nil] --progress or --no-progress; + # nil defers to the unit count, the format and the stream + # @param format [Symbol] machine formats are never narrated + # @param io [IO] never the stream carrying the report + # @param style [Report::Style, nil] nil detects from the + # stream; --plain passes a plain one + # @return [Progress] + def for(units: nil, wanted: nil, format: :text, io: $stderr, + style: nil) + renderer = renderer_for(units, wanted, format, io, style) + renderer ? new(renderer: renderer) : SILENT + end + + private + + def renderer_for(units, wanted, format, io, style) + return nil if wanted == false + + terminal = Renderer.terminal?(io) + auto = terminal && format == :text && + (units.nil? || units >= AUTO_THRESHOLD) + return nil unless wanted || auto + + klass = terminal ? Line : Log + klass.new(io, style: style) + end + end + + attr_reader :label, :stage_label, :count, :total, :renderer + + # What the phase as a whole covers; its stages divide that up. + attr_reader :phase_total, :unit + + # How many Ractors the phase is running on, nil while it is + # serial. + attr_reader :ractors + + # @param renderer [Renderer, nil] nil narrates nothing + def initialize(renderer:) + @renderer = renderer + @count = 0 + @started = self.class.now + @phase_started = @started + end + + # Handed to analysis entry points as their default, so none of + # them has to ask whether narration is wanted. Shareable, so a + # worker can hold it, and the guards below keep it that way. + SILENT = Ractor.make_shareable(new(renderer: nil)) + + def enabled? + !@renderer.nil? + end + + # Narrates one named phase for the duration of the block. + # + # @param label [String] phase name shown to the reader + # @param total [Integer, nil] units expected, nil when unknown + # @param unit [String] what the total counts + def phase(label, total: nil, unit: "files") + return yield self unless @renderer + + @label = label + @phase_total = total + @unit = unit + @phase_started = self.class.now + @ractors = nil + restart(nil, total) + yield self + ensure + @renderer&.phase_done(self) + end + + # Set by whoever spawns the workers, so the narration can say + # how much of the machine is at work. The guard keeps {SILENT} + # frozen and so shareable. + # + # @param count [Integer, nil] nil for serial work + def ractors=(count) + @ractors = count if @renderer + end + + # Renames the work within the current phase and restarts its + # count. A nil total marks a step whose length is not known + # until it ends, which the renderer is then free to animate. + def stage(label, total: nil) + restart(label, total) if @renderer + end + + def tick(count = 1) + return unless @renderer + + @count += count + @renderer.update(self) + end + + # Counts one unit and names it, for phases whose units are few + # enough to name. The name takes the stage slot, so nothing in + # the renderers has to make room for it. + def item(label) + return unless @renderer + + @stage_label = label + @count += 1 + @renderer.item(self) + end + + # Clears whatever the narration left behind. + def finish + @renderer&.clear + end + + # Seconds since the scan began. + def elapsed + self.class.now - @started + end + + # Seconds since the current phase began. + def phase_elapsed + self.class.now - @phase_started + end + + # @return [Integer, nil] 0..100, nil when nothing bounds it + def percent + return nil unless @total&.positive? + + (100 * @count / @total).clamp(0, 100) + end + + def countable? + !@total.nil? || @count.positive? + end + + private + + def restart(stage_label, total) + @stage_label = stage_label + @total = total + @count = 0 + @renderer.update(self) + end + end +end diff --git a/lib/audition/report.rb b/lib/audition/report.rb index f79c76d..a2fb004 100644 --- a/lib/audition/report.rb +++ b/lib/audition/report.rb @@ -9,7 +9,7 @@ class Report not_ready: "not ractor-ready", blocked: "own code is ractor-ready; blocked by dependencies", risky: "risky: warnings only, no hard errors", - ready: "ractor-ready as far as audition can tell" + ready: "ractor-ready as far as Audition can tell" }.freeze attr_reader :target_type, :target_root, :findings, @@ -60,13 +60,19 @@ def dependency_errors? counts[:dep_error].positive? end + # Test findings count apart at every severity: they are the + # target's code, but a production boot never loads them, so + # they never touch the verdict. def counts @counts ||= begin base = {error: 0, dep_error: 0, warning: 0, info: 0, + test_error: 0, test_warning: 0, test_info: 0, fixable: 0} findings.each_with_object(base) do |f, acc| if f.error? && f.dependency? acc[:dep_error] += 1 + elsif f.test? + acc[:"test_#{f.severity}"] += 1 else acc[f.severity] += 1 end @@ -86,3 +92,4 @@ def counts require_relative "report/text" require_relative "report/json" require_relative "report/github" +require_relative "report/sweep" diff --git a/lib/audition/report/github.rb b/lib/audition/report/github.rb index 9dd18e9..0cf9b3e 100644 --- a/lib/audition/report/github.rb +++ b/lib/audition/report/github.rb @@ -19,7 +19,7 @@ def initialize(report) def render lines = @report.findings.map { |f| annotation(f) } lines << - "audition verdict: #{VERDICTS.fetch(@report.verdict)}" + "Audition verdict: #{VERDICTS.fetch(@report.verdict)}" lines.join("\n") end @@ -27,7 +27,7 @@ def render def summary c = @report.counts <<~MARKDOWN - ## audition: #{VERDICTS.fetch(@report.verdict)} + ## Audition: #{VERDICTS.fetch(@report.verdict)} | findings | count | | --- | --- | @@ -35,6 +35,7 @@ def summary | dependency errors | #{c[:dep_error]} | | warnings | #{c[:warning]} | | info | #{c[:info]} | + | test findings | #{c[:test_error] + c[:test_warning] + c[:test_info]} | | fixable | #{c[:fixable]} | MARKDOWN end @@ -48,7 +49,7 @@ def annotation(f) # Annotations anchor to workspace-relative paths; a `./` # prefix (from `audition .`) keeps them off the diff. file = property_escape(f.path.delete_prefix("./")) - title = property_escape("audition #{f.check}") + title = property_escape("Audition #{f.check}") "::#{level} file=#{file}#{location}," \ "title=#{title}::#{body}" end diff --git a/lib/audition/report/json.rb b/lib/audition/report/json.rb index 3aa8c5c..385a74e 100644 --- a/lib/audition/report/json.rb +++ b/lib/audition/report/json.rb @@ -32,6 +32,9 @@ def summary "dependency_errors" => counts[:dep_error], "warnings" => counts[:warning], "infos" => counts[:info], + "test_errors" => counts[:test_error], + "test_warnings" => counts[:test_warning], + "test_infos" => counts[:test_info], "fixable" => counts[:fixable] } end @@ -48,7 +51,8 @@ def findings "line" => f.line, "source" => f.source, "fixable" => f.fixable?, - "dependency" => f.dependency? + "dependency" => f.dependency?, + "test" => f.test? } end end diff --git a/lib/audition/report/sweep.rb b/lib/audition/report/sweep.rb new file mode 100644 index 0000000..ea62741 --- /dev/null +++ b/lib/audition/report/sweep.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "json" +require "table_tennis" + +module Audition + class Report + # Renders a bundle sweep. Its unit is a gem rather than a + # finding, so it gets its own renderer instead of bending + # Report around a shape it does not have. + class Sweep + CELLS = { + :not_ready => "not ready", :blocked => "blocked", + :risky => "risky", :ready => "ready", nil => "-" + }.freeze + + # The severity glyphs the text report uses, so a verdict + # reads the same in a cell as it does in a summary. + GLYPHS = { + not_ready: :error, blocked: :warning, + risky: :warning, ready: :pass + }.freeze + + # Foreground only, and applied to the whole row: a + # background fill reads as a bar across the table, and a + # painted cell would widen its column by the invisible + # length of its own escape sequence. Ready gems keep the + # default color, so what is colored is what needs reading. + PAINTS = Ractor.make_shareable({ + :not_ready => [:red], :blocked => [:magenta], + :risky => [:yellow], nil => [:faint] + }) + + TITLE = "Audition bundle sweep" + + # @param rows [Array] one per locked gem + # @param style [Style] palette for the text rendering + def initialize(rows, style) + @rows = rows + @style = style + end + + # @param table_opts [Hash] what the terminal supports; see + # the caller for why color cannot be detected here + # @return [String] the table plus a summary line + def render(**table_opts) + table = TableTennis.new(cells, title: TITLE, mark: paint, + **table_opts) + "#{table}\n#{summary}" + end + + def json + JSON.pretty_generate( + "audition" => VERSION, + "ruby" => RUBY_VERSION, + "bundle" => @rows.map { |r| json_row(r) } + ) + end + + # Sweep rows carry no file or line, so annotations land on + # the run summary rather than a diff. + # @return [String] annotation lines plus a summary line + def annotations + lines = @rows.filter_map do |row| + level = annotation_level(row) + next unless level + + "::#{level} title=Audition::gem #{row.name} " \ + "#{row.version}: #{row.errors} errors, " \ + "#{row.dep_errors} dependency errors, " \ + "#{row.warnings} warnings (#{CELLS.fetch(row.verdict)})" + end + (lines << plain_summary).join("\n") + end + + # @return [String] job summary page table for Actions + def markdown + lines = [ + "## #{TITLE}", "", + "| gem | version | verdict | errors | dep errors " \ + "| warnings | fixable |", + "| --- | --- | --- | --- | --- | --- | --- |" + ] + @rows.each do |r| + lines << "| #{r.name} | #{r.version} | " \ + "#{CELLS.fetch(r.verdict)} | #{r.errors} | " \ + "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |" + end + lines.push("", plain_summary).join("\n") + end + + private + + def cells + @rows.map do |r| + { + "gem" => r.name, + "version" => r.version, + "verdict" => verdict_cell(r.verdict), + "errors" => clean_as_blank(r.errors), + "dep errors" => clean_as_blank(r.dep_errors), + "warnings" => clean_as_blank(r.warnings), + "fixable" => clean_as_blank(r.fixable), + "status" => r.status + } + end + end + + def verdict_cell(verdict) + cell = CELLS.fetch(verdict) + glyph = GLYPHS[verdict] + glyph ? "#{@style.glyph(glyph)} #{cell}" : cell + end + + # A clean count is the common case across hundreds of gems; + # leaving it to the table's placeholder keeps the eye on the + # rows that carry something. + def clean_as_blank(count) + count.positive? ? count : nil + end + + # The table gem hands the lambda back the row it was given, + # which carries the gem but not the verdict symbol. + def paint + paints = @rows.to_h do |r| + [[r.name, r.version], PAINTS[r.verdict]] + end + ->(row) { paints[[row["gem"], row["version"]]] } + end + + def summary + glyph, paint = if blockers.positive? + [:error, :red] + elsif ready == @rows.size + [:pass, :green] + else + [:warning, :yellow] + end + head = @style.public_send(paint, + "#{@style.glyph(glyph)} #{plain_summary}") + return head if blockers.zero? + + "#{head} #{@style.dim("· #{blockers} not ready")}" + end + + def plain_summary + "#{ready} of #{@rows.size} gems ractor-ready" + end + + def ready + @ready ||= @rows.count { |r| r.verdict == :ready } + end + + def blockers + @blockers ||= @rows.count { |r| r.verdict == :not_ready } + end + + def annotation_level(row) + if row.verdict == :not_ready || + (row.errors + row.dep_errors).positive? + "error" + elsif row.warnings.positive? + "warning" + end + end + + def json_row(row) + { + "gem" => row.name, + "version" => row.version, + "verdict" => row.verdict&.to_s, + "errors" => row.errors, + "dependency_errors" => row.dep_errors, + "warnings" => row.warnings, + "infos" => row.infos, + "fixable" => row.fixable, + "status" => row.status + } + end + end + end +end diff --git a/lib/audition/report/text.rb b/lib/audition/report/text.rb index 42497b1..e8bd1f5 100644 --- a/lib/audition/report/text.rb +++ b/lib/audition/report/text.rb @@ -21,7 +21,7 @@ def render def header s = @style - title = s.bold("audition #{VERSION}") + title = s.bold("Audition #{VERSION}") meta = s.dim( "ruby #{RUBY_VERSION} · #{@report.target_type} at " \ "#{@report.target_root}" @@ -45,8 +45,10 @@ def finding_lines(finding) fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : "" dep_mark = finding.dependency? ? " #{s.dim("(dependency)")}" : "" + test_mark = finding.test? ? " #{s.dim("(tests)")}" : "" head = " #{glyph} #{loc}#{finding.message}" \ - "#{fix_mark}#{dep_mark} #{s.dim(finding.check)}" + "#{fix_mark}#{dep_mark}#{test_mark} " \ + "#{s.dim(finding.check)}" [head, *annotation("why", finding.why), *annotation("fix", finding.fix)] @@ -113,6 +115,15 @@ def summary parts << s.yellow(pluralize(c[:warning], "warning")) end parts << s.cyan("#{c[:info]} info") if c[:info].positive? + test_total = c[:test_error] + c[:test_warning] + + c[:test_info] + if test_total.positive? + parts << s.dim( + pluralize(test_total, "test finding") + + " (#{c[:test_error]} error / " \ + "#{c[:test_warning]} warning / #{c[:test_info]} info)" + ) + end if c[:fixable].positive? parts << s.cyan( "#{c[:fixable]} fixable #{s.glyph(:fix)} " \ diff --git a/lib/audition/rewriters.rb b/lib/audition/rewriters.rb index d80f01c..e1f6b09 100644 --- a/lib/audition/rewriters.rb +++ b/lib/audition/rewriters.rb @@ -81,7 +81,10 @@ def self.plan(file, findings) values = collector.values kinds = values.map { |v| classifier.classify(v) } flagged = kinds.reject do |k| - %i[shareable unknown].include?(k) + # A magic comment cannot fix opaque values, so they + # must not veto one either. + %i[shareable unknown instance_new opaque_call + shallow_opaque].include?(k) end scv_ok = values.any? && values.all? { |value| deep_literal?(value, classifier) } diff --git a/lib/audition/static/analyzer.rb b/lib/audition/static/analyzer.rb index d59e416..a7f7c71 100644 --- a/lib/audition/static/analyzer.rb +++ b/lib/audition/static/analyzer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "etc" +require_relative "work_split" module Audition module Static @@ -27,6 +27,10 @@ def analyze_path(path) PARALLEL_THRESHOLD = 16 + # Workers report in batches: a port message per file would + # cost more than the redraw it feeds. + TICK_STRIDE = 25 + # Scans files across Ractors when there are enough of them to # be worth the spawn cost. Checks are plain shareable classes # with deeply frozen catalogs, findings copy back through @@ -34,47 +38,112 @@ def analyze_path(path) # serial path. # # @param paths [Array] files to analyze - # @param workers [Integer] Ractor count (defaults to - # processor count minus one) + # @param workers [Integer, nil] Ractor count (defaults to + # {#default_workers}) # @param threshold [Integer] minimum file count before # Ractors are used at all + # @param progress [Progress] ticked per file # @return [Array] - def analyze_paths(paths, workers: default_workers, - threshold: PARALLEL_THRESHOLD) + def analyze_paths(paths, workers: nil, + threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT) + workers ||= default_workers if paths.size < threshold || workers <= 1 - return paths.flat_map { |path| analyze_path(path) } + return serial_analyze(paths, progress) end - parallel_analyze(paths, workers) + parallel_analyze(paths, workers, progress) rescue Ractor::Error => e # The silent fallback would otherwise mask a check that is # itself Ractor-hostile; surface it under -w. if $VERBOSE - warn "audition: parallel scan fell back to serial: " \ + warn "Audition: parallel scan fell back to serial: " \ "#{e.class}: #{e.message}" end - paths.flat_map { |path| analyze_path(path) } + progress.ractors = nil + serial_analyze(paths, progress) end private - def parallel_analyze(paths, workers) + def serial_analyze(paths, progress) + paths.flat_map do |path| + findings = analyze_path(path) + progress.tick + findings + end + end + + # A port carries counts out of the workers while they run: + # the alternative is a status line frozen for the whole + # parallel phase, which is most of a large scan. + def parallel_analyze(paths, workers, progress) experimental = Warning[:experimental] Warning[:experimental] = false - slice = (paths.size / workers.to_f).ceil checks = @checks - paths.each_slice(slice).map do |chunk| - Ractor.new(chunk, checks) do |files, active_checks| - analyzer = Analyzer.new(checks: active_checks) - files.flat_map { |file| analyzer.analyze_path(file) } + port = progress.enabled? ? Ractor::Port.new : nil + chunks = balanced_chunks(paths, workers) + progress.ractors = chunks.size + ractors = chunks.map do |chunk| + # The sentinel goes out through `ensure` so a worker that + # raises still releases the drain loop; the exception + # itself still surfaces from `Ractor#value`. + Ractor.new(chunk, checks, port) do |files, active, tap| + analyzer = Analyzer.new(checks: active) + pending = 0 + begin + files.flat_map do |file| + findings = analyzer.analyze_path(file) + pending += 1 + if tap && pending >= TICK_STRIDE + tap.send(pending) + pending = 0 + end + findings + end + ensure + tap&.send(pending) + tap&.send(:done) + end end - end.flat_map(&:value) + end + drain(port, ractors.size, progress) if port + ractors.flat_map(&:value) ensure Warning[:experimental] = experimental end + def drain(port, workers, progress) + done = 0 + while done < workers + message = port.receive + if message == :done + done += 1 + else + progress.tick(message) + end + end + rescue + # Narration is cosmetic; a closed port ends it quietly and + # `Ractor#value` still reports what went wrong. + nil + end + def default_workers - [Etc.nprocessors - 1, 1].max + WorkSplit.workers + end + + # Byte size stands in for parse cost, and the stat it costs + # is nothing beside the parse it schedules. + def balanced_chunks(paths, workers) + WorkSplit.chunks( + paths.map { |path| [path, file_size(path)] }, workers + ) + end + + def file_size(path) + File.size(path) + rescue SystemCallError + 0 end def analyze_file(file) diff --git a/lib/audition/static/checks.rb b/lib/audition/static/checks.rb index c52778e..ba3630c 100644 --- a/lib/audition/static/checks.rb +++ b/lib/audition/static/checks.rb @@ -1,18 +1,21 @@ # frozen_string_literal: true require_relative "checks/base" +require_relative "checks/dependency_class_state" require_relative "checks/global_variables" require_relative "checks/mutable_constants" require_relative "checks/ractor_isolation" require_relative "checks/runtime_require" require_relative "checks/unsafe_calls" +require_relative "checks/unshareable_reads" module Audition module Static module Checks BUILT_IN = [ - GlobalVariables, MutableConstants, RactorIsolation, - RuntimeRequire, UnsafeCalls + DependencyClassState, GlobalVariables, MutableConstants, + RactorIsolation, RuntimeRequire, UnsafeCalls, + UnshareableReads ].freeze # Expression-level checks, run per file. Class variables and diff --git a/lib/audition/static/checks/dependency_class_state.rb b/lib/audition/static/checks/dependency_class_state.rb new file mode 100644 index 0000000..a9a5b28 --- /dev/null +++ b/lib/audition/static/checks/dependency_class_state.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Audition + module Static + module Checks + # Class-level state owned by a dependency. A gem's own source + # is outside the scanned tree, so the graph audit never sees + # the ivar and the call site is the only place left to flag. + # Generated type stubs in the target's tree record it: an + # attribute reader on a singleton class is a class-level ivar + # read by definition, and the generator marks those methods + # apart from ones that compute their answer. + class DependencyClassState < Base + explain :read, + severity: :warning, + message: "%{owner}.%{name} reads class-level state " \ + "owned by a dependency", + why: "The stub for %{owner} declares %{name} as an " \ + "attribute on its singleton class, so the call " \ + "returns a class-level instance variable. A " \ + "non-main Ractor raises Ractor::IsolationError " \ + "(\"can not get unshareable values from instance " \ + "variables of classes/modules\") unless whatever " \ + "the value happens to be is shareable.", + fix: "Read it once on the main Ractor and pass the " \ + "value in, or make the dependency's assignment " \ + "deeply shareable before any Ractor starts." + + explain :write, + severity: :error, + message: "%{owner}.%{name}= writes class-level state " \ + "owned by a dependency", + why: "Assigning an attribute on %{owner}'s singleton " \ + "class writes a class-level instance variable, " \ + "which a non-main Ractor cannot do at all: it " \ + "raises Ractor::IsolationError regardless of what " \ + "the value is.", + fix: "Configure the dependency at boot on the main " \ + "Ractor and leave it alone afterwards." + + # The generator documents a reader it generated from an + # attribute; a hand-written method of the same shape gets + # its own prose and stays quiet. + STUB_READER = /Returns the value of attribute (\w+)/ + STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/ + STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/ + STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/ + STUB_COMMENT = /\A\s*#/ + + class << self + attr_reader :attributes + + # Owner name => set of attribute names, kept shareable + # for parallel scanning. + def attributes=(pairs) + grouped = pairs.group_by(&:first) + .transform_values { |v| v.map(&:last).uniq } + @attributes = Ractor.make_shareable( # audition:disable + grouped + ) + end + + # @param paths [Array] scanned files; only + # generated type stubs carry this evidence + # @return [void] + def learn(paths) + pairs = [] + paths.each do |path| + next unless path.end_with?(".rbi") + + read_stub(path, pairs) + end + self.attributes = pairs + end + + private + + # Stub indentation tracks nesting exactly, so the scope + # stack follows it rather than parsing the file. + def read_stub(path, pairs) + scope = [] + singleton = [] + indents = [] + documented = nil + File.foreach(path) do |raw| + line = raw.rstrip + next if line.empty? + + indent = line[/\A */].size + while indents.any? && indent <= indents.last + indents.pop + scope.pop + singleton.pop + end + documented = + step_stub(line, scope, singleton, indents, + documented, pairs) + end + rescue SystemCallError + nil + end + + def step_stub(line, scope, singleton, indents, + documented, pairs) + case line + when STUB_COMMENT + return STUB_READER.match(line)&.[](1) || documented + when STUB_SINGLETON + scope.push(scope.last) + singleton.push(true) + indents.push(line[/\A */].size) + when STUB_SCOPE + name = Regexp.last_match(1) + scope.push(scope.empty? ? name : "#{scope.last}::#{name}") + singleton.push(false) + indents.push(line[/\A */].size) + when STUB_DEF + name = Regexp.last_match(1) + if singleton.last && scope.last && documented == name + pairs << [scope.last, name] + end + end + nil + end + end + + self.attributes = [] + + on :call_node do |node| + examine(node) + end + + private + + def examine(node) + owner = constant_name(node.receiver) + return unless owner + + name = node.name.to_s + writer = name.end_with?("=") + attribute = writer ? name.chomp("=") : name + names = self.class.attributes[owner] + return unless names&.include?(attribute) + + flag(node, writer ? :write : :read, + owner: owner, name: attribute) + end + + def constant_name(node) + case node + when Prism::ConstantReadNode + node.name.to_s + when Prism::ConstantPathNode + node.location.slice.delete_prefix("::") + end + end + end + end + end +end diff --git a/lib/audition/static/checks/mutable_constants.rb b/lib/audition/static/checks/mutable_constants.rb index a0635a5..a366b74 100644 --- a/lib/audition/static/checks/mutable_constants.rb +++ b/lib/audition/static/checks/mutable_constants.rb @@ -103,6 +103,44 @@ class MutableConstants < Base "or drop the default proc and fetch with a " \ "literal default: hash.fetch(key, [])." + explain :unshareable_instance, + severity: :warning, + message: "constant %{name} holds an unfrozen %{klass} " \ + "instance", + why: "A fresh instance is unfrozen, so non-main " \ + "Ractors raise Ractor::IsolationError reading " \ + "it. A warning, not an error: .new can be " \ + "overridden to return a shareable value.", + fix: "Freeze it when deeply immutable, wrap in " \ + "Ractor.make_shareable, or keep a per-Ractor " \ + "copy via Ractor.store_if_absent." + + explain :shallow_opaque, + severity: :warning, + message: "constant %{name} is frozen at the top level " \ + "only; what it holds is unproven", + why: "Freezing is shallow: elements and instance " \ + "variables stay as their calls returned them, and " \ + "unless those are deeply frozen a non-main Ractor " \ + "reading the constant raises " \ + "Ractor::IsolationError. The dynamic probe settles " \ + "it when the target boots.", + fix: "Build the value with Ractor.make_shareable for a " \ + "deep freeze, or silence a known-shareable value " \ + "with a disable comment." + + explain :opaque_constant, + severity: :warning, + message: "constant %{name} holds the result of " \ + "%{method}; shareability unproven", + why: "Unless the call returns a deeply frozen value, " \ + "non-main Ractors raise Ractor::IsolationError " \ + "reading the constant. The dynamic probe settles " \ + "it when the target boots.", + fix: "Freeze the result at definition time, or " \ + "silence a known-shareable value with a disable " \ + "comment." + explain :constant_mutation, severity: :warning, message: "in-place %{method} on constant %{name}", @@ -152,6 +190,10 @@ def examine(name, node, value) # finding stays, the autofix goes. fix_ok = !mutated?(name) && !customized?(name) kind = classifier.classify(value) + # A Sorbet cast returns its argument and a begin + # block its last statement: fixes, type names and + # depth checks target the value inside. + value = classifier.unwrap(value) # Build-then-freeze: a bare `NAME.freeze` later in the # same body makes the literal as good as frozen, so # only provably mutable elements remain to report. @@ -195,6 +237,20 @@ def examine(name, node, value) autofix: wrappable ? wrap_make_shareable(value) : nil) when :default_proc flag(node, :hash_default_proc, name: name) + when :shallow_opaque + flag(node, :shallow_opaque, name: name) + # Build-then-freeze leaves depth unknown: stay silent. + when :instance_new + unless frozen_later?(name) + flag(node, :unshareable_instance, name: name, + klass: + classifier.const_name(value.receiver) || "new") + end + when :opaque_call + unless frozen_later?(name) + flag(node, :opaque_constant, name: name, + method: opaque_display(value)) + end end end @@ -313,6 +369,21 @@ def call_display(call) owner ? "#{owner}.#{call.name}" : "String##{call.name}" end + # The receiver is arbitrary, often a chain, so the + # fallback names only the method. + def opaque_display(call) + owner = classifier.const_name(call.receiver) + method = + if owner + "#{owner}.#{call.name}" + elsif call.receiver + ".#{call.name}" + else + call.name.to_s + end + "a #{method} call" + end + # `.freeze` binds tighter than an operator: `"a" + "b".freeze` # freezes only "b", so operator calls get parentheses while # literals and parenthesized or argument-free calls take diff --git a/lib/audition/static/checks/unshareable_reads.rb b/lib/audition/static/checks/unshareable_reads.rb new file mode 100644 index 0000000..6a5ac72 --- /dev/null +++ b/lib/audition/static/checks/unshareable_reads.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module Audition + module Static + module Checks + # Constants known to hold unshareable objects. Their + # definitions live outside the scanned tree (or are swept + # from it at setup), so the read site is the only place a + # static pass can flag. Learned type aliases match only in + # Sorbet type positions, where a constant can only name a + # type—a class sharing a name stays quiet. Namespaces that + # build constants with const_set at boot get their constant + # reads flagged too: the values never pass through a literal + # the analyzer could classify. + class UnshareableReads < Base + explain :sorbet_type_alias, + severity: :warning, + message: "read of %{name}, a Sorbet type alias that " \ + "is not Ractor-shareable", + why: "T.type_alias wraps its block in an unfrozen " \ + "T::Private::Types::TypeAlias that also memoizes " \ + "on first use, so a non-main Ractor evaluating " \ + "the reference raises Ractor::IsolationError. " \ + "sig blocks evaluate lazily, on the first call " \ + "of the method they annotate, so the raise can " \ + "surface there too.", + fix: "Evaluate sigs at boot on the main Ractor " \ + "(T::Utils.run_all_sig_blocks) and avoid runtime " \ + "T.let casts against the alias on Ractor code " \ + "paths, or rebind the constant to a deeply " \ + "shareable equivalent at boot." + + explain :dynamic_constant, + severity: :warning, + message: "read of %{name}, a constant its namespace " \ + "defines dynamically at boot", + why: "The owning namespace binds this constant with " \ + "const_set from runtime data, so no literal ever " \ + "reaches the analyzer; loaders of this shape " \ + "typically bind unfrozen strings or hashes, which " \ + "a non-main Ractor cannot read from a constant.", + fix: "Make each value shareable as it is bound " \ + "(Ractor.make_shareable) or hold the data in a " \ + "frozen registry instead of loose constants." + + KNOWN = Ractor.make_shareable( + {"T::Boolean" => :sorbet_type_alias} + ) + + # Stub generators write rbi definitions fully qualified on + # one line, so a regex sweep is enough there. + ALIAS_DEFINITION = + /^\s*((?:[A-Z]\w*::)*[A-Z]\w*)\s*=\s*T\.type_alias\b/ + + VALUE_NAME = /\A[A-Z][A-Z0-9_]*\z/ + + class << self + attr_reader :learned, :dynamic + + # Both hold names as segment arrays, written eagerly and + # kept shareable so parallel scans read them from + # non-main Ractors. + def learned=(names) + @learned = Ractor.make_shareable( # audition:disable + names.uniq.group_by(&:last) + ) + end + + def dynamic=(names) + @dynamic = Ractor.make_shareable( # audition:disable + names.uniq + ) + end + + # Sweeps the tree for type-alias assignments and for + # namespaces that const_set under a computed name. + # Ruby sources are parsed so definitions keep their + # nesting; rbi files are line-scanned. + def learn(paths, progress: Progress::SILENT) + aliases = [] + owners = [] + paths.each do |path| + progress.tick + source = begin + File.read(path) + rescue SystemCallError + next + end + if path.end_with?(".rbi") + source.scan(ALIAS_DEFINITION) do |(name)| + aliases << name.split("::") + end + else + result = Prism.parse(source) + next unless result.success? + + sweep(result.value, [], aliases, owners) + end + end + self.learned = aliases + self.dynamic = owners + end + + private + + def sweep(node, nesting, aliases, owners) + case node + when Prism::ClassNode, Prism::ModuleNode + nesting = [*nesting, *segments(node.constant_path)] + when Prism::ConstantWriteNode + if alias_value?(node.value) + aliases << [*nesting, node.name.to_s] + end + when Prism::ConstantPathWriteNode + if alias_value?(node.value) + aliases << [*nesting, *segments(node.target)] + end + when Prism::CallNode + if dynamic_definer?(node) && + (owner = owner_of(node, nesting)) + owners << owner + end + end + node.compact_child_nodes.each do |child| + sweep(child, nesting, aliases, owners) + end + end + + def segments(node) + node.location.slice.delete_prefix("::").split("::") + end + + def alias_value?(value) + value.is_a?(Prism::CallNode) && + value.name == :type_alias && + value.receiver&.location&.slice + &.delete_prefix("::") == "T" + end + + # A literal name would be classifiable on its own; the + # loader shape worth learning computes the name. + def dynamic_definer?(node) + return false unless node.name == :const_set + + name = node.arguments&.arguments&.first + !(name.nil? || + name.is_a?(Prism::SymbolNode) || + name.is_a?(Prism::StringNode)) + end + + def owner_of(node, nesting) + owner = case node.receiver + when nil, Prism::SelfNode + nesting + when Prism::ConstantReadNode, Prism::ConstantPathNode + segments(node.receiver) + end + owner unless owner.nil? || owner.empty? + end + end + + self.learned = [] + self.dynamic = [] + + on :call_node do |node| + note_type_position(node) + end + + on :constant_path_node, :constant_read_node do |node| + examine(node) + end + + def initialize(file) + super + @typed = Set.new + end + + private + + def examine(node) + name = node.location.slice.delete_prefix("::") + if (key = KNOWN[name]) + flag(node, key, name: name) + elsif @typed.include?(node.object_id) && alias_read?(name) + flag(node, :sorbet_type_alias, name: name) + elsif dynamic_read?(name) + flag(node, :dynamic_constant, name: name) + end + end + + # The read has to line up with a definition's tail; a + # definition swept without nesting only pins its own name, + # a qualified one pins the namespace too. + def tail_match?(read, full) + overlap = [read.size, full.size].min + full.last(overlap) == read.last(overlap) + end + + def alias_read?(name) + segments = name.split("::") + self.class.learned[segments.last]&.any? do |full| + tail_match?(segments, full) + end + end + + # Only SCREAMING_CASE reads count: a loader binds values, + # and classes nested under the namespace stay quiet. + def dynamic_read?(name) + segments = name.split("::") + return false if segments.size < 2 || + !segments.last.match?(VALUE_NAME) + + parent = segments[0..-2] + self.class.dynamic.any? do |owner| + tail_match?(parent, owner) + end + end + + # Constants under a sig block or a T type argument name + # types, so learned aliases may match there. + def note_type_position(node) + if node.name == :sig + mark(node.block) + elsif t_receiver?(node) + case node.name + when :let, :cast, :assert_type! + mark(node.arguments&.arguments&.dig(1)) + when :nilable, :any, :all, :class_of, :type_alias + node.arguments&.arguments&.each { |arg| mark(arg) } + mark(node.block) + end + end + end + + def t_receiver?(node) + receiver = node.receiver + case receiver + when Prism::ConstantReadNode + receiver.name == :T + when Prism::ConstantPathNode + receiver.location.slice.delete_prefix("::") == "T" + else + false + end + end + + def mark(node) + return if node.nil? + + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + return @typed << node.object_id + end + node.each_child_node { |child| mark(child) } + end + end + end + end +end diff --git a/lib/audition/static/gem_calls.rb b/lib/audition/static/gem_calls.rb new file mode 100644 index 0000000..4086137 --- /dev/null +++ b/lib/audition/static/gem_calls.rb @@ -0,0 +1,1770 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Audition + module Static + # Call sites into bundled gems whose compiled extensions do + # not declare Ractor safety (rb_ext_ractor_safe), so calling + # into them from a non-main Ractor raises Ractor::UnsafeError. + # The extension's code is outside the scanned tree; the call + # site is the only place a static pass can flag. + # + # Rules are derived from the target's own bundle, never from a + # gem list: a pinned gem whose installed extension lacks the + # declaration—or that pins a platform-specific build Audition + # cannot inspect (the running Ruby's own copy of the extension + # settles it when one is shipped)—gets its call sites + # flagged, anchored to the + # gem's own namespace (read from its entry file's module + # nesting, or the require-path convention when the gem is not + # installed). Matched calls taint: the value handed back is + # presumed to still live in the extension, so calls chained + # onto it, or on the local or ivar it is assigned to, are + # flagged and keep the taint moving; a predicate ends the + # chain, and freeze, tap, itself, and class pass it along + # silently. Sorbet + # annotations extend the reach—a sig param, T.let, or T.cast + # typed with a constant under a flagged gem's namespace taints + # the annotated variable the same way, and a plain type clears + # the guess. A per-class pre-pass makes the body order-free: + # a method whose return expression or sig return type is + # rooted in a flagged namespace hands the taint to callers on + # self, self.class, or the class's own constant, and an ivar + # assigned such a value anywhere in the body—directly or + # through its attr writer—is tainted throughout. A pass over + # the whole tree first promotes classes that hold extension + # values in their instances—or subclass one that does—to + # rules of their own, so constructing or receiving one in + # another file carries the taint across files. + class GemCalls + UNSAFE_WHY = + "%{gem}'s compiled extension does not declare Ractor " \ + "safety (rb_ext_ractor_safe), so its methods raise " \ + "Ractor::UnsafeError (\"ractor unsafe method called from " \ + "not main ractor\") from any non-main Ractor." + + UNVERIFIED_WHY = + "%{gem} pins a compiled extension Audition could not " \ + "inspect (the gem is not installed here). An extension " \ + "that does not declare Ractor safety " \ + "(rb_ext_ractor_safe) raises Ractor::UnsafeError from " \ + "any non-main Ractor; install the bundle to verify." + + MESSAGE = + "%{receiver}.%{method} calls into %{gem}, whose compiled " \ + "extension does not declare Ractor safety" + + UNVERIFIED_MESSAGE = + "%{receiver}.%{method} calls into %{gem}, which pins a " \ + "compiled extension Audition could not inspect" + + DERIVED_MESSAGE = + "%{method} called on a value handed out by %{gem}'s " \ + "native extension" + + FIX = + "Keep calls into %{gem} on the main Ractor and share " \ + "only extracted plain data (frozen strings, numbers) " \ + "between Ractors, or get the extension to declare " \ + "rb_ext_ractor_safe(true)." + + # Object/Kernel identity methods that never enter the + # extension, even on a gem object. + CORE_METHODS = Ractor.make_shareable( + Set.new( + %i[nil? is_a? kind_of? instance_of? respond_to? frozen? + equal? class object_id itself hash tap then freeze] + ) + ) + + # Core methods that hand back the receiver—or, for class, + # the extension's own class object: no finding, but a + # tainted receiver's taint passes through. + CHAIN_METHODS = Ractor.make_shareable( + Set.new(%i[class itself tap freeze]) + ) + + CHECK = "native-gem-calls" + + EMPTY_NESTING = Ractor.make_shareable([]) + + # Predicates whose constant argument proves the receiver's + # class when they gate a branch. + TYPE_CHECKS = Ractor.make_shareable( + Set.new(%i[is_a? kind_of? instance_of?]) + ) + + EVAL_REOPENINGS = Ractor.make_shareable( + Set.new(%i[class_eval module_eval]) + ) + + # Methods that hand the receiver itself to their block. + YIELD_SELF = Ractor.make_shareable( + Set.new(%i[then yield_self]) + ) + + EMPTY_SET = Ractor.make_shareable(Set.new) + + # What a stub has to show before its gem counts as compiled: + # one source location holding this share of the gem's located + # methods, across at least this many classes. + STUB_SHARE = 0.6 + STUB_OWNERS = 2 + STUB_SOURCE = %r{# source://(\S+)} + STUB_DEF = /\A\s*def [\w\[\]<>=!+\-*\/%~^&|?]/ + STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/ + + # Everything an object answers before any extension gets + # involved: a bare call to anything else inside a reopened + # bound class lands in the extension. + RUBY_METHODS = Ractor.make_shareable( + Set.new( + [Object, Kernel, Module, Class].flat_map do |mod| + mod.instance_methods + mod.private_instance_methods + end + ) + ) + + # Core iterators hand elements to their block; these + # positions carry the memo or index instead. + BLOCK_MEMO_POSITIONS = Ractor.make_shareable( + {each_with_object: 1, with_object: 1, each_with_index: 1, + with_index: 1, inject: 0, reduce: 0} + ) + + # One flagged gem: verified means its installed extension + # binary was read and lacks the declaration; unverified + # means the lockfile proves compiled code exists but no + # binary was available to read. A bound rule's namespace IS + # the extension's own class or module—the gem namespace, + # a constant assigned an extension-rooted value, or a + # subclass of either—so reopening it defines methods on + # extension instances; an unbound rule marks app code that + # merely holds extension values. + Rule = Data.define(:gem, :namespace, :verified, :bound) do + def initialize(gem:, namespace:, verified:, bound: false) + super + end + end + + # @param root [String] target root, where Gemfile.lock lives + # @param stubs [Array] the target's .rbi stubs + # @param rules [Array, nil] override bundle resolution + def initialize(root:, stubs: [], rules: nil) + @stubs = stubs + @rules = rules || resolve(root) + @nesting = EMPTY_NESTING + @self_rule = nil + @self_defs = EMPTY_SET + @param_seeds = {} + @return_taints = {} + reset_seed_ledger + index_rules + end + + # @param paths [Array] files to scan + # @param progress [Progress] narrates the + # three passes this phase makes over the tree + # @return [Array] + def analyze_paths(paths, progress: Progress::SILENT) + return [] if @rules.empty? + + progress.stage("subclasses", total: paths.size) + @rules += derived_class_rules(paths, progress) + index_rules + @param_seeds = {} + reset_seed_ledger + findings = {} + progress.stage("scanning", total: paths.size) + paths.each do |path| + progress.tick + source = read_source(path) + findings[path] = analyze_file(path, source) if source + end + settle_seeds(findings, progress) + findings.values.flatten(1) + end + + private + + # A call in one file seeds a parameter of a method defined + # in another, so a file walked before the call was seen has + # to walk again. Rounds are capped: each one costs a walk of + # every file the round before it taught something new. + SEED_ROUNDS = 3 + + def reset_seed_ledger + @seed_clock = 0 + @seed_at = {} + @def_keys = {} + @walked_at = {} + end + + def settle_seeds(findings, progress = Progress::SILENT) + SEED_ROUNDS.times do |round| + stale = findings.keys.select { |path| stale?(path) } + break if stale.empty? + + progress.stage("settling #{round + 1}", total: stale.size) + stale.each do |path| + progress.tick + source = read_source(path) + findings[path] = analyze_file(path, source) if source + end + end + end + + def stale?(path) + walked = @walked_at[path] + return false unless walked + + @def_keys[path].any? do |key| + (@seed_at[key] || -1) > walked + end + end + + def read_source(path) + File.read(path) + rescue SystemCallError + nil + end + + def resolve(root) + lockfile = File.join(root, "Gemfile.lock") + return [] unless File.file?(lockfile) + + pinned(lockfile).flat_map do |name, version, platform| + rules_for(name, version, platform) + end + end + + # Lockfile rows collapse platform variants into one gem with + # a platform mark: a platform suffix on any variant proves + # the gem ships compiled code. + def pinned(lockfile) + rows = {} + File.foreach(lockfile) do |line| + match = line.match(/\A ([A-Za-z0-9_-]+) \(([^)\s]+)\)/) + next unless match + + version, platform = + match[2].match(/\A([0-9][\w.]*?)(?:-(.+))?\z/)&.captures + next unless version + + row = rows[match[1]] ||= [match[1], version, nil] + row[2] ||= platform + end + rows.values + end + + # An installed copy is the best evidence. Without one, the + # generated type stubs in the target's own tree name the + # classes whose methods are not Ruby-defined, and a + # platform pin proves compiled code ships under the gem's + # own name: both hold, so both are flagged. + def rules_for(name, version, platform) + spec = installed_spec(name, version) + return Array(installed_rule(name, spec)) if spec + + namespaces = stub_namespaces(name, version) + namespaces += [convention_namespace(name)] if platform + namespaces.uniq.filter_map { |ns| unverified_rule(name, ns) } + end + + def installed_rule(name, spec) + compiled = Target.compiled_for(spec) + if compiled.any? + return nil if compiled.all? { |path| declares_safety?(path) } + + Rule.new(gem: name, namespace: namespace_for(name, spec), + verified: true, bound: true) + elsif spec.extensions.any? + unverified_rule(name, namespace_for(name, spec)) + end + end + + # A pin with no readable binary—a platform build the + # bundle did not install, or a default gem whose extension + # lives outside its require paths—can still be judged when + # the running Ruby ships the same extension: Ruby's own + # binary is the evidence. + def unverified_rule(name, namespace) + shipped = ruby_shipped_extension(name) + unless shipped + return Rule.new(gem: name, namespace: namespace, + verified: false, bound: true) + end + return nil if declares_safety?(shipped) + + Rule.new(gem: name, namespace: namespace, verified: true, + bound: true) + end + + def ruby_shipped_extension(name) + base = File.join(RbConfig::CONFIG["archdir"], + name.tr("-", "/")) + Dir["#{base}.{so,bundle}"].first + end + + # Bundler hides gems outside the current bundle from + # find_by_name, so fall back to reading gemspecs straight + # from the installed specification directories. + def installed_spec(name, version) + Gem::Specification.find_by_name(name, version) + rescue Gem::LoadError + Gem.path.each do |base| + pattern = File.join(base, "specifications", + "#{name}-#{version}{,-*}.gemspec") + Dir[pattern].each do |path| + spec = Gem::Specification.load(path) + return spec if spec&.name == name + end + end + nil + end + + def declares_safety?(path) + File.binread(path).include?(NativeExtensions::SYMBOL) + rescue SystemCallError + false + end + + # A generated type stub is the target's own record of what a + # gem's methods look like. The generator writes each method's + # Ruby source location, and methods a compiled extension + # defines all land on the one line that loads it: a single + # location holding most of a gem's methods, spread over + # several classes, is that shape. A metaprogramming block + # defines its methods on the class it sits in, so one owner + # is not evidence. The owners of that cluster are the + # extension's own classes, which name it better than any + # convention can. + # Generators name a gem's stub for the gem and version it + # documents, which identifies it wherever the generator was + # told to write. + def stub_namespaces(name, version) + wanted = "#{name}@#{version}.rbi" + path = @stubs.find { |s| File.basename(s) == wanted } + return [] unless path + + clusters = stub_clusters(path) + located = clusters.sum { |_, owners| owners.values.sum } + return [] if located.zero? + + owners = clusters.each_value.max_by { |o| o.values.sum } + return [] if owners.size < STUB_OWNERS || + owners.values.sum < located * STUB_SHARE + + owners.keys + end + + def stub_clusters(path) + clusters = Hash.new { |h, k| h[k] = Hash.new(0) } + scope = [] + indents = [] + source = nil + File.foreach(path) do |raw| + line = raw.rstrip + next if line.empty? + + indent = line[/\A */].size + while indents.any? && indent <= indents.last + indents.pop + scope.pop + end + if (match = line.match(STUB_SCOPE)) + scope.push(stub_scope_name(scope, match[1])) + indents.push(indent) + source = nil + elsif (match = line.match(STUB_SOURCE)) + source = match[1] + else + if source && scope.last && line.match?(STUB_DEF) + clusters[source][scope.last] += 1 + end + source = nil + end + end + clusters + rescue SystemCallError + {} + end + + # Stubs write top-level definitions under their full path and + # nested ones relative to the enclosing scope. + def stub_scope_name(scope, written) + scope.empty? ? written : "#{scope.last}::#{written}" + end + + # The entry file's module nesting names the gem's namespace + # more reliably than name conventions do. + def namespace_for(name, spec) + entry = spec.full_require_paths + .map { |rp| File.join(rp, "#{name.tr("-", "/")}.rb") } + .find { |path| File.file?(path) } + (entry && nesting_namespace(entry)) || + convention_namespace(name) + end + + def nesting_namespace(path) + result = Prism.parse(File.read(path)) + return nil unless result.success? + + names = [] + body = result.value.statements.body + while (mod = sole_module(body)) + names << mod.constant_path.location.slice + .delete_prefix("::") + body = + mod.body.is_a?(Prism::StatementsNode) ? mod.body.body : [] + end + names.join("::") unless names.empty? + rescue SystemCallError + nil + end + + def sole_module(statements) + mods = statements.select do |node| + node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode) + end + mods.first if mods.size == 1 + end + + def convention_namespace(name) + name.split("-").map do |seg| + seg.split("_").map(&:capitalize).join + end.join("::") + end + + # Each pass may learn argument seeds and computed return + # taints that change what an earlier line would flag, so + # the file re-walks until a pass learns nothing new and + # only that pass's findings stand. + def analyze_file(path, source) + file = SourceFile.new(source: source, path: path) + return [] unless file.valid_syntax? + + @path = path + + @return_taints = {} + @def_keys[path] = Set.new + scan = nil + loop do + before = knowledge_size + @registry = nil + @singleton = false + @nesting = EMPTY_NESTING + @self_rule = nil + @self_defs = EMPTY_SET + scan = Scan.new(path: path, findings: [], producers: {}) + walk(file.root, {}, {}, scan) + break if knowledge_size == before + end + @walked_at[path] = @seed_clock + scan.findings + end + + # Seeds and return taints only grow, so the loop stops at + # the first pass that learns nothing. + def knowledge_size + @param_seeds.sum { |_, seeds| seeds.size } + + @return_taints.size + end + + Scan = Data.define(:path, :findings, :producers) + + # Ordered walk carrying the taint state: tainted maps a local + # name to the rule whose call produced its value, ivars does + # the same for instance variables; producers remembers which + # call nodes are rooted in a flagged namespace so a chained + # call or an assignment can pick the taint up. Defs open + # fresh local scopes (seeded from the preceding sig); classes + # and modules open fresh ivar scopes; blocks close over the + # enclosing. + def walk(node, tainted, ivars, scan) + case node + when Prism::ClassNode, Prism::ModuleNode + registry, singleton, nesting = @registry, @singleton, @nesting + self_rule, self_defs = @self_rule, @self_defs + bound = source_rule(node.constant_path.location.slice) + @self_rule = bound&.bound ? bound : nil + @self_defs = @self_rule ? body_defs(node.body) : EMPTY_SET + @nesting = nested_scope(node) + @registry, @singleton = build_registry(node), false + each_child(node) do |child| + walk(child, {}, @registry.ivars[:instance].dup, scan) + end + @registry, @singleton, @nesting = registry, singleton, nesting + @self_rule, @self_defs = self_rule, self_defs + when Prism::SingletonClassNode + if @registry && node.expression.is_a?(Prism::SelfNode) + singleton = @singleton + @singleton = true + each_child(node) do |child| + walk(child, {}, @registry.ivars[:singleton].dup, scan) + end + @singleton = singleton + else + registry = @registry + @registry = nil + each_child(node) { |child| walk(child, {}, {}, scan) } + @registry = registry + end + when Prism::DefNode + walk_def(node, nil, ivars, scan) + when Prism::StatementsNode + sig = nil + node.body.each do |child| + if child.is_a?(Prism::DefNode) + walk_def(child, sig, ivars, scan) + else + walk(child, tainted, ivars, scan) + guard_taint(child, tainted, ivars) + end + sig = sig_node?(child) ? child : nil + end + when Prism::CaseNode + walk_case(node, tainted, ivars, scan) + when Prism::IfNode + walk_if(node, tainted, ivars, scan) + when Prism::CallNode + if (rule = reopening_rule(node)) + walk_reopening(node, rule, tainted, ivars, scan) + else + walk_call(node, tainted, ivars, scan) + end + when Prism::LocalVariableWriteNode, + Prism::LocalVariableOrWriteNode, + Prism::LocalVariableAndWriteNode, + Prism::LocalVariableOperatorWriteNode + walk(node.value, tainted, ivars, scan) + rule = value_rule(node.value, tainted, ivars, scan) + rule ? tainted[node.name] = rule : tainted.delete(node.name) + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode + walk(node.value, tainted, ivars, scan) + rule = value_rule(node.value, tainted, ivars, scan) + rule ? ivars[node.name] = rule : ivars.delete(node.name) + when Prism::MultiWriteNode + each_child(node) { |child| walk(child, tainted, ivars, scan) } + multi_targets(node).each do |target| + case target + when Prism::LocalVariableTargetNode + tainted.delete(target.name) + when Prism::InstanceVariableTargetNode + ivars.delete(target.name) + end + end + else + each_child(node) { |child| walk(child, tainted, ivars, scan) } + end + end + + # Branches run on their own copy: a value one branch + # assigns may still be live after the branch, and one + # every branch replaces is not. + def walk_if(node, tainted, ivars, scan) + walk(node.predicate, tainted, ivars, scan) + key, rule = type_check(node.predicate) + branches = [] + if node.statements + copies = [tainted.dup, ivars.dup] + with_taint(key, rule, *copies) do + walk(node.statements, *copies, scan) + end + branches << copies + end + if node.subsequent + copies = [tainted.dup, ivars.dup] + walk(node.subsequent, *copies, scan) + branches << copies + end + merge_branches(tainted, ivars, branches, + node.statements && node.subsequent) + end + + def merge_branches(tainted, ivars, branches, total) + branches.each do |branch_tainted, branch_ivars| + branch_tainted.each { |k, v| tainted[k] ||= v } + branch_ivars.each { |k, v| ivars[k] ||= v } + end + return unless total + + tainted.delete_if do |k, _| + branches.none? { |t, _| t.key?(k) } + end + ivars.delete_if do |k, _| + branches.none? { |_, i| i.key?(k) } + end + end + + def walk_def(node, sig, ivars, scan) + singleton_def = node.receiver.is_a?(Prism::SelfNode) + side = (@singleton || singleton_def) ? :singleton : :instance + tainted = param_taints(node, sig, side) + if singleton_def && !@singleton + singleton = @singleton + @singleton = true + ivars = @registry ? @registry.ivars[:singleton].dup : {} + each_child(node) { |child| walk(child, tainted, ivars, scan) } + @singleton = singleton + else + each_child(node) { |child| walk(child, tainted, ivars, scan) } + end + note_return_taint(node, side, scan) + end + + # Sig taints seed the def's scope, then call sites already + # walked add argument taints for params the sig leaves + # untyped or erased; a plain constant type keeps the param + # clean. + def param_taints(node, sig, side) + taints = sig ? sig_taints(sig) : {} + seeds = seeds_for(side, node.name) + return taints unless seeds + + plain = sig ? plain_params(sig) : EMPTY_SET + seeds.each do |key, rule| + name = seed_param_name(node.parameters, key) + next if name.nil? || taints.key?(name) || + plain.include?(name) + + taints[name] = rule + end + taints + end + + # A call through the module's own name reaches an instance + # method the module extended itself with. + def seeds_for(side, name) + key = [seed_scope, side, name] + note_def_key(key) + seeds = @param_seeds[key] + return seeds if seeds || side != :instance || + !@registry&.self_extended + + fallback = [seed_scope, :singleton, name] + note_def_key(fallback) + @param_seeds[fallback] + end + + # What this file's definitions would consume, so a seed + # learned later can call the file back. + def note_def_key(key) + @def_keys[@path]&.add(key) + end + + def seed_param_name(params, key) + return nil unless params + + if key.is_a?(Integer) + requireds = params.requireds + param = if key < requireds.size + requireds[key] + else + params.optionals[key - requireds.size] + end + case param + when Prism::RequiredParameterNode, + Prism::OptionalParameterNode + param.name + end + else + params.keywords.find do |kw| + kw.respond_to?(:name) && kw.name == key + end&.name + end + end + + # A def whose final expression produces taint hands it to + # callers even when its sig says nothing; predicates stay + # plain. Learned during one pass, applied on the next. + def note_return_taint(node, side, scan) + return if node.name.end_with?("?") + + expr = def_return_expr(node) + rule = expr && scan.producers[expr.object_id] + @return_taints[[seed_scope, side, node.name]] = rule if rule + end + + def seed_scope + @registry&.class_name + end + + # A bare or self call names a method in the scope being + # walked. A constant receiver names the scope itself, by its + # last segment: a call site and a definition rarely spell + # the path the same way. + def seed_key(node) + case node.receiver + when nil, Prism::SelfNode + [seed_scope, @singleton ? :singleton : :instance, + node.name] + when Prism::ConstantReadNode, Prism::ConstantPathNode + name = node.receiver.location.slice + .delete_prefix("::").split("::").last + [name, :singleton, node.name] + end + end + + # The block walks after the call resolves: a flagged call's + # block iterates values living in the same extension, so its + # element parameters carry the taint. + def walk_call(node, tainted, ivars, scan) + block = node.block + each_child(node) do |child| + walk(child, tainted, ivars, scan) unless child.equal?(block) + end + rule = visit_call(node, tainted, ivars, scan) || + yielded_self_rule(node, tainted, ivars, scan) + note_argument_taints(node, tainted, ivars, scan) + return unless block + + if rule && block.is_a?(Prism::BlockNode) + walk_block(node, rule, tainted, ivars, scan) + else + walk(block, tainted, ivars, scan) + end + end + + # A tainted argument seeds the named method's parameter for + # the passes that follow. + def note_argument_taints(node, tainted, ivars, scan) + key = seed_key(node) + return unless key + + args = node.arguments&.arguments + return unless args + + args.each_with_index do |arg, i| + if arg.is_a?(Prism::KeywordHashNode) + arg.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + + note_seed(key, assoc.key.unescaped.to_sym, + value_rule(assoc.value, tainted, ivars, scan)) + end + else + note_seed(key, i, + value_rule(arg, tainted, ivars, scan)) + end + end + end + + def note_seed(key, param, rule) + return unless rule + + seeds = (@param_seeds[key] ||= {}) + fresh = !seeds.key?(param) + seeds[param] = rule + @seed_at[key] = (@seed_clock += 1) if fresh + end + + # then and yield_self pass the receiver straight in, so + # the block parameter stands for the receiver. + def yielded_self_rule(node, tainted, ivars, scan) + return nil unless YIELD_SELF.include?(node.name) + + taint_source(node, tainted, ivars, scan) || + receiver_rule(node.receiver) + end + + # What an expression hands over: a variable read carries + # whatever the variable holds, self carries the body's own + # binding, anything else carries what the walk recorded. + def value_rule(node, tainted, ivars, scan) + case node + when Prism::LocalVariableReadNode then tainted[node.name] + when Prism::InstanceVariableReadNode then ivars[node.name] + when Prism::SelfNode then @singleton ? nil : @self_rule + else scan.producers[node.object_id] + end + end + + def walk_block(node, rule, tainted, ivars, scan) + names = element_params(node) + saved = names.map { |n| [n, tainted.key?(n), tainted[n]] } + names.each { |n| tainted[n] = rule } + each_child(node.block) do |child| + walk(child, tainted, ivars, scan) + end + saved.each do |name, had, prev| + had ? tainted[name] = prev : tainted.delete(name) + end + end + + def element_params(node) + params = node.block.parameters + skip = BLOCK_MEMO_POSITIONS[node.name] + case params + when Prism::NumberedParametersNode + (1..params.maximum).filter_map do |i| + :"_#{i}" unless skip == i - 1 + end + when Prism::BlockParametersNode + names = [] + (params.parameters&.requireds || []) + .each_with_index do |param, i| + collect_param_names(param, names) unless skip == i + end + names + else + [] + end + end + + def collect_param_names(param, names) + case param + when Prism::RequiredParameterNode, + Prism::LocalVariableTargetNode + names << param.name + when Prism::MultiTargetNode + [*param.lefts, param.rest, *param.rights].compact + .each { |part| collect_param_names(part, names) } + end + end + + def each_child(node, &block) + node.child_nodes.compact.each(&block) + end + + def nested_scope(node) + slice = node.constant_path.location.slice + segments = slice.delete_prefix("::").split("::") + slice.start_with?("::") ? segments : [*@nesting, *segments] + end + + def multi_targets(node) + [*node.lefts, node.rest, *node.rights].compact + end + + # A when clause matching the subject against a flagged + # constant proves its type inside the branch; if every + # branch proves the same rule, the else clause sees that + # value too, and a branch handing the value back makes the + # whole case expression a producer. + def walk_case(node, tainted, ivars, scan) + walk(node.predicate, tainted, ivars, scan) if node.predicate + key = node.predicate && taint_key(node.predicate) + rules = node.conditions.map { |c| key ? when_rule(c) : nil } + produced = nil + node.conditions.each_with_index do |clause, i| + clause.conditions.each do |cond| + walk(cond, tainted, ivars, scan) unless constant_type?(cond) + end + next unless clause.statements + + with_taint(key, rules[i], tainted, ivars) do + walk(clause.statements, tainted, ivars, scan) + produced ||= branch_rule(clause.statements, key, rules[i], scan) + end + end + if node.else_clause + shared = (rules.uniq.size == 1) ? rules.first : nil + with_taint(key, shared, tainted, ivars) do + walk(node.else_clause, tainted, ivars, scan) + produced ||= + branch_rule(node.else_clause.statements, key, shared, scan) + end + end + scan.producers[node.object_id] = produced if produced + end + + # One rule only when every condition in the clause is a + # constant resolving to it: mixed or non-constant + # conditions are not a class match. + def when_rule(clause) + rules = clause.conditions.map do |cond| + constant_type?(cond) ? source_rule(cond.location.slice) : nil + end + (rules.uniq.size == 1) ? rules.first : nil + end + + # The value a branch hands back, seen through trailing + # modifier conditionals. + def branch_rule(statements, key, rule, scan) + tail = statements&.body&.last + while tail.is_a?(Prism::IfNode) || tail.is_a?(Prism::UnlessNode) + tail = tail.statements&.body&.last + end + return nil unless tail + + scan.producers[tail.object_id] || + (rule if key && taint_key(tail) == key) + end + + # Reopening a bound name through class_eval defines methods + # on the extension's own class; the eval call itself never + # enters the extension. + def reopening_rule(node) + return nil unless EVAL_REOPENINGS.include?(node.name) && + node.block.is_a?(Prism::BlockNode) + + rule = receiver_rule(node.receiver) + rule&.bound ? rule : nil + end + + def walk_reopening(node, rule, tainted, ivars, scan) + self_rule, self_defs = @self_rule, @self_defs + @self_rule = rule + @self_defs = body_defs(node.block.body) + each_child(node) { |child| walk(child, tainted, ivars, scan) } + @self_rule, @self_defs = self_rule, self_defs + end + + # Names the reopened body itself defines: self-calls on + # these stay ordinary Ruby. + def body_defs(body) + defs = Set.new + statements = + body.is_a?(Prism::StatementsNode) ? body.body : [] + statements.each do |stmt| + case stmt + when Prism::DefNode + defs << stmt.name + when Prism::CallNode + roles = ATTR_ROLES[stmt.name] + next unless roles && stmt.receiver.nil? + + args = stmt.arguments&.arguments || [] + args.grep(Prism::SymbolNode).each do |sym| + defs << sym.unescaped.to_sym + if roles.include?(:writer) + defs << :"#{sym.unescaped}=" + end + end + end + end + defs + end + + # The store slot a subject narrows to: a local or an + # instance variable; anything else cannot hold taint. + def taint_key(node) + case node + when Prism::LocalVariableReadNode then [:local, node.name] + when Prism::InstanceVariableReadNode then [:ivar, node.name] + end + end + + def with_taint(key, rule, tainted, ivars) + return yield unless key && rule + + store = (key[0] == :local) ? tainted : ivars + name = key[1] + had, prev = store.key?(name), store[name] + store[name] = rule + yield + had ? store[name] = prev : store.delete(name) + end + + # A guard that bails unless the variable is one of a + # flagged gem's classes proves its type for whatever + # follows in the surrounding sequence. + def guard_taint(child, tainted, ivars) + return unless child.is_a?(Prism::UnlessNode) && + terminates?(child.statements) + + key, rule = type_check(child.predicate) + return unless key && rule + + store = (key[0] == :local) ? tainted : ivars + store[key[1]] = rule + end + + def terminates?(statements) + last = statements&.body&.last + last.is_a?(Prism::ReturnNode) || last.is_a?(Prism::NextNode) || + last.is_a?(Prism::BreakNode) || + (last.is_a?(Prism::CallNode) && last.name == :raise) + end + + # x.is_a?(Some::Const) as a type proof for x, through the + # left side of a && chain. + def type_check(predicate) + node = predicate + node = node.left while node.is_a?(Prism::AndNode) + return unless node.is_a?(Prism::CallNode) && + TYPE_CHECKS.include?(node.name) && node.receiver + + key = taint_key(node.receiver) + return unless key + + args = node.arguments&.arguments + return unless args&.size == 1 && constant_type?(args.first) + + rule = source_rule(args.first.location.slice) + [key, rule] if rule + end + + # Classes whose instances hold extension values—an ivar or + # method rooted in a flagged namespace—become rules under + # their own qualified name: constructing one, or receiving + # one through a sig or annotation, taints in any file. A + # subclass of a promoted or flagged class is promoted too, + # and so is a constant assigned a value rooted in a flagged + # namespace, the way protobuf-style generated Ruby binds + # extension classes to its own names. + def derived_class_rules(paths, progress = Progress::SILENT) + found = {} + mixins = [] + @nesting = EMPTY_NESTING + paths.each do |path| + progress.tick + source = read_source(path) + next unless source + + result = Prism.parse(source) + next unless result.success? + + collect_classes(result.value, [], found) + collect_mixins(result.value, [], mixins) + end + settle_subclasses(found) + settle_mixins(found, mixins) + found.values.filter_map { |info| info[:rule] } + end + + # A mixin's instance methods run with the host as self: + # include and prepend bind self to a host instance, extend + # to the host itself. Either way, a module mixed into a + # name bound to an extension value hands that value out + # through self. + MIXINS = Ractor.make_shareable( + Set.new(%i[include prepend extend]) + ) + + def collect_mixins(node, nesting, out) + case node + when Prism::ClassNode, Prism::ModuleNode + path = node.constant_path.location.slice + .delete_prefix("::") + return each_child(node) do |child| + collect_mixins(child, [*nesting, path], out) + end + when Prism::CallNode + note_mixin(node, nesting, out) + end + each_child(node) { |child| collect_mixins(child, nesting, out) } + end + + # A bare call mixes into the body it sits in; a receiver + # names the host itself. + def note_mixin(node, nesting, out) + return unless MIXINS.include?(node.name) + + host = case node.receiver + when nil then nesting.last + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.receiver.location.slice.delete_prefix("::") + end + return unless host + + Array(node.arguments&.arguments).each do |arg| + next unless constant_type?(arg) + + out << [host, arg.location.slice.delete_prefix("::"), + nesting] + end + end + + def settle_mixins(found, mixins) + loop do + changed = false + mixins.each do |host, mixin, nesting| + info = found[mixin] ||= {rule: nil, superclass: nil, + nesting: nesting} + next if info[:rule] + + rule = resolve_name(host, nesting, found) + next unless rule&.bound + + info[:rule] = derived_rule(rule, mixin, bound: true) + changed = true + end + break unless changed + end + end + + # A reference resolves the way Ruby would: against each + # level of the enclosing nesting, then as written. + def resolve_name(name, nesting, found) + nesting.size.downto(0) do |depth| + candidate = [*nesting.first(depth), name].join("::") + rule = namespace_rule(candidate) || + found.dig(candidate, :rule) + return rule if rule + end + nil + end + + def collect_classes(node, nesting, found) + case node + when Prism::ClassNode, Prism::ModuleNode + path = node.constant_path.location.slice + .delete_prefix("::") + note_class(node, nesting, path, found) if + node.is_a?(Prism::ClassNode) + return each_child(node) do |child| + collect_classes(child, [*nesting, path], found) + end + when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode, + Prism::ConstantPathWriteNode, + Prism::ConstantPathOrWriteNode + note_constant(node, nesting, found) + end + each_child(node) { |child| collect_classes(child, nesting, found) } + end + + # A constant bound to an extension-rooted value is the value + # under a new name; the name inherits the rule. + def note_constant(node, nesting, found) + path = if node.respond_to?(:target) + node.target.location.slice.delete_prefix("::") + else + node.name.to_s + end + fqn = [*nesting, path].join("::") + return if namespace_rule(fqn) || found.dig(fqn, :rule) + + @nesting = nesting + taint = prepass_rule(node.value, :instance, EMPTY_REGISTRY) + return unless taint + + found[fqn] = {rule: derived_rule(taint, fqn, bound: true), + superclass: nil, nesting: nesting} + end + + def note_class(node, nesting, path, found) + fqn = [*nesting, path].join("::") + return if namespace_rule(fqn) + + @nesting = [*nesting, path] + registry = build_registry(node) + taint = registry.ivars[:instance].values.first || + registry.methods[:instance].values.first + superclass = node.superclass + info = found[fqn] ||= {rule: nil, superclass: nil, + nesting: nesting} + info[:rule] ||= taint && derived_rule(taint, fqn) + if info[:superclass].nil? && constant_type?(superclass) + info[:superclass] = + superclass.location.slice.delete_prefix("::") + end + end + + def derived_rule(taint, fqn, bound: false) + Rule.new(gem: taint.gem, namespace: fqn, + verified: taint.verified, bound: bound) + end + + # Promotion follows inheritance to a fixpoint: the superclass + # reference resolves the way Ruby would, against each level + # of the subclass's own nesting, then as written. + def settle_subclasses(found) + loop do + changed = false + found.each do |fqn, info| + next if info[:rule] || info[:superclass].nil? + + parent = superclass_rule(info, found) + next unless parent + + info[:rule] = derived_rule(parent, fqn, + bound: parent.bound) + changed = true + end + break unless changed + end + end + + def superclass_rule(info, found) + resolve_name(info[:superclass], info[:nesting], found) + end + + # Pre-pass results for one class or module body: methods + # whose return value is rooted in a flagged namespace and + # ivars assigned such a value, split by definition side. + Registry = Data.define(:class_name, :methods, :ivars, + :self_extended) + + EMPTY_REGISTRY = Ractor.make_shareable( + Registry.new(class_name: "", + methods: {instance: {}, singleton: {}}, + ivars: {instance: {}, singleton: {}}, + self_extended: false) + ) + + Entry = Data.define(:side, :name, :return_expr, :ivar_writes, + :writer_calls) + + ATTR_ROLES = Ractor.make_shareable( + {attr_accessor: %i[reader writer], attr_reader: %i[reader], + attr_writer: %i[writer]} + ) + + WRITER_NAME = /\A[a-z_]\w*=\z/ + + # Collects the body's defs, attr declarations, and ivar + # writes, then settles taint to a fixpoint, so a factory + # defined below its callers still taints them. + def build_registry(node) + registry = Registry.new( + class_name: node.constant_path.location.slice + .delete_prefix("::"), + methods: {instance: {}, singleton: {}}, + ivars: {instance: {}, singleton: {}}, + self_extended: self_extended?(node.body) + ) + entries = [] + attrs = {instance: {reader: [], writer: []}, + singleton: {reader: [], writer: []}} + body = node.body + if body.is_a?(Prism::StatementsNode) + collect_entries(body.body, :instance, entries, attrs, + registry) + end + settle(registry, entries, attrs) + registry + end + + # extend self and a bare module_function make the body's + # instance methods singleton methods too, so a call through + # the module's own name reaches them. + def self_extended?(body) + return false unless body.is_a?(Prism::StatementsNode) + + body.body.any? do |stmt| + next false unless stmt.is_a?(Prism::CallNode) && + stmt.receiver.nil? + + case stmt.name + when :extend + Array(stmt.arguments&.arguments) + .any?(Prism::SelfNode) + when :module_function then stmt.arguments.nil? + end + end + end + + def collect_entries(statements, side, entries, attrs, registry) + sig = nil + statements.each do |stmt| + case stmt + when Prism::DefNode + def_side = + stmt.receiver.is_a?(Prism::SelfNode) ? :singleton : side + entries << def_entry(stmt, def_side) + note_sig_return(sig, stmt, def_side, registry) + when Prism::SingletonClassNode + if stmt.expression.is_a?(Prism::SelfNode) && + stmt.body.is_a?(Prism::StatementsNode) + collect_entries(stmt.body.body, :singleton, entries, + attrs, registry) + end + when Prism::CallNode + collect_attr(stmt, side, attrs) + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + entries << Entry.new(side: :singleton, name: nil, + return_expr: nil, + ivar_writes: [[stmt.name, stmt.value]], + writer_calls: []) + end + sig = sig_node?(stmt) ? stmt : nil + end + end + + # A sig return type under a flagged namespace marks the + # method as handing out extension values, body regardless. + def note_sig_return(sig, def_node, side, registry) + return unless sig + + returns = sig_chain_call(sig, :returns) + args = returns&.arguments&.arguments + rule = args && args.size == 1 && type_rule(args[0]) + registry.methods[side][def_node.name] = rule if rule + end + + def collect_attr(call, side, attrs) + roles = ATTR_ROLES[call.name] + return unless roles && call.receiver.nil? + + args = call.arguments&.arguments || [] + args.grep(Prism::SymbolNode).each do |sym| + name = sym.unescaped.to_sym + roles.each { |role| attrs[side][role] << name } + end + end + + def def_entry(node, side) + ivar_writes = [] + writer_calls = [] + scan_def_body(node.body, ivar_writes, writer_calls) + Entry.new(side: side, name: node.name, + return_expr: def_return_expr(node), + ivar_writes: ivar_writes, writer_calls: writer_calls) + end + + def scan_def_body(node, ivar_writes, writer_calls) + return if node.nil? || node.is_a?(Prism::ClassNode) || + node.is_a?(Prism::ModuleNode) || + node.is_a?(Prism::SingletonClassNode) || + node.is_a?(Prism::DefNode) + + case node + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + ivar_writes << [node.name, node.value] + when Prism::CallNode + args = node.arguments&.arguments + if node.receiver && args && args.size == 1 && + node.name.match?(WRITER_NAME) + writer_calls << [node.name.to_s.chomp("=").to_sym, + args.first] + end + end + node.child_nodes.compact.each do |child| + scan_def_body(child, ivar_writes, writer_calls) + end + end + + def def_return_expr(node) + body = node.body + body = body.statements if body.is_a?(Prism::BeginNode) + body.body.last if body.is_a?(Prism::StatementsNode) + end + + def settle(registry, entries, attrs) + loop do + changed = false + entries.each do |entry| + changed = true if settle_entry(entry, registry, attrs) + end + changed = true if settle_readers(registry, attrs) + break unless changed + end + end + + # An attr reader over a tainted ivar hands the taint out + # like a method returning it would. + def settle_readers(registry, attrs) + changed = false + %i[instance singleton].each do |side| + attrs[side][:reader].each do |name| + next if registry.methods[side][name] + + rule = registry.ivars[side][:"@#{name}"] + if rule + registry.methods[side][name] = rule + changed = true + end + end + end + changed + end + + def settle_entry(entry, registry, attrs) + changed = false + if entry.name && !registry.methods[entry.side][entry.name] && + (rule = return_rule(entry.return_expr, entry.side, registry)) + registry.methods[entry.side][entry.name] = rule + changed = true + end + entry.ivar_writes.each do |ivar, value| + next if registry.ivars[entry.side][ivar] + + rule = prepass_rule(value, entry.side, registry) + if rule + registry.ivars[entry.side][ivar] = rule + changed = true + end + end + entry.writer_calls.each do |name, value| + side = writer_side(name, entry.side, attrs) + next unless side + next if registry.ivars[side][:"@#{name}"] + + rule = prepass_rule(value, entry.side, registry) + if rule + registry.ivars[side][:"@#{name}"] = rule + changed = true + end + end + changed + end + + # A writer call taints the ivar behind the attr on the side + # that declares it, wherever the call sits. + def writer_side(name, caller_side, attrs) + %i[instance singleton] + .sort_by { |side| (side == caller_side) ? 0 : 1 } + .find { |side| attrs[side][:writer].include?(name) } + end + + def return_rule(expr, side, registry) + case expr + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + prepass_rule(expr.value, side, registry) + else + prepass_rule(expr, side, registry) + end + end + + # The pre-pass mirror of visit_call's taint sources, over + # syntax alone: a chain is tainted when its root is a + # flagged constant, a tainted ivar, or a call to a method + # the registry already holds. + def prepass_rule(node, side, registry) + case node + when Prism::InstanceVariableReadNode + registry.ivars[side][node.name] + when Prism::CallNode + if CORE_METHODS.include?(node.name) + return nil unless CHAIN_METHODS.include?(node.name) + + return prepass_rule(node.receiver, side, registry) + end + return nil if node.name.end_with?("?") + + if t_call?(node, :let) || t_call?(node, :cast) + args = node.arguments&.arguments + return (args && args.size >= 2) ? type_rule(args[1]) : nil + end + prepass_receiver_rule(node, side, registry) + end + end + + def prepass_receiver_rule(node, side, registry) + case (receiver = node.receiver) + when Prism::ConstantReadNode, Prism::ConstantPathNode + receiver_rule(receiver) || + (registry.methods[:singleton][node.name] if + names_class?(receiver, registry.class_name)) + when Prism::CallNode + if receiver.name == :class && + receiver.receiver.is_a?(Prism::SelfNode) + registry.methods[:singleton][node.name] + else + prepass_rule(receiver, side, registry) + end + when nil, Prism::SelfNode + registry.methods[side][node.name] + when Prism::InstanceVariableReadNode + registry.ivars[side][receiver.name] + end + end + + # The class's own name in receiver position reaches its + # singleton: the full path, or the trailing segments an + # inner reference would use. + def names_class?(receiver, class_name) + path = receiver.location.slice.delete_prefix("::") + path == class_name || class_name.end_with?("::#{path}") || + path == class_name.split("::").last + end + + # Returns the matched rule so the caller can taint the + # call's block even when the return value stays clean. + def visit_call(node, tainted, ivars, scan) + if CORE_METHODS.include?(node.name) + return chain_through(node, tainted, ivars, scan) + end + + if (rule = receiver_rule(node.receiver)) + scan.findings << finding_for(rule, node, scan.path) + scan.producers[node.object_id] = rule + elsif (rule = annotation_rule(node, scan)) + scan.producers[node.object_id] = rule + elsif (rule = taint_source(node, tainted, ivars, scan)) + scan.findings << derived_finding(rule, node, scan.path) + # A predicate returns a plain boolean and ends the + # chain; anything else is presumed to still live in the + # extension. + unless node.name.end_with?("?") + scan.producers[node.object_id] = rule + end + rule + elsif (rule = registry_rule(node)) + scan.producers[node.object_id] = rule + elsif (rule = self_call_rule(node)) + scan.findings << derived_finding(rule, node, scan.path) + unless node.name.end_with?("?") + scan.producers[node.object_id] = rule + end + rule + end + end + + # Inside a reopened bound class, self is the extension's + # own object: a self-call the body does not define lands in + # the extension, and so does a bare argumentless call that + # is not plain Ruby. + def self_call_rule(node) + return nil unless @self_rule + + if node.receiver.is_a?(Prism::SelfNode) + return nil if @self_defs.include?(node.name) + + @self_rule + elsif node.receiver.nil? && node.arguments.nil? && !node.block + return nil if @self_defs.include?(node.name) || + RUBY_METHODS.include?(node.name) + + @self_rule + end + end + + def chain_through(node, tainted, ivars, scan) + return unless CHAIN_METHODS.include?(node.name) + + rule = taint_source(node, tainted, ivars, scan) || + receiver_rule(node.receiver) + scan.producers[node.object_id] = rule if rule + end + + # Calls to methods the pre-pass proved to hand out extension + # values produce taint but no finding of their own: the + # finding lands where the value is used. + def registry_rule(node) + return nil unless @registry + + case (receiver = node.receiver) + when nil, Prism::SelfNode + method_rule(@singleton ? :singleton : :instance, node.name) + when Prism::CallNode + if receiver.name == :class && + receiver.receiver.is_a?(Prism::SelfNode) + method_rule(:singleton, node.name) + end + when Prism::ConstantReadNode, Prism::ConstantPathNode + if names_class?(receiver, @registry.class_name) + method_rule(:singleton, node.name) + end + end + end + + def method_rule(side, name) + @registry.methods[side][name] || + @return_taints[[seed_scope, side, name]] + end + + def taint_source(node, tainted, ivars, scan) + case (receiver = node.receiver) + when Prism::LocalVariableReadNode + tainted[receiver.name] + when Prism::InstanceVariableReadNode + ivars[receiver.name] + when Prism::CallNode + scan.producers[receiver.object_id] + end + end + + # T.let and T.cast assert the value's type: a type under a + # flagged namespace taints, any other constant type clears + # whatever the value expression suggested. + def annotation_rule(node, scan) + return nil unless t_call?(node, :let) || t_call?(node, :cast) + + args = node.arguments&.arguments + return nil unless args && args.size >= 2 + + rule = type_rule(args[1]) + return rule if rule + + constant_type?(args[1]) ? nil : scan.producers[args[0].object_id] + end + + def constant_type?(node) + node.is_a?(Prism::ConstantReadNode) || + node.is_a?(Prism::ConstantPathNode) + end + + def sig_node?(node) + node.is_a?(Prism::CallNode) && node.name == :sig && node.block + end + + # Params typed with a constant under a flagged namespace seed + # the def's taint scope. + def sig_taints(sig) + params = sig_chain_call(sig, :params) + args = params&.arguments&.arguments + return {} unless args + + taints = {} + args.grep(Prism::KeywordHashNode).each do |kw| + kw.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + + rule = type_rule(assoc.value) + taints[assoc.key.unescaped.to_sym] = rule if rule + end + end + taints + end + + # Param names the sig types with a constant outside every + # flagged namespace: proven plain, immune to seeding. + def plain_params(sig) + params = sig_chain_call(sig, :params) + args = params&.arguments&.arguments + return EMPTY_SET unless args + + plain = Set.new + args.grep(Prism::KeywordHashNode).each do |kw| + kw.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + next unless plain_type?(assoc.value) + + plain << assoc.key.unescaped.to_sym + end + end + plain + end + + # A bare unflagged constant, T.nilable of one, or a T:: + # container holding only such constants; T.untyped inside + # a container keeps the erasure. + def plain_type?(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + source_rule(node.location.slice).nil? + when Prism::CallNode + args = node.arguments&.arguments + if t_call?(node, :nilable) + args&.size == 1 && plain_type?(args.first) + elsif t_container?(node) + !args.nil? && args.all? { |arg| plain_type?(arg) } + else + false + end + else + false + end + end + + def t_container?(node) + return false unless node.name == :[] && + constant_type?(node.receiver) + + node.receiver.location.slice.delete_prefix("::") + .start_with?("T::") + end + + def sig_chain_call(sig, name) + body = sig.block.body + node = body.is_a?(Prism::StatementsNode) ? body.body.first : body + while node.is_a?(Prism::CallNode) + return node if node.name == name + + node = node.receiver + end + nil + end + + def type_rule(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + source_rule(node.location.slice) + when Prism::CallNode + args = node.arguments&.arguments + if t_call?(node, :nilable) && args&.size == 1 + type_rule(args.first) + end + end + end + + def t_call?(node, name) + node.name == name && + node.receiver.is_a?(Prism::ConstantReadNode) && + node.receiver.name == :T + end + + def index_rules + @rule_index = @rules.to_h { |rule| [rule.namespace, rule] } + end + + # Cumulative :: prefixes of the path against the namespace + # index, shortest first, tried under each level of the given + # nesting before the path as written—flat cost however + # many classes the tree promotes to rules. + def namespace_rule(path, nesting = EMPTY_NESTING) + segments = path.split("::") + nesting.size.downto(1) do |depth| + rule = prefix_rule(nesting.first(depth), segments) + return rule if rule + end + prefix_rule(EMPTY_NESTING, segments) + end + + def prefix_rule(base, segments) + prefix = base.empty? ? nil : base.join("::") + segments.each do |seg| + prefix = prefix ? "#{prefix}::#{seg}" : seg + rule = @rule_index[prefix] + return rule if rule + end + nil + end + + # An anchored path resolves from the root; anything else the + # way Ruby would look it up, innermost scope first. + def source_rule(slice) + if slice.start_with?("::") + namespace_rule(slice.delete_prefix("::")) + else + namespace_rule(slice, @nesting) + end + end + + def receiver_rule(receiver) + return nil unless constant_type?(receiver) + + source_rule(receiver.location.slice) + end + + def finding_for(rule, node, path) + interp = { + method: node.name, gem: rule.gem, + receiver: node.receiver.location.slice.delete_prefix("::") + } + message = rule.verified ? MESSAGE : UNVERIFIED_MESSAGE + # In a chained call the node spans its whole receiver; + # the method-name line is where the reader looks. + location = node.message_loc || node.location + Finding.new( + check: CHECK, + severity: :warning, + message: format(message, interp), + why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp), + fix: format(FIX, interp), + path: path, + line: location.start_line, + source: node.location.slice.lines.first&.strip + ) + end + + def derived_finding(rule, node, path) + interp = {method: node.name, gem: rule.gem} + location = node.message_loc || node.location + Finding.new( + check: CHECK, + severity: :warning, + message: format(DERIVED_MESSAGE, interp), + why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp), + fix: format(FIX, interp), + path: path, + line: location.start_line, + source: node.location.slice.lines.first&.strip + ) + end + end + end +end diff --git a/lib/audition/static/graph_audit.rb b/lib/audition/static/graph_audit.rb index 855e8d2..a5f8835 100644 --- a/lib/audition/static/graph_audit.rb +++ b/lib/audition/static/graph_audit.rb @@ -2,6 +2,7 @@ require "rubydex" require_relative "../rewriters" +require_relative "work_split" module Audition module Static @@ -65,40 +66,172 @@ class GraphAudit "containers) before spawning Ractors. Configuration " \ "that cannot be shareable needs per-Ractor state or a " \ "main-Ractor proxy instead." + DERIVED_WHY = + "The value comes from another constant whose own " \ + "definition is already flagged: freezing this one is " \ + "shallow and does not change what the referent holds, " \ + "so a non-main Ractor reading it can raise the same " \ + "Ractor::IsolationError." + DERIVED_FIX = + "Make the referenced constant deeply shareable first; " \ + "this finding follows its verdict." + CONTAINED_WHY = + "The assigned expression is flagged on this same line: " \ + "the constant holds whatever unshareable value that " \ + "expression produces, so a non-main Ractor reading the " \ + "constant hits the same problem." + SINGLETON_SCAN_WHY = + "The receiver is a runtime value, so the graph cannot " \ + "attribute the state this body writes to any class. If " \ + "the receiver is one, these are class-level instance " \ + "variables and a non-main Ractor raises " \ + "Ractor::IsolationError writing them." + SINGLETON_SCAN_FIX = + "Open the singleton on the constant itself so the state " \ + "resolves, or confirm the receiver with the dynamic " \ + "probe, which reads the live object graph." + ANCESTOR_SCAN_WHY = + "The ancestor is a runtime value, so whatever it " \ + "contributes—class-level state, class variables, " \ + "hostile APIs—is invisible here. A clean report for " \ + "this class covers only what the class itself declares." + ANCESTOR_SCAN_FIX = + "Name the superclass or module directly where the " \ + "hierarchy is static. Where it cannot be, audit the " \ + "candidates separately; the dynamic probe sweeps the " \ + "ancestors that are actually in play." + CONSTANT_SCAN_WHY = + "The path starts from a runtime value, so the constant " \ + "this assignment defines holds something the shareability " \ + "checks never saw." + CONSTANT_SCAN_FIX = + "Reference the constant by its static path, or let the " \ + "dynamic probe check the value with Ractor.shareable?." + + # Findings from these per-file checks seed the derived- + # constant propagation. + PROPAGATED_CHECKS = [ + "mutable-constants", "unshareable-reads", "native-gem-calls" + ].freeze + + # Checks that flag an expression rather than the constant + # it may be assigned to; when such a finding sits inside a + # constant assignment, the constant inherits it by name. + EXPRESSION_CHECKS = + ["unshareable-reads", "native-gem-calls"].freeze + + # The expressions rubydex reports as unresolvable. Each is a + # hole in the walks above, so where the shape could hide + # something they look for, the hole is reported. rubydex's + # parse rules are left out—the per-file syntax check already + # reports those—as are its visibility rules, which say + # nothing about Ractors. + SCAN_RULES = { + "DynamicSingletonDefinition" => :singleton, + "DynamicAncestor" => :ancestor, + "DynamicConstantReference" => :constant + }.freeze + + MIXINS = ["include", "extend", "prepend"].freeze + + STATE_WRITES = [ + Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode, + Prism::ClassVariableWriteNode, + Prism::ClassVariableOrWriteNode, + Prism::ClassVariableAndWriteNode, + Prism::ClassVariableOperatorWriteNode + ].freeze # @param sources [Hash{String => String}] path => source + # @param constant_findings [Array] per-file findings + # whose definition sites seed derived-constant propagation + # @param progress [Progress] # @return [Array] - def analyze_sources(sources) + def analyze_sources(sources, constant_findings: [], + workers: nil, progress: Progress::SILENT) graph = Rubydex::Graph.new + progress.stage("indexing", total: sources.size) sources.each do |path, code| + progress.tick graph.index_source(path, code, "ruby") end @sources = sources + @workers = workers @frozen_memos = frozen_memo_map(sources) - audit(graph) + @reported_lines = constant_findings + .map { |f| [f.path, f.line] }.to_set + @constant_findings = constant_findings.select do |f| + PROPAGATED_CHECKS.include?(f.check) + end + audit(graph, progress) end # rubydex's index_all descends directories but skips bare file # lists, so files are fed through index_source individually. # # @param paths [Array] files to index and audit + # @param constant_findings [Array] see + # {#analyze_sources} + # @param progress [Progress] # @return [Array] - def analyze_paths(paths) + def analyze_paths(paths, constant_findings: [], workers: nil, + progress: Progress::SILENT) sources = {} + progress.stage("reading", total: paths.size) paths.each do |path| + progress.tick sources[path] = File.read(path) rescue SystemCallError next end - analyze_sources(sources) + analyze_sources(sources, constant_findings: constant_findings, + workers: workers, progress: progress) + end + + # Names one slice of a scan declares, for a parallel audit to + # merge before any walk emits. + # + # @api private + # @param sources [Hash{String => String}] path => source + # @return [Hash{Symbol => Object}] + def gathered_names(sources) + @sources = sources + {writers: declared_writers, declared: declared_names, + extended: extended_names} + end + + # The class-state walks over one slice, against names + # gathered from the whole scan. + # + # @api private + # @param sources [Hash{String => String}] path => source + # @param seen [Set] path/line pairs a declaration claimed + # @param names [Hash] from {#gathered_names}, merged + # @return [Array>] one batch per walk + def walk_batches(sources, seen, names) + @sources = sources + [singleton_attr_findings, + extended_module_findings(seen, names[:extended]), + dynamic_ivar_findings(seen, names[:declared]), + attribute_write_findings(seen, names[:writers])] end private - def audit(graph) + # Resolution is one opaque call inside rubydex; everything + # after it walks declarations rather than files, so the stage + # names say which pass a waiting reader is watching. + def audit(graph, progress = Progress::SILENT) + progress.stage("resolving") graph.resolve findings = [] - graph.declarations.each do |decl| + declarations = graph.declarations + progress.stage("declarations", total: declarations.size) + declarations.each do |decl| + progress.tick case decl when Rubydex::ClassVariable findings.concat(class_variable_findings(decl)) @@ -106,9 +239,501 @@ def audit(graph) findings.concat(class_state_findings(decl)) end end + seen = findings.map { |f| [f.path, f.line] }.to_set + class_state_batches(seen, progress).each do |batch| + findings.concat(batch) + batch.each { |f| seen << [f.path, f.line] } + end + progress.stage("constants") + findings.concat(derived_constant_findings(graph)) + progress.stage("diagnostics") + findings.concat(static_scan_findings(graph, findings)) findings.sort_by { |f| [f.path, f.line] } end + # rubydex says which expressions it could not resolve; this + # turns the ones that matter into findings, so a blind spot + # reads as a blind spot rather than as a clean line. A + # diagnostic is dropped where something else already reported + # that line, and where the shape cannot hide what the walks + # look for. + def static_scan_findings(graph, found) + reported = @reported_lines + + found.map { |f| [f.path, f.line] } + seen = Set.new + graph.diagnostics.filter_map do |diagnostic| + kind = SCAN_RULES[diagnostic.rule.rule_name] + next unless kind + + path = path_from_uri(diagnostic.location.uri) + line = diagnostic.location.start_line + 1 + next unless seen.add?([path, line, kind]) + next if reported.include?([path, line]) + + scan_finding(kind, path, line) + end + end + + def scan_finding(kind, path, line) + root = diagnostic_root(path) + return nil unless root + + case kind + when :singleton then singleton_scan(root, path, line) + when :ancestor then ancestor_scan(root, path, line) + else constant_scan(root, path, line) + end + end + + # Only the files a diagnostic points at are parsed here. The + # walks may have run in Ractors, whose trees never reach this + # one, and the shapes are rare enough that re-reading the + # whole tree would cost more than the checks on it. + def diagnostic_root(path) + @diagnostic_roots ||= {} + return @diagnostic_roots[path] if @diagnostic_roots.key?(path) + + code = @sources[path] + file = code && SourceFile.new(source: code, path: path) + @diagnostic_roots[path] = file&.valid_syntax? ? file.root : nil + end + + # A runtime singleton target only matters when the body it + # opens writes state: that is what the graph could not + # attribute to a class. + def singleton_scan(root, path, line) + node = node_at(root, line) do |candidate| + candidate.is_a?(Prism::SingletonClassNode) || + (candidate.is_a?(Prism::DefNode) && candidate.receiver) + end + return nil unless node && state_writes?(node) + + scan(path, line, severity: :warning, + message: "class-level state behind an unresolved " \ + "singleton receiver", + why: SINGLETON_SCAN_WHY, fix: SINGLETON_SCAN_FIX) + end + + # A runtime superclass always defines a class. A runtime + # mixin argument only names an ancestor inside a class or + # module body: a matcher called `include` in a test reads the + # same way to the graph and has to stay silent. + def ancestor_scan(root, path, line) + klass = node_at(root, line) do |candidate| + candidate.is_a?(Prism::ClassNode) && candidate.superclass && + constant_slice(candidate.superclass).nil? + end + mixin = klass ? nil : body_mixins(path, root)[line] + return nil unless klass || mixin + + subject = + if klass + "superclass of #{klass.constant_path.slice}" + else + "#{mixin[0]} argument in #{mixin[1]}" + end + scan(path, line, severity: :warning, + message: "unresolved #{subject}", + why: ANCESTOR_SCAN_WHY, fix: ANCESTOR_SCAN_FIX) + end + + # A runtime constant path only matters where it defines a + # constant: elsewhere there is no name whose shareability + # anything would have checked. + def constant_scan(root, path, line) + name = constant_assigned_at(root, line) + return nil unless name + + scan(path, line, severity: :info, + message: "unresolved constant path in #{name}", + why: CONSTANT_SCAN_WHY, fix: CONSTANT_SCAN_FIX) + end + + def scan(path, line, severity:, message:, why:, fix:) + Finding.new( + check: "static-scan", + severity: severity, + message: message, + why: why, + fix: fix, + path: path, + line: line, + source: source_line(path, line) + ) + end + + def node_at(root, line) + queue = [root] + until queue.empty? + node = queue.shift + if node.location.start_line == line && yield(node) + return node + end + + queue.concat(node.compact_child_nodes) + end + nil + end + + # Nested classes and modules are skipped: their state is + # their own, and the graph resolves it. + def state_writes?(node) + queue = node.compact_child_nodes + until queue.empty? + child = queue.shift + return true if STATE_WRITES.include?(child.class) + next if child.is_a?(Prism::ClassNode) || + child.is_a?(Prism::ModuleNode) + + queue.concat(child.compact_child_nodes) + end + false + end + + def body_mixins(path, root) + @body_mixins ||= {} + @body_mixins[path] ||= collect_body_mixins(root) + end + + # Method and block bodies are skipped: a receiverless call in + # one is not a mixin into the enclosing class. + def collect_body_mixins(root) + found = {} + queue = root.compact_child_nodes.map { |node| [node, nil] } + until queue.empty? + node, owner = queue.shift + case node + when Prism::ClassNode, Prism::ModuleNode + owner = node.constant_path.slice + when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode + next + when Prism::CallNode + record_mixin(found, node, owner) + end + queue.concat( + node.compact_child_nodes.map { |child| [child, owner] } + ) + end + found + end + + # Keyed by every line the call spans, since the diagnostic + # points at the argument rather than the call. + def record_mixin(found, node, owner) + return unless owner && node.receiver.nil? && + MIXINS.include?(node.name.to_s) + return if resolved_mixin?(node) + + location = node.location + (location.start_line..location.end_line).each do |line| + found[line] ||= [node.name.to_s, owner] + end + end + + # The graph gave up on the line, but if every argument + # names a constant the walks read it anyway, and saying + # otherwise would contradict the finding they emit. + def resolved_mixin?(node) + arguments = Array(node.arguments&.arguments) + arguments.any? && + arguments.all? { |arg| constant_slice(arg) } + end + + def constant_assigned_at(root, line) + queue = [root] + until queue.empty? + node = queue.shift + location = node.location + next unless line + .between?(location.start_line, location.end_line) + + name = constant_target_name(node) + return name if name + + queue.concat(node.compact_child_nodes) + end + nil + end + + # The constant a node assigns, nil for anything else. + def constant_target_name(node) + case node + when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode + node.name.to_s + when Prism::ConstantPathWriteNode, + Prism::ConstantPathOrWriteNode + node.target.location.slice + end + end + + # Below this many files the Ractor spawn and the copy of the + # sources cost more than the walks they divide. + PARALLEL_THRESHOLD = 100 + + # Every batch is computed against the declaration findings + # alone, so the dedup set only suppresses lines a declaration + # already claimed. Each walks every parsed source once, which + # is why they are named separately. + def class_state_batches(seen, progress) + workers = @workers || WorkSplit.workers + if @sources.size < PARALLEL_THRESHOLD || workers <= 1 + serial_batches(seen, progress) + else + parallel_batches(seen, progress, workers) + end + rescue Ractor::Error, Ractor::ClosedError => e + # The silent fallback would otherwise mask a walk that is + # itself Ractor-hostile; surface it under -w. ClosedError + # is not a Ractor::Error: it is what sending to a worker + # that already died raises. + if $VERBOSE + warn "Audition: parallel graph audit fell back to " \ + "serial: #{e.class}: #{e.message}" + end + progress.ractors = nil + serial_batches(seen, progress) + end + + def serial_batches(seen, progress) + progress.stage("parsing", total: @sources.size) + source_roots(progress) + progress.stage("singletons") + singletons = singleton_attr_findings + progress.stage("extends") + extended = extended_module_findings(seen) + progress.stage("ivars") + ivars = dynamic_ivar_findings(seen) + progress.stage("writers") + [singletons, extended, ivars, + attribute_write_findings(seen)] + end + + # A walk may only emit once every name the whole scan + # declares is known, so the workers run in two rounds: gather + # the names of a slice, then walk it against the merged set. + # Parsed trees cannot cross a Ractor boundary, so the workers + # stay alive between the rounds and keep theirs. + def parallel_batches(seen, progress, workers) + experimental = Warning[:experimental] + Warning[:experimental] = false + port = Ractor::Port.new + chunks = chunks_for(workers) + progress.ractors = chunks.size + progress.stage("parsing") + ractors = chunks.map { |paths| worker(paths, port) } + names = merge_names(gather(port, ractors)) + progress.stage("state") + claimed = seen.to_a + ractors.each { |ractor| ractor.send([claimed, names]) } + collect_batches(ractors) + ensure + Warning[:experimental] = experimental + end + + def chunks_for(workers) + WorkSplit.chunks( + @sources.map { |path, code| [path, code.bytesize] }, workers + ) + end + + def worker(paths, port) + Ractor.new(slice(paths), port) do |sources, out| + audit = GraphAudit.new + begin + names = audit.gathered_names(sources) + ensure + # Sent from `ensure` so a gather that raises still + # releases the main Ractor; `Ractor#value` reports why. + out.send(names) + end + # Ruby cannot copy a Set whose elements are compound, so + # the claimed lines travel as pairs. + claimed, gathered = Ractor.receive + audit.walk_batches(sources, claimed.to_set, gathered) + end + end + + def gather(port, ractors) + parts = ractors.map { port.receive } + # nil stands in for a worker that died gathering; asking + # for its value raises what killed it. + ractors.each(&:value) if parts.any?(&:nil?) + parts + end + + # Each worker returns one batch per walk, in the order + # {#serial_batches} runs them; the batches concatenate walk + # by walk so the dedup a batch carries still holds. + def collect_batches(ractors) + ractors.map(&:value).transpose.map { |batch| batch.flatten(1) } + end + + def slice(paths) + paths.to_h { |path| [path, @sources[path]] } + end + + def merge_names(parts) + merged = {writers: {}, declared: Set.new, extended: Set.new} + parts.each do |part| + part[:writers].each do |owner, ivars| + (merged[:writers][owner] ||= Set.new).merge(ivars) + end + merged[:declared].merge(part[:declared]) + merged[:extended].merge(part[:extended]) + end + merged + end + + # A constant defined from another constant—an alias + # (`DEFAULT = PRIMARY`) or a frozen container of references + # (`ALL = [A, B].freeze`)—inherits the referent's problem, + # which the per-file classifier cannot see. The graph knows + # every reference, so flagged definitions propagate to any + # constant assignment that references them, transitively. + def derived_constant_findings(graph) + flagged = {} + @constant_findings.each do |f| + flagged[[f.path, f.line]] ||= f + end + return [] if flagged.empty? + + spans = assignment_spans + by_site = constant_declarations_by_site(graph) + results = [] + queue = flagged.keys.flat_map { |site| by_site[site] }.uniq + contained_findings(spans, flagged, results) do |site| + queue.concat(by_site[site]) + end + until queue.empty? + decl = queue.shift + source = flagged_source(decl, flagged) + next unless source + + decl.references.each do |ref| + path = path_from_uri(ref.location.uri) + line = ref.location.start_line + 1 + span = spans[path].find { |s| s[:lines].cover?(line) } + next unless span + + site = [path, span[:line]] + next if flagged.key?(site) + + finding = Finding.new( + check: "derived-constants", + severity: source.severity, + message: "constant #{span[:name]} references " \ + "#{decl.name}, itself flagged", + why: DERIVED_WHY, + fix: DERIVED_FIX, + path: path, + line: span[:line], + source: source_line(path, span[:line]) + ) + flagged[site] = finding + results << finding + queue.concat(by_site[site]) + end + end + results + end + + # An expression-check finding inside a constant assignment + # marks the constant itself: the assignment captures the + # flagged value under a name the graph can then follow. + def contained_findings(spans, flagged, results) + emitted = Set.new + flagged.to_a.each do |(path, line), seed| + next unless EXPRESSION_CHECKS.include?(seed.check) + + span = spans[path].find { |s| s[:lines].cover?(line) } + next unless span + + site = [path, span[:line]] + next unless emitted.add?(site) + + already = flagged[site] + next if already && !EXPRESSION_CHECKS.include?(already.check) + + finding = Finding.new( + check: "derived-constants", + severity: seed.severity, + message: "constant #{span[:name]} is assigned a " \ + "value flagged on this line", + why: CONTAINED_WHY, + fix: DERIVED_FIX, + path: path, + line: span[:line], + source: source_line(path, span[:line]) + ) + flagged[site] ||= finding + results << finding + yield site + end + end + + def constant_declarations_by_site(graph) + by_site = Hash.new { |h, k| h[k] = [] } + graph.declarations.each do |decl| + next unless constant_declaration?(decl) + + each_local_definition(decl).each do |defn| + site = [path_from_uri(defn.location.uri), + defn.location.start_line + 1] + by_site[site] << decl + end + end + by_site + end + + # rubydex leaves a declaration as a Todo when some reference + # keeps it from settling on a type. One with a constant name + # still carries definitions and references, and propagation + # already requires a flagged definition site plus references + # inside constant assignments, so it qualifies. + def constant_declaration?(decl) + case decl + when Rubydex::Constant, Rubydex::ConstantAlias + true + when Rubydex::Todo + decl.name.split("::").last&.match?(/\A[A-Z]/) || false + else + false + end + end + + def flagged_source(decl, flagged) + each_local_definition(decl).filter_map do |defn| + flagged[[path_from_uri(defn.location.uri), + defn.location.start_line + 1]] + end.first + end + + # Line spans of every constant assignment, so a reference + # landing inside one attributes to the constant it defines. + def assignment_spans + spans = Hash.new { |h, k| h[k] = [] } + @sources.each do |path, code| + file = SourceFile.new(source: code, path: path) + next unless file.valid_syntax? + + queue = [file.root] + until queue.empty? + node = queue.shift + queue.concat(node.child_nodes.compact) + name = constant_target_name(node) + next unless name + + location = node.location + spans[path] << { + lines: (location.start_line..location.end_line), + line: location.start_line, + name: name + } + end + end + spans + end + def class_variable_findings(decl) variable = decl.name.split("#").last owner = display_owner(decl.owner) @@ -164,6 +789,424 @@ def class_state_findings(decl) end end + # Parsed once: the passes below all walk every file. + def source_roots(progress = Progress::SILENT) + @source_roots ||= @sources.filter_map do |path, code| + progress.tick + file = SourceFile.new(source: code, path: path) + [path, file.root] if file.valid_syntax? + end.to_h + end + + # An attribute writer on a singleton class declares + # class-level state the same way an assignment does, but + # no line assigns the ivar, so the graph never sees it. + # A reader alone is left to the graph: whatever writes + # the ivar is already a declaration. + SINGLETON_ATTRS = ["attr_accessor", "attr_writer"].freeze + + def singleton_attr_findings + singleton_attrs.flat_map do |path, attrs| + attrs.map do |owner, ivar, line| + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "@#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + def singleton_attrs + @singleton_attrs ||= source_roots.to_h do |path, root| + attrs = [] + walk_singletons(root, [], attrs) + [path, attrs] + end + end + + # Assigning a declared singleton attribute writes the + # class-level instance variable behind it, which a non-main + # Ractor cannot do at all. The declaration is flagged where + # it sits; this is the assignment, which is a line of its + # own. Readers are left out: a read raises only on an + # unshareable value, and the declaration already says so. + def attribute_write_findings(seen, writers = declared_writers) + return [] if writers.empty? + + source_roots.flat_map do |path, root| + calls = [] + collect_writes(root, writers, calls) + calls.filter_map do |owner, name, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "@#{name} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Attribute names by their owner's last segment: a write + # site and the declaration rarely spell the path the same + # way. + def declared_writers + writers = {} + singleton_attrs.each_value do |attrs| + attrs.each do |owner, ivar, _| + key = owner.split("::").last + (writers[key] ||= Set.new) << ivar + end + end + writers + end + + def collect_writes(node, writers, out) + if node.is_a?(Prism::CallNode) + name = node.name.to_s + owner = name.end_with?("=") && + constant_slice(node.receiver) + if owner && writers[owner.split("::").last] + &.include?(name.chomp("=")) + out << [owner, name.chomp("="), + node.location.start_line] + end + end + node.compact_child_nodes.each do |child| + collect_writes(child, writers, out) + end + end + + # Extending a module makes its instance methods run with + # a class as self, so the ivars they assign live on that + # class. Which class is not knowable from the module, so + # the finding lands on the assignment. + IVAR_WRITES = [ + Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode + ].freeze + + def extended_module_findings(seen, names = extended_names) + return [] if names.empty? + + source_roots.flat_map do |path, root| + writes = [] + walk_extended(root, [], names, nil, writes) + writes.filter_map do |owner, ivar, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Writing an instance variable through its name reaches + # the same class-level state an assignment does, and the + # graph indexes assignments only. Verified on Ruby 4.0: + # set and remove always raise in a non-main Ractor, while + # get raises only on an unshareable value, so it rides on + # the declaration the way a plain read does. + DYNAMIC_WRITES = Ractor.make_shareable( + Set.new(%i[instance_variable_set remove_instance_variable]) + ) + + # Ruby hands these hooks the class that triggered them. + CLASS_HOOKS = Ractor.make_shareable( + Set.new(%i[inherited included extended prepended]) + ) + + def dynamic_ivar_findings(seen, declared = declared_names) + source_roots.flat_map do |path, root| + writes = [] + walk_dynamic(root, Context.new([], false, false, {}), + declared, writes) + writes.filter_map do |owner, ivar, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Every class and module the target declares, by last name + # segment: a receiver spelled one way at the call site and + # another at the definition still names the same thing. + def declared_names + names = Set.new + source_roots.each_value do |root| + collect_declared(root, names) + end + names + end + + def collect_declared(node, names) + case node + when Prism::ClassNode, Prism::ModuleNode + names << node.constant_path.slice.split("::").last + end + node.compact_child_nodes.each do |child| + collect_declared(child, names) + end + end + + # nesting: enclosing class and module names. + # singleton: whether self is a class or module here. + # sclass: whether a bare def here defines a class method. + # hooks: locals a class hook bound to a class. + Context = Struct.new(:nesting, :singleton, :sclass, :hooks) + + def walk_dynamic(node, context, declared, out) + context = descend_dynamic(node, context) + if node.is_a?(Prism::CallNode) + record_dynamic(node, context, declared, out) + end + node.compact_child_nodes.each do |child| + walk_dynamic(child, context, declared, out) + end + end + + # A class body has the class as self, but a bare def in + # one defines an instance method, where self is not. Only + # a receiver or an enclosing singleton class makes it a + # class method again. + def descend_dynamic(node, context) + case node + when Prism::ClassNode, Prism::ModuleNode + Context.new( + context.nesting + [node.constant_path.slice], + true, false, {} + ) + when Prism::SingletonClassNode + Context.new(context.nesting, true, true, context.hooks) + when Prism::DefNode + singleton = !node.receiver.nil? || context.sclass + Context.new(context.nesting, singleton, false, + singleton ? hook_locals(node) : {}) + else + context + end + end + + def hook_locals(node) + return {} unless CLASS_HOOKS.include?(node.name) + + first = node.parameters&.requireds&.first + return {} unless first.is_a?(Prism::RequiredParameterNode) + + {first.name => true} + end + + def record_dynamic(node, context, declared, out) + return unless DYNAMIC_WRITES.include?(node.name) + + name = node.arguments&.arguments&.first + return unless name.is_a?(Prism::SymbolNode) && + name.unescaped.start_with?("@") + + owner = dynamic_owner(node.receiver, context, declared) + return unless owner + + out << [owner, name.unescaped, node.location.start_line] + end + + # Only receivers that are a class or module for certain: + # self in a singleton, a constant the target declares, a + # hook's class argument, or anything's own class. + def dynamic_owner(receiver, context, declared) + case receiver + when nil, Prism::SelfNode + context.nesting.last if context.singleton + when Prism::ConstantReadNode, Prism::ConstantPathNode + name = constant_slice(receiver) + name if declared.include?(name.split("::").last) + when Prism::LocalVariableReadNode + receiver.slice if context.hooks[receiver.name] + when Prism::CallNode + receiver.slice if receiver.name == :class + end + end + + # The name a concern gives the module it puts on the class. + # Nothing in the concern's own source extends it: the mixin + # that does lives in whatever library defines the pattern, + # so within the target the name is the only evidence. + CLASS_METHODS = "ClassMethods" + + # The same methods declared as a block, with no module in + # the source to hang the name on: the concern synthesizes + # one under the conventional name at load time. + CLASS_METHODS_BLOCK = :class_methods + + # Mixing into a singleton class lands a module's instance + # methods on the class, the way extend does. + SINGLETON_MIXINS = Ractor.make_shareable( + Set.new(%i[prepend include]) + ) + + # A module reached through its last name segment: the + # extend site and the definition rarely spell the path + # the same way. + def extended_names + names = Set.new([CLASS_METHODS]) + source_roots.each_value { |root| collect_extends(root, names) } + names + end + + def collect_extends(node, names) + if node.is_a?(Prism::CallNode) && extend_call?(node) + Array(node.arguments&.arguments).each do |arg| + name = constant_slice(arg) + names << name.split("::").last if name + end + end + node.compact_child_nodes.each { |c| collect_extends(c, names) } + end + + def extend_call?(node) + return true if node.name == :extend + return false unless SINGLETON_MIXINS.include?(node.name) + + node.receiver.is_a?(Prism::CallNode) && + node.receiver.name == :singleton_class + end + + def constant_slice(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.slice.delete_prefix("::") + when Prism::CallNode + const_get_slice(node) + end + end + + # A constant fetched through `const_get` names it as + # plainly as the constant does, and a concern resolving + # its own companion module writes the extend that way. + # Only on self: an explicit receiver picks the scope at + # runtime, which is the blind spot the scan reports. + def const_get_slice(node) + return unless node.name == :const_get + return unless node.receiver.nil? || + node.receiver.is_a?(Prism::SelfNode) + + argument = Array(node.arguments&.arguments).first + case argument + when Prism::SymbolNode, Prism::StringNode + argument.unescaped + end + end + + # A def with a receiver writes the module's own state, + # which the graph already owns; a nested class starts + # its own instance side. + def walk_extended(node, nesting, names, owner, out) + case node + when Prism::ModuleNode + name = node.constant_path.slice + nesting += [name] + owner = nesting.join("::") if + names.include?(name.split("::").last) + when Prism::ClassNode + nesting += [node.constant_path.slice] + owner = nil + when Prism::CallNode + if node.block && node.name == CLASS_METHODS_BLOCK + owner = (nesting + [CLASS_METHODS]).join("::") + end + when Prism::DefNode + return if node.receiver + else + if owner && IVAR_WRITES.any? { |k| node.is_a?(k) } + out << [owner, node.name.to_s, node.location.start_line] + end + end + node.compact_child_nodes.each do |child| + walk_extended(child, nesting, names, owner, out) + end + end + + # Tracks the lexical nesting so `class << self` resolves + # to the class it sits in, and `class << Name` to that name. + def walk_singletons(node, nesting, out) + case node + when Prism::ClassNode, Prism::ModuleNode + nesting += [node.constant_path.slice] + when Prism::SingletonClassNode + owner = singleton_owner(node, nesting) + collect_attrs(node.body, owner, out) if owner + end + node.compact_child_nodes.each do |child| + walk_singletons(child, nesting, out) + end + end + + def singleton_owner(node, nesting) + case node.expression + when Prism::SelfNode then nesting.last + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.expression.slice + end + end + + # Only the body's own statements count: a nested def or + # block calling attr_accessor defines something else. + def collect_attrs(body, owner, out) + return unless body.is_a?(Prism::StatementsNode) + + body.body.each do |stmt| + next unless stmt.is_a?(Prism::CallNode) && + stmt.receiver.nil? && + SINGLETON_ATTRS.include?(stmt.name.to_s) + + Array(stmt.arguments&.arguments).each do |arg| + name = attr_name(arg) + out << [owner, name, stmt.location.start_line] if name + end + end + end + + def attr_name(node) + case node + when Prism::SymbolNode, Prism::StringNode then node.unescaped + end + end + def each_local_definition(decl) decl.definitions.reject do |defn| defn.location.uri.start_with?("rubydex:") @@ -353,7 +1396,7 @@ def frozen_memo_value?(value, classifier) end end - # "Payments::" reads as noise; show "Payments". + # "Widget::" reads as noise; show "Widget". # Nested singleton owners produce nested angle brackets, so # the strip repeats until the tail is gone. def display_owner(owner) diff --git a/lib/audition/static/literal_classifier.rb b/lib/audition/static/literal_classifier.rb index 6113ec7..3739ec9 100644 --- a/lib/audition/static/literal_classifier.rb +++ b/lib/audition/static/literal_classifier.rb @@ -16,6 +16,10 @@ module Static # :proc lambda or proc # :default_proc Hash.new with a block; the block # survives .freeze and stays unshareable + # :instance_new unfrozen instance of an arbitrary class + # :opaque_call method call with an unprovable return + # :shallow_opaque frozen at the top, but what it holds + # is an unprovable call result # :unknown cannot tell statically class LiteralClassifier SYNC_PRIMITIVES = %w[ @@ -51,6 +55,18 @@ class LiteralClassifier ].freeze FORMATTERS = %i[format sprintf].freeze REGEXP_FACTORIES = %i[new union compile].freeze + # File methods returning a fresh path String. + FILE_PATH_METHODS = %i[ + expand_path join dirname basename absolute_path realpath + ].freeze + + # Calls returning a shareable primitive on any receiver. + SHAREABLE_RETURNS = %i[ + to_i to_int to_f to_r to_c to_sym size length count + bytesize ord hash + ].freeze + # Sorbet's inline casts, which return their value argument. + SORBET_CASTS = %i[let cast must].freeze # @param frozen_string_literal [Boolean] whether the file has # the frozen_string_literal magic comment @@ -61,6 +77,7 @@ def initialize(frozen_string_literal:) # @param node [Prism::Node] an expression node # @return [Symbol] classification, see class docs def classify(node) + node = begin_value(node) case node when Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode, @@ -94,6 +111,29 @@ def classify(node) end end + # The value an expression hands back once begin blocks + # and inline casts are peeled off. + def unwrap(node) + loop do + inner = begin_value(node) + inner = cast_value(inner) || inner + break node if inner.equal?(node) + + node = inner + end + end + + # A begin block's value is its last statement. A rescue, + # else, or ensure clause can supply a different one, so + # only the plain form resolves. + def begin_value(node) + return node unless node.is_a?(Prism::BeginNode) && + node.rescue_clause.nil? && node.else_clause.nil? && + node.ensure_clause.nil? + + node.statements&.body&.last || node + end + # `::Mutex` and `Mutex` are the same constant for matching # purposes; the leading colons are stripped. def const_name(node) @@ -105,6 +145,17 @@ def const_name(node) end end + # The value inside a Sorbet inline cast, or nil. The cast + # returns its argument, so fixes and type names belong on + # the value, not the cast. + def cast_value(node) + return nil unless node.is_a?(Prism::CallNode) + return nil unless const_name(node.receiver) == "T" && + SORBET_CASTS.include?(node.name) + + node.arguments&.arguments&.first + end + # What `value.freeze` would classify as, for the check to # choose a plain `.freeze` over a deep wrap: :shareable # when every element is provably shareable, :shallow_freeze @@ -165,9 +216,11 @@ def classify_call(node) return :mutable_container end - :unknown - when :[], :to_set + name ? :instance_new : :opaque_call + when :to_set set_kind(node) + when :[] + index_kind(node) when :define (const_name(receiver) == "Data") ? :shareable : :unknown when :make_shareable @@ -175,14 +228,46 @@ def classify_call(node) when :lambda, :proc (receiver.nil? && node.block) ? :proc : :unknown else - :unknown + opaque_kind(node) end end + # Catch-all for unrecognized calls: shareable returns pass, + # predicates stay silent, everything else is opaque. + def opaque_kind(node) + return :shareable if SHAREABLE_RETURNS.include?(node.name) + return :unknown if node.name.end_with?("?") + + if (value = cast_value(node)) + return classify(value) + end + + :opaque_call + end + + # Indexing into a constant, another call's result, or a + # fresh instance returns a value of unprovable shareability. + # Sorbet type constructors and ENV, whose values are frozen + # strings, stay silent. + def index_kind(node) + owner = const_name(node.receiver) + return set_kind(node) if owner == "Set" + if owner.nil? + receiver_kind = classify(node.receiver) + return :opaque_call if %i[opaque_call instance_new] + .include?(receiver_kind) + return :unknown + end + return :unknown if owner == "ENV" || + owner == "T" || owner.start_with?("T::") + + :opaque_call + end + def classify_freeze(node, receiver) return :unknown unless node.arguments.nil? && receiver - case receiver + case (receiver = begin_value(receiver)) when Prism::StringNode :shareable when Prism::ArrayNode, Prism::HashNode @@ -194,6 +279,7 @@ def classify_freeze(node, receiver) when :default_proc then :default_proc when :mutable_call then :shareable when :mutable_container then frozen_kind(receiver) + when :instance_new, :opaque_call then :shallow_opaque else :unknown end else @@ -214,6 +300,7 @@ def fresh_string?(node) owner = const_name(receiver) (name == :new && owner == "String") || (owner == "Kernel" && FORMATTERS.include?(name)) || + (owner == "File" && FILE_PATH_METHODS.include?(name)) || STRING_ONLY_METHODS.include?(name) else false @@ -243,12 +330,12 @@ def elements_kind(elements) end # A Set built from literals is a container of them: - # `Set.new([...])`, `Set[...]`, `%w[...].to_set`. Anything - # else, such as `Set.new(compute)` or a mapping block, - # stays unknown. + # `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set + # built from an opaque source or a mapping block is still + # a fresh unfrozen Set, mutable no matter its contents. def set_kind(node) elements = set_elements(node) - elements ? elements_kind(elements) : :unknown + elements ? elements_kind(elements) : :mutable_container end def set_elements(node) @@ -308,20 +395,33 @@ def single_statement(statements) body && body.size == 1 && body[0] end - # Fold element classifications: everything provably shareable - # gives :shareable; anything provably mutable gives - # :shallow_freeze; a sync primitive poisons the whole - # container; anything unknowable gives :unknown (stay silent - # rather than guess). + # Fold element classifications by the strongest evidence: + # a sync primitive poisons the whole container; a provably + # mutable element makes it :shallow_freeze; an opaque call + # result makes it :shallow_opaque. A bare constant read is + # commonly a class or another frozen constant, so on its + # own it keeps the container silent (:unknown), but it + # cannot excuse a bad element elsewhere. + VERDICT_RANK = { + shareable: 0, unknown: 1, shallow_opaque: 2, + shallow_freeze: 3 + }.freeze + def deep_classify(elements) verdict = :shareable elements.each do |element| element_children(element).each do |child| - case classify(child) - when :shareable then nil - when :sync_primitive then return :sync_primitive - when :unknown then return :unknown - else verdict = :shallow_freeze + kind = + case classify(child) + when :shareable then :shareable + when :sync_primitive then return :sync_primitive + when :unknown then :unknown + when :instance_new, :opaque_call, :shallow_opaque + :shallow_opaque + else :shallow_freeze + end + if VERDICT_RANK[kind] > VERDICT_RANK[verdict] + verdict = kind end end end diff --git a/lib/audition/static/native_extensions.rb b/lib/audition/static/native_extensions.rb index 2bdd1bd..9535065 100644 --- a/lib/audition/static/native_extensions.rb +++ b/lib/audition/static/native_extensions.rb @@ -22,9 +22,9 @@ class NativeExtensions INIT = /\bInit_\w+\s*\(/ SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}" BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze - # Harness crates and test trees under ext/ are not compiled - # into the extension; a fuzz target that links the VM itself - # may call rb_ext_ractor_safe without the extension doing so. + # Harness crates and test trees are not compiled into the + # extension; a fuzz target that links the VM itself may call + # rb_ext_ractor_safe without the extension doing so. HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze SILENT_WHY = @@ -51,7 +51,7 @@ class NativeExtensions DECLARED_WHY = "rb_ext_ractor_safe(true) is the maintainer's assertion " \ "that the extension keeps no process-global mutable state; " \ - "Ruby does not verify it and neither can audition. Its " \ + "Ruby does not verify it and neither can Audition. Its " \ "methods run from any Ractor, in parallel, on the strength " \ "of that assertion alone." DECLARED_FIX = @@ -91,12 +91,16 @@ def compiled_finding(path) nil end - # One finding per extension directory (ext/), anchored + # One finding per extension directory, anchored # at the declaration when there is one, else at Init_* or the # build file, where the declaration belongs. def source_finding(dir, root) sources = sources_under(dir) - label = dir.delete_prefix("#{root}/") + label = if dir == root + File.basename(root) + else + dir.delete_prefix("#{root}/") + end path, line = locate(sources, DECLARATION) if path return finding(:info, path, line, @@ -111,18 +115,26 @@ def source_finding(dir, root) "safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX) end - # ext/ directories holding native sources; ext/ itself - # when the sources sit directly in it. + # Directories holding native sources, found by the build file + # that compiles them rather than by convention: a gem is free + # to put its extension anywhere. A manifest above another one + # is dropped, so a workspace does not report the sources of + # the crates under it a second time. def extension_dirs(root) - ext = File.join(root, "ext") - return [] unless File.directory?(ext) - - dirs = Dir[File.join(ext, "*")].sort.select do |dir| - File.directory?(dir) && !skipped?(File.basename(dir)) && - sources_under(dir).any? + dirs = build_dirs(root).select { |dir| sources_under(dir).any? } + dirs.reject do |dir| + dirs.any? { |other| other.start_with?("#{dir}/") } end - dirs << ext if Dir[File.join(ext, SOURCES)].any? - dirs + end + + def build_dirs(root) + pattern = "{#{BUILD_FILES.join(",")}}" + Dir[File.join(root, "**", pattern)].filter_map do |path| + relative = path.delete_prefix("#{root}/").split("/") + next if relative[0..-2].any? { |part| skipped?(part) } + + File.dirname(path) + end.uniq.sort end def sources_under(dir) diff --git a/lib/audition/static/work_split.rb b/lib/audition/static/work_split.rb new file mode 100644 index 0000000..ba81ec0 --- /dev/null +++ b/lib/audition/static/work_split.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "etc" + +module Audition + module Static + # How a scan is divided between Ractor workers. + module WorkSplit + # Ractors run on a fixed pool of native threads sized by + # RUBY_MAX_CPU, which only the environment can set. Spawning + # past it buys no parallelism and costs a Ractor each. + RACTOR_CPU_DEFAULT = 8 + + module_function + + # One worker per core the Ractor pool can actually run. The + # main Ractor only waits while workers scan, so no core is + # held back for it. + # + # @return [Integer] + def workers + Etc.nprocessors.clamp(1, ractor_cpu_limit) + end + + def ractor_cpu_limit + limit = ENV["RUBY_MAX_CPU"].to_i + limit.positive? ? limit : RACTOR_CPU_DEFAULT + end + + # Longest-processing-time-first: deal the heaviest item onto + # the lightest worker. Consecutive files are neighbors in the + # tree and so alike in size, so contiguous slices come out + # lopsided: one worker can draw a slice weighing several times + # the mean and still be running once the rest have finished. + # Greedy is enough here: LPT finishes within 4/3 of an + # optimal split. + # + # @param weighted [Array] `[item, weight]` pairs + # @param count [Integer] worker count + # @return [Array] one chunk of items per busy worker + def chunks(weighted, count) + chunks = Array.new(count) { [] } + loads = Array.new(count, 0) + weighted.sort_by { |item, weight| [-weight, item] } + .each do |item, weight| + lightest = loads.index(loads.min) + chunks[lightest] << item + loads[lightest] += weight + end + chunks.reject(&:empty?) + end + end + end +end diff --git a/lib/audition/target.rb b/lib/audition/target.rb index 5ec6c7d..40a644e 100644 --- a/lib/audition/target.rb +++ b/lib/audition/target.rb @@ -12,6 +12,12 @@ class Target vendor node_modules tmp log coverage pkg .git .bundle ].freeze + # Directories holding the target's tests rather than the code + # a production boot loads. The default; `test_dirs` in + # .audition.yml replaces it for a project that files them + # elsewhere. + TEST_DIRS = %w[test spec features].freeze + # Cargo and Zig build output: full of .so/.dylib artifacts that # are not the extension `require` loads. BUILD_DIRS = %w[target zig-out].freeze @@ -20,6 +26,11 @@ class Target # "bundle" on macOS, "so" everywhere else, Windows included). COMPILED = "*.{bundle,so}" + # Generated type stubs. The extension is the convention; where + # the generator writes them is not, so they are found by + # extension anywhere in the tree. + STUBS = "*.rbi" + # @return [Symbol] one of `:script`, `:gem`, `:rack`, `:rails`, # `:directory`, `:bundle` attr_reader :type @@ -34,6 +45,10 @@ class Target # shipped with the target; empty for scripts and file lists attr_reader :compiled_files + # @return [Array] generated type stubs (.rbi) found + # anywhere in the target's tree + attr_reader :stub_files + # @return [Hash, nil] dynamic probe entry (`:mode` plus # mode-specific keys), nil for static-only targets attr_reader :entry @@ -75,7 +90,8 @@ def self.for_files(paths) type: :files, root: Dir.pwd, ruby_files: paths, - entry: nil + entry: nil, + stub_files: stubs(Dir.pwd) ) end @@ -96,10 +112,26 @@ def self.from_file(path) type: :script, root: File.dirname(path), ruby_files: [path], - entry: {mode: :script, path: path} + # A file inside a Rails root cannot run standalone; the + # script probe would report a misleading boot failure, so + # such files stay static-only. + entry: rails_member?(path) ? nil : {mode: :script, path: path}, + stub_files: stubs(File.dirname(path)) ) end + def self.rails_member?(path) + dir = File.dirname(File.expand_path(path)) + until dir == (parent = File.dirname(dir)) + return true if File.file?( + File.join(dir, "config", "application.rb") + ) + + dir = parent + end + false + end + def self.from_directory(dir) application_rb = File.join(dir, "config", "application.rb") config_ru = File.join(dir, "config.ru") @@ -117,7 +149,8 @@ def self.from_directory(dir) root: dir, ruby_files: glob(dir), entry: nil, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end end @@ -131,16 +164,21 @@ def self.from_gem_name(name) glob(File.join(spec.full_gem_path, rp)) end, entry: {mode: :require, feature: name, + load_paths: spec.full_require_paths, root: spec.full_gem_path}, - compiled_files: compiled_for(spec) + compiled_files: compiled_for(spec), + stub_files: stubs(spec.full_gem_path) ) rescue Gem::MissingSpecError raise Error, "#{name} is not a file, directory, or installed gem" end + # An app's own Ruby is not confined to app/lib/config: local + # gems, engines and tests boot into the same process, so the + # whole root is scanned. def self.rails_target(dir) - files = %w[app lib config].flat_map { |d| glob(File.join(dir, d)) } + files = glob(dir) files << File.join(dir, "config.ru") new( type: :rails, @@ -151,7 +189,8 @@ def self.rails_target(dir) environment: File.join(dir, "config", "environment.rb"), root: dir }, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end @@ -160,24 +199,31 @@ def self.rack_target(dir, config_ru) type: :rack, root: dir, ruby_files: [config_ru] + glob(dir), - entry: {mode: :rack, config_ru: config_ru}, - compiled_files: compiled(dir) + entry: {mode: :rack, config_ru: config_ru, root: dir}, + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end + # Reading require_paths off the gemspec would mean evaluating + # it, which the static pass never does; `lib` is the packaging + # default, and a gem that keeps its code elsewhere falls back to + # the whole checkout rather than scanning nothing. def self.gem_dir_target(dir, gemspec) lib = File.join(dir, "lib") + paths = File.directory?(lib) ? [lib] : [dir] new( type: :gem, root: dir, - ruby_files: glob(lib), + ruby_files: paths.flat_map { |path| glob(path) }, entry: { mode: :require, feature: File.basename(gemspec, ".gemspec"), - load_paths: [lib], + load_paths: paths, root: dir }, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end @@ -199,6 +245,14 @@ def self.glob(dir, pattern = "*.rb", skip: EXCLUDED_DIRS) end.sort end + # Generated type stubs anywhere under a directory. + # + # @param dir [String] + # @return [Array] + def self.stubs(dir) + glob(dir, STUBS) + end + # macOS debug-symbol bundles (x.bundle.dSYM/...) carry a file # with the extension's name that nothing ever loads. def self.compiled(dir) @@ -222,15 +276,30 @@ def self.compiled_for(spec) private_class_method :from_file, :from_directory, :from_gem_name, :rails_target, :rack_target, :gem_dir_target, - :glob, :compiled, :normalize + :rails_member?, :glob, :compiled, :normalize def initialize(type:, root:, ruby_files:, entry:, - compiled_files: []) + compiled_files: [], stub_files: []) @type = type @root = root @ruby_files = ruby_files @entry = entry @compiled_files = compiled_files + @stub_files = stub_files + end + + # Whether a path is test/spec code rather than code the + # production boot loads. + # + # @param path [String] absolute or root-relative + # @param dirs [Array] directory names that hold tests + # @return [Boolean] + def test_file?(path, dirs: TEST_DIRS) + relative = File.expand_path(path, root) + .delete_prefix("#{root}/") + parts = relative.split("/") + parts[0..-2].any? { |p| dirs.include?(p) } || + parts.last.to_s.end_with?("_test.rb", "_spec.rb") end end end diff --git a/skills/ractor-readiness/SKILL.md b/skills/ractor-readiness/SKILL.md index d857066..18a8be0 100644 --- a/skills/ractor-readiness/SKILL.md +++ b/skills/ractor-readiness/SKILL.md @@ -12,18 +12,18 @@ description: Use when asked whether Ruby code (a gem, script, license: MIT --- -# Checking and fixing Ractor readiness with audition +# Checking and fixing Ractor readiness with Audition ## Overview -audition (a Ruby gem) probes any target for Ractor readiness: +Audition (a Ruby gem) probes any target for Ractor readiness: script, gem, directory, Rack app, Rails root, or a whole Gemfile.lock. Static Prism and rubydex checks plus a dynamic probe that loads the target inside a real Ractor; every finding carries a why and a fix, and mechanical fixes are automated. Do not hand-roll Ractor analysis or re-verify Ruby 4.0 Ractor semantics; the tool encodes them, verified empirically. Written -against audition 0.3.x. +against Audition 0.3.x. ## Quick reference @@ -91,7 +91,7 @@ the natural place. ## Native extensions (C, Rust, and Zig) -audition cannot read native code, but it reports the one +Audition cannot read native code, but it reports the one fact that decides its Ractor behavior. Ractor::UnsafeError ("ractor unsafe method called from not main ractor") is not an IsolationError: it means a compiled extension never @@ -101,7 +101,7 @@ extension is C, Rust, or Zig. The `native-extension` check byte-scans every compiled file in the target (*.bundle on macOS, *.so elsewhere) for that import and warns when it is missing; an unbuilt checkout is scanned at the source level -(ext/**) instead. A declared extension gets an info note: the +instead, beside the build file that compiles it. A declared extension gets an info note: the declaration is the maintainer's assertion, not a proof, and only real calls from a Ractor (script and Rack probes, or the gem's own tests under Ractor.new) verify it. diff --git a/spec/audition/bundle_sweep_spec.rb b/spec/audition/bundle_sweep_spec.rb index e61fad0..ca3d068 100644 --- a/spec/audition/bundle_sweep_spec.rb +++ b/spec/audition/bundle_sweep_spec.rb @@ -46,6 +46,25 @@ def write_lockfile(dir, specs) end end + it "narrates one named unit per gem" do + Dir.mktmpdir do |dir| + lock = write_lockfile(dir, ["no-such-gem-xyz (1.0.0)"]) + io = StringIO.new + progress = Audition::Progress.new( + renderer: Audition::Progress::Log.new(io, + style: Audition::Report::Style.new(color: false, + hyperlinks: false)) + ) + + described_class.new(lockfile: lock, static_only: true) + .rows(progress: progress) + + expect(io.string) + .to include("Audition: sweep no-such-gem-xyz (1/1)") + expect(io.string).to match(/sweep: 1 gems in/) + end + end + it "audits the installed spec of the locked version" do Dir.mktmpdir do |dir| lock = write_lockfile(dir, ["mygem (1.2.3)"]) diff --git a/spec/audition/cli_spec.rb b/spec/audition/cli_spec.rb index 76d4dda..99b0c8a 100644 --- a/spec/audition/cli_spec.rb +++ b/spec/audition/cli_spec.rb @@ -182,6 +182,23 @@ def self.config = (@config ||= { "a" => 1 }) end end + it "rates findings under a configured test dir as test code" do + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "qa")) + File.write(File.join(dir, "qa", "case.rb"), "$x = 1\n") + + default, plain, = run(dir, "--static-only") + File.write(File.join(dir, ".audition.yml"), + "test_dirs:\n - qa\n") + _, tagged, = run(dir, "--static-only") + + expect(plain).not_to include("(tests)") + expect(default).to eq(1) + expect(tagged).to include("(tests)") + expect(tagged).to include("1 test finding") + end + end + it "supports the baseline adoption workflow" do Dir.mktmpdir do |dir| path = File.join(dir, "bad.rb") @@ -358,13 +375,31 @@ def tty? end end - it "zebra-stripes the sweep table on interactive terminals" do + it "colors sweep rows by verdict, foreground only" do Dir.mktmpdir do |dir| - lock = stub_sweep(dir, [sweep_row, sweep_row(name: "zz")]) + lock = stub_sweep(dir, [ + sweep_row(name: "baddy", verdict: :not_ready, errors: 3), + sweep_row(name: "goody", verdict: :ready, warnings: 0) + ]) _, out, = tty_run(lock, "--static-only") - expect(out).to match(/\e\[[0-9;]*48;2;/) + expect(out).to include("\e[31m") + expect(out).not_to match(/48;2;/) + end + end + + it "blanks clean counts and glyphs the sweep verdicts" do + Dir.mktmpdir do |dir| + lock = stub_sweep(dir, [ + sweep_row(name: "goody", verdict: :ready, warnings: 0) + ]) + + _, out, = run(lock, "--static-only") + + expect(out).to include("ok ready") + expect(out).to include("Audition bundle sweep") + expect(out).not_to match(/\|\s+0\s+\|/) end end diff --git a/spec/audition/config_spec.rb b/spec/audition/config_spec.rb index 28d7a72..3f1ddaa 100644 --- a/spec/audition/config_spec.rb +++ b/spec/audition/config_spec.rb @@ -39,6 +39,42 @@ def write_config(dir, content) end end end + + it "defaults test_dirs to the conventional three" do + Dir.mktmpdir do |dir| + write_config(dir, "fail_on: warning\n") + + expect(described_class.load(dir).test_dirs) + .to eq(Audition::Target::TEST_DIRS) + end + end + + it "replaces test_dirs rather than adding to them" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs:\n - qa\n - checks\n") + + expect(described_class.load(dir).test_dirs) + .to eq(%w[qa checks]) + end + end + + it "honors an empty test_dirs list" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs: []\n") + + expect(described_class.load(dir).test_dirs).to eq([]) + end + end + + it "rejects a test_dirs entry that is a path" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs:\n - qa/unit\n") + + expect { described_class.load(dir) }.to raise_error( + Audition::Error, /without slashes/ + ) + end + end end describe "#excluded?" do diff --git a/spec/audition/dynamic/prober_spec.rb b/spec/audition/dynamic/prober_spec.rb index 03be1f2..d548812 100644 --- a/spec/audition/dynamic/prober_spec.rb +++ b/spec/audition/dynamic/prober_spec.rb @@ -102,6 +102,21 @@ module HelperDep end end + it "pins the failing line from the Ractor backtrace" do + Dir.mktmpdir do |dir| + path = write(dir, "late_fail.rb", <<~RUBY) + x = [1, 2].sum + $boom = x + RUBY + + result = prober.probe(mode: :script, path: path) + + finding = result.findings.first + expect(finding.path).to eq(path) + expect(finding.line).to eq(2) + end + end + it "passes a self-contained script" do Dir.mktmpdir do |dir| path = write(dir, "clean.rb", "x = [1, 2].sum\nraise unless x == 3\n") @@ -159,6 +174,59 @@ class Engine end end + it "catches state the boot plants on reopened core classes" do + Dir.mktmpdir do |dir| + write(dir, "lib/reopener.rb", <<~RUBY) + class String + @audition_planted = {"k" => 1} + end + module Comparable + @@audition_legacy = true + end + module Reopener + end + RUBY + + result = prober.probe( + mode: :require, + feature: "reopener", + load_paths: [File.join(dir, "lib")] + ) + + messages = result.findings.map(&:message) + expect(messages.grep(/@audition_planted on String/)) + .not_to be_empty + expect(messages.grep(/@@audition_legacy.* on Comparable/)) + .not_to be_empty + end + end + + it "names the blocker inside an unshareable constant" do + Dir.mktmpdir do |dir| + write(dir, "lib/blockers.rb", <<~RUBY) + module Blockers + JUST_UNFROZEN = [1, 2] + DEEP = {"handler" => proc { 1 }}.freeze + LEAF = [["s".dup].freeze].freeze + end + RUBY + + result = prober.probe( + mode: :require, + feature: "blockers", + load_paths: [File.join(dir, "lib")] + ) + + messages = result.findings.map(&:message) + expect(messages.grep(/JUST_UNFROZEN.*just not frozen/)) + .not_to be_empty + expect(messages.grep(/DEEP.*blocked by Proc inside/)) + .not_to be_empty + expect(messages.grep(/LEAF.*blocked by unfrozen String inside/)) + .not_to be_empty + end + end + it "falls back to the single top-level lib file of the target" do Dir.mktmpdir do |dir| write(dir, "lib/active_thing.rb", <<~RUBY) @@ -181,6 +249,28 @@ module ActiveThing end end + it "finds that fallback on a load path that is not lib" do + Dir.mktmpdir do |dir| + write(dir, "src/active_thing.rb", <<~RUBY) + # frozen_string_literal: true + + module ActiveThing + VERSION = "1.0" + end + RUBY + + result = prober.probe( + mode: :require, + feature: "activething", + load_paths: [File.join(dir, "src")], + root: dir + ) + + expect(result.raw["error"]).to be_nil + expect(result.passed).to be(true) + end + end + it "attributes constants loaded through the fallback to the target" do # The fallback requires the entry by absolute path, and an # absolute require keeps the path as given, so the candidate @@ -227,6 +317,31 @@ module Widgets end end + it "reports a truncated constant sweep instead of staying silent" do + Dir.mktmpdir do |dir| + write(dir, "lib/many_consts.rb", <<~RUBY) + module ManyConstsA; end + module ManyConstsB; end + module ManyConstsC; end + module ManyConstsD; end + RUBY + + result = prober.probe( + mode: :require, + feature: "many_consts", + load_paths: [File.join(dir, "lib")], + max_constants: 2 + ) + + expect(result.raw["truncated"]).to be(true) + expect(result.raw["scanned"]).to eq(2) + scan = result.findings.find { |f| f.check == "runtime-scan" } + expect(scan).not_to be_nil + expect(scan.severity).to eq(:warning) + expect(scan.message).to include("limit 2") + end + end + it "notes shareable class-level state at info level" do Dir.mktmpdir do |dir| write(dir, "lib/warmed_lib.rb", <<~RUBY) @@ -343,6 +458,54 @@ module VendoredDep end end + describe "rails probing" do + it "keeps the partial sweep when boot fails midway" do + Dir.mktmpdir do |dir| + environment = write(dir, "config/environment.rb", <<~RUBY) + module PartialApp + DIRTY = [1, 2] + end + raise "boot exploded after defining constants" + RUBY + + result = prober.probe( + mode: :rails, environment: environment, root: dir + ) + + expect(result.passed).to be(false) + boot = result.findings.first + expect(boot.check).to eq("dynamic-rails") + expect(boot.message).to include("boot exploded") + expect(boot.why).to include("loaded before the failure") + expect(boot.path).to end_with("config/environment.rb") + expect(boot.line).to eq(4) + dirty = result.findings.find do |f| + f.message.include?("PartialApp::DIRTY") + end + expect(dirty).not_to be_nil + expect(dirty.check).to eq("runtime-unshareable-constant") + expect(dirty.dependency?).to be(false) + end + end + + it "does not promise partial findings when nothing loaded" do + Dir.mktmpdir do |dir| + environment = write(dir, "config/environment.rb", <<~RUBY) + raise "dead on arrival" + RUBY + + result = prober.probe( + mode: :rails, environment: environment, root: dir + ) + + expect(result.passed).to be(false) + boot = result.findings.first + expect(boot.message).to include("dead on arrival") + expect(boot.why).not_to include("loaded before the failure") + end + end + end + describe "rack probing" do it "boots, serves, and hammers the app across Ractors" do Dir.mktmpdir do |dir| @@ -373,7 +536,34 @@ module VendoredDep result = prober.probe(mode: :rack, config_ru: config_ru) expect(result.passed).to be(false) - expect(result.findings.first.severity).to eq(:error) + finding = result.findings.first + expect(finding.severity).to eq(:error) + expect(finding.path).to eq(config_ru) + expect(finding.line).to eq(1) + end + end + + it "sweeps the constant graph the main-process boot defined" do + Dir.mktmpdir do |dir| + dir = File.realpath(dir) + config_ru = write(dir, "config.ru", <<~RUBY) + RACK_APP_STATE = [1, 2] + run ->(env) { [200, {}, ["ok"]] } + RUBY + + result = prober.probe( + mode: :rack, config_ru: config_ru, root: dir + ) + + expect(result.passed).to be(false) + swept = result.findings.find do |f| + f.check == "runtime-unshareable-constant" + end + expect(swept).not_to be_nil + expect(swept.message).to include("RACK_APP_STATE") + expect(swept.path).to eq(config_ru) + expect(swept.line).to eq(1) + expect(swept.dependency?).to be(false) end end end diff --git a/spec/audition/progress_spec.rb b/spec/audition/progress_spec.rb new file mode 100644 index 0000000..07f05e9 --- /dev/null +++ b/spec/audition/progress_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.describe Audition::Progress do + let(:plain) do + Audition::Report::Style.new(color: false, hyperlinks: false) + end + + def terminal + io = StringIO.new + allow(io).to receive(:tty?).and_return(true) + io + end + + # Zero interval so every update leaves a frame to assert on. + def line(io) + described_class.new( + renderer: described_class::Line.new(io, style: plain, + interval: 0) + ) + end + + def log(io) + described_class.new( + renderer: described_class::Log.new(io, style: plain) + ) + end + + describe ".for" do + it "stays silent below the auto threshold" do + expect(described_class.for(units: 10, io: terminal)) + .not_to be_enabled + end + + it "stays silent when the stream is not a terminal" do + expect(described_class.for(units: 10_000, io: StringIO.new)) + .not_to be_enabled + end + + it "stays silent for machine-readable formats" do + io = terminal + + %i[json github].each do |format| + expect(described_class.for(units: 10_000, format: format, + io: io)).not_to be_enabled + end + end + + it "narrates a large tree on a terminal" do + expect(described_class.for(units: 10_000, io: terminal)) + .to be_enabled + end + + it "honors --progress on a small tree and a plain stream" do + expect(described_class.for(units: 1, wanted: true, + io: StringIO.new)).to be_enabled + end + + it "honors --no-progress on a large tree" do + expect(described_class.for(units: 10_000, wanted: false, + io: terminal)).not_to be_enabled + end + + it "rewrites a line on a terminal and logs off one" do + expect(described_class.for(units: 1, wanted: true, + io: terminal).renderer).to be_a(described_class::Line) + expect(described_class.for(units: 1, wanted: true, + io: StringIO.new).renderer).to be_a(described_class::Log) + end + end + + describe "the status line" do + it "rewrites one line and erases it at the end" do + io = StringIO.new + progress = line(io) + + progress.phase("checking", total: 4) { |p| p.tick(2) } + progress.finish + + frames = io.string.split("\r").reject(&:empty?) + expect(frames.first).to include("Audition checking 0/4 0%") + expect(io.string).not_to include("\n") + expect(frames.last.strip).to be_empty + end + + it "names the stage inside a phase" do + io = StringIO.new + + line(io).phase("gem calls") do |p| + p.stage("scanning", total: 10) + p.tick(5) + end + + expect(io.string).to include("gem calls scanning 5/10 50%") + end + + it "says how many Ractors the phase is running on" do + io = StringIO.new + + line(io).phase("checking", total: 10) do |p| + p.ractors = 8 + p.tick(5) + end + + expect(io.string) + .to match(/checking 5\/10 50% \(\d+\.\ds, on 8 ractors\)/) + end + + it "claims no Ractors while the work is serial" do + io = StringIO.new + + progress = line(io) + progress.phase("checking", total: 2) { |p| p.ractors = 4 } + progress.phase("graph", total: 2) { |p| p.tick } + + expect(io.string.split("graph").last).not_to include("ractors") + end + + it "spins when there is nothing to count" do + io = StringIO.new + + line(io).phase("graph") { |p| p.stage("resolving") } + + expect(io.string).to match(%r{graph resolving [|/\\-] \(\d+\.\ds\)}) + end + + it "clamps a count that overruns its total" do + io = StringIO.new + + line(io).phase("checking", total: 2) { |p| p.tick(50) } + + expect(io.string).to include(" 100% (") + end + + it "keeps the visible width inside the terminal" do + io = StringIO.new + progress = nil + with_columns("40") { progress = line(io) } + + progress.phase("a phase with a very long name indeed", + total: 1_000_000) { |p| p.tick } + + widest = io.string.split("\r").map(&:length).max + expect(widest).to be <= 39 + end + + it "survives a stream that goes away" do + io = StringIO.new + progress = line(io) + allow(io).to receive(:write).and_raise(IOError) + + expect { progress.phase("checking", total: 1, &:tick) } + .not_to raise_error + end + + def with_columns(value) + previous = ENV["COLUMNS"] + ENV["COLUMNS"] = value + yield + ensure + ENV["COLUMNS"] = previous + end + end + + describe "named units" do + it "counts and names without restarting the count" do + io = StringIO.new + + line(io).phase("sweep", total: 2, unit: "gems") do |p| + p.item("pastel") + p.item("tty-link") + end + + expect(io.string).to include("sweep tty-link 2/2 100%") + end + end + + describe "the log" do + it "writes one completion line per phase" do + io = StringIO.new + progress = log(io) + + progress.phase("learning", total: 30) { |p| p.tick } + progress.phase("graph") { |p| p.stage("resolving") } + progress.finish + + lines = io.string.lines.map(&:chomp) + expect(lines.first).to match(/\AAudition: learning: 30 files in/) + expect(lines.last).to match(/\AAudition: graph in \d+\.\ds\z/) + expect(io.string).not_to include("\r") + end + + it "reports the Ractor count a phase ran on" do + io = StringIO.new + + log(io).phase("checking", total: 30) { |p| p.ractors = 8 } + + expect(io.string.lines.last.chomp).to match( + /\AAudition: checking: 30 files in \d+\.\ds on 8 ractors\z/ + ) + end + + it "writes a line per named unit and counts the unit" do + io = StringIO.new + + log(io).phase("sweep", total: 2, unit: "gems") do |p| + p.item("pastel") + end + + lines = io.string.lines.map(&:chomp) + expect(lines.first).to eq("Audition: sweep pastel (1/2)") + expect(lines.last).to match(/\AAudition: sweep: 2 gems in/) + end + end + + describe "when silent" do + it "yields itself and absorbs every call" do + silent = described_class::SILENT + + expect(silent).not_to be_enabled + expect(silent.phase("x", total: 2) { |p| p.tick || 7 }).to eq(7) + expect(silent.count).to eq(0) + expect { silent.stage("y", total: 1) }.not_to raise_error + expect { silent.ractors = 4 }.not_to raise_error + expect(silent.ractors).to be_nil + expect { silent.finish }.not_to raise_error + end + + # The default every analysis entry point holds, so a worker + # can be handed one. + it "is shareable" do + expect(Ractor.shareable?(described_class::SILENT)).to be(true) + end + end +end diff --git a/spec/audition/report_spec.rb b/spec/audition/report_spec.rb index ae5e843..bf77700 100644 --- a/spec/audition/report_spec.rb +++ b/spec/audition/report_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Audition::Report do def finding(severity: :error, path: "lib/a.rb", line: 3, autofix: nil, check: "global-variables", - dependency: false) + dependency: false, test: false) Audition::Finding.new( check: check, severity: severity, @@ -14,7 +14,8 @@ def finding(severity: :error, path: "lib/a.rb", line: 3, line: line, source: "$x + 1", autofix: autofix, - dependency: dependency + dependency: dependency, + test: test ) end @@ -70,6 +71,18 @@ def render_json(report) expect(report_for([]).verdict).to eq(:ready) end + it "ignores test findings at every severity" do + findings = [finding(test: true, path: "spec/a_spec.rb"), + finding(severity: :warning, test: true, + path: "spec/a_spec.rb")] + report = report_for(findings) + + expect(report.verdict).to eq(:ready) + expect(report.counts[:error]).to eq(0) + expect(report.counts[:test_error]).to eq(1) + expect(report.counts[:test_warning]).to eq(1) + end + it "is blocked when a dynamic probe failed without findings" do failed = Audition::Dynamic::Result.new( mode: :rack, raw: {}, findings: [], passed: false @@ -167,6 +180,23 @@ def render_json(report) expect(two).to include("2 errors") end + it "marks test findings and totals them in the summary" do + style = Audition::Report::Style.new( + color: false, hyperlinks: false + ) + text = render_text(report_for([ + finding(test: true, path: "spec/a_spec.rb"), + finding(severity: :warning, test: true, + path: "spec/a_spec.rb") + ]), style) + + expect(text).to include("(tests)") + expect(text).to include( + "2 test findings (1 error / 1 warning / 0 info)" + ) + expect(text).to include("ractor-ready as far as Audition") + end + it "keeps overlong tokens intact when wrapping" do url = "https://example.com/#{"z" * 90}" style = Audition::Report::Style.new( @@ -185,7 +215,7 @@ def render_json(report) out = render_github(report_for([finding])) expect(out).to include( - "::error file=lib/a.rb,line=3,title=audition " \ + "::error file=lib/a.rb,line=3,title=Audition " \ "global-variables::" ) expect(out).to include("read of global variable $x") @@ -199,7 +229,7 @@ def render_json(report) out = render_github(report_for([weird])) expect(out).to include("file=lib/a%2Cb%25%3Ac.rb,line=3,") - expect(out).to include("title=audition global-variables::") + expect(out).to include("title=Audition global-variables::") end it "strips the ./ prefix so annotations anchor to files" do @@ -215,7 +245,7 @@ def render_json(report) finding, finding(severity: :warning) ])).summary - expect(md).to include("## audition: not ractor-ready") + expect(md).to include("## Audition: not ractor-ready") expect(md).to include("| errors | 1 |") expect(md).to include("| warnings | 1 |") end @@ -242,5 +272,16 @@ def render_json(report) expect(json["summary"]["errors"]).to eq(0) expect(json["summary"]["dependency_errors"]).to eq(1) end + + it "splits test findings out in the summary" do + json = JSON.parse(render_json(report_for([ + finding(test: true, path: "spec/a_spec.rb") + ]))) + + expect(json["verdict"]).to eq("ready") + expect(json["summary"]["errors"]).to eq(0) + expect(json["summary"]["test_errors"]).to eq(1) + expect(json["findings"].first["test"]).to be(true) + end end end diff --git a/spec/audition/rewriters_spec.rb b/spec/audition/rewriters_spec.rb index 4fdab13..5f49437 100644 --- a/spec/audition/rewriters_spec.rb +++ b/spec/audition/rewriters_spec.rb @@ -160,7 +160,10 @@ def self.record(value) expect(content.lines.first) .to eq("# frozen_string_literal: true\n") expect(content).to include(%(NAME = "audition"\n)) - expect(all_findings(path)).to be_empty + # The opaque DYNAMIC warning stays; only the string is fixed. + remaining = all_findings(path) + expect(remaining.map(&:severity)).to eq([:warning]) + expect(remaining.first.message).to include("DYNAMIC") end end diff --git a/spec/audition/static/analyzer_spec.rb b/spec/audition/static/analyzer_spec.rb index 07abb39..17e06e9 100644 --- a/spec/audition/static/analyzer_spec.rb +++ b/spec/audition/static/analyzer_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "tmpdir" +require "stringio" RSpec.describe Audition::Static::Analyzer do it "produces identical findings in serial and parallel" do @@ -18,12 +19,65 @@ # Called directly so a broken Ractor path raises instead of # being masked by the serial fallback. parallel = described_class.new.send( - :parallel_analyze, paths, 4 + :parallel_analyze, paths, 4, Audition::Progress::SILENT ) expect(serial.size).to eq(40) - expect(parallel.map { |f| [f.location, f.check] }) - .to eq(serial.map { |f| [f.location, f.check] }) + # Chunks are packed by size, so the concatenation order is + # the scheduler's; the report sorts before anyone reads it. + expect(parallel.map { |f| [f.location, f.check] }.sort) + .to eq(serial.map { |f| [f.location, f.check] }.sort) + end + end + + it "narrates the Ractor count it is scanning on" do + Dir.mktmpdir do |dir| + paths = 4.times.map do |i| + path = File.join(dir, "f#{i}.rb") + File.write(path, "X#{i} = {}\n") + path + end + progress = Audition::Progress.new( + renderer: Audition::Progress::Renderer.new(StringIO.new) + ) + + described_class.new.send( + :parallel_analyze, paths, 2, progress + ) + + expect(progress.ractors).to eq(2) + end + end + + # The split itself is WorkSplit's; this is the weight the + # Analyzer gives it. + describe "#balanced_chunks" do + it "weighs files by size and isolates the heaviest" do + Dir.mktmpdir do |dir| + heavy = File.join(dir, "heavy.rb") + File.write(heavy, "# padding\n" * 5_000) + light = 8.times.map do |i| + path = File.join(dir, "light#{i}.rb") + File.write(path, "X = #{i}\n") + path + end + + chunks = described_class.new.send( + :balanced_chunks, light + [heavy], 3 + ) + + expect(chunks.size).to eq(3) + expect(chunks.flatten).to match_array(light + [heavy]) + expect(chunks.find { |c| c.include?(heavy) }.size).to eq(1) + end + end + + it "weighs a file it cannot stat as nothing" do + chunks = described_class.new.send( + :balanced_chunks, ["/nonexistent/gone.rb"], 2 + ) + + expect(chunks).to eq([["/nonexistent/gone.rb"]]) end end end diff --git a/spec/audition/static/checks/dependency_class_state_spec.rb b/spec/audition/static/checks/dependency_class_state_spec.rb new file mode 100644 index 0000000..92bc38a --- /dev/null +++ b/spec/audition/static/checks/dependency_class_state_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "tmpdir" + +RSpec.describe Audition::Static::Checks::DependencyClassState do + around do |example| + previous = described_class.attributes + example.run + described_class.attributes = previous.flat_map do |owner, names| + names.map { |name| [owner, name] } + end + end + + def learn(stub) + Dir.mktmpdir do |dir| + path = File.join(dir, "kit@1.0.0.rbi") + File.write(path, stub) + described_class.learn([path]) + yield + end + end + + def findings_for(source) + Audition::Static::Analyzer.new(checks: [described_class]) + .analyze_source(source, path: "a.rb") + end + + # The generator's own marker for a method it wrote from an + # attribute, alongside one that computes its answer. + let(:attribute_stub) do + <<~RBI + # typed: true + + module Kit + class << self + # Returns the value of attribute registry. + # + # source://kit//lib/kit.rb#9 + def registry; end + + # source://kit//lib/kit.rb#11 + def registry=(value); end + + # Builds a fresh client from the configuration. + # + # source://kit//lib/kit.rb#20 + def client; end + end + end + RBI + end + + it "flags a read of a documented singleton attribute" do + learn(attribute_stub) do + findings = findings_for("Kit.registry\n") + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "dependency-class-state", line: 1, severity: :warning + ) + expect(findings.first.message).to include("Kit.registry") + end + end + + it "flags a write as an error" do + learn(attribute_stub) do + findings = findings_for("Kit.registry = {}\n") + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 1, severity: :error + ) + expect(findings.first.message).to include("Kit.registry=") + end + end + + it "leaves a method that computes its answer alone" do + learn(attribute_stub) do + expect(findings_for("Kit.client\n")).to be_empty + end + end + + it "ignores an attribute on the instance side" do + stub = <<~RBI + # typed: true + + module Kit + # Returns the value of attribute registry. + # + # source://kit//lib/kit.rb#9 + def registry; end + end + RBI + + learn(stub) do + expect(findings_for("Kit.registry\n")).to be_empty + end + end + + it "matches a nested owner through its full path" do + stub = <<~RBI + # typed: true + + module Kit + class Vault + class << self + # Returns the value of attribute store. + # + # source://kit//lib/kit/vault.rb#4 + def store; end + end + end + end + RBI + + learn(stub) do + findings = findings_for("Kit::Vault.store\nVault.store\n") + + expect(findings.map(&:line)).to contain_exactly(1) + end + end + + it "reads nothing from sources that are not stubs" do + Dir.mktmpdir do |dir| + path = File.join(dir, "kit.rb") + File.write(path, attribute_stub) + described_class.learn([path]) + + expect(findings_for("Kit.registry\n")).to be_empty + end + end +end diff --git a/spec/audition/static/checks/mutable_constants_spec.rb b/spec/audition/static/checks/mutable_constants_spec.rb index 4063ad2..a6a125b 100644 --- a/spec/audition/static/checks/mutable_constants_spec.rb +++ b/spec/audition/static/checks/mutable_constants_spec.rb @@ -292,12 +292,54 @@ def self.record(v) = PARAMS[:k] = v expect(findings).to be_empty end - it "stays silent on values it cannot classify statically" do + it "warns on constants holding unprovable call results" do findings = findings_for("SETTINGS = YAML.load_file('config.yml')\n") + expect(findings.size).to eq(1) + finding = findings.first + expect(finding.severity).to eq(:warning) + expect(finding.message) + .to include("SETTINGS", "a YAML.load_file call") + expect(finding).not_to be_fixable + end + + it "trusts calls that return shareable primitives" do + findings = findings_for(<<~RUBY) + TTL = 5.minutes.to_i + WIDTH = Config.fetch(:width).to_f + KEY = ENV.fetch("MODE").to_sym + READY = Feature.enabled? + RUBY + expect(findings).to be_empty end + it "sees through Sorbet inline casts" do + findings = findings_for(<<~RUBY) + # frozen_string_literal: true + TYPED = T.let("x", String) + LOOSE = T.let(compute, T.untyped) + GOOD = T.must(42) + RUBY + + expect(findings.map(&:line)).to eq([3]) + expect(findings.first.message).to include("LOOSE") + end + + it "names the literal and fixes inside a Sorbet cast" do + code = "# frozen_string_literal: true\n" \ + "MAP = T.let({a: 1}, T::Hash[Symbol, Integer])\n" + findings = findings_for(code) + + expect(findings.size).to eq(1) + expect(findings.first.message).to include("Hash") + fix = findings.first.autofix + expect(fix.unsafe?).to be(false) + fixed = code.dup + fixed[fix.start_offset...fix.end_offset] = fix.replacement + expect(fixed).to include("T.let({a: 1}.freeze,") + end + it "flags writes through constant paths" do findings = findings_for("Foo::BAR = []\n") @@ -385,7 +427,7 @@ def self.record(v) = PARAMS[:k] = v expect(findings).to be_empty end - it "leaves ambiguous calls on non-literal receivers alone" do + it "warns on ambiguous calls on non-literal receivers" do findings = findings_for(<<~RUBY) # frozen_string_literal: true SUM = Totals.sum + 1 @@ -393,8 +435,100 @@ def self.record(v) = PARAMS[:k] = v PATH = Settings.dup RUBY + expect(findings.map(&:line)).to eq([2, 3, 4]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings).to all(satisfy { |f| !f.fixable? }) + end + + it "stays quiet once the opaque result is frozen later" do + findings = findings_for(<<~RUBY) + SETTINGS = YAML.load_file("config.yml") + SETTINGS.freeze + RUBY + expect(findings).to be_empty end + + it "warns when a freeze covers only the surface of opaque values" do + findings = findings_for(<<~RUBY) + # frozen_string_literal: true + COUNTERS = T.let({cache: Stats.build("c")}.freeze, T.untyped) + FORMATS = [Mime[:xml], Mime[:json]].freeze + EPOCH = T.let(Time.at(0).freeze, Time) + CODEC = Encoder.new(0, MAP).freeze + CLASSES = [Widget, Gadget].freeze + RUBY + + expect(findings.map(&:line)).to eq([2, 3, 4, 5]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings).to all(satisfy { |f| !f.fixable? }) + expect(findings.first.message).to include("frozen at the top") + end + + it "does not let constant-read keys excuse opaque values" do + findings = findings_for(<<~RUBY) + CONFIGS = { + Sizes::SMALL => Widget.new(a: 1), + }.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("frozen at the top") + end + + it "reports a provably mutable element despite unknown siblings" do + findings = findings_for(<<~RUBY) + REGISTRY = [Widget, [1]].freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("frozen only") + end + + it "warns when a container of opaque values is frozen later" do + findings = findings_for(<<~RUBY) + ROUTES = {home: Router.build} + ROUTES.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("frozen at the top") + end + + it "trusts indexing into ENV and Sorbet type constructors" do + findings = findings_for(<<~RUBY) + HOME = ENV["HOME"] + LIST = T::Array[String] + XML = Mime[:xml] + RUBY + + expect(findings.map(&:line)).to eq([3]) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("Mime.[]") + end + + it "flags indexing into another call's result" do + findings = findings_for(<<~RUBY) + KEY = Registry.by_name["WIDTH"] + RUBY + + expect(findings.map(&:line)).to eq([1]) + expect(findings.first.severity).to eq(:warning) + end + + it "treats frozen File path results as shareable" do + findings = findings_for(<<~RUBY) + ROOT = File.expand_path("..", __dir__).freeze + BARE = File.join("a", "b") + RUBY + + expect(findings.map(&:line)).to eq([2]) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("File.join") + end describe "plain freeze for provably shareable containers" do it "appends .freeze when every element is shareable" do code = "# frozen_string_literal: true\nLIST = [1, \"a\", :b]\n" @@ -481,12 +615,23 @@ def NULL.to_s = "null" end it "leaves Object.new with arguments or a block alone" do + findings = findings_for("WITH_BLOCK = Object.new { }\n") + + expect(findings).to be_empty + end + + it "warns on fresh instances of arbitrary classes" do findings = findings_for(<<~RUBY) - WITH_BLOCK = Object.new { } SOMETHING = Widget.new + FROZEN = Widget.new.freeze + QUERY = Db::Query.new(name: "q") { |x| x } RUBY - expect(findings).to be_empty + expect(findings.map(&:line)).to eq([1, 2, 3]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings.first.message) + .to include("SOMETHING", "Widget") + expect(findings[1].message).to include("frozen at the top") end end @@ -525,11 +670,21 @@ def NULL.to_s = "null" # frozen_string_literal: true IDS = %w(id id=).to_set.freeze DIRS = Set.new([:asc]).freeze + SEALED = Set.new(compute).freeze + RUBY + + expect(findings).to be_empty + end + + it "flags unfrozen Sets built from opaque sources" do + findings = findings_for(<<~RUBY) DYNAMIC = Set.new(compute) MAPPED = Set.new([1]) { |x| x.to_s } RUBY - expect(findings).to be_empty + expect(findings.map(&:line)).to eq([1, 2]) + expect(findings).to all(have_attributes(severity: :error)) + expect(findings.map(&:message)).to all(include("Set")) end end @@ -579,6 +734,41 @@ def self.finalize! = REGISTRY.freeze end end + describe "begin blocks" do + it "classifies a constant by the block's last statement" do + findings = findings_for(<<~RUBY) + TABLE = begin + {a: {b: 1}} + end.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.message).to include("top level") + end + + it "accepts a block whose last statement is shareable" do + findings = findings_for(<<~RUBY) + LIST = begin + [1, 2] + end.freeze + RUBY + + expect(findings).to be_empty + end + + it "leaves a block with a rescue clause alone" do + findings = findings_for(<<~RUBY) + TABLE = begin + {a: {b: 1}} + rescue StandardError + {} + end.freeze + RUBY + + expect(findings).to be_empty + end + end + it "flags Concurrent::Map constants as never shareable" do findings = findings_for("CACHE = Concurrent::Map.new\n") diff --git a/spec/audition/static/checks/unshareable_reads_spec.rb b/spec/audition/static/checks/unshareable_reads_spec.rb new file mode 100644 index 0000000..2c3580b --- /dev/null +++ b/spec/audition/static/checks/unshareable_reads_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "tmpdir" + +RSpec.describe Audition::Static::Checks::UnshareableReads do + def findings_for(code) + analyzer = + Audition::Static::Analyzer.new(checks: [described_class]) + analyzer.analyze_source(code, path: "test.rb") + end + + it "flags every read site of T::Boolean" do + findings = findings_for(<<~RUBY) + class Widget + sig { returns(T::Boolean) } + def ready? + @ready = T.let(compute, T::Boolean) + end + end + RUBY + + expect(findings.size).to eq(2) + expect(findings.map(&:line)).to eq([2, 4]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings.first.message).to include("T::Boolean") + expect(findings.first.why).to include("sig blocks") + end + + it "matches the fully qualified form" do + findings = findings_for("x = ::T::Boolean\n") + + expect(findings.size).to eq(1) + end + + it "leaves other Sorbet constants alone" do + findings = findings_for(<<~RUBY) + sig { params(items: T::Array[String]).void } + def go(items); end + RUBY + + expect(findings).to be_empty + end + + describe "dynamic constants" do + after { described_class.dynamic = [] } + + it "flags value reads under a learned namespace" do + described_class.dynamic = [%w[Kit Vault]] + findings = findings_for(<<~RUBY) + token = Kit::Vault::API_TOKEN + client = Kit::Vault::Client.new + RUBY + + expect(findings.map(&:line)).to eq([1]) + expect(findings.first.message).to include("API_TOKEN") + expect(findings.first.why).to include("const_set") + end + + it "learns namespaces that const_set computed names" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "vault.rb"), <<~RUBY) + module Vault + class << self + def load(data) + data.each { |name, value| const_set(name, value) } + end + end + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + + expect(described_class.dynamic).to eq([%w[Vault]]) + end + end + + it "ignores const_set with a literal name" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "kit.rb"), <<~RUBY) + module Kit + const_set(:VERSION, "1.0") + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + + expect(described_class.dynamic).to be_empty + end + end + end + + describe "learned aliases" do + after { described_class.learned = [] } + + it "flags learned aliases read in type positions" do + described_class.learned = [%w[Kit Widget RowType]] + findings = findings_for(<<~RUBY) + class Builder + sig { params(row: RowType).void } + def go(row) + T.cast(fetch, Kit::Widget::RowType) + end + end + RUBY + + expect(findings.map(&:line)).to eq([2, 4]) + expect(findings.first.message).to include("RowType") + end + + it "leaves a same-named constant outside type positions alone" do + described_class.learned = [%w[Kit RowType]] + findings = findings_for(<<~RUBY) + row = RowType.new + T.let(RowType.parse(raw), String) + RUBY + + expect(findings).to be_empty + end + + it "requires the read to line up with the definition's tail" do + described_class.learned = [%w[Kit RowType]] + findings = findings_for(<<~RUBY) + sig { returns(Other::RowType) } + def row; end + RUBY + + expect(findings).to be_empty + end + + it "learns definitions from swept files" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "kit.rbi"), <<~RUBY) + Kit::RowType = T.type_alias { T::Hash[Symbol, Symbol] } + RUBY + File.write(File.join(dir, "widget.rb"), <<~RUBY) + module Kit + ColType = T.type_alias do + T::Array[String] + end + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + findings = findings_for(<<~RUBY) + sig { params(row: Kit::RowType, col: ColType).void } + def go(row, col); end + RUBY + + expect(findings.size).to eq(2) + end + end + end +end diff --git a/spec/audition/static/gem_calls_spec.rb b/spec/audition/static/gem_calls_spec.rb new file mode 100644 index 0000000..fb7c450 --- /dev/null +++ b/spec/audition/static/gem_calls_spec.rb @@ -0,0 +1,1364 @@ +# frozen_string_literal: true + +require "tmpdir" +require "fileutils" + +RSpec.describe Audition::Static::GemCalls do + def rule(gem: "fast_widget", namespace: "FastWidget", verified: true) + described_class::Rule.new( + gem: gem, namespace: namespace, verified: verified + ) + end + + def scan(code, rules: [rule]) + Dir.mktmpdir do |dir| + path = File.join(dir, "a.rb") + File.write(path, code) + return described_class.new(root: dir, rules: rules) + .analyze_paths([path]) + end + end + + # Stubs reach the check from the target, which finds them by + # extension rather than in a fixed directory. + def scanner(root) + described_class.new(root: root, + stubs: Audition::Target.stubs(root)) + end + + def lockfile(dir, pins) + specs = pins.map { |p| " #{p}\n" }.join + File.write(File.join(dir, "Gemfile.lock"), + "GEM\n specs:\n#{specs}") + end + + def install_fake_gem(base, name:, binary: nil, version: "1.0.0", + entry: nil, extensions: []) + full = "#{name}-#{version}" + lib = File.join(base, "gems", full, "lib") + entry_path = File.join(lib, "#{name.tr("-", "/")}.rb") + FileUtils.mkdir_p(File.dirname(entry_path)) + File.write(entry_path, + entry || "module FastWidget\nend\n") + File.binwrite(File.join(lib, "#{name}.bundle"), binary) if binary + spec_dir = File.join(base, "specifications") + FileUtils.mkdir_p(spec_dir) + File.write(File.join(spec_dir, "#{full}.gemspec"), <<~RUBY) + Gem::Specification.new do |s| + s.name = #{name.inspect} + s.version = #{version.inspect} + s.summary = "fake" + s.authors = ["spec"] + s.extensions = #{extensions.inspect} + end + RUBY + end + + # One entry per method, each with its own source line, unless + # shared: is given—the shape a compiled extension leaves. The + # directory is arbitrary: stubs are found by extension. + def write_stub(root, name:, methods:, version: "1.0.0", + shared: nil, dir: "types/generated") + dir = File.join(root, dir) + FileUtils.mkdir_p(dir) + line = 10 + body = methods.map do |owner, names| + defs = names.map do |method| + line += 1 + at = shared || line + " # source://#{name}//lib/#{name}.rb##{at}\n" \ + " def #{method}; end\n" + end.join("\n") + "class #{owner}\n#{defs}end\n" + end.join("\n") + File.write(File.join(dir, "#{name}@#{version}.rbi"), + "# typed: true\n\n#{body}") + end + + describe "bundle resolution" do + it "derives a verified rule from an installed extension " \ + "lacking the declaration" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", + binary: "\x00mach\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "native-gem-calls", severity: :warning, line: 1 + ) + expect(findings.first.message) + .to include("does not declare Ractor safety") + expect(findings.first.why).to include("Ractor::UnsafeError") + end + end + end + + it "drops gems whose extension declares safety" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", + binary: "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "reads the namespace from the entry file's module nesting" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", binary: "\x00", + entry: "require \"json\"\n" \ + "module Fast\n" \ + " module WidgetCore\n" \ + " VERSION = \"1\"\n" \ + " end\n" \ + "end\n") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::WidgetCore.render(v)\n" \ + "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1) + end + end + end + + it "flags platform-pinned gems it cannot inspect as unverified" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)", + "fast_widget (1.0.0-x86_64-linux-gnu)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("could not inspect") + expect(findings.first.why).to include("install the bundle") + end + end + end + + it "settles a spec with no readable binary against Ruby's " \ + "own extension" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + install_fake_gem(base, name: "fast_widget", + extensions: ["ext/fast_widget/extconf.rb"]) + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root) + .analyze_paths([path])).to be_empty + end + end + end + end + + it "clears a platform pin when Ruby's own extension declares " \ + "safety" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root) + .analyze_paths([path])).to be_empty + end + end + end + end + + it "verifies a platform pin against Ruby's own extension when " \ + "it lacks the declaration" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00mach\x00") + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first.message) + .to include("does not declare Ractor safety") + end + end + end + end + + it "ignores pure-Ruby pins and gems it knows nothing about" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["plainlib (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Plainlib.run(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "reads compiled evidence from a generated type stub" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n" \ + "Fast::Other.render(v)\n") + + findings = scanner(root).analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1) + expect(findings.first.message) + .to include("could not inspect") + end + end + end + + it "ignores a stub whose methods are Ruby-defined" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n") + + expect(scanner(root).analyze_paths([path])).to be_empty + end + end + end + + it "ignores a stub cluster owned by a single class" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse items]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n") + + expect(scanner(root).analyze_paths([path])).to be_empty + end + end + end + + it "adds stub namespaces to a platform pin's own name" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n" \ + "Fast::Widget::Doc.items\n") + + findings = scanner(root).analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + end + end + + it "returns nothing without a lockfile" do + Dir.mktmpdir do |root| + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "flags calls anywhere under the gem's namespace" do + findings = scan( + "FastWidget.parse(v)\n" \ + "FastWidget::HTML::Document.parse(v)\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + expect(findings.first.fix).to include("main Ractor") + end + + it "leaves lookalike receivers alone" do + findings = scan( + "FastWidgetry.parse(v)\n" \ + "Acme::FastWidget.parse(v)\n" \ + "registry.parse(v)\n" + ) + + expect(findings).to be_empty + end + + it "skips core Object methods on the namespace" do + findings = scan("FastWidget.respond_to?(:parse)\n") + + expect(findings).to be_empty + end + + it "follows a gem object through the local it is assigned to" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.items\n" \ + "doc.nil?\n" \ + "doc if doc.frozen?\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + derived = findings.find { |f| f.line == 2 } + expect(derived.message).to include("items") + expect(derived.message).to include("handed out") + end + + it "drops the taint when the local is reassigned" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc = plain\n" \ + "doc.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "keeps taint out of other method scopes" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "def render(doc)\n" \ + " doc.items\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "follows a syntactic chain rooted in the namespace" do + findings = scan( + "klass = FastWidget.pool\n" \ + " .lookup(\"w\").msgclass\n" \ + "klass.decode(bytes)\n" + ) + + methods = findings.map { |f| f.message[/\A\w+/] } + expect(methods).to include("lookup", "msgclass", "decode") + decode = findings.find { |f| f.message.start_with?("decode") } + expect(decode.line).to eq(3) + end + + it "follows the taint through a local hop" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "items = doc.items\n" \ + "items.push(v)\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2, 3) + end + + it "stops the chain at a predicate" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "ok = doc.valid?\n" \ + "ok.inspect\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + + it "passes the taint through class, freeze, and tap silently" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.class.descriptor\n" \ + "doc.freeze.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2, 3) + methods = findings.map { |f| f.message[/\A\w+/] } + expect(methods).to include("descriptor", "items") + expect(methods).not_to include("class", "freeze") + end + + it "flags the chained call on its own line" do + findings = scan( + "class Widget\n" \ + " def initialize\n" \ + " @raw = FastWidget::Codec.decode(data)\n" \ + " end\n" \ + " def names\n" \ + " @raw.fetch(:names)\n" \ + " .map { |v| v.to_sym }\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line).uniq).to contain_exactly(3, 6, 7) + end + + it "taints params typed under the namespace in a sig" do + findings = scan( + "sig { params(doc: FastWidget::Document, n: Integer).void }\n" \ + "def render(doc, n)\n" \ + " doc.items\n" \ + " n.to_s\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "taints locals annotated with T.let under the namespace" do + findings = scan( + "doc = T.let(fetch_doc, T.nilable(FastWidget::Document))\n" \ + "doc.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(2) + end + + it "clears the taint when T.let names a plain type" do + findings = scan( + "s = T.let(FastWidget.render(v), String)\n" \ + "s.length\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "carries taint through ivars across methods" do + findings = scan( + "class Renderer\n" \ + " def initialize(markup)\n" \ + " @doc = FastWidget.parse(markup)\n" \ + " end\n" \ + "\n" \ + " def links\n" \ + " @doc.items\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3, 7) + end + + it "carries taint into a local copied from an ivar" do + findings = scan( + "class Renderer\n" \ + " def initialize(markup)\n" \ + " @doc = FastWidget.parse(markup)\n" \ + " end\n" \ + "\n" \ + " def links\n" \ + " doc = @doc\n" \ + " doc.items\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3, 8) + end + + it "hands a tainted receiver to a then block" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.then { |d| d.items } + RUBY + + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + + it "keeps taint one branch introduces past the branch" do + findings = scan(<<~RUBY) + if native? + doc = FastWidget.parse(markup) + else + doc = plain_parse(markup) + end + doc.items + RUBY + + expect(findings.map(&:line)).to contain_exactly(2, 6) + end + + it "drops taint every branch replaces" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + if native? + doc = plain_parse(markup) + else + doc = other_parse(markup) + end + doc.items + RUBY + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "skips unparseable files" do + findings = scan("def broken(\n") + + expect(findings).to be_empty + end + + describe "block parameters" do + it "taints block parameters of a call on a tainted receiver" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + names = doc.items.map do |item| + item.label + end + names.first + RUBY + + label = findings.find { |f| f.message.start_with?("label") } + expect(label&.line).to eq(3) + end + + it "drops the block taint after the block" do + findings = scan(<<~RUBY) + item = plain + doc = FastWidget.parse(markup) + doc.items.each do |item| + item.label + end + item.label + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(2, 3, 4) + end + + it "keeps the memo parameter of each_with_object clean" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.fields.each_with_object({}) do |field, acc| + acc.store(field.key, 1) + end + RUBY + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("key") + expect(methods).not_to include("store") + end + + it "taints block parameters of a predicate iterator" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + ok = doc.items.any? { |item| item.valid? } + ok.inspect + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(1, 2) + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("valid?") + end + + it "taints numbered block parameters" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.items.each { _1.label }\n" + ) + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("label") + end + + it "taints destructured block parameters" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.entries.each do |(key, val)| + val.pack + end + RUBY + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("pack") + end + + it "leaves blocks on plain calls alone" do + findings = scan("rows.each { |row| row.save }\n") + + expect(findings).to be_empty + end + end + + describe "argument taint" do + it "seeds a callee parameter from a tainted argument" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = widget.errors + log(errors) + end + + sig { params(errors: T::Array[T.untyped]).void } + def log(errors) + errors.each do |error| + error.message + end + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(10, 11) + end + + it "keeps a plainly typed parameter clean" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = widget.errors + log(errors) + end + + sig { params(errors: String).void } + def log(errors) + errors.chars + end + end + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(4) + end + + it "carries a computed return into callers above the def" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = collect(widget) + errors.first.message + end + + def collect(widget) + rows = widget.rows + rows + extra + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(5, 9, 10) + end + + it "seeds keyword parameters" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + log(errors: widget.errors) + end + + def log(errors: nil) + errors.message + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(4, 8) + end + + it "keeps seeds inside their own class" do + findings = scan(<<~RUBY) + class Alpha + sig { params(widget: FastWidget::Row).void } + def handle(widget) + log(widget.errors) + end + + def log(errors) + errors.message + end + end + + class Beta + def log(errors) + errors.message + end + end + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(4, 8) + end + + it "seeds through a call on the defining module's name" do + findings = scan(<<~RUBY) + module Packer + def self.pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(3, 7) + end + + it "reaches an instance method a module extended itself with" do + findings = scan(<<~RUBY) + module Packer + extend self + + def pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(5, 9) + end + + it "keeps an instance method the module never exposed clean" do + findings = scan(<<~RUBY) + module Packer + def pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(7) + end + end + + describe "type narrowing" do + it "taints the subject inside a matching when branch" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when Integer + node.succ + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "covers the else clause when every branch matches the rule" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when FastWidget::Col + node.rows + else + node.val + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4, 6, 8) + end + + it "leaves the else clause alone on mixed branches" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when Integer + node.succ + else + node.val + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "makes a case handing the subject back a producer" do + findings = scan(<<~RUBY) + def unwrap(node) + ref = case node + when FastWidget::Row then node + when FastWidget::Node then node.row if deep + end + ref.cells + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4, 6) + end + + it "taints a variable proven by an is_a? guard" do + findings = scan(<<~RUBY) + def unwrap(node) + return node unless node.is_a?(FastWidget::Node) + + node.fields + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "taints inside an is_a? conditional body only" do + findings = scan(<<~RUBY) + def visit(node) + if node.is_a?(FastWidget::Node) && node.val + node.fields + else + node.entries + end + node.items + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "ignores guards that do not bail" do + findings = scan(<<~RUBY) + def visit(node) + log unless node.is_a?(FastWidget::Node) + node.fields + end + RUBY + + expect(findings).to be_empty + end + + it "ignores type checks against plain constants" do + findings = scan(<<~RUBY) + def visit(node) + return unless node.is_a?(Widget) + + node.fields + end + RUBY + + expect(findings).to be_empty + end + end + + describe "class-body registry" do + it "taints callers of a memoized factory defined below them" do + findings = scan(<<~RUBY) + class Widget + def initialize + @raw = Widget.native_class.new + end + + def id + @raw["id"] + end + + def id=(value) + @raw["id"] = value + end + + class << self + def native_class + @native_class ||= FastWidget::Pool.pool.lookup("w") + end + end + end + RUBY + + expect(findings.map(&:line)).to include(3, 7, 11, 16) + deref = findings.find { |f| f.line == 7 } + expect(deref.message).to include("handed out") + end + + it "reaches the singleton through self.class and bare calls" do + findings = scan(<<~RUBY) + class Widget + def native_class + self.class.native_class + end + + def dup_native + native_class.new + end + + def self.native_class + FastWidget::Pool.lookup("w") + end + end + RUBY + + expect(findings.map(&:line)).to include(7, 11) + end + + it "taints an ivar assigned through its attr writer" do + findings = scan(<<~RUBY) + class Widget + attr_writer :raw + + def field + @raw["f"] + end + + def self.decode(data) + widget = new + widget.raw = FastWidget::Codec.decode(data) + widget + end + end + RUBY + + expect(findings.map(&:line)).to include(5, 10) + end + + it "hands taint out through an attr reader over a tainted ivar" do + findings = scan(<<~RUBY) + class Widget + attr_reader :raw + + def initialize + @raw = FastWidget::Codec.decode("x") + end + + def field + raw.field("f") + end + end + RUBY + + expect(findings.map(&:line)).to include(5, 9) + end + + it "taints a method whose sig returns a namespace type" do + findings = scan(<<~RUBY) + class Widget + sig { returns(FastWidget::Blob) } + def blob + fetch_blob + end + + def use + blob.compress + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(8) + end + + it "leaves plain methods and sibling classes alone" do + findings = scan(<<~RUBY) + class Widget + def self.native_class + FastWidget::Pool.lookup("w") + end + end + + class Gadget + def initialize + @raw = Gadget.native_class.new + end + + def builder + Builder.new + end + + def use + builder.build + @raw["f"] + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(3) + end + end + + describe "cross-file promotion" do + def scan_files(files, rules: [rule]) + Dir.mktmpdir do |dir| + paths = files.map.with_index do |code, i| + path = File.join(dir, "f#{i}.rb") + File.write(path, code) + path + end + return described_class.new(root: dir, rules: rules) + .analyze_paths(paths) + .group_by { |f| File.basename(f.path) } + end + end + + it "promotes a class holding extension values to a rule" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + end + RUBY + caller_file = <<~RUBY + widget = Kit::Widget.new(data) + widget.pack + RUBY + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1, 2) + end + + it "taints params typed with a promoted class in a sig" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + end + RUBY + caller_file = <<~RUBY + sig { params(widget: Kit::Widget).void } + def read(widget) + widget.fields + end + RUBY + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(3) + end + + it "promotes subclasses of a promoted class" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + + class Gadget < Widget + end + end + RUBY + caller_file = "Kit::Gadget.new(data)\n" + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1) + end + + it "promotes constants bound to extension-rooted values" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + Widget::Inner = FastWidget::Pool.generated.lookup("wi").msgclass + end + RUBY + caller_file = <<~RUBY + message = Kit::Widget.decode(data) + message.id + Kit::Widget::Inner.new(a: 1) + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1, 2, 3) + end + + it "resolves a promoted name through the lexical nesting" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + caller_file = <<~RUBY + module Kit + class Builder + def build + Widget.new(a: 1) + end + end + end + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4) + end + + it "keeps an anchored path out of the nesting" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + caller_file = <<~RUBY + module Kit + def self.build = ::Widget.new + end + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"]).to be_nil + end + + it "flags self-calls in a class_eval reopening of a bound name" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + Widget.class_eval do + def to_ruby + case self.kind + when :list_value then self.list_value + else self.helper + end + end + + def helper = 1 + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4, 5) + end + + it "flags self-calls when a bound class is reopened by name" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + class Widget + def self.from_hash(hash) + self.new(hash) + end + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4) + end + + it "flags bare argumentless calls in a bound reopening" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + Widget.class_eval do + def to_h + fields.each_with_object({}) do |field, acc| + acc[field.key] = field.val + end + end + + def helper = 1 + + def wrapped = helper + + def show = inspect + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line).uniq).to contain_exactly(4, 5) + end + + it "leaves self-calls in merely promoted classes alone" do + holder_file = <<~RUBY + class Renderer + def initialize(markup) + @doc = FastWidget.parse(markup) + end + + def render + self.finish + end + end + RUBY + + findings = scan_files([holder_file]) + + expect(findings["f0.rb"].map(&:line)).to contain_exactly(3) + end + + it "leaves classes without extension values alone" do + plain = <<~RUBY + module Kit + class Clean + def initialize(data) + @data = data + end + end + end + RUBY + caller_file = "Kit::Clean.new(data).pack\n" + + findings = scan_files([plain, caller_file]) + + expect(findings["f1.rb"]).to be_nil + end + + it "seeds a parameter from a call in another file" do + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + caller_file = "Packer.pack(FastWidget::Row.new)\n" + + findings = scan_files([def_file, caller_file]) + + expect(findings["f0.rb"].map(&:line)).to contain_exactly(3) + end + + it "seeds a parameter from a call in an earlier file" do + caller_file = "Packer.pack(FastWidget::Row.new)\n" + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + + findings = scan_files([caller_file, def_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(3) + end + + it "binds self in a module prepended to a bound class" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + mixin_file = <<~RUBY + module Helper + def keys + fields.map(&:key) + end + end + + Kit::Widget.prepend(Helper) + RUBY + + findings = scan_files([binding_file, mixin_file]) + + expect(findings["f1.rb"].map(&:line)).to include(3) + end + + it "binds self in a module a bound class extends" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + mixin_file = <<~RUBY + module Builder + def from_hash(hash) + Packer.pack(self) + end + end + + Kit::Widget.extend(Builder) + RUBY + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + + findings = scan_files([binding_file, mixin_file, def_file]) + + expect(findings["f2.rb"].map(&:line)).to contain_exactly(3) + end + + it "leaves a module mixed into a plain class alone" do + plain_file = <<~RUBY + module Helper + def keys + fields.map(&:key) + end + end + + Kit::Clean.include(Helper) + RUBY + + findings = scan_files([plain_file]) + + expect(findings["f0.rb"]).to be_nil + end + end +end diff --git a/spec/audition/static/graph_audit_spec.rb b/spec/audition/static/graph_audit_spec.rb index a5a9b02..68874e1 100644 --- a/spec/audition/static/graph_audit_spec.rb +++ b/spec/audition/static/graph_audit_spec.rb @@ -26,14 +26,14 @@ def bump = (@@count += 1) it "unifies class-level ivars across contexts and files" do findings = findings_for( "a.rb" => <<~RUBY, - class Payments + class Widget @registry = {} def self.reset! = (@registry = {}) - def pay = (@amount = 1) + def tag = (@label = 1) end RUBY "b.rb" => <<~RUBY - class Payments + class Widget class << self def prime = (@registry ||= {}) end @@ -47,7 +47,7 @@ def prime = (@registry ||= {}) ) expect(state).to all(have_attributes(severity: :error)) expect(state.first.message).to include("@registry") - expect(state.first.message).to include("Payments") + expect(state.first.message).to include("Widget") expect(state.first.fix).to include("store_if_absent") end @@ -179,4 +179,851 @@ def self.reset! = (@all = nil) expect(findings.first.message).to include("@config") expect(findings.first.message).to include("Settings") end + + describe "singleton attributes" do + it "flags a writable attribute on a singleton class" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + attr_accessor :current + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "class-level-state", line: 3, severity: :error + ) + expect(findings.first.message).to include("@current") + expect(findings.first.message).to include("Vault") + end + + it "names the class a reopened singleton belongs to" do + findings = findings_for( + "a.rb" => <<~RUBY + class << Vault + attr_writer :store, "backup" + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@store on Vault"), + a_string_including("@backup on Vault") + ) + end + + it "leaves a lone reader to the graph" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + attr_reader :current + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "ignores an attribute on the instance side" do + findings = findings_for( + "a.rb" => "class Vault\n attr_accessor :name\nend\n" + ) + + expect(findings).to be_empty + end + + it "ignores an attribute call nested inside a def" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + def install = attr_accessor(:current) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "flags an assignment to a declared attribute" do + findings = findings_for( + "a.rb" => <<~RUBY, + class Vault + class << self + attr_accessor :current + end + end + RUBY + "b.rb" => "Vault.current = Vault.new\n" + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to contain_exactly(["a.rb", 3], ["b.rb", 1]) + expect(findings.last.message).to include("@current on Vault") + end + + it "matches a write spelled through a longer path" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Kit + class Vault + class << self + attr_writer :store + end + end + end + RUBY + "b.rb" => "Kit::Vault.store = {}\n" + ) + + expect(findings.map(&:path)).to contain_exactly("a.rb", "b.rb") + end + + it "leaves a read of a declared attribute alone" do + findings = findings_for( + "a.rb" => <<~RUBY, + class Vault + class << self + attr_accessor :current + end + end + RUBY + "b.rb" => "Vault.current\n" + ) + + expect(findings.map(&:path)).to contain_exactly("a.rb") + end + + it "leaves a writer nothing declares alone" do + findings = findings_for( + "a.rb" => "Vault.current = 1\n" + ) + + expect(findings).to be_empty + end + end + + describe "extended modules" do + it "flags an ivar written in a module something extends" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) + @scope = name + end + end + RUBY + "b.rb" => "class Vault\n extend Scoped\nend\n" + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 3, severity: :error + ) + expect(findings.first.message).to include("@scope on Scoped") + end + + it "matches an extend written through a longer path" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Kit + module Scoped + def scope(name) = (@scope = name) + end + end + RUBY + "b.rb" => "class Vault\n extend Kit::Scoped\nend\n" + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Kit::Scoped") + ) + end + + it "leaves a module nothing extends alone" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Plain + def plain(name) + @plain = name + end + end + RUBY + "b.rb" => "class Vault\n include Plain\nend\n" + ) + + expect(findings).to be_empty + end + + it "starts a nested class on its own instance side" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + class Row + def scope(name) = (@scope = name) + end + end + RUBY + "b.rb" => "class Vault\n extend Scoped\nend\n" + ) + + expect(findings).to be_empty + end + + it "reads an extend resolved through const_get" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) = (@scope = name) + end + RUBY + "b.rb" => <<~RUBY + class Vault + extend const_get(:Scoped) + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Scoped") + ) + end + + it "counts a module prepended to a singleton class" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) = (@scope = name) + end + RUBY + "b.rb" => <<~RUBY + class Vault + singleton_class.prepend Scoped + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Scoped") + ) + end + end + + # The mixin that puts these on the class lives in the library + # defining the concern, never in the target. + describe "concern class methods" do + it "flags an ivar in a companion module no source extends" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + module ClassMethods + def warm? + return @warm if defined?(@warm) + @warm = true + end + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 5, severity: :error + ) + expect(findings.first.message).to include( + "@warm on Cache::ClassMethods" + ) + end + + it "flags an ivar written in a class-methods block" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + class_methods do + def warm? + return @warm if defined?(@warm) + @warm = true + end + end + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@warm on Cache::ClassMethods") + ) + end + + it "leaves the instance side of a concern alone" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + def warm! + @warm = true + end + end + RUBY + ) + + expect(findings).to be_empty + end + end + + describe "dynamic instance variable writes" do + it "flags a write through a class hook's argument" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.inherited(subclass) + subclass.instance_variable_set(:@name, @name) + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 3, severity: :error + ) + expect(findings.first.message).to include("@name on subclass") + end + + it "flags a removal on implicit self in a singleton" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.reset + remove_instance_variable(:@cache) + end + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@cache on Widget") + ) + end + + it "flags a write on a constant the target declares" do + findings = findings_for( + "a.rb" => "class Vault\nend\n", + "b.rb" => "Vault.instance_variable_set(:@store, {})\n" + ) + + expect(findings.map(&:path)).to contain_exactly("b.rb") + end + + it "leaves a constant the target never declares alone" do + findings = findings_for( + "a.rb" => "Absent::Thing.instance_variable_set(:@x, 1)\n" + ) + + expect(findings).to be_empty + end + + it "leaves a receiver it cannot prove is a class alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(other) + other.instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "reads a receiver's own class as a class" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(other) + other.class.instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "leaves the instance side alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def touch + instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "leaves a name built at runtime alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(key) + instance_variable_set("@\#{key}", 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + end + + describe "derived constants" do + def seed(path, line, severity: :error, + check: "mutable-constants") + Audition::Finding.new( + check: check, + severity: severity, + message: "constant seed", + why: "w", + fix: "f", + path: path, + line: line + ) + end + + def derived_for(sources, seeds) + described_class.new + .analyze_sources(sources, constant_findings: seeds) + .select { |f| f.check == "derived-constants" } + end + + it "flags an alias of a flagged constant" do + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nDEFAULT = PRIMARY\n"}, + [seed("a.rb", 1)] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 2, severity: :error + ) + expect(findings.first.message).to include("DEFAULT") + expect(findings.first.message).to include("PRIMARY") + end + + it "flags a frozen container holding a flagged constant" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + module Registry + WIDGET = Object.new + ALL = [WIDGET].freeze + end + RUBY + [seed("a.rb", 2)] + ) + + expect(findings.map(&:line)).to contain_exactly(3) + expect(findings.first.message).to include("ALL") + end + + it "propagates through chains and across files" do + findings = derived_for( + { + "a.rb" => "module Config\n BASE = Object.new\nend\n", + "b.rb" => <<~RUBY + module Config + DEFAULT = BASE + FALLBACK = DEFAULT + end + RUBY + }, + [seed("a.rb", 2)] + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to contain_exactly(["b.rb", 2], ["b.rb", 3]) + end + + it "propagates a declaration rubydex could not settle" do + findings = derived_for( + { + "a.rb" => <<~RUBY, + module Kit + module Ops + Fn = T.let(->(v) { v }, Proc) + end + end + RUBY + "b.rb" => <<~RUBY, + class KitTest + Short = Kit::Ops::Fn + def go + Short.call(1) + end + end + RUBY + "c.rb" => <<~RUBY + module Shared + def self.build(data, v) + {x: data.fetch(:x, Kit::Ops::Fn.call(v))} + end + end + RUBY + }, + [seed("a.rb", 3)] + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to include(["b.rb", 2]) + end + + it "attributes a reference inside a multi-line assignment " \ + "to the assignment line" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + GADGET = Object.new + TABLE = { + default: GADGET + }.freeze + RUBY + [seed("a.rb", 1)] + ) + + expect(findings.map(&:line)).to contain_exactly(2) + end + + it "names a constant assigned an expression another check " \ + "flagged" do + findings = derived_for( + {"a.rb" => "TOKEN = Vault::API_TOKEN\n"}, + [seed("a.rb", 1, severity: :warning, + check: "unshareable-reads")] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 1, severity: :warning + ) + expect(findings.first.message).to include("constant TOKEN") + end + + it "attributes a contained seed to the assignment line and " \ + "follows the new name" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + WIDGET = Kit.build( + Vault::API_TOKEN + ) + COPY = WIDGET + RUBY + [seed("a.rb", 2, check: "native-gem-calls")] + ) + + expect(findings.map(&:line)).to contain_exactly(1, 4) + expect(findings.first.message).to include("WIDGET") + end + + it "defers to a constant finding already at the line" do + findings = derived_for( + {"a.rb" => "TOKEN = Vault::API_TOKEN\n"}, + [seed("a.rb", 1), + seed("a.rb", 1, check: "unshareable-reads")] + ) + + expect(findings).to be_empty + end + + it "leaves expression findings outside assignments alone" do + findings = derived_for( + {"a.rb" => "def use = Vault::API_TOKEN\n"}, + [seed("a.rb", 1, check: "unshareable-reads")] + ) + + expect(findings).to be_empty + end + + it "skips sites the per-file pass already flagged" do + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nCOPY = PRIMARY\n"}, + [seed("a.rb", 1), seed("a.rb", 2)] + ) + + expect(findings).to be_empty + end + + it "emits one finding per site and follows the seed severity" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + FIRST = Object.new + SECOND = Object.new + BOTH = [FIRST, SECOND].freeze + RUBY + [seed("a.rb", 1, severity: :warning), + seed("a.rb", 2, severity: :warning)] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 3, severity: :warning + ) + end + + it "leaves reads outside constant assignments alone" do + findings = derived_for( + {"a.rb" => "GADGET = Object.new\ndef use = GADGET\n"}, + [seed("a.rb", 1)] + ) + + expect(findings).to be_empty + end + + it "ignores findings from other checks" do + other = Audition::Finding.new( + check: "class-variables", + severity: :error, + message: "class variable @@x on A", + why: "w", + fix: "f", + path: "a.rb", + line: 1 + ) + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nDEFAULT = PRIMARY\n"}, + [other] + ) + + expect(findings).to be_empty + end + end + + # The parallel path falls back to serial on any Ractor error, so + # only an explicit check keeps the walks—and everything they + # cross a Ractor boundary with—Ractor-safe. + describe "parallel walks" do + def wide_sources(count) + count.times.to_h do |i| + ["f#{i}.rb", <<~RUBY] + class Wide#{i} + @registry = {} + class << self + attr_accessor :cache + end + def self.prime = (@seen ||= []) + end + Wide#{i}.cache = {} + RUBY + end + end + + it "reports what the serial walks report" do + sources = wide_sources( + Audition::Static::GraphAudit::PARALLEL_THRESHOLD + 4 + ) + + serial = described_class.new + .analyze_sources(sources, workers: 1) + parallel = described_class.new + .analyze_sources(sources, workers: 4) + + expect(parallel.size).to be > sources.size + expect(parallel.map(&:location)).to eq(serial.map(&:location)) + expect(parallel.map(&:message)).to eq(serial.map(&:message)) + end + + it "runs the walks in Ractors rather than falling back" do + audit = described_class.new + expect(audit).not_to receive(:serial_batches) + + findings = audit.analyze_sources( + wide_sources( + Audition::Static::GraphAudit::PARALLEL_THRESHOLD + 4 + ), + workers: 4 + ) + + expect(findings).not_to be_empty + end + end + + describe "static scan" do + def scans_for(sources) + findings_for(sources).select { |f| f.check == "static-scan" } + end + + # Silence is the claim under test as much as the findings are: + # rubydex reports far more than matters here, and a check that + # fired on all of it would be unreadable. + it "flags class-level state behind a runtime receiver" do + scans = scans_for( + "a.rb" => <<~RUBY + target = Registry.lookup(:thing) + class << target + @cache = {} + end + RUBY + ) + + expect(scans.map(&:line)).to contain_exactly(2) + expect(scans.first).to have_attributes(severity: :warning) + expect(scans.first.message).to include("singleton receiver") + expect(scans.first.why).to include("Ractor::IsolationError") + end + + it "flags a def on a runtime receiver that writes state" do + scans = scans_for( + "a.rb" => <<~RUBY + obj = build + def obj.tweak = (@state = 1) + RUBY + ) + + expect(scans.map(&:line)).to contain_exactly(2) + end + + it "ignores a runtime singleton that writes no state" do + expect( + scans_for( + "a.rb" => <<~RUBY + target = Registry.lookup(:thing) + class << target + def pure(x) = x + 1 + end + RUBY + ) + ).to be_empty + end + + # A nested class owns its own state and the graph resolves it, + # so the unresolved receiver hides nothing. + it "ignores state a class nested in the singleton declares" do + expect( + scans_for( + "a.rb" => <<~RUBY + thing = pick + class << thing + class Inner + @own = {} + end + end + RUBY + ) + ).to be_empty + end + + it "flags an unresolved superclass" do + scans = scans_for( + "a.rb" => <<~RUBY + base = Struct.new(:a) + class Widget < base + end + RUBY + ) + + expect(scans.map(&:message)) + .to contain_exactly("unresolved superclass of Widget") + expect(scans.first).to have_attributes(severity: :warning) + end + + it "flags an unresolved mixin in a class body" do + scans = scans_for( + "a.rb" => <<~RUBY + class Widget + include Object.const_get(name) + extend Kernel.const_get("Helpers") + end + RUBY + ) + + expect(scans.map(&:message)).to contain_exactly( + "unresolved include argument in Widget", + "unresolved extend argument in Widget" + ) + end + + # RSpec's include matcher parses as a mixin with a runtime + # argument. It is the single loudest shape in a spec tree, so + # the check has to stay out of method and block bodies. + it "ignores a mixin-shaped call in a block" do + expect( + scans_for( + "a_spec.rb" => <<~RUBY + RSpec.describe Thing do + it "works" do + expect(subject.body).to include("substring") + end + end + RUBY + ) + ).to be_empty + end + + it "flags an unresolved path assigned to a constant" do + scans = scans_for( + "a.rb" => <<~RUBY + TABLE = mod::Lookup + RUBY + ) + + expect(scans.map(&:message)) + .to contain_exactly("unresolved constant path in TABLE") + expect(scans.first).to have_attributes(severity: :info) + end + + it "ignores an unresolved path outside an assignment" do + expect( + scans_for( + "a.rb" => <<~RUBY + def use = mod::Elsewhere + RUBY + ) + ).to be_empty + end + + # rubydex can report the same location twice. + it "reports one finding per location" do + expect( + scans_for( + "a.rb" => <<~RUBY + TABLE = mod::Lookup + RUBY + ).size + ).to eq(1) + end + + it "leaves a line another check already reported alone" do + sources = { + "a.rb" => <<~RUBY + base = Struct.new(:a) + class Widget < base + end + RUBY + } + claimed = Audition::Finding.new( + check: "mutable-constants", severity: :error, message: "x", + why: "x", fix: "x", path: "a.rb", line: 2 + ) + + expect( + described_class.new + .analyze_sources(sources, constant_findings: [claimed]) + .select { |f| f.check == "static-scan" } + ).to be_empty + end + + # The rule names are rubydex's public labels, and a rename + # would leave the check silently reporting nothing. + it "names rules rubydex still ships" do + shipped = Rubydex::Rules.constants.map(&:to_s) + + expect(shipped) + .to include(*described_class::SCAN_RULES.keys) + end + end end diff --git a/spec/audition/static/native_extensions_spec.rb b/spec/audition/static/native_extensions_spec.rb index 4ebdb7b..4497d0f 100644 --- a/spec/audition/static/native_extensions_spec.rb +++ b/spec/audition/static/native_extensions_spec.rb @@ -141,6 +141,48 @@ def analyze(dir) expect(analyze(dir)).to eq([]) end end + it "finds an extension the gem did not put under ext" do + gem_dir do |dir| + native = File.join(dir, "native/cool_gem") + FileUtils.mkdir_p(native) + File.write(File.join(native, "extconf.rb"), "require \"mkmf\"\n") + File.write(File.join(native, "cool_gem.c"), + "void\nInit_cool_gem(void)\n{\n}\n") + + findings = analyze(dir) + + expect(findings.map(&:severity)).to eq([:warning]) + expect(findings.first.message).to include("native/cool_gem") + end + end + + it "names the checkout when the build file sits at the root" do + gem_dir do |dir| + File.write(File.join(dir, "extconf.rb"), "require \"mkmf\"\n") + File.write(File.join(dir, "cool_gem.c"), + "void\nInit_cool_gem(void)\n{\n}\n") + + findings = analyze(dir) + + expect(findings.first.message).to include(File.basename(dir)) + end + end + + it "reports a crate under a workspace manifest once" do + gem_dir do |dir| + ext = File.join(dir, "ext/cool_gem") + FileUtils.mkdir_p(File.join(ext, "src")) + File.write(File.join(dir, "Cargo.toml"), "[workspace]\n") + File.write(File.join(ext, "Cargo.toml"), "[package]\n") + File.write(File.join(ext, "src/lib.rs"), + "#[magnus::init]\nfn init() {}\n") + + findings = analyze(dir) + + expect(findings.map(&:path)).to eq([File.join(ext, "Cargo.toml")]) + end + end + it "ignores fuzz, bench, and test harness sources" do gem_dir do |dir| ext = File.join(dir, "ext/cool_gem") diff --git a/spec/audition/static/work_split_spec.rb b/spec/audition/static/work_split_spec.rb new file mode 100644 index 0000000..04f1f35 --- /dev/null +++ b/spec/audition/static/work_split_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe Audition::Static::WorkSplit do + describe ".workers" do + def workers(nprocessors:, limit:) + allow(Etc).to receive(:nprocessors).and_return(nprocessors) + stub_const("ENV", ENV.to_h.merge("RUBY_MAX_CPU" => limit)) + described_class.workers + end + + it "uses every core the Ractor pool can run" do + expect(workers(nprocessors: 4, limit: nil)).to eq(4) + expect(workers(nprocessors: 32, limit: "24")).to eq(24) + end + + it "never spawns past the Ractor CPU limit" do + expect(workers(nprocessors: 32, limit: nil)).to eq(8) + expect(workers(nprocessors: 32, limit: "4")).to eq(4) + end + + it "always keeps one worker" do + expect(workers(nprocessors: 1, limit: "0")).to eq(1) + end + end + + describe ".chunks" do + it "keeps one heavy item from trailing the whole scan" do + light = 8.times.map { |i| ["light#{i}", 1] } + + chunks = described_class.chunks(light + [["heavy", 500]], 3) + + expect(chunks.size).to eq(3) + expect(chunks.flatten).to match_array( + light.map(&:first) + ["heavy"] + ) + expect(chunks.find { |c| c.include?("heavy") }.size).to eq(1) + end + + it "leaves no worker idle when items outnumber workers" do + weighted = 6.times.map { |i| ["f#{i}", i + 1] } + + chunks = described_class.chunks(weighted, 3) + + expect(chunks.map(&:size)).to all(be_positive) + end + + it "returns one chunk per item when workers outnumber items" do + chunks = described_class.chunks([["a", 1], ["b", 1]], 8) + + expect(chunks.map(&:size)).to eq([1, 1]) + end + + it "splits equal weights the same way every time" do + weighted = 10.times.map { |i| ["f#{i}", 10] } + + expect(described_class.chunks(weighted, 4)) + .to eq(described_class.chunks(weighted.shuffle, 4)) + end + + it "balances the load, not the item count" do + weighted = [["a", 30], ["b", 10], ["c", 10], ["d", 10]] + + chunks = described_class.chunks(weighted, 2) + + expect(chunks).to contain_exactly(["a"], %w[b c d]) + end + end +end diff --git a/spec/audition/target_spec.rb b/spec/audition/target_spec.rb index 8690985..a79af73 100644 --- a/spec/audition/target_spec.rb +++ b/spec/audition/target_spec.rb @@ -19,6 +19,21 @@ def in_tmpdir end end + it "keeps a file inside a Rails root static-only" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "config")) + FileUtils.mkdir_p(File.join(dir, "app/models")) + File.write(File.join(dir, "config/application.rb"), "\n") + path = File.join(dir, "app/models/user.rb") + File.write(path, "class User; end\n") + + target = described_class.detect(path) + expect(target.type).to eq(:script) + expect(target.ruby_files).to eq([path]) + expect(target.entry).to be_nil + end + end + it "detects a directory with config.ru as a rack app" do in_tmpdir do |dir| File.write(File.join(dir, "config.ru"), "run ->(e) {}\n") @@ -30,7 +45,8 @@ def in_tmpdir File.join(dir, "config.ru"), File.join(dir, "app.rb") ) expect(target.entry) - .to eq(mode: :rack, config_ru: File.join(dir, "config.ru")) + .to eq(mode: :rack, config_ru: File.join(dir, "config.ru"), + root: dir) end end @@ -54,6 +70,68 @@ def in_tmpdir end end + it "scans a Rails root beyond app, lib, and config" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "config")) + FileUtils.mkdir_p(File.join(dir, "gems/toolkit/lib")) + FileUtils.mkdir_p(File.join(dir, "vendor/bundle")) + File.write(File.join(dir, "config/application.rb"), "module A;end\n") + File.write(File.join(dir, "gems/toolkit/lib/toolkit.rb"), + "module Toolkit;end\n") + File.write(File.join(dir, "vendor/bundle/dep.rb"), "module D;end\n") + + target = described_class.detect(dir) + expect(target.ruby_files) + .to include(File.join(dir, "gems/toolkit/lib/toolkit.rb")) + expect(target.ruby_files) + .not_to include(File.join(dir, "vendor/bundle/dep.rb")) + end + end + + describe "#test_file?" do + it "recognizes test directories and suffixed files" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + expect(target.test_file?(File.join(dir, "spec/a_spec.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "test/models/a.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "features/x.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "lib/a_test.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "app/models/a.rb"))) + .to be(false) + expect(target.test_file?(File.join(dir, "lib/testing.rb"))) + .to be(false) + end + end + + it "takes the directory names it is given" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + expect(target.test_file?(File.join(dir, "qa/a.rb"), + dirs: %w[qa])).to be(true) + expect(target.test_file?(File.join(dir, "spec/a.rb"), + dirs: %w[qa])).to be(false) + end + end + + it "matches nested test directories inside engines" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + path = File.join(dir, "gems/toolkit/spec/a.rb") + expect(target.test_file?(path)).to be(true) + end + end + end + it "detects a directory with a gemspec as a gem" do in_tmpdir do |dir| FileUtils.mkdir_p(File.join(dir, "lib")) @@ -71,6 +149,36 @@ def in_tmpdir end end + it "falls back to the whole checkout when a gem has no lib" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "src")) + File.write(File.join(dir, "cool_gem.gemspec"), "") + File.write(File.join(dir, "src/cool_gem.rb"), "module CoolGem;end\n") + + target = described_class.detect(dir) + expect(target.ruby_files) + .to eq([File.join(dir, "src/cool_gem.rb")]) + expect(target.entry[:load_paths]).to eq([dir]) + end + end + + it "finds type stubs wherever the generator wrote them" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "types/gems")) + FileUtils.mkdir_p(File.join(dir, "vendor/types")) + File.write(File.join(dir, "types/gems/kit@1.0.0.rbi"), "\n") + File.write(File.join(dir, "top.rbi"), "\n") + File.write(File.join(dir, "vendor/types/dep.rbi"), "\n") + File.write(File.join(dir, "thing.rb"), "X = 1\n") + + target = described_class.detect(dir) + expect(target.stub_files).to contain_exactly( + File.join(dir, "types/gems/kit@1.0.0.rbi"), + File.join(dir, "top.rbi") + ) + end + end + it "resolves an installed gem by name" do target = described_class.detect("rubydex")