rigortype 0.1.4 → 0.1.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. metadata +39 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8142401ce01630e0adcc3e1d59dedfc0a123ae247617b7bf91d33cbffe4cb193
4
- data.tar.gz: ca747bb44214c0bf7dca8203f1c2fcf84657b8a639dbdc5448ede1f32b3f48ae
3
+ metadata.gz: b5960ec17b35768103e97d752f8cc6fd78fcb3f12e12fc43dfa41be07ec5317b
4
+ data.tar.gz: e79c9b25c973c8938e9b2f0a2741cca5195342619827b320ca521ec09e54321e
5
5
  SHA512:
6
- metadata.gz: 02ef30047bcbf17ee716634315664522449610396c0645cf2e9f289fab7b1bc5667ac60a3cbe4069b59c7435bc1ad2ff9142f35f525df019b439ef74ad58191b
7
- data.tar.gz: 6e6fe48ffce033b46a1f44be6b2eff62c3e11733f6fd5a583a17e631e18a752d3b77464783da88e9c9f5f0ee345440ca5c751d05e3d77d5253334f3a988bbcf4
6
+ metadata.gz: af1e033a25410c0f87943f12d43ab18a3a0d2a79c01307c2117c2fc15be4c9db3cb28e6fec10ce598ef6a5bfa063227f280c023a0f7e9025b06c69946df4654d
7
+ data.tar.gz: 351b3275dd35f37a11d30a696627e23a6cdca31bfb94fa3eacae762d2de624e4a914c6f8f4eebdd7df0bd17fd9fac13fe55ac434957509b742771a00d352a981
data/README.md CHANGED
@@ -421,11 +421,14 @@ analyzer guarantees live under
421
421
  with the [ADR-9](docs/adr/9-cross-plugin-api.md) cross-plugin
422
422
  fact channel (one plugin publishes a fact like `:model_index`,
423
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.
424
+ Sorbet ingestion, [ADR-13](docs/adr/13-typenode-resolver-plugin.md)
425
+ plugin-supplied type-vocabulary resolvers, and
426
+ [ADR-16](docs/adr/16-macro-expansion.md) macro / DSL expansion
427
+ substrate (declarative Tier A block-as-method / Tier B
428
+ trait-inlining-registry / Tier C heredoc-template / Tier D
429
+ external-file inclusion). **Twenty-four worked examples** ship
430
+ under [`examples/`](examples/) — each is a fully-shaped plugin
431
+ gem with a runnable demo and an end-to-end integration spec.
429
432
 
430
433
  **Plugin-contract teaching examples** (focus on a single
431
434
  extension-point):
@@ -448,6 +451,25 @@ extension-point):
448
451
  (`Pick` / `Omit` / `Partial` / `Required` / `Readonly`) onto
449
452
  Rigor's shape-projection type functions.
450
453
 
454
+ **Macro expansion substrate consumers** (ADR-16 — declarative
455
+ manifest entries, no walker code):
456
+
457
+ - [`rigor-sinatra`](examples/rigor-sinatra/) — **Tier A**
458
+ block-as-method. Recognises Sinatra's nine class-level HTTP
459
+ verb methods and narrows the route block's `self_type` so
460
+ bare `params` / `redirect` / `halt` resolve through
461
+ `Sinatra::Base`'s RBS.
462
+ - [`rigor-dry-struct`](examples/rigor-dry-struct/) — **Tier C**
463
+ heredoc-template. Synthesises a reader on every `Dry::Struct`
464
+ subclass for each `attribute :name, T` / `attribute? :name, T`
465
+ call.
466
+ - [`rigor-devise`](examples/rigor-devise/) — **Tier B**
467
+ trait-inlining registry mirroring `lib/devise/modules.rb`.
468
+ Each `devise :strategy_a, :strategy_b` call explodes the
469
+ included module's RBS instance methods onto the calling model
470
+ class (Devise's `user.valid_password?` returns the module's
471
+ authored `bool`).
472
+
451
473
  **Rails ecosystem plugins** (Tier 1 + Tier 2 + Tier 3 + Sorbet):
452
474
 
453
475
  - Tier 1: [`rigor-rails-routes`](examples/rigor-rails-routes/),
@@ -510,15 +532,15 @@ Common knobs the file exposes:
510
532
 
511
533
  ## Status
512
534
 
513
- Current released version: **`v0.1.2`**. The analyzer is usable
535
+ Current released version: **`v0.1.4`**. The analyzer is usable
514
536
  on real Ruby code today; the rule catalogue is deliberately
515
537
  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
518
- detail lives in [`CHANGELOG.md`](CHANGELOG.md).
538
+ while the inference surface stabilises. Forward-looking commitments
539
+ (in-flight cycle + queued work) live in
540
+ [`docs/ROADMAP.md`](docs/ROADMAP.md); the release-by-release
541
+ "what shipped" record is [`CHANGELOG.md`](CHANGELOG.md).
519
542
 
520
- `v0.1.4` is the active development cluster on `master` and
521
- delivers:
543
+ `v0.1.4` (released 2026-05-14) delivered:
522
544
 
523
545
  - **[ADR-10](docs/adr/10-dependency-source-inference.md) closed
524
546
  end-to-end** — opt-in gem-source inference, per-gem budget,
@@ -553,10 +575,15 @@ delivers:
553
575
  `rigor-activerecord` publishing `:model_index` via the
554
576
  ADR-9 cross-plugin fact channel.
555
577
 
556
- Nineteen worked plugin examples now ship under
578
+ Twenty-four worked plugin examples now ship under
557
579
  [`examples/`](examples/) — see
558
580
  [`examples/README.md`](examples/README.md) for the comparison
559
- table.
581
+ table. The current `[Unreleased]` cycle on `master` (release
582
+ pending) also delivered the [ADR-16](docs/adr/16-macro-expansion.md)
583
+ macro / DSL expansion substrate (four-tier declarative
584
+ manifest contract + engine integration + Tier B/C precision
585
+ promotion); see `CHANGELOG.md` `[Unreleased]` for the full
586
+ landing notes.
560
587
 
561
588
  ## Contributing
562
589
 
@@ -47,9 +47,14 @@ module Rigor
47
47
  attr_reader :facts
48
48
 
49
49
  class << self
50
- def empty
51
- @empty ||= new
52
- end
50
+ # ADR-15 Phase 4b.x — return the eagerly-loaded
51
+ # singleton-class `@empty` ivar. Lazy `@empty ||= new`
52
+ # would write to a class/module ivar from non-main
53
+ # Ractors and trip `Ractor::IsolationError`. The
54
+ # `@empty = new.freeze` at module body below
55
+ # pre-populates the ivar on the main Ractor at load
56
+ # time.
57
+ attr_reader :empty
53
58
  end
54
59
 
55
60
  def initialize(facts: [])
@@ -136,6 +141,13 @@ module Rigor
136
141
 
137
142
  [target]
138
143
  end
144
+
145
+ # ADR-15 Phase 4b.x — eager-load the singleton `@empty`
146
+ # on the main Ractor at module-load time. Workers then
147
+ # READ the populated ivar without ever attempting a
148
+ # class/module ivar WRITE (which non-main Ractors are
149
+ # forbidden from doing).
150
+ @empty = new.freeze
139
151
  end
140
152
  end
141
153
  end
@@ -3,10 +3,16 @@
3
3
  module Rigor
4
4
  module Analysis
5
5
  class Result
6
- attr_reader :diagnostics
6
+ attr_reader :diagnostics, :stats
7
7
 
8
- def initialize(diagnostics: [])
8
+ # @param stats [Rigor::Analysis::RunStats, nil] end-of-run
9
+ # telemetry (target file count, RBS class breakdown,
10
+ # wall + RSS) collected by the Runner. Nil when stats
11
+ # collection wasn't requested or wasn't applicable
12
+ # (early-exit paths like `validate_target_ruby` failure).
13
+ def initialize(diagnostics: [], stats: nil)
9
14
  @diagnostics = diagnostics
15
+ @stats = stats
10
16
  end
11
17
 
12
18
  def success?
@@ -18,11 +24,13 @@ module Rigor
18
24
  end
19
25
 
20
26
  def to_h
21
- {
27
+ hash = {
22
28
  "success" => success?,
23
29
  "error_count" => error_count,
24
30
  "diagnostics" => diagnostics.map(&:to_h)
25
31
  }
32
+ hash["stats"] = @stats.to_h if @stats
33
+ hash
26
34
  end
27
35
  end
28
36
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # End-of-run telemetry for the `rigor check` CLI's `--stats`
8
+ # output. Captures four cheap-to-measure groups:
9
+ #
10
+ # - **Check targets** — the Ruby files the analyser actually
11
+ # walks for diagnostics (`expand_paths` output).
12
+ # - **Type universe** — RBS class/module declarations the
13
+ # analyser had visibility of, broken down by source:
14
+ # `project_sig` (declarations whose source file lives under
15
+ # the configured `signature_paths`) vs `bundled` (RBS core,
16
+ # stdlib libraries, gem-bundled RBS — everything outside
17
+ # the project's own `sig/` tree).
18
+ # - **Gem source-walk** — the ADR-10
19
+ # `dependencies.source_inference` catalogue. Reports the
20
+ # class count and the number of opt-in gems contributing.
21
+ # - **Process** — wall-clock seconds + peak resident set size.
22
+ #
23
+ # The split between "check targets" and "type universe" makes
24
+ # explicit that the analyser's diagnostic surface is bounded
25
+ # by the user-controlled `paths:` configuration; the (typically
26
+ # much larger) RBS class universe is symbol-discovery, not a
27
+ # diagnostic surface.
28
+ #
29
+ # Stats collection is intentionally cheap: wall + RSS are
30
+ # single syscalls, target file count is already in
31
+ # `expand_paths`, gem source-walk uses
32
+ # `Index#class_to_gem.size`, and the RBS class breakdown
33
+ # walks `class_decl_paths` (a frozen `Hash<String, String>`
34
+ # populated once per environment by the RBS loader; ~1000-2000
35
+ # entries × one `String#start_with?`).
36
+ class RunStats
37
+ attr_reader :wall_seconds, :peak_rss_bytes,
38
+ :target_files,
39
+ :rbs_classes_total, :rbs_classes_project_sig, :rbs_classes_bundled,
40
+ :gem_walk_classes, :gem_walk_gems, :rbs_attribution_available
41
+
42
+ def initialize(wall_seconds:, peak_rss_bytes:, # rubocop:disable Metrics/ParameterLists
43
+ target_files:,
44
+ rbs_classes_total:, rbs_classes_project_sig:, rbs_classes_bundled:,
45
+ gem_walk_classes:, gem_walk_gems:,
46
+ rbs_attribution_available: true)
47
+ @wall_seconds = wall_seconds
48
+ @peak_rss_bytes = peak_rss_bytes
49
+ @target_files = target_files
50
+ @rbs_classes_total = rbs_classes_total
51
+ @rbs_classes_project_sig = rbs_classes_project_sig
52
+ @rbs_classes_bundled = rbs_classes_bundled
53
+ @gem_walk_classes = gem_walk_classes
54
+ @gem_walk_gems = gem_walk_gems
55
+ @rbs_attribution_available = rbs_attribution_available
56
+ freeze
57
+ end
58
+
59
+ # Reports the process's resident set size in bytes. Source
60
+ # ordering: `/proc/self/status` (Linux — reads `VmHWM:`,
61
+ # the peak RSS the kernel records) first; otherwise
62
+ # `ps -o rss= -p <pid>` (macOS / BSD — reports CURRENT
63
+ # RSS, the closest universally-available proxy). Returns
64
+ # nil when neither route works so the formatter can render
65
+ # `unavailable` instead of misleading zero.
66
+ def self.peak_rss_bytes
67
+ from_proc = read_vmhwm_from_proc
68
+ return from_proc unless from_proc.nil?
69
+
70
+ from_ps = read_rss_via_ps
71
+ return from_ps unless from_ps.nil?
72
+
73
+ nil
74
+ end
75
+
76
+ def self.read_vmhwm_from_proc
77
+ return nil unless File.readable?("/proc/self/status")
78
+
79
+ File.foreach("/proc/self/status") do |line|
80
+ next unless line.start_with?("VmHWM:")
81
+
82
+ kb_token = line.split.find { |token| token.match?(/\A\d+\z/) }
83
+ return Integer(kb_token) * 1024 if kb_token
84
+ end
85
+ nil
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ def self.read_rss_via_ps
91
+ out = `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip
92
+ return nil if out.empty?
93
+
94
+ Integer(out) * 1024
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ # Source-attribution sentinel produced by `RBS::Environment`
100
+ # entries restored from a cached blob (Marshal-loaded
101
+ # `RBS::Environment` loses real file-path attribution; every
102
+ # buffer reports `"<cached>"`). When every entry carries
103
+ # this sentinel the partition_classes routine returns
104
+ # `[0, total]` AND `attribution_available: false`, which
105
+ # the format routine consumes to suppress the misleading
106
+ # breakdown row.
107
+ CACHED_SENTINEL = "<cached>"
108
+
109
+ # Computes `(project_sig, bundled)` counts from a frozen
110
+ # `Hash<class_name => source_path>` snapshot and the
111
+ # configured `signature_paths`. `project_sig` is the count
112
+ # of classes whose source path begins with any of the
113
+ # signature path prefixes (after expansion to absolute
114
+ # paths); `bundled` is the remainder.
115
+ def self.partition_classes(class_decl_paths:, signature_paths:)
116
+ prefixes = Array(signature_paths).map { |p| File.expand_path(p.to_s) }
117
+ return [0, class_decl_paths.size] if prefixes.empty?
118
+
119
+ project = 0
120
+ class_decl_paths.each_value do |path|
121
+ expanded = File.expand_path(path)
122
+ project += 1 if prefixes.any? { |prefix| expanded.start_with?("#{prefix}/") || expanded == prefix }
123
+ end
124
+ [project, class_decl_paths.size - project]
125
+ end
126
+
127
+ # True when at least one entry in `class_decl_paths` carries
128
+ # a real source file path (i.e. not the cached-sentinel
129
+ # marker). Used by callers to decide whether the
130
+ # `project_sig` / `bundled` split is meaningful.
131
+ def self.attribution_available?(class_decl_paths:)
132
+ return false if class_decl_paths.empty?
133
+
134
+ class_decl_paths.each_value.any? { |path| path != CACHED_SENTINEL }
135
+ end
136
+
137
+ # Writes a human-facing rendering of the stats to `out`
138
+ # (typically `$stderr` from the CLI). Format is intentionally
139
+ # plain text — JSON consumers should parse the structured
140
+ # output of `rigor check --format=json` and consult `stats`
141
+ # there.
142
+ def format(out, prefix: "")
143
+ out.puts("#{prefix}Check targets")
144
+ out.puts("#{prefix} Ruby source files: #{@target_files}")
145
+ out.puts("#{prefix}Type universe (symbol discovery; not analyzed for diagnostics)")
146
+ out.puts("#{prefix} RBS classes available: #{@rbs_classes_total}")
147
+ if @rbs_attribution_available
148
+ out.puts("#{prefix} project sig/: #{@rbs_classes_project_sig}")
149
+ out.puts("#{prefix} bundled (core+stdlib+gems): #{@rbs_classes_bundled}")
150
+ elsif @rbs_classes_total.positive?
151
+ out.puts("#{prefix} (source attribution unavailable on cache-hit runs; --no-cache surfaces it)")
152
+ end
153
+ if @gem_walk_gems.positive?
154
+ out.puts("#{prefix} Gem source-walk classes: #{@gem_walk_classes} " \
155
+ "(across #{@gem_walk_gems} #{@gem_walk_gems == 1 ? 'gem' : 'gems'} " \
156
+ "via dependencies.source_inference)")
157
+ end
158
+ out.puts("#{prefix}Process")
159
+ out.puts("#{prefix} Wall time: #{Kernel.format('%.2fs', @wall_seconds)}")
160
+ out.puts("#{prefix} Memory peak: #{format_bytes(@peak_rss_bytes)}")
161
+ end
162
+
163
+ def to_h
164
+ {
165
+ target_files: @target_files,
166
+ rbs_classes_total: @rbs_classes_total,
167
+ rbs_classes_project_sig: @rbs_classes_project_sig,
168
+ rbs_classes_bundled: @rbs_classes_bundled,
169
+ rbs_attribution_available: @rbs_attribution_available,
170
+ gem_walk_classes: @gem_walk_classes,
171
+ gem_walk_gems: @gem_walk_gems,
172
+ wall_seconds: @wall_seconds,
173
+ peak_rss_bytes: @peak_rss_bytes
174
+ }
175
+ end
176
+
177
+ private
178
+
179
+ def format_bytes(bytes)
180
+ return "unavailable" if bytes.nil?
181
+
182
+ units = %w[B KB MB GB TB]
183
+ size = bytes.to_f
184
+ index = 0
185
+ while size >= 1024 && index < units.size - 1
186
+ size /= 1024
187
+ index += 1
188
+ end
189
+ Kernel.format("%<size>.1f %<unit>s", size: size, unit: units[index])
190
+ end
191
+ end
192
+ end
193
+ end