rigortype 0.3.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +32 -0
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +34 -13
- data/lib/rigor/analysis/run_cache_key.rb +10 -0
- data/lib/rigor/analysis/runner.rb +2 -1
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +12 -6
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment.rb +10 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/sig_gen/writer.rb +183 -47
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- metadata +7 -3
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -279,9 +279,7 @@ module Rigor
|
|
|
279
279
|
return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
|
|
280
280
|
|
|
281
281
|
state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
|
|
282
|
-
|
|
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))
|
|
282
|
+
merge_candidates(state, candidates)
|
|
285
283
|
|
|
286
284
|
action = state.applied.empty? ? :noop : :updated
|
|
287
285
|
unless action == :updated
|
|
@@ -304,25 +302,42 @@ module Rigor
|
|
|
304
302
|
action: action, applied: state.applied, skipped: state.skipped)
|
|
305
303
|
end
|
|
306
304
|
|
|
305
|
+
# Applies every class group, then every requested shell, then normalises the layout the three steps
|
|
306
|
+
# leave behind. Each step mutates `state` in place and re-parses, so the next one sees current offsets.
|
|
307
|
+
def merge_candidates(state, candidates)
|
|
308
|
+
supers = merged_superclasses(candidates)
|
|
309
|
+
kinds = merged_namespace_kinds(candidates)
|
|
310
|
+
shells = collect_class_shells(candidates)
|
|
311
|
+
groups = candidates.group_by(&:class_name)
|
|
312
|
+
# Ancestors first, so `Foo` is created (or found) before `Foo::Bar` looks for a parent to nest under.
|
|
313
|
+
# A plain sort suffices: a name always sorts before every name it is a strict prefix of.
|
|
314
|
+
groups.keys.sort.each { |name| merge_class(state, name, groups.fetch(name), kinds, supers) }
|
|
315
|
+
merge_class_shells(state, shells, kinds, supers)
|
|
316
|
+
collapse_nested_declarations(state, groups.keys + shells.to_a)
|
|
317
|
+
end
|
|
318
|
+
|
|
307
319
|
# ADR-14 gap-#3 (e): for every requested class shell that isn't already declared in the target file,
|
|
308
320
|
# insert an empty `class Const\nend` block inside the nearest existing ancestor. Shells already covered by
|
|
309
321
|
# an existing declaration are silently a no-op. The `applied` accumulator does NOT grow — shells are
|
|
310
322
|
# structural declarations, not methods, so the action-count surface (`updated +N`) keeps reflecting method
|
|
311
323
|
# changes only.
|
|
312
|
-
def merge_class_shells(state, shells, kinds)
|
|
324
|
+
def merge_class_shells(state, shells, kinds, supers)
|
|
313
325
|
shells.each do |qualified|
|
|
314
326
|
next if find_class_decl(state.decls, qualified)
|
|
315
327
|
|
|
316
|
-
|
|
328
|
+
insert_namespace_chain(state, qualified, kinds, supers, [])
|
|
317
329
|
end
|
|
318
330
|
end
|
|
319
331
|
|
|
320
|
-
|
|
332
|
+
# Splices the missing part of `qualified`'s namespace chain into the nearest declaration the file
|
|
333
|
+
# already has for one of its ancestors, or at top level when it has none. `methods` land on the leaf
|
|
334
|
+
# node, so this is the single insertion path for both an empty class shell and a brand-new class.
|
|
335
|
+
def insert_namespace_chain(state, qualified, kinds, supers, methods)
|
|
321
336
|
segments = qualified.split("::")
|
|
322
337
|
anchor_segs, missing = split_at_existing_ancestor(state.decls, segments)
|
|
323
338
|
anchor_decl = anchor_segs.empty? ? nil : find_class_decl(state.decls, anchor_segs.join("::"))
|
|
324
|
-
depth = anchor_decl ?
|
|
325
|
-
snippet =
|
|
339
|
+
depth = anchor_decl ? member_indent_depth(anchor_decl) : 0
|
|
340
|
+
snippet = render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
|
|
326
341
|
state.source = if anchor_decl
|
|
327
342
|
insert_before_end(state.source, anchor_decl, snippet)
|
|
328
343
|
else
|
|
@@ -331,6 +346,113 @@ module Rigor
|
|
|
331
346
|
state.decls = parse_signature(state.source) || state.decls
|
|
332
347
|
end
|
|
333
348
|
|
|
349
|
+
# ADR-14 gap-#3 follow-up (c), update half: the create path folds a strict-prefix pair into one nested
|
|
350
|
+
# tree, but a file written before that fix — or by hand — can still carry the flat sibling layout
|
|
351
|
+
# (`class Foo` next to a top-level `class Foo::Bar`). After merging, relocate each declaration this run
|
|
352
|
+
# touched underneath its parent's declaration when the same file holds both, so an update converges on
|
|
353
|
+
# the same canonical layout a fresh generation would produce.
|
|
354
|
+
#
|
|
355
|
+
# Scope is deliberately the touched names only: an unrelated flat pair elsewhere in the file is none of
|
|
356
|
+
# sig-gen's business, and rewriting it would be a layout change the user never asked for. Shallowest
|
|
357
|
+
# first, so `Foo::Bar` has already moved under `Foo` by the time `Foo::Bar::Baz` looks for its parent.
|
|
358
|
+
def collapse_nested_declarations(state, names)
|
|
359
|
+
names.uniq.sort.each { |name| relocate_under_parent(state, name) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def relocate_under_parent(state, qualified)
|
|
363
|
+
segments = qualified.split("::")
|
|
364
|
+
return if segments.size < 2
|
|
365
|
+
|
|
366
|
+
parent_name = segments[0...-1].join("::")
|
|
367
|
+
parent = find_class_decl(state.decls, parent_name)
|
|
368
|
+
decl = parent && find_class_decl(state.decls, qualified)
|
|
369
|
+
return if decl.nil? || overlapping?(parent, decl)
|
|
370
|
+
|
|
371
|
+
region = decl_region(state.source, decl)
|
|
372
|
+
block = region && relocated_block(state.source, decl, parent, segments.last, region)
|
|
373
|
+
return if block.nil?
|
|
374
|
+
|
|
375
|
+
apply_relocation(state, region, parent_name, block)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Cuts the declaration's region out, re-parses so the parent's byte range reflects the removal, and
|
|
379
|
+
# splices the re-indented block back in as the parent's last member. Any step that cannot be carried
|
|
380
|
+
# out cleanly (an unparseable intermediate, a parent that vanished) abandons the move and leaves the
|
|
381
|
+
# merged source exactly as it was — a flat layout is cosmetic, a mangled `.rbs` is not.
|
|
382
|
+
def apply_relocation(state, region, parent_name, block)
|
|
383
|
+
source = splice_out(state.source, region)
|
|
384
|
+
decls = parse_signature(source)
|
|
385
|
+
anchor = decls && find_class_decl(decls, parent_name)
|
|
386
|
+
return if anchor.nil?
|
|
387
|
+
|
|
388
|
+
state.source = insert_before_end(source, anchor, block)
|
|
389
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# True when the two declarations' source ranges are not disjoint — either one already nests the other,
|
|
393
|
+
# or the file's shape is one this pass does not understand. Both are reasons not to move anything.
|
|
394
|
+
def overlapping?(one, other)
|
|
395
|
+
one.location.start_pos < other.location.end_pos && other.location.start_pos < one.location.end_pos
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The declaration's full source region, extended to cover its leading comment and annotations and to
|
|
399
|
+
# end just past the newline that closes it. Returns `nil` when either edge shares a line with something
|
|
400
|
+
# else, because cutting there would move — or strand — text the declaration does not own.
|
|
401
|
+
def decl_region(source, decl)
|
|
402
|
+
start_pos = ([decl.location.start_pos] + leading_positions(decl)).min
|
|
403
|
+
line_start = line_start_index(source, start_pos)
|
|
404
|
+
return nil unless blank_range?(source, line_start, start_pos)
|
|
405
|
+
|
|
406
|
+
finish = decl.location.end_pos
|
|
407
|
+
line_end = source.index("\n", finish) || source.size
|
|
408
|
+
return nil unless blank_range?(source, finish, line_end)
|
|
409
|
+
|
|
410
|
+
line_start...(line_end < source.size ? line_end + 1 : line_end)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def leading_positions(decl)
|
|
414
|
+
positions = decl.annotations.filter_map { |a| a.location&.start_pos }
|
|
415
|
+
comment_location = decl.comment&.location
|
|
416
|
+
positions << comment_location.start_pos if comment_location
|
|
417
|
+
positions
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The moved text, with its compact `Foo::Bar` head shortened to `Bar` and every line pushed in to the
|
|
421
|
+
# parent's member depth. The name's own sub-location drives the rewrite, so a superclass, type
|
|
422
|
+
# parameters, and the whole body survive byte-for-byte.
|
|
423
|
+
def relocated_block(source, decl, parent, short_name, region)
|
|
424
|
+
name_location = decl.location[:name]
|
|
425
|
+
return nil if name_location.nil?
|
|
426
|
+
|
|
427
|
+
text = source[region].to_s
|
|
428
|
+
head = name_location.start_pos - region.begin
|
|
429
|
+
tail = name_location.end_pos - region.begin
|
|
430
|
+
renamed = text[0...head].to_s + short_name + text[tail..].to_s
|
|
431
|
+
reindent(renamed, member_indent_depth(parent) - decl_indent_depth(decl))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def reindent(text, delta)
|
|
435
|
+
return text unless delta.positive?
|
|
436
|
+
|
|
437
|
+
prefix = INDENT * delta
|
|
438
|
+
text.lines.map { |line| line.match?(/\A\s*\z/) ? line : prefix + line }.join
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Removes `region`, collapsing the newline run left behind at the seam so the cut never shows up as a
|
|
442
|
+
# widening gap (or a trailing blank line at EOF) in the file it edited. Spacing that was already fine
|
|
443
|
+
# is left exactly as the user wrote it.
|
|
444
|
+
def splice_out(source, region)
|
|
445
|
+
before = source[0...region.begin].to_s
|
|
446
|
+
after = source[region.end..].to_s
|
|
447
|
+
return after.sub(/\A\n+/, "") if before.match?(/\A\s*\z/)
|
|
448
|
+
return before.sub(/\n{2,}\z/, "\n") if after.match?(/\A\s*\z/)
|
|
449
|
+
|
|
450
|
+
seam = before[/\n+\z/].to_s.size + after[/\A\n+/].to_s.size
|
|
451
|
+
return before + after if seam <= 2
|
|
452
|
+
|
|
453
|
+
"#{before.sub(/\n+\z/, "\n\n")}#{after.sub(/\A\n+/, '')}"
|
|
454
|
+
end
|
|
455
|
+
|
|
334
456
|
def split_at_existing_ancestor(decls, segments)
|
|
335
457
|
(segments.size - 1).downto(0).each do |i|
|
|
336
458
|
ancestor = segments[0...i].join("::")
|
|
@@ -339,37 +461,61 @@ module Rigor
|
|
|
339
461
|
[[], segments]
|
|
340
462
|
end
|
|
341
463
|
|
|
342
|
-
#
|
|
343
|
-
# Pre-existing members might be missing (an empty `class Foo; end`)
|
|
344
|
-
# signal.
|
|
345
|
-
def
|
|
346
|
-
|
|
347
|
-
(decl_column / INDENT.size) + 1
|
|
464
|
+
# The indent depth (in `INDENT` units) a member of `decl` sits at: one level deeper than the
|
|
465
|
+
# declaration's own keyword column. Pre-existing members might be missing (an empty `class Foo; end`)
|
|
466
|
+
# so the keyword column is the robust signal.
|
|
467
|
+
def member_indent_depth(decl)
|
|
468
|
+
decl_indent_depth(decl) + 1
|
|
348
469
|
end
|
|
349
470
|
|
|
350
|
-
def
|
|
471
|
+
def decl_indent_depth(decl)
|
|
472
|
+
decl.location[:keyword].start_column / INDENT.size
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Renders the `missing` segment chain as one nested block, carrying `methods` on its leaf, by handing a
|
|
476
|
+
# synthesised tree node to the create path's renderer. Both paths therefore agree on the keyword, the
|
|
477
|
+
# superclass suffix, and the indentation of every level. A leaf with no methods is a class shell, which
|
|
478
|
+
# is exactly the create path's `shell:` node (gap-#3 (e)).
|
|
479
|
+
def render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
|
|
351
480
|
return "" if missing.empty?
|
|
352
481
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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"
|
|
482
|
+
node = missing.reverse.each_with_index.inject(nil) do |child, (segment, index)|
|
|
483
|
+
{ name: segment, children: child ? { child[:name] => child } : {},
|
|
484
|
+
methods: index.zero? ? methods : [], shell: index.zero? && methods.empty? }
|
|
363
485
|
end
|
|
486
|
+
render_tree_node(node, kinds, supers, depth, anchor_segs)
|
|
364
487
|
end
|
|
365
488
|
|
|
489
|
+
# Splices `snippet` in just before the declaration's closing `end`. When that `end` starts its own line
|
|
490
|
+
# the insertion point moves to the START of that line: the snippet carries its own indentation, and
|
|
491
|
+
# anchoring at the keyword would otherwise donate the `end`'s indent to the snippet's first line and
|
|
492
|
+
# strand the `end` in column zero.
|
|
366
493
|
def insert_before_end(source, decl, snippet)
|
|
494
|
+
return source if snippet.empty?
|
|
495
|
+
|
|
367
496
|
end_pos = decl.location[:end].start_pos
|
|
368
|
-
|
|
497
|
+
line_start = line_start_index(source, end_pos)
|
|
498
|
+
return source[0...line_start] + snippet + source[line_start..] if blank_range?(source, line_start, end_pos)
|
|
499
|
+
|
|
500
|
+
"#{source[0...end_pos]}\n#{snippet}#{source[end_pos..]}"
|
|
369
501
|
end
|
|
370
502
|
|
|
503
|
+
# Appends a top-level block, separated from whatever precedes it by exactly one blank line.
|
|
371
504
|
def append_top_level(source, snippet)
|
|
372
|
-
|
|
505
|
+
return snippet if source.empty?
|
|
506
|
+
|
|
507
|
+
base = ends_with_newline?(source) ? source : "#{source}\n"
|
|
508
|
+
base.end_with?("\n\n") ? base + snippet : "#{base}\n#{snippet}"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def line_start_index(source, pos)
|
|
512
|
+
return 0 unless pos.positive?
|
|
513
|
+
|
|
514
|
+
(source.rindex("\n", pos - 1) || -1) + 1
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def blank_range?(source, from, to)
|
|
518
|
+
source[from...to].to_s.match?(/\A[ \t]*\z/)
|
|
373
519
|
end
|
|
374
520
|
|
|
375
521
|
def parse_signature(source)
|
|
@@ -379,14 +525,15 @@ module Rigor
|
|
|
379
525
|
nil
|
|
380
526
|
end
|
|
381
527
|
|
|
382
|
-
def merge_class(state, class_name, methods, supers
|
|
528
|
+
def merge_class(state, class_name, methods, kinds, supers)
|
|
383
529
|
decl = find_class_decl(state.decls, class_name)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
530
|
+
if decl.nil?
|
|
531
|
+
state.applied.concat(methods)
|
|
532
|
+
insert_namespace_chain(state, class_name, kinds, supers, methods)
|
|
533
|
+
else
|
|
534
|
+
state.source = merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
|
|
535
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
536
|
+
end
|
|
390
537
|
end
|
|
391
538
|
|
|
392
539
|
# Walks the parsed decl tree recursively, tracking the enclosing module/class prefix, and returns the
|
|
@@ -411,16 +558,6 @@ module Rigor
|
|
|
411
558
|
nil
|
|
412
559
|
end
|
|
413
560
|
|
|
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
561
|
def ends_with_newline?(source)
|
|
425
562
|
source.end_with?("\n")
|
|
426
563
|
end
|
|
@@ -477,9 +614,8 @@ module Rigor
|
|
|
477
614
|
def insert_into_class(source, decl, new_methods)
|
|
478
615
|
return source if new_methods.empty?
|
|
479
616
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
source[0...end_pos] + addition + source[end_pos..]
|
|
617
|
+
indent = INDENT * member_indent_depth(decl)
|
|
618
|
+
insert_before_end(source, decl, new_methods.map { |c| "#{indent}#{c.rbs}\n" }.join)
|
|
483
619
|
end
|
|
484
620
|
|
|
485
621
|
# Walks the class's existing method declarations; for each replaceable candidate that matches a member
|
data/lib/rigor/version.rb
CHANGED
|
@@ -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(
|
data/sig/rigor/cache.rbs
CHANGED
|
@@ -14,6 +14,12 @@ module Rigor
|
|
|
14
14
|
# PRODUCER_ID constant and a private `self.compute(loader)`.
|
|
15
15
|
class RbsCacheProducer
|
|
16
16
|
def self.fetch: (loader: untyped, store: untyped) -> untyped
|
|
17
|
+
|
|
18
|
+
# The generation cap this producer declares to `Cache::Store#evict!`
|
|
19
|
+
# (issue #151). `rigor sig-gen` infers the literal `2` from the base
|
|
20
|
+
# body; the contract is the wider `Integer`, since a subclass may
|
|
21
|
+
# override it.
|
|
22
|
+
def self.generation_cap: () -> Integer
|
|
17
23
|
end
|
|
18
24
|
end
|
|
19
25
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Rigor
|
|
2
|
+
module Inference
|
|
3
|
+
# `VoidOrigin = Data.define(:class_name, :method_name, :kind)`. Declared as a `::Data` subclass because
|
|
4
|
+
# that is the shape the runtime actually builds; `sig-gen` cannot yet read a `Data.define` assignment
|
|
5
|
+
# (it reports the block body's `#label` against the enclosing namespace and drops the members).
|
|
6
|
+
class VoidOrigin < ::Data
|
|
7
|
+
attr_reader class_name: String
|
|
8
|
+
attr_reader method_name: Symbol
|
|
9
|
+
attr_reader kind: Symbol
|
|
10
|
+
|
|
11
|
+
def self.new: (class_name: String, method_name: Symbol, kind: Symbol) -> instance
|
|
12
|
+
| (String class_name, Symbol method_name, Symbol kind) -> instance
|
|
13
|
+
|
|
14
|
+
# A human-facing `Class#method` / `Class.method` label for the diagnostic message.
|
|
15
|
+
def label: () -> String
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/sig/rigor/plugin/base.rbs
CHANGED
|
@@ -14,7 +14,7 @@ class Rigor::Plugin::Base
|
|
|
14
14
|
# the no-arg `manifest` reads the cached value back.
|
|
15
15
|
def self.manifest: (**untyped fields) -> Rigor::Plugin::Manifest
|
|
16
16
|
|
|
17
|
-
def self.producer: (untyped id, ?watch: untyped, ?serialize: untyped, ?deserialize: untyped) { (untyped params) -> untyped } -> Symbol
|
|
17
|
+
def self.producer: (untyped id, ?watch: untyped, ?serialize: untyped, ?deserialize: untyped, ?generation_cap: untyped) { (untyped params) -> untyped } -> Symbol
|
|
18
18
|
def self.producers: () -> Hash[Symbol, untyped]
|
|
19
19
|
|
|
20
20
|
def self.node_rule: (untyped node_type) { (*untyped) -> untyped } -> untyped
|
|
@@ -23,11 +23,12 @@ class Rigor::Plugin::Base
|
|
|
23
23
|
def self.node_file_context: () { (untyped root, untyped scope) -> untyped } -> untyped
|
|
24
24
|
def self.node_file_context_block: () -> untyped
|
|
25
25
|
|
|
26
|
-
def self.dynamic_return: (?receivers: (Array[String] | ^() -> Array[String])?, ?methods: (Array[untyped] | ^() -> Array[untyped])?) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
26
|
+
def self.dynamic_return: (?receivers: (Array[String] | ^() -> Array[String])?, ?methods: (Array[untyped] | ^() -> Array[untyped])?, ?file_methods: (^(untyped path) -> Array[untyped])?) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
27
27
|
def self.dynamic_returns: () -> Array[untyped]
|
|
28
28
|
|
|
29
29
|
def self.narrowing_facts: (methods: Array[untyped]) { (untyped call_node, untyped scope) -> untyped } -> nil
|
|
30
|
-
#
|
|
30
|
+
# The engine-facing reader for the declared `narrowing_facts` rules. Named
|
|
31
|
+
# `type_specifiers` before ADR-80; that spelling was removed in 0.3.0.
|
|
31
32
|
def self.narrowing_facts_rules: () -> Array[untyped]
|
|
32
33
|
|
|
33
34
|
def self.suggest: (untyped name, untyped candidates) -> String?
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rigortype
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rigor contributors
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 1980-01-
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: language_server-protocol
|
|
@@ -460,6 +460,7 @@ files:
|
|
|
460
460
|
- lib/rigor/cli/annotate_command.rb
|
|
461
461
|
- lib/rigor/cli/baseline_command.rb
|
|
462
462
|
- lib/rigor/cli/check_command.rb
|
|
463
|
+
- lib/rigor/cli/check_invocation.rb
|
|
463
464
|
- lib/rigor/cli/check_runner_factory.rb
|
|
464
465
|
- lib/rigor/cli/command.rb
|
|
465
466
|
- lib/rigor/cli/coverage_command.rb
|
|
@@ -491,6 +492,7 @@ files:
|
|
|
491
492
|
- lib/rigor/cli/show_bleedingedge_command.rb
|
|
492
493
|
- lib/rigor/cli/sig_gen_command.rb
|
|
493
494
|
- lib/rigor/cli/skill_command.rb
|
|
495
|
+
- lib/rigor/cli/skill_deep_probe.rb
|
|
494
496
|
- lib/rigor/cli/skill_describe.rb
|
|
495
497
|
- lib/rigor/cli/trace_command.rb
|
|
496
498
|
- lib/rigor/cli/trace_renderer.rb
|
|
@@ -622,6 +624,7 @@ files:
|
|
|
622
624
|
- lib/rigor/language_server/folding_range_provider.rb
|
|
623
625
|
- lib/rigor/language_server/hover_provider.rb
|
|
624
626
|
- lib/rigor/language_server/hover_renderer.rb
|
|
627
|
+
- lib/rigor/language_server/incremental_sync.rb
|
|
625
628
|
- lib/rigor/language_server/loop.rb
|
|
626
629
|
- lib/rigor/language_server/project_context.rb
|
|
627
630
|
- lib/rigor/language_server/selection_range_provider.rb
|
|
@@ -876,6 +879,7 @@ files:
|
|
|
876
879
|
- sig/rigor/inference/builtins.rbs
|
|
877
880
|
- sig/rigor/inference/builtins/method_catalog.rbs
|
|
878
881
|
- sig/rigor/inference/builtins/numeric_catalog.rbs
|
|
882
|
+
- sig/rigor/inference/void_origin.rbs
|
|
879
883
|
- sig/rigor/plugin.rbs
|
|
880
884
|
- sig/rigor/plugin/access_denied_error.rbs
|
|
881
885
|
- sig/rigor/plugin/base.rbs
|
|
@@ -987,7 +991,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
987
991
|
- !ruby/object:Gem::Version
|
|
988
992
|
version: '0'
|
|
989
993
|
requirements: []
|
|
990
|
-
rubygems_version:
|
|
994
|
+
rubygems_version: 4.0.10
|
|
991
995
|
specification_version: 4
|
|
992
996
|
summary: Inference-first static analysis for Ruby.
|
|
993
997
|
test_files: []
|