rigortype 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +135 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  9. data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +206 -6
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +59 -6
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +235 -0
  24. data/lib/rigor/configuration.rb +45 -11
  25. data/lib/rigor/environment.rb +47 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +7 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +233 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +70 -6
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +49 -7
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +6 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. metadata +58 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47346567152367cb408115b6a33214e43468f2255bd632b4894d8c68164b8ada
4
- data.tar.gz: 407d5cda9e08342670a4eeb05ae2c565d398866174d292c586793cb213a77fdb
3
+ metadata.gz: 8142401ce01630e0adcc3e1d59dedfc0a123ae247617b7bf91d33cbffe4cb193
4
+ data.tar.gz: ca747bb44214c0bf7dca8203f1c2fcf84657b8a639dbdc5448ede1f32b3f48ae
5
5
  SHA512:
6
- metadata.gz: f6ffecd573e1bbb387c861cbe3a6d909d217dfadef453a7a84339a4f7fd44e2c86dbbac54c8369298b56a2de3771c0f75c28cfe098cb895355a52155657f4f9d
7
- data.tar.gz: 6f924aed4bc15581bae58ffa62a5d8194db3fabbdc3056f2b6cfc4f068f972063e0148f85df8b8ef3e5f5003fad35b4e9016e534ef604cbfc581d0cdab76bb7b
6
+ metadata.gz: 02ef30047bcbf17ee716634315664522449610396c0645cf2e9f289fab7b1bc5667ac60a3cbe4069b59c7435bc1ad2ff9142f35f525df019b439ef74ad58191b
7
+ data.tar.gz: 6e6fe48ffce033b46a1f44be6b2eff62c3e11733f6fd5a583a17e631e18a752d3b77464783da88e9c9f5f0ee345440ca5c751d05e3d77d5253334f3a988bbcf4
data/README.md CHANGED
@@ -79,6 +79,12 @@ bundle exec rigor type-of lib/foo.rb:10:5
79
79
  # Report Scope#type_of coverage across a tree (handy when
80
80
  # diagnosing why a particular call site reads as `untyped`).
81
81
  bundle exec rigor type-scan lib
82
+
83
+ # Emit RBS skeletons from inference results — review with
84
+ # `--diff`, write to `sig/` with `--write`. ADR-14 sig-gen.
85
+ bundle exec rigor sig-gen --print lib/foo.rb
86
+ bundle exec rigor sig-gen --diff lib/foo.rb
87
+ bundle exec rigor sig-gen --write lib/foo.rb
82
88
  ```
83
89
 
84
90
  ### Sample output
@@ -145,6 +151,7 @@ out of the box, without you writing a single annotation.
145
151
  | **Intersection** (`Type::Intersection`) | Composition of multiple refinements | `non-empty-lowercase-string = non-empty-string ∩ lowercase-string` |
146
152
  | **Tuple / HashShape** | Heterogeneous arrays / known-key hashes that carry per-position / per-key types | `[1, "two", :three]` types as `Tuple[Constant<1>, Constant<"two">, Constant<:three>]`; `{name: "Alice", age: 30}` as `HashShape{name: Constant<"Alice">, age: Constant<30>}` |
147
153
  | **Union** (`Type::Union`) | "One of these literal values" — finite enums Rigor can enumerate | `Constant<:zero> \| Constant<:small> \| Constant<:large>` |
154
+ | **`Method` binding** (`Type::BoundMethod`) | The receiver / method-name pair `Object#method(:sym)` produces, so `.call` / `.()` / `[]` recover the precise backing dispatch | `"1".method(:to_i).call` resolves to `Constant<1>` instead of `untyped` |
148
155
  | **`Dynamic[T]`** | The gradual carrier — wraps a static facet with a "could be anything" admission | `Dynamic[Top]` is the conservative fallback Rigor uses when it cannot prove a narrower type |
149
156
 
150
157
  Each refinement / range / literal carrier **erases to its base
@@ -188,6 +195,12 @@ label = case n
188
195
  label # Constant<:zero> | Constant<:small>
189
196
  # | Constant<:large>
190
197
 
198
+ # Method bindings keep their receiver — `.method(:sym).call`
199
+ # round-trips through the original dispatch.
200
+ [:to_i, :to_f, :to_sym].map { |m| "1".method(m).call }
201
+ # Tuple[Constant<1>, Constant<1.0>, Constant<:"1">]
202
+ # — per-element fold + BoundMethod backward fold
203
+
191
204
  # RBS::Extended directives let you tighten beyond what RBS expresses.
192
205
  class Slug
193
206
  %a{rigor:v1:return: non-empty-string}
@@ -239,6 +252,16 @@ Rigor consults, in order:
239
252
  Rigor walks `def` / `define_method` / `attr_*` /
240
253
  `Data.define(*Symbol)` so user-defined methods on a class
241
254
  are recognised.
255
+ 5. **Opt-in gem-source inference (ADR-10).** Gems listed
256
+ under `dependencies.source_inference:` in `.rigor.yml`
257
+ have their `lib/` walked the same way project source is,
258
+ so methods on those gems' classes resolve even without
259
+ RBS. Inferred returns crossing the gem boundary are
260
+ wrapped in `Dynamic[T]` so the call site retains the
261
+ provenance — RBS / RBS::Inline / generated stubs / plugin
262
+ contracts always win on conflict. Default behaviour is
263
+ unchanged: gems not listed stay at the
264
+ RBS-or-`Dynamic[Top]` boundary.
242
265
 
243
266
  If a type cannot be proved, the engine returns `Dynamic[Top]`
244
267
  (Rigor's gradual carrier) and stays silent — Rigor never invents
@@ -343,6 +366,10 @@ sees `id` as `non-empty-string` (so `id.empty?` reduces to
343
366
  optional policies, per-element block fold over
344
367
  `map`, `select`, `filter_map`, `flat_map`, `find` /
345
368
  `find_index`, `count`, `any?` / `all?` / `none?`, `zip`.
369
+ `&:symbol` block-pass on these methods is treated as
370
+ `{ |x| x.symbol }` and dispatches against the element type
371
+ so `Hash#transform_values(&:freeze)` returns `Hash[K, V]`
372
+ instead of `Enumerator[...]`.
346
373
  - **Constant folding** — aggressive arithmetic / string /
347
374
  Symbol / Tuple-shaped `divmod` folding, cartesian fold over
348
375
  `Union[Constant…]`, integer-range arithmetic
@@ -360,26 +387,48 @@ sees `id` as `non-empty-string` (so `id.empty?` reduces to
360
387
  - **Refinement carriers** — `Type::Difference`,
361
388
  `Type::Refined`, `Type::Intersection` provide the
362
389
  imported-built-in catalogue end-to-end through
363
- `Builtins::ImportedRefinements`.
390
+ `Builtins::ImportedRefinements`. The parser accepts Symbol
391
+ / String literals and `|`-unions at type-arg position
392
+ (`pick_of[Shape, :a | :b]`, `Pick[T, "name" | "email"]`).
393
+ - **`Method` carrier (`Type::BoundMethod`)** —
394
+ `Object#method(:sym)` lifts into a binding carrier so
395
+ `.call` / `.()` / `[]` recover the precise dispatch
396
+ (`"1".method(:to_i).call` resolves to `Constant<1>`).
397
+ Reflective Method members (`#owner` / `#name` / `#arity`)
398
+ still resolve via the Method RBS sig.
364
399
  - **`RBS::Extended` directive routes** — `return:`, `param:`
365
400
  (call-site + body-side), `assert:` /
366
401
  `predicate-if-(true|false)` accept refinement payloads, and
367
402
  roll up into a single `Rigor::FlowContribution` bundle per
368
403
  method (the v0.1.0 plugin contribution merger reads bundles
369
404
  directly).
405
+ - **Opt-in gem-source inference (ADR-10)** — gems listed under
406
+ `dependencies.source_inference:` have their `lib/` walked.
407
+ Per-gem budget, per-gem-version cache slice,
408
+ `dynamic.dependency-source.*` diagnostic family covering
409
+ gem-not-found / budget-exceeded / config-conflict /
410
+ boundary-cross (the last surfaces RBS+gem-source overlap
411
+ on `mode: :full` gems for audit).
370
412
 
371
413
  The full per-release surface lives in
372
414
  [`CHANGELOG.md`](CHANGELOG.md). The internal contracts the
373
415
  analyzer guarantees live under
374
416
  [`docs/internal-spec/`](docs/internal-spec/).
375
417
 
376
- ## Plugins (v0.1.0)
418
+ ## Plugins
419
+
420
+ `v0.1.0` introduced the extension API; `v0.1.x` rounds it out
421
+ with the [ADR-9](docs/adr/9-cross-plugin-api.md) cross-plugin
422
+ fact channel (one plugin publishes a fact like `:model_index`,
423
+ another consumes it), [ADR-11](docs/adr/11-sorbet-input-adapter.md)
424
+ Sorbet ingestion, and [ADR-13](docs/adr/13-typenode-resolver-plugin.md)
425
+ plugin-supplied type-vocabulary resolvers. **Nineteen worked
426
+ examples** ship under [`examples/`](examples/) — each is a
427
+ fully-shaped plugin gem with a runnable demo and an end-to-end
428
+ integration spec.
377
429
 
378
- `v0.1.0` adds an extension API so projects can teach Rigor about
379
- their own DSLs. Seven worked examples ship under
380
- [`examples/`](examples/) — each is a fully-shaped plugin gem
381
- with a runnable demo and an end-to-end integration spec, and
382
- each spotlights a different facet of the plugin contract:
430
+ **Plugin-contract teaching examples** (focus on a single
431
+ extension-point):
383
432
 
384
433
  - [`rigor-deprecations`](examples/rigor-deprecations/) —
385
434
  smallest possible plugin (~80 lines); config-driven rules.
@@ -393,21 +442,45 @@ each spotlights a different facet of the plugin contract:
393
442
  - [`rigor-units`](examples/rigor-units/) — local-variable flow
394
443
  tracking through arithmetic.
395
444
  - [`rigor-routes`](examples/rigor-routes/) — `Plugin::IoBoundary`
396
- reads under `TrustPolicy` plus cache producers (slice 2 +
397
- slice 6).
398
- - [`rigor-activerecord`](examples/rigor-activerecord/)
399
- validates `Model.find` / `.find_by` / `.where` against
400
- `db/schema.rb` and discovered AR model classes; combines
401
- DSL interpretation, multi-file `IoBoundary`, and chained
402
- cache producers the most architecturally complete example.
445
+ reads under `TrustPolicy` plus cache producers.
446
+ - [`rigor-typescript-utility-types`](examples/rigor-typescript-utility-types/)
447
+ `Plugin::TypeNodeResolver` chain wiring TS-canonical names
448
+ (`Pick` / `Omit` / `Partial` / `Required` / `Readonly`) onto
449
+ Rigor's shape-projection type functions.
450
+
451
+ **Rails ecosystem plugins** (Tier 1 + Tier 2 + Tier 3 + Sorbet):
452
+
453
+ - Tier 1: [`rigor-rails-routes`](examples/rigor-rails-routes/),
454
+ [`rigor-rails-i18n`](examples/rigor-rails-i18n/),
455
+ [`rigor-actionmailer`](examples/rigor-actionmailer/),
456
+ [`rigor-activejob`](examples/rigor-activejob/).
457
+ - Tier 2: [`rigor-actionpack`](examples/rigor-actionpack/)
458
+ (4 phases — routes / filters / renders / strong-params),
459
+ [`rigor-factorybot`](examples/rigor-factorybot/),
460
+ [`rigor-activerecord`](examples/rigor-activerecord/) —
461
+ publishes `:model_index` via ADR-9 for the other two
462
+ to consume.
463
+ - Tier 3: [`rigor-pundit`](examples/rigor-pundit/),
464
+ [`rigor-sidekiq`](examples/rigor-sidekiq/),
465
+ [`rigor-rspec`](examples/rigor-rspec/),
466
+ [`rigor-actioncable`](examples/rigor-actioncable/).
467
+ - Parallel: [`rigor-sorbet`](examples/rigor-sorbet/) — ingests
468
+ Sorbet `sig` / `T.let` / `T.cast` / `T.must` / `T.bind` /
469
+ `T.assert_type!` / `T.reveal_type` / `T.absurd` and RBI
470
+ files as type sources.
403
471
 
404
472
  [`examples/README.md`](examples/README.md) is the plugin
405
473
  authoring landing page — comparison table, recommended reading
406
474
  order, and the architectural map of which surface each example
407
475
  exercises. The binding contract for the plugin API lives in
408
- [`docs/adr/2-extension-api.md`](docs/adr/2-extension-api.md)
409
- and the slice-by-slice normative specs under
410
- [`docs/internal-spec/plugin*.md`](docs/internal-spec/).
476
+ [`docs/adr/2-extension-api.md`](docs/adr/2-extension-api.md);
477
+ the slice-by-slice normative specs are under
478
+ [`docs/internal-spec/plugin*.md`](docs/internal-spec/); the
479
+ sibling ADRs that extend it ride the same surface
480
+ ([ADR-9](docs/adr/9-cross-plugin-api.md) cross-plugin facts,
481
+ [ADR-11](docs/adr/11-sorbet-input-adapter.md) Sorbet adapter,
482
+ [ADR-13](docs/adr/13-typenode-resolver-plugin.md) TypeNode
483
+ resolver).
411
484
 
412
485
  ## Configuration
413
486
 
@@ -437,22 +510,53 @@ Common knobs the file exposes:
437
510
 
438
511
  ## Status
439
512
 
440
- Current released version: **`v0.0.8`** (the eighth preview).
441
- The analyzer is usable on real Ruby code today but the rule
442
- catalogue is deliberately narrow — Rigor's stance is to surface
443
- zero false positives while the inference surface stabilises.
444
- The roadmap is tracked in
445
- [`docs/MILESTONES.md`](docs/MILESTONES.md); release-by-release
513
+ Current released version: **`v0.1.2`**. The analyzer is usable
514
+ on real Ruby code today; the rule catalogue is deliberately
515
+ narrow — Rigor's stance is to surface zero false positives
516
+ while the inference surface stabilises. The roadmap is tracked
517
+ in [`docs/MILESTONES.md`](docs/MILESTONES.md); release-by-release
446
518
  detail lives in [`CHANGELOG.md`](CHANGELOG.md).
447
519
 
448
- `v0.0.9` is the active development cluster on `master` and
449
- covers the persistent cache infrastructure (`.rigor/cache/`,
450
- `--cache-stats`, `--clear-cache`, `--no-cache`),
451
- paired-complement Refined narrowing, `literal-string` flow
452
- tracking, the `Rigor::FlowContribution` bundle struct, and
453
- six additional built-in catalogues (Random, Struct, Encoding,
454
- Regexp + MatchData, Proc / Method / UnboundMethod, Exception).
455
- The next release after `0.0.9` will be `0.1.0`.
520
+ `v0.1.4` is the active development cluster on `master` and
521
+ delivers:
522
+
523
+ - **[ADR-10](docs/adr/10-dependency-source-inference.md) closed
524
+ end-to-end** opt-in gem-source inference, per-gem budget,
525
+ cache slice, and the `dynamic.dependency-source.boundary-cross`
526
+ `:info` diagnostic that surfaces RBS / gem-source overlap
527
+ under `mode: :full`.
528
+ - **[ADR-11](docs/adr/11-sorbet-input-adapter.md) primary surface
529
+ + per-call-site assertion gating** — `rigor-sorbet` ingests
530
+ Sorbet `sig { ... }` blocks, `T.let` / `T.cast` / `T.must` /
531
+ `T.bind` / `T.assert_type!` / `T.reveal_type` / `T.absurd`,
532
+ and RBI files. Per-call-site `enforce_sigil` gates assertion
533
+ recognisers by the caller file's `# typed:` sigil.
534
+ - **[ADR-13](docs/adr/13-typenode-resolver-plugin.md) plugin
535
+ TypeNode resolver + TypeScript-utility-type adapter** —
536
+ `Plugin::TypeNodeResolver` extension point + five
537
+ Rigor-canonical shape-projection type functions
538
+ (`pick_of` / `omit_of` / `partial_of` / `required_of` /
539
+ `readonly_of`) + the opt-in `rigor-typescript-utility-types`
540
+ plugin mapping TS spellings onto the core functions.
541
+ `Pick[T, :a | :b]` round-trips through the directive grammar.
542
+ - **[ADR-14](docs/adr/14-rbs-sig-generation.md) — `rigor sig-gen`
543
+ CLI** — emits RBS from inference results across five
544
+ classifications (`new-file` / `new-method` / `tighter-return`
545
+ / `equivalent` / `skipped`); `--params=untyped` default,
546
+ `--params=observed` opt-in via `--observe=PATH`.
547
+ - **`Method` carrier (`Type::BoundMethod`)** —
548
+ `Object#method(:sym).call` / `.()` / `[]` round-trip with
549
+ full precision instead of collapsing to `untyped`.
550
+ - **Rails ecosystem (Tier 1 + Tier 2)** — `rigor-rails-routes`,
551
+ `rigor-rails-i18n`, `rigor-actionmailer`, `rigor-activejob`,
552
+ `rigor-actionpack` (4 phases), `rigor-factorybot`, and
553
+ `rigor-activerecord` publishing `:model_index` via the
554
+ ADR-9 cross-plugin fact channel.
555
+
556
+ Nineteen worked plugin examples now ship under
557
+ [`examples/`](examples/) — see
558
+ [`examples/README.md`](examples/README.md) for the comparison
559
+ table.
456
560
 
457
561
  ## Contributing
458
562
 
@@ -262,7 +262,7 @@ module Rigor
262
262
  # errors, internal analyzer errors) are NEVER
263
263
  # suppressed — they represent failures the user cannot
264
264
  # silence away.
265
- def filter_suppressed(diagnostics, comments:, disabled_rules:) # rubocop:disable Metrics/CyclomaticComplexity
265
+ def filter_suppressed(diagnostics, comments:, disabled_rules:)
266
266
  line_suppressions, file_suppressions = parse_suppression_comments(comments)
267
267
  disabled = expand_rule_tokens(disabled_rules)
268
268
 
@@ -329,7 +329,7 @@ module Rigor
329
329
  class << self
330
330
  private
331
331
 
332
- def undefined_method_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
332
+ def undefined_method_diagnostic(path, call_node, scope_index)
333
333
  return nil if call_node.receiver.nil?
334
334
 
335
335
  scope = scope_index[call_node]
@@ -429,7 +429,7 @@ module Rigor
429
429
  # by `undefined_method_diagnostic`; it returns nil
430
430
  # when the call's receiver / RBS coverage / call shape
431
431
  # disqualifies the rule.
432
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
432
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
433
433
  def wrong_arity_diagnostic(path, call_node, scope_index)
434
434
  return nil if call_node.receiver.nil?
435
435
  return nil unless plain_positional_call?(call_node)
@@ -459,7 +459,7 @@ module Rigor
459
459
 
460
460
  build_arity_diagnostic(path, call_node, class_name, min, max, actual)
461
461
  end
462
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
462
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
463
463
 
464
464
  def plain_positional_call?(call_node)
465
465
  arguments = call_node.arguments
@@ -534,7 +534,7 @@ module Rigor
534
534
  # and union receivers where every member already
535
535
  # disqualifies the call (avoid duplicating the
536
536
  # undefined-method diagnostic).
537
- def nil_receiver_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
537
+ def nil_receiver_diagnostic(path, call_node, scope_index)
538
538
  return nil if call_node.receiver.nil?
539
539
  # Safe-navigation calls (`recv&.method`) already
540
540
  # short-circuit on nil at runtime, so a nil-bearing
@@ -615,7 +615,7 @@ module Rigor
615
615
  # The diagnostic does NOT count toward `Result#error_count`
616
616
  # so a fixture peppered with `dump_type` calls still
617
617
  # passes `rigor check`.
618
- def dump_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
618
+ def dump_type_diagnostic(path, call_node, scope_index)
619
619
  return nil unless rigor_testing_call?(call_node, :dump_type)
620
620
  return nil if call_node.arguments.nil? || call_node.arguments.arguments.empty?
621
621
 
@@ -644,7 +644,7 @@ module Rigor
644
644
  # is emitted; matching calls produce no output. This
645
645
  # lets a fixture document its expected types inline:
646
646
  # subsequent `rigor check` runs flag any drift.
647
- def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
647
+ def assert_type_diagnostic(path, call_node, scope_index)
648
648
  return nil unless rigor_testing_call?(call_node, :assert_type)
649
649
  return nil if call_node.arguments.nil? || call_node.arguments.arguments.size < 2
650
650
 
@@ -972,7 +972,6 @@ module Rigor
972
972
  )
973
973
  end
974
974
 
975
- # rubocop:disable Metrics/ParameterLists
976
975
  def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
977
976
  location = node.name_loc || node.location
978
977
  Diagnostic.new(
@@ -985,7 +984,6 @@ module Rigor
985
984
  severity: :error
986
985
  )
987
986
  end
988
- # rubocop:enable Metrics/ParameterLists
989
987
 
990
988
  # Returns the dead-branch node for a literal-predicate
991
989
  # if/unless, or nil when no observable branch is dead.
@@ -1034,7 +1032,6 @@ module Rigor
1034
1032
  # (no splat / kw / block-pass / forwarded).
1035
1033
  # - Per-argument: skip when EITHER side is `Dynamic`
1036
1034
  # (the call cannot be statically refuted).
1037
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
1038
1035
  def argument_type_diagnostic(path, call_node, scope_index)
1039
1036
  return nil if call_node.receiver.nil?
1040
1037
  return nil unless plain_positional_call?(call_node)
@@ -1059,15 +1056,13 @@ module Rigor
1059
1056
  return nil if method_def.nil? || method_def == true
1060
1057
  return nil unless method_def.method_types.size == 1
1061
1058
 
1062
- param_overrides = Rigor::RbsExtended.param_type_override_map(method_def)
1059
+ param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
1063
1060
  mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope, param_overrides)
1064
1061
  return nil if mismatch.nil?
1065
1062
 
1066
1063
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1067
1064
  end
1068
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
1069
1065
 
1070
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
1071
1066
  def first_argument_mismatch(method_type, call_node, scope, param_overrides)
1072
1067
  function = method_type.type
1073
1068
  return nil unless argument_check_eligible?(function)
@@ -1094,7 +1089,6 @@ module Rigor
1094
1089
  end
1095
1090
  nil
1096
1091
  end
1097
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
1098
1092
 
1099
1093
  def argument_check_eligible?(function)
1100
1094
  # See `arity_eligible?`: `UntypedFunction` lacks
@@ -1132,7 +1126,6 @@ module Rigor
1132
1126
  )
1133
1127
  end
1134
1128
 
1135
- # rubocop:disable Metrics/ParameterLists
1136
1129
  def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
1137
1130
  location = call_node.message_loc || call_node.location
1138
1131
  range = min == max ? min.to_s : "#{min}..#{max}"
@@ -1147,7 +1140,6 @@ module Rigor
1147
1140
  severity: :error
1148
1141
  )
1149
1142
  end
1150
- # rubocop:enable Metrics/ParameterLists
1151
1143
 
1152
1144
  def build_undefined_method_diagnostic(path, call_node, receiver_type)
1153
1145
  location = call_node.message_loc || call_node.location
@@ -1186,7 +1178,7 @@ module Rigor
1186
1178
  # :maybe → emit at :warning. Promoted to :error
1187
1179
  # under `severity_profile: strict` per
1188
1180
  # ADR-8 § "Severity profile".
1189
- def return_type_mismatch_diagnostic(path, def_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1181
+ def return_type_mismatch_diagnostic(path, def_node, scope_index)
1190
1182
  return nil if def_node.body.nil?
1191
1183
 
1192
1184
  last_expr = body_last_expression(def_node.body)
@@ -1255,7 +1247,7 @@ module Rigor
1255
1247
  end
1256
1248
  return nil if method_def.nil?
1257
1249
 
1258
- override = Rigor::RbsExtended.read_return_type_override(method_def)
1250
+ override = Rigor::RbsExtended.read_return_type_override(method_def, environment: scope.environment)
1259
1251
  return override if override
1260
1252
 
1261
1253
  declared_return_union(method_def, scope.environment)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module DependencySourceInference
6
+ # ADR-10 slice 5c — per-run accumulator for the
7
+ # `dynamic.dependency-source.boundary-cross` `:info`
8
+ # diagnostic.
9
+ #
10
+ # The diagnostic fires when both an authoritative source
11
+ # (RBS today; plugins later) AND a `mode: :full` opt-in
12
+ # gem's source catalog resolve the same `(class_name,
13
+ # method_name)`. The dispatcher takes the authoritative
14
+ # source's answer (per ADR-10's tier order), but records
15
+ # the boundary crossing so the user can audit whether RBS
16
+ # and the gem source have drifted.
17
+ #
18
+ # The accumulator deduplicates per `(class_name,
19
+ # method_name, gem_name)` so a method called from many
20
+ # files yields one diagnostic.
21
+ #
22
+ # Used in the same pattern as
23
+ # {Rigor::RbsExtended::Reporter}: the dispatcher writes
24
+ # events into the per-run instance; {Rigor::Analysis::Runner}
25
+ # drains it at end-of-run into a flat
26
+ # `Rigor::Analysis::Diagnostic` list.
27
+ class BoundaryCrossReporter
28
+ Entry = Data.define(:class_name, :method_name, :gem_name, :rbs_display)
29
+
30
+ def initialize
31
+ @entries = []
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # @return [Array<Entry>] frozen snapshot of the recorded
36
+ # boundary-cross events. Each entry is a Data with
37
+ # `class_name` (String), `method_name` (Symbol),
38
+ # `gem_name` (String), and `rbs_display` (String —
39
+ # the authoritative-side type's human-facing form,
40
+ # embedded into the diagnostic message).
41
+ def entries
42
+ @mutex.synchronize { @entries.dup.freeze }
43
+ end
44
+
45
+ def empty?
46
+ @mutex.synchronize { @entries.empty? }
47
+ end
48
+
49
+ # Records one boundary-cross event. Deduplicates on the
50
+ # `(class_name, method_name, gem_name)` triple — the
51
+ # diagnostic per-receiver-per-method-per-owning-gem is
52
+ # the actionable unit.
53
+ def record(class_name:, method_name:, gem_name:, rbs_display:)
54
+ entry = Entry.new(
55
+ class_name: class_name, method_name: method_name,
56
+ gem_name: gem_name, rbs_display: rbs_display
57
+ )
58
+ @mutex.synchronize do
59
+ return if @entries.any? { |existing| same_key?(existing, entry) }
60
+
61
+ @entries << entry
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def same_key?(existing, entry)
68
+ existing.class_name == entry.class_name &&
69
+ existing.method_name == entry.method_name &&
70
+ existing.gem_name == entry.gem_name
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_resolver"
4
+ require_relative "index"
5
+ require_relative "walker"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module DependencySourceInference
10
+ # Folds a `Configuration::Dependencies` value into a
11
+ # frozen {Index}. Resolves each non-disabled entry through
12
+ # {GemResolver}, walks each resolved gem's `roots:` via
13
+ # {Walker.walk} under the configured `budget_per_gem` cap,
14
+ # and aggregates the per-gem method catalogs into the
15
+ # Index's flat `(class_name, method_name) → kind` table.
16
+ #
17
+ # Entries with `mode: :disabled` are skipped without
18
+ # resolution attempts so users can "list and disable" a
19
+ # gem in configuration without provoking a missing-gem
20
+ # diagnostic.
21
+ module Builder
22
+ module_function
23
+
24
+ # @param dependencies [Rigor::Configuration::Dependencies]
25
+ # @return [Index]
26
+ def build(dependencies)
27
+ return Index::EMPTY if dependencies.empty?
28
+
29
+ state = BuildState.new
30
+ dependencies.source_inference.each do |entry|
31
+ next if entry.disabled?
32
+
33
+ state.absorb(GemResolver.resolve(entry), dependencies.budget_per_gem, self)
34
+ end
35
+
36
+ state.to_index(dependencies)
37
+ end
38
+
39
+ # Per-build mutable accumulator. The original inline
40
+ # variables (`resolved` / `unresolvable` / `catalog` /
41
+ # `class_to_gem` / `budget_exceeded` / `gem_modes`)
42
+ # pushed the method past the AbcSize budget; the
43
+ # struct collects the same fields, narrows
44
+ # `absorb`'s branching, and yields one
45
+ # `Index.new(...)` call from `to_index`.
46
+ class BuildState
47
+ def initialize
48
+ @resolved = []
49
+ @unresolvable = []
50
+ @catalog = {}
51
+ @class_to_gem = {}
52
+ @budget_exceeded = []
53
+ @gem_modes = {}
54
+ end
55
+
56
+ def absorb(outcome, budget, builder)
57
+ case outcome
58
+ when GemResolver::Resolved
59
+ absorb_resolved(outcome, budget, builder)
60
+ when GemResolver::Unresolvable
61
+ @unresolvable << outcome
62
+ end
63
+ end
64
+
65
+ def absorb_resolved(resolved, budget, builder)
66
+ @resolved << resolved
67
+ @gem_modes[resolved.gem_name] = resolved.mode
68
+ walked = builder.walker_outcome_for(resolved, budget)
69
+ @catalog.merge!(walked.catalog)
70
+ builder.record_class_to_gem(walked.catalog, resolved.gem_name, @class_to_gem)
71
+ @budget_exceeded << resolved.gem_name if walked.truncated?
72
+ end
73
+
74
+ def to_index(dependencies)
75
+ Index.new(
76
+ resolved_gems: @resolved, unresolvable: @unresolvable,
77
+ method_catalog: @catalog, budget_exceeded: @budget_exceeded,
78
+ class_to_gem: @class_to_gem,
79
+ budget_overrun_strategy: dependencies.budget_overrun_strategy,
80
+ gem_modes: @gem_modes
81
+ )
82
+ end
83
+ end
84
+
85
+ # ADR-10 5b — per-class reverse-lookup table (β budget
86
+ # semantics). Records `class_name → gem_name` for every
87
+ # class observed in the gem's catalog. First-write-wins:
88
+ # if two opt-in gems re-open the same class, the first
89
+ # gem to harvest the class owns it in the reverse index.
90
+ # The dispatcher only consults this map when the
91
+ # `budget_overrun_strategy` is `:dependency_silence`,
92
+ # so the storage cost is never paid back unless the
93
+ # user opts in.
94
+ def record_class_to_gem(catalog, gem_name, class_to_gem)
95
+ catalog.each_key do |(class_name, _method_name)|
96
+ class_to_gem[class_name] ||= gem_name
97
+ end
98
+ end
99
+
100
+ # Per-resolved-gem walk. Isolated so a single gem's
101
+ # filesystem error / parse failure cannot abort the
102
+ # build; the walker swallows its own per-file errors,
103
+ # and a top-level raise here degrades the gem to "no
104
+ # contributions" without touching the rest of the run.
105
+ def walker_outcome_for(resolved, budget)
106
+ Walker.walk(gem_dir: resolved.gem_dir, roots: resolved.roots, budget: budget)
107
+ rescue StandardError
108
+ Walker::Outcome.new(catalog: {}.freeze, truncated: false)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module DependencySourceInference
6
+ # Maps a `Configuration::Dependencies::Entry` to the gem's
7
+ # on-disk installation directory by consulting RubyGems
8
+ # (`Gem.loaded_specs` first, falling back to
9
+ # `Gem::Specification.find_by_name`). Returns either a
10
+ # frozen {Resolved} value object or an {Unresolvable} value
11
+ # describing why the gem cannot participate in this run.
12
+ #
13
+ # Resolution failures are surfaced as
14
+ # `dynamic.dependency-source.gem-not-found` diagnostics by
15
+ # {Analysis::Runner} rather than crashing the run, so a
16
+ # missing gem in `dependencies.source_inference` degrades
17
+ # cleanly to "no contributions from that gem" — every other
18
+ # gem and the project source remain unaffected.
19
+ module GemResolver
20
+ # Successful resolution. `version` is the spec version as
21
+ # a String so it round-trips into cache descriptors
22
+ # (slice 3) without leaking a `Gem::Version` instance
23
+ # through public surfaces.
24
+ class Resolved < Data.define(:gem_name, :version, :gem_dir, :mode, :roots)
25
+ def descriptor_key
26
+ [gem_name, version, mode].freeze
27
+ end
28
+ end
29
+
30
+ # Unresolvable reasons. `:not_in_bundle` covers both the
31
+ # "RubyGems doesn't know this gem" case and the
32
+ # `LoadError`-style raise from `find_by_name`. Future
33
+ # reasons (`:c_extension_only`, `:no_lib_root`) are
34
+ # introduced as the walker discovers them in slice 2b.
35
+ Unresolvable = Data.define(:gem_name, :reason)
36
+
37
+ VALID_REASONS = %i[not_in_bundle].freeze
38
+
39
+ module_function
40
+
41
+ # @param entry [Rigor::Configuration::Dependencies::Entry]
42
+ # @return [Resolved, Unresolvable]
43
+ def resolve(entry)
44
+ spec = locate_gem_spec(entry.gem)
45
+ return Unresolvable.new(gem_name: entry.gem, reason: :not_in_bundle) if spec.nil?
46
+
47
+ Resolved.new(
48
+ gem_name: entry.gem,
49
+ version: spec.version.to_s,
50
+ gem_dir: spec.full_gem_path, # rigor:disable undefined-method
51
+ mode: entry.mode,
52
+ roots: entry.roots
53
+ )
54
+ end
55
+
56
+ # Locator. `Gem.loaded_specs` reflects the bundle (cheap
57
+ # lookup, no filesystem walk); `find_by_name` is the
58
+ # broader fallback for gems present on the gem path but
59
+ # not yet `require`'d. `Gem::MissingSpecError` is a
60
+ # `LoadError` subclass, so the rescue covers both
61
+ # missing-spec and load-error signals.
62
+ def locate_gem_spec(name)
63
+ Gem.loaded_specs[name] || begin
64
+ Gem::Specification.find_by_name(name)
65
+ rescue LoadError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end