rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -275,13 +275,23 @@ module Rigor
275
275
 
276
276
  def update_existing(source_path, target, candidates)
277
277
  source = target.read
278
+ # Pre-parser encoding guard, mirroring `Environment::RbsLoader.invalid_encoding?`: handed invalid
279
+ # UTF-8, `RBS::Parser` raises a bare `ArgumentError` on rbs 4.1 (not the `ParsingError` that
280
+ # `parse_signature` below rescues) and could hang outright on the older releases the gemspec
281
+ # supports. The refusal is loud — unlike an unparseable file (`:noop`, surfaced by `rigor check`'s
282
+ # own quarantine warning), a mojibake file would otherwise fail with a stack trace naming neither
283
+ # the file nor the fix.
284
+ unless source.valid_encoding?
285
+ return WriteResult.new(source_path: source_path, target_path: target,
286
+ action: :skipped_invalid_encoding,
287
+ error: "#{target} is not valid UTF-8")
288
+ end
289
+
278
290
  decls = parse_signature(source)
279
291
  return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
280
292
 
281
293
  state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
282
- supers = merged_superclasses(candidates)
283
- candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods, supers) }
284
- merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
294
+ merge_candidates(state, candidates)
285
295
 
286
296
  action = state.applied.empty? ? :noop : :updated
287
297
  unless action == :updated
@@ -304,25 +314,42 @@ module Rigor
304
314
  action: action, applied: state.applied, skipped: state.skipped)
305
315
  end
306
316
 
317
+ # Applies every class group, then every requested shell, then normalises the layout the three steps
318
+ # leave behind. Each step mutates `state` in place and re-parses, so the next one sees current offsets.
319
+ def merge_candidates(state, candidates)
320
+ supers = merged_superclasses(candidates)
321
+ kinds = merged_namespace_kinds(candidates)
322
+ shells = collect_class_shells(candidates)
323
+ groups = candidates.group_by(&:class_name)
324
+ # Ancestors first, so `Foo` is created (or found) before `Foo::Bar` looks for a parent to nest under.
325
+ # A plain sort suffices: a name always sorts before every name it is a strict prefix of.
326
+ groups.keys.sort.each { |name| merge_class(state, name, groups.fetch(name), kinds, supers) }
327
+ merge_class_shells(state, shells, kinds, supers)
328
+ collapse_nested_declarations(state, groups.keys + shells.to_a)
329
+ end
330
+
307
331
  # ADR-14 gap-#3 (e): for every requested class shell that isn't already declared in the target file,
308
332
  # insert an empty `class Const\nend` block inside the nearest existing ancestor. Shells already covered by
309
333
  # an existing declaration are silently a no-op. The `applied` accumulator does NOT grow — shells are
310
334
  # structural declarations, not methods, so the action-count surface (`updated +N`) keeps reflecting method
311
335
  # changes only.
312
- def merge_class_shells(state, shells, kinds)
336
+ def merge_class_shells(state, shells, kinds, supers)
313
337
  shells.each do |qualified|
314
338
  next if find_class_decl(state.decls, qualified)
315
339
 
316
- insert_class_shell(state, qualified, kinds)
340
+ insert_namespace_chain(state, qualified, kinds, supers, [])
317
341
  end
318
342
  end
319
343
 
320
- def insert_class_shell(state, qualified, kinds)
344
+ # Splices the missing part of `qualified`'s namespace chain into the nearest declaration the file
345
+ # already has for one of its ancestors, or at top level when it has none. `methods` land on the leaf
346
+ # node, so this is the single insertion path for both an empty class shell and a brand-new class.
347
+ def insert_namespace_chain(state, qualified, kinds, supers, methods)
321
348
  segments = qualified.split("::")
322
349
  anchor_segs, missing = split_at_existing_ancestor(state.decls, segments)
323
350
  anchor_decl = anchor_segs.empty? ? nil : find_class_decl(state.decls, anchor_segs.join("::"))
324
- depth = anchor_decl ? anchor_decl_indent_depth(anchor_decl) : 0
325
- snippet = build_shell_snippet(missing, anchor_segs, kinds, depth)
351
+ depth = anchor_decl ? member_indent_depth(anchor_decl) : 0
352
+ snippet = render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
326
353
  state.source = if anchor_decl
327
354
  insert_before_end(state.source, anchor_decl, snippet)
328
355
  else
@@ -331,6 +358,113 @@ module Rigor
331
358
  state.decls = parse_signature(state.source) || state.decls
332
359
  end
333
360
 
361
+ # ADR-14 gap-#3 follow-up (c), update half: the create path folds a strict-prefix pair into one nested
362
+ # tree, but a file written before that fix — or by hand — can still carry the flat sibling layout
363
+ # (`class Foo` next to a top-level `class Foo::Bar`). After merging, relocate each declaration this run
364
+ # touched underneath its parent's declaration when the same file holds both, so an update converges on
365
+ # the same canonical layout a fresh generation would produce.
366
+ #
367
+ # Scope is deliberately the touched names only: an unrelated flat pair elsewhere in the file is none of
368
+ # sig-gen's business, and rewriting it would be a layout change the user never asked for. Shallowest
369
+ # first, so `Foo::Bar` has already moved under `Foo` by the time `Foo::Bar::Baz` looks for its parent.
370
+ def collapse_nested_declarations(state, names)
371
+ names.uniq.sort.each { |name| relocate_under_parent(state, name) }
372
+ end
373
+
374
+ def relocate_under_parent(state, qualified)
375
+ segments = qualified.split("::")
376
+ return if segments.size < 2
377
+
378
+ parent_name = segments[0...-1].join("::")
379
+ parent = find_class_decl(state.decls, parent_name)
380
+ decl = parent && find_class_decl(state.decls, qualified)
381
+ return if decl.nil? || overlapping?(parent, decl)
382
+
383
+ region = decl_region(state.source, decl)
384
+ block = region && relocated_block(state.source, decl, parent, segments.last, region)
385
+ return if block.nil?
386
+
387
+ apply_relocation(state, region, parent_name, block)
388
+ end
389
+
390
+ # Cuts the declaration's region out, re-parses so the parent's byte range reflects the removal, and
391
+ # splices the re-indented block back in as the parent's last member. Any step that cannot be carried
392
+ # out cleanly (an unparseable intermediate, a parent that vanished) abandons the move and leaves the
393
+ # merged source exactly as it was — a flat layout is cosmetic, a mangled `.rbs` is not.
394
+ def apply_relocation(state, region, parent_name, block)
395
+ source = splice_out(state.source, region)
396
+ decls = parse_signature(source)
397
+ anchor = decls && find_class_decl(decls, parent_name)
398
+ return if anchor.nil?
399
+
400
+ state.source = insert_before_end(source, anchor, block)
401
+ state.decls = parse_signature(state.source) || state.decls
402
+ end
403
+
404
+ # True when the two declarations' source ranges are not disjoint — either one already nests the other,
405
+ # or the file's shape is one this pass does not understand. Both are reasons not to move anything.
406
+ def overlapping?(one, other)
407
+ one.location.start_pos < other.location.end_pos && other.location.start_pos < one.location.end_pos
408
+ end
409
+
410
+ # The declaration's full source region, extended to cover its leading comment and annotations and to
411
+ # end just past the newline that closes it. Returns `nil` when either edge shares a line with something
412
+ # else, because cutting there would move — or strand — text the declaration does not own.
413
+ def decl_region(source, decl)
414
+ start_pos = ([decl.location.start_pos] + leading_positions(decl)).min
415
+ line_start = line_start_index(source, start_pos)
416
+ return nil unless blank_range?(source, line_start, start_pos)
417
+
418
+ finish = decl.location.end_pos
419
+ line_end = source.index("\n", finish) || source.size
420
+ return nil unless blank_range?(source, finish, line_end)
421
+
422
+ line_start...(line_end < source.size ? line_end + 1 : line_end)
423
+ end
424
+
425
+ def leading_positions(decl)
426
+ positions = decl.annotations.filter_map { |a| a.location&.start_pos }
427
+ comment_location = decl.comment&.location
428
+ positions << comment_location.start_pos if comment_location
429
+ positions
430
+ end
431
+
432
+ # The moved text, with its compact `Foo::Bar` head shortened to `Bar` and every line pushed in to the
433
+ # parent's member depth. The name's own sub-location drives the rewrite, so a superclass, type
434
+ # parameters, and the whole body survive byte-for-byte.
435
+ def relocated_block(source, decl, parent, short_name, region)
436
+ name_location = decl.location[:name]
437
+ return nil if name_location.nil?
438
+
439
+ text = source[region].to_s
440
+ head = name_location.start_pos - region.begin
441
+ tail = name_location.end_pos - region.begin
442
+ renamed = text[0...head].to_s + short_name + text[tail..].to_s
443
+ reindent(renamed, member_indent_depth(parent) - decl_indent_depth(decl))
444
+ end
445
+
446
+ def reindent(text, delta)
447
+ return text unless delta.positive?
448
+
449
+ prefix = INDENT * delta
450
+ text.lines.map { |line| line.match?(/\A\s*\z/) ? line : prefix + line }.join
451
+ end
452
+
453
+ # Removes `region`, collapsing the newline run left behind at the seam so the cut never shows up as a
454
+ # widening gap (or a trailing blank line at EOF) in the file it edited. Spacing that was already fine
455
+ # is left exactly as the user wrote it.
456
+ def splice_out(source, region)
457
+ before = source[0...region.begin].to_s
458
+ after = source[region.end..].to_s
459
+ return after.sub(/\A\n+/, "") if before.match?(/\A\s*\z/)
460
+ return before.sub(/\n{2,}\z/, "\n") if after.match?(/\A\s*\z/)
461
+
462
+ seam = before[/\n+\z/].to_s.size + after[/\A\n+/].to_s.size
463
+ return before + after if seam <= 2
464
+
465
+ "#{before.sub(/\n+\z/, "\n\n")}#{after.sub(/\A\n+/, '')}"
466
+ end
467
+
334
468
  def split_at_existing_ancestor(decls, segments)
335
469
  (segments.size - 1).downto(0).each do |i|
336
470
  ancestor = segments[0...i].join("::")
@@ -339,37 +473,61 @@ module Rigor
339
473
  [[], segments]
340
474
  end
341
475
 
342
- # Pulls the indent depth (in `INDENT` units) one level deeper than the anchor decl's own column.
343
- # Pre-existing members might be missing (an empty `class Foo; end`) so the keyword column is the robust
344
- # signal.
345
- def anchor_decl_indent_depth(decl)
346
- decl_column = decl.location[:keyword].start_column
347
- (decl_column / INDENT.size) + 1
476
+ # The indent depth (in `INDENT` units) a member of `decl` sits at: one level deeper than the
477
+ # declaration's own keyword column. Pre-existing members might be missing (an empty `class Foo; end`)
478
+ # so the keyword column is the robust signal.
479
+ def member_indent_depth(decl)
480
+ decl_indent_depth(decl) + 1
481
+ end
482
+
483
+ def decl_indent_depth(decl)
484
+ decl.location[:keyword].start_column / INDENT.size
348
485
  end
349
486
 
350
- def build_shell_snippet(missing, anchor_segs, kinds, depth)
487
+ # Renders the `missing` segment chain as one nested block, carrying `methods` on its leaf, by handing a
488
+ # synthesised tree node to the create path's renderer. Both paths therefore agree on the keyword, the
489
+ # superclass suffix, and the indentation of every level. A leaf with no methods is a class shell, which
490
+ # is exactly the create path's `shell:` node (gap-#3 (e)).
491
+ def render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
351
492
  return "" if missing.empty?
352
493
 
353
- head, *rest = missing
354
- qualified = (anchor_segs + [head]).join("::")
355
- indent = INDENT * depth
356
- if rest.empty?
357
- keyword = kinds[qualified] || :class
358
- "#{indent}#{keyword} #{head}\n#{indent}end\n"
359
- else
360
- inner = build_shell_snippet(rest, anchor_segs + [head], kinds, depth + 1)
361
- keyword = kinds[qualified] || :module
362
- "#{indent}#{keyword} #{head}\n#{inner}#{indent}end\n"
494
+ node = missing.reverse.each_with_index.inject(nil) do |child, (segment, index)|
495
+ { name: segment, children: child ? { child[:name] => child } : {},
496
+ methods: index.zero? ? methods : [], shell: index.zero? && methods.empty? }
363
497
  end
498
+ render_tree_node(node, kinds, supers, depth, anchor_segs)
364
499
  end
365
500
 
501
+ # Splices `snippet` in just before the declaration's closing `end`. When that `end` starts its own line
502
+ # the insertion point moves to the START of that line: the snippet carries its own indentation, and
503
+ # anchoring at the keyword would otherwise donate the `end`'s indent to the snippet's first line and
504
+ # strand the `end` in column zero.
366
505
  def insert_before_end(source, decl, snippet)
506
+ return source if snippet.empty?
507
+
367
508
  end_pos = decl.location[:end].start_pos
368
- source[0...end_pos] + snippet + source[end_pos..]
509
+ line_start = line_start_index(source, end_pos)
510
+ return source[0...line_start] + snippet + source[line_start..] if blank_range?(source, line_start, end_pos)
511
+
512
+ "#{source[0...end_pos]}\n#{snippet}#{source[end_pos..]}"
369
513
  end
370
514
 
515
+ # Appends a top-level block, separated from whatever precedes it by exactly one blank line.
371
516
  def append_top_level(source, snippet)
372
- ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
517
+ return snippet if source.empty?
518
+
519
+ base = ends_with_newline?(source) ? source : "#{source}\n"
520
+ base.end_with?("\n\n") ? base + snippet : "#{base}\n#{snippet}"
521
+ end
522
+
523
+ def line_start_index(source, pos)
524
+ return 0 unless pos.positive?
525
+
526
+ (source.rindex("\n", pos - 1) || -1) + 1
527
+ end
528
+
529
+ def blank_range?(source, from, to)
530
+ source[from...to].to_s.match?(/\A[ \t]*\z/)
373
531
  end
374
532
 
375
533
  def parse_signature(source)
@@ -379,14 +537,15 @@ module Rigor
379
537
  nil
380
538
  end
381
539
 
382
- def merge_class(state, class_name, methods, supers = {})
540
+ def merge_class(state, class_name, methods, kinds, supers)
383
541
  decl = find_class_decl(state.decls, class_name)
384
- state.source = if decl.nil?
385
- append_new_class(state.source, class_name, methods, state.applied, supers[class_name])
386
- else
387
- merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
388
- end
389
- state.decls = parse_signature(state.source) || state.decls
542
+ if decl.nil?
543
+ state.applied.concat(methods)
544
+ insert_namespace_chain(state, class_name, kinds, supers, methods)
545
+ else
546
+ state.source = merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
547
+ state.decls = parse_signature(state.source) || state.decls
548
+ end
390
549
  end
391
550
 
392
551
  # Walks the parsed decl tree recursively, tracking the enclosing module/class prefix, and returns the
@@ -411,16 +570,6 @@ module Rigor
411
570
  nil
412
571
  end
413
572
 
414
- # Appends an entirely new `class Foo … end` block at the end of the file (with a leading blank line as
415
- # separator).
416
- def append_new_class(source, class_name, methods, applied, superclass = nil)
417
- body = methods.map { |c| "#{INDENT}#{c.rbs}" }.join("\n")
418
- header = superclass ? "class #{class_name} < #{superclass}" : "class #{class_name}"
419
- snippet = "\n#{header}\n#{body}\nend\n"
420
- applied.concat(methods)
421
- ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
422
- end
423
-
424
573
  def ends_with_newline?(source)
425
574
  source.end_with?("\n")
426
575
  end
@@ -477,9 +626,8 @@ module Rigor
477
626
  def insert_into_class(source, decl, new_methods)
478
627
  return source if new_methods.empty?
479
628
 
480
- end_pos = decl.location[:end].start_pos
481
- addition = new_methods.map { |c| "#{INDENT}#{c.rbs}\n" }.join
482
- source[0...end_pos] + addition + source[end_pos..]
629
+ indent = INDENT * member_indent_depth(decl)
630
+ insert_before_end(source, decl, new_methods.map { |c| "#{indent}#{c.rbs}\n" }.join)
483
631
  end
484
632
 
485
633
  # Walks the class's existing method declarations; for each replaceable candidate that matches a member
data/lib/rigor/sig_gen.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "sig_gen/classification"
4
4
  require_relative "sig_gen/method_candidate"
5
5
  require_relative "sig_gen/observed_call"
6
+ require_relative "sig_gen/meta_class_shape"
6
7
  require_relative "sig_gen/type_elaborator"
7
8
  require_relative "sig_gen/observation_collector"
8
9
  require_relative "sig_gen/generator"
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -42,6 +42,25 @@ module Rigor
42
42
  # informational only (deferred: cross-plugin handoff to a JS-side analyzer).
43
43
  # - **`broadcast_to` arity isn't checked.** The method takes any record + any data hash; there's no
44
44
  # useful arity envelope.
45
+ #
46
+ # ## `#receive(data)` protocol contract (ADR-28)
47
+ #
48
+ # ActionCable's `Connection::Subscriptions#perform_action` dispatches an incoming message to the
49
+ # action named by the payload's `"action"` key, defaulting to `:receive` when that key is absent —
50
+ # i.e. `#receive(data)` is the framework-documented catch-all handler for messages with no explicit
51
+ # action. Either way the single positional argument is always the `ActiveSupport::JSON.decode` of the
52
+ # client-sent payload, which is a JSON object and therefore a `Hash` by convention (the bundled JS
53
+ # client only ever calls `perform(action, data = {})` with an object). This plugin declares a
54
+ # path-scoped protocol contract (`method_name: :receive`, `param_types: [{index: 0, type_name:
55
+ # "Hash"}]`) so `data` types as `Hash` instead of `Dynamic[Top]` inside a channel's `#receive` body.
56
+ #
57
+ # Deliberately narrower than Hanami's `#handle`: custom action methods (`def speak(data)`) also
58
+ # receive the decoded Hash, but their names are project-chosen, not a single fixed Symbol a
59
+ # `ProtocolContract` can name — only `:receive` is a framework-reserved, uniformly-shaped method name.
60
+ # Per ADR-28, the contract is path-scoped, not class-scoped: any `#receive(data)` defined anywhere
61
+ # under the (possibly multi-root) `channel_search_paths` is typed, even on a stray non-channel class
62
+ # that happens to live in that directory — the same accepted-risk shape as `rigor-hanami`'s
63
+ # `#handle`.
45
64
  class Actioncable < Rigor::Plugin::Base
46
65
  manifest(
47
66
  id: "actioncable",
@@ -53,7 +72,15 @@ module Rigor
53
72
  kind: :array,
54
73
  default: ["ApplicationCable::Channel", "ActionCable::Channel::Base"]
55
74
  }
56
- }
75
+ },
76
+ protocol_contracts: [
77
+ Rigor::Plugin::ProtocolContract.new(
78
+ path_glob: "app/channels/**/*.rb",
79
+ method_name: :receive,
80
+ param_types: [{ index: 0, type_name: "Hash" }]
81
+ # return_type_name: nil — receive's return value is discarded by the framework dispatcher.
82
+ )
83
+ ]
57
84
  )
58
85
 
59
86
  # `watch:` covers every `.rb` file under the channel search paths so the cache invalidates when
@@ -70,6 +97,20 @@ module Rigor
70
97
  def init(_services)
71
98
  @channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
72
99
  @channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
100
+
101
+ # ADR-28 WD5 — `channel_search_paths` is user-configurable and may name multiple roots; retarget
102
+ # the manifest's default `app/channels/**/*.rb` glob to the actual configured root(s) so the
103
+ # contract neither goes inert on a project that renames/adds channel directories nor (more
104
+ # importantly, per the project's false-positive discipline) risks matching an unrelated `#receive`
105
+ # outside them. `File::FNM_EXTGLOB` (enabled by `Registry#contracts_for_path`) makes a `{a,b}`
106
+ # brace group a single valid glob for multiple roots.
107
+ @protocol_contracts = manifest.protocol_contracts.map { |c| c.with_path_glob(channel_search_glob) }
108
+ end
109
+
110
+ # ADR-28 — override so the per-project `channel_search_paths` config reaches both the engine's
111
+ # parameter-provision tier and this plugin's own path-scoped reasoning.
112
+ def protocol_contracts
113
+ @protocol_contracts || manifest.protocol_contracts
73
114
  end
74
115
 
75
116
  # File-level only: the load-error emission. Per-call broadcast validation runs over the
@@ -91,6 +132,15 @@ module Rigor
91
132
 
92
133
  private
93
134
 
135
+ # Builds the `#receive` contract's `path_glob` from the configured `channel_search_paths`. A single
136
+ # root becomes a plain `**/*.rb` glob; multiple roots become an `FNM_EXTGLOB` brace group so every
137
+ # configured root — and only a configured root — is covered.
138
+ def channel_search_glob
139
+ roots = @channel_search_paths.map { |p| p.chomp("/") }
140
+ base = roots.length == 1 ? roots.first : "{#{roots.join(',')}}"
141
+ "#{base}/**/*.rb"
142
+ end
143
+
94
144
  def load_error_diagnostic(path)
95
145
  error = producer_error(:channel_index)
96
146
  Rigor::Analysis::Diagnostic.new(
@@ -248,10 +248,10 @@ class Time
248
248
  def self.zone: () -> untyped # ActiveSupport::TimeZone | nil
249
249
  def self.zone=: (String | Symbol | untyped) -> untyped
250
250
 
251
- # `core_ext/time/conversions` — also `Time.parse` from stdlib
252
- # `time`, which is already in core RBS.
253
- def self.httpdate: (String) -> Time
254
- def self.iso8601: (String) -> Time
251
+ # `core_ext/time/conversions` — nothing to declare. `Time.parse`, `Time.httpdate` and
252
+ # `Time.iso8601` all come from stdlib `time`, which rbs types; ActiveSupport only re-opens them.
253
+ # Declaring them here raised `RBS::DuplicatedMethodDefinitionError` and collapsed the whole `Time`
254
+ # definition to `Dynamic[top]`.
255
255
 
256
256
  # `core_ext/time/calculations`
257
257
  def yesterday: () -> Time
@@ -281,7 +281,7 @@ class Time
281
281
  def midday: () -> Time
282
282
  def midnight: () -> Time
283
283
  def noon: () -> Time
284
- def utc?: () -> bool
284
+ # NOTE: `Time#utc?` is NOT declared here — it is Ruby core, already typed by rbs's `core/time.rbs`.
285
285
  def acts_like_time?: () -> true
286
286
  # `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
287
287
  def advance: (untyped options) -> Time
@@ -468,7 +468,7 @@ end
468
468
 
469
469
  class DateTime
470
470
  def utc: () -> Time
471
- def to_time: () -> Time
471
+ # NOTE: `DateTime#to_time` is NOT declared here — stdlib `date` already types it.
472
472
  def in_time_zone: (?String | Symbol zone) -> untyped
473
473
  def yesterday: () -> DateTime
474
474
  def tomorrow: () -> DateTime
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DrySchema < Rigor::Plugin::Base
8
+ # Translates one `:dry_schema_table` entry into the `Rigor::Type::HashShape` that
9
+ # `SomeSchema.call(input).to_h` returns, and recognises the call chain that earns it.
10
+ #
11
+ # The table entry is the shape {SchemaScanner} publishes:
12
+ #
13
+ # { required: { email: { type: "String", list: false } },
14
+ # optional: { nickname: { type: "String", list: true } } }
15
+ #
16
+ # ## Why the shape mirrors the declaration's own vocabulary
17
+ #
18
+ # `Dry::Schema::Result#to_h` returns the *coerced input*, so on a failed validation a
19
+ # `required(:email)` key can be absent. Modelling that worst case — every key optional — would be
20
+ # the sound reading and the wrong one: the idiomatic consumer checks `success?` first, and typing
21
+ # `result.to_h[:age]` as `Integer?` inside that branch draws a nil diagnostic on correct code.
22
+ # Rigor weighs a false positive above a worst-case static reading (AGENTS.md § Implementation
23
+ # Guidelines), and already took the same call for RBS's `%a{implicitly-returns-nil}`.
24
+ #
25
+ # So `required` rows become required keys and `optional` rows become optional keys — the schema's
26
+ # own words. A reader who wrote `optional(:nickname)` is not surprised that the key reads as
27
+ # possibly-absent, and one who wrote `required(:email)` is not surprised that it does not.
28
+ #
29
+ # The shape is **open**: dry-schema's key map only emits declared keys, but a closed shape would
30
+ # turn any read of an undeclared key into a diagnostic, and being wrong there costs more than the
31
+ # extra precision buys.
32
+ module ResultShape
33
+ # `:bool` rows land in the published fact as "TrueClass" — the fact's vocabulary names one
34
+ # underlying class per row and has no union slot. A hash value typed `TrueClass` would false-fire
35
+ # on every `false`, so the shape widens it back to the two-class union here rather than in the
36
+ # fact, whose consumers (rigor-dry-struct, rigor-dry-validation) read it for a class name.
37
+ BOOL_CLASSES = %w[TrueClass FalseClass].freeze
38
+
39
+ module_function
40
+
41
+ # The schema constant `to_h_node`'s receiver chain names, or nil when the chain isn't the
42
+ # recognised `<Const>.call(...).to_h` form.
43
+ #
44
+ # Floor: the schema must be named by a constant *as written* at the call site, because the FQN is
45
+ # matched against the table's keys verbatim. A schema referenced through a local
46
+ # (`schema = NewUserSchema; schema.call(x).to_h`) or by a relative constant path from inside the
47
+ # declaring module resolves to no entry and contributes nothing — the pre-slice behaviour.
48
+ def schema_name(to_h_node)
49
+ return nil unless to_h_node.is_a?(Prism::CallNode) && to_h_node.name == :to_h
50
+ return nil unless to_h_node.arguments.nil? && to_h_node.block.nil?
51
+
52
+ inner = to_h_node.receiver
53
+ return nil unless inner.is_a?(Prism::CallNode) && inner.name == :call
54
+
55
+ constant_name(inner.receiver)
56
+ end
57
+
58
+ # The HashShape for one table entry, or nil when the schema declares no key at all. An empty open
59
+ # shape would be a carrier that says nothing the bare `untyped` did not already say, so declining
60
+ # keeps the hash's type honest about how little is known.
61
+ def build(entry)
62
+ unmodelled = entry[:unmodelled] || {}
63
+ required = pairs_for(entry[:required]).merge(untyped_pairs(unmodelled[:required]))
64
+ optional = pairs_for(entry[:optional]).merge(untyped_pairs(unmodelled[:optional]))
65
+ return nil if required.empty? && optional.empty?
66
+
67
+ Rigor::Type::Combinator.hash_shape_of(
68
+ required.merge(optional),
69
+ required_keys: required.keys,
70
+ optional_keys: optional.keys,
71
+ extra_keys: :open
72
+ )
73
+ end
74
+
75
+ # A key the schema declares but the scanner could not type still belongs in the shape, as
76
+ # `untyped`.
77
+ #
78
+ # This costs nothing at a read and buys nothing at one either — measured: with and without these
79
+ # entries, `payload[:address]`, `payload.fetch(:address)` and every other read infer identically,
80
+ # because #249 made an undeclared key on an OPEN shape read as `untyped` rather than `nil`. What
81
+ # it buys is the rendered shape, which is what hover and `dump_type` show: `{ email: String,
82
+ # address: Dynamic[top], ... }` says the schema declares `address` and Rigor cannot type it, while
83
+ # `{ email: String, ... }` is indistinguishable from a schema that never mentioned it — the
84
+ # trailing `...` only ever means "keys beyond these are permitted".
85
+ #
86
+ # It was originally load-bearing for a different reason: before #249 a key outside the shape read
87
+ # as `nil`, so dropping `required(:address).schema { … }` put a `call.undefined-method` on the next
88
+ # line of correct code. That hazard is gone; the entries stay for the honesty of the rendering.
89
+ def untyped_pairs(keys)
90
+ (keys || []).to_h { |key| [key, Rigor::Type::Combinator.untyped] }
91
+ end
92
+
93
+ def pairs_for(rows)
94
+ (rows || {}).each_with_object({}) do |(key, row), pairs|
95
+ type = row_type(row)
96
+ pairs[key] = type unless type.nil?
97
+ end
98
+ end
99
+
100
+ def row_type(row)
101
+ element = element_type(row[:type])
102
+ return nil if element.nil?
103
+
104
+ row[:list] ? Rigor::Type::Combinator.nominal_of("Array", type_args: [element]) : element
105
+ end
106
+
107
+ # `type` is either a class-name String (the scalar case) or a `{nested: <shape>}` Hash — an
108
+ # `each do ... end` element-type recursion (issue #137's ceiling slice): {SchemaScanner} already
109
+ # collected the nested block with the same `collect_schema_shape` algorithm a top-level schema
110
+ # body uses, so building its HashShape is just a recursive {.build} call.
111
+ def element_type(type)
112
+ return build(type[:nested]) if type.is_a?(Hash)
113
+
114
+ class_type(type)
115
+ end
116
+
117
+ def class_type(class_name)
118
+ return nil if class_name.nil? || class_name.empty?
119
+ return Rigor::Type::Combinator.union(*BOOL_CLASSES.map { |c| Rigor::Type::Combinator.nominal_of(c) }) \
120
+ if class_name == "TrueClass"
121
+
122
+ Rigor::Type::Combinator.nominal_of(class_name)
123
+ end
124
+
125
+ # Renders `Foo` / `Foo::Bar` / `::Foo::Bar` as the `::`-joined String the table is keyed by.
126
+ # Mirrors the helper rigor-sorbet's TypeTranslator and rigor-activerecord's ModelDiscoverer use.
127
+ def constant_name(node)
128
+ case node
129
+ when Prism::ConstantReadNode then node.name.to_s
130
+ when Prism::ConstantPathNode then constant_path_name(node)
131
+ end
132
+ end
133
+
134
+ def constant_path_name(node)
135
+ parent = node.parent
136
+ name = node.name&.to_s
137
+ return nil if name.nil?
138
+ return name if parent.nil? # `::Foo` — the table keys are unrooted, so drop the leading `::`
139
+
140
+ prefix = constant_name(parent)
141
+ prefix.nil? ? nil : "#{prefix}::#{name}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end