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.
- checksums.yaml +4 -4
- data/README.md +10 -10
- 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/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +11 -5
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- 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 +103 -11
- data/docs/manual/03-configuration.md +16 -1
- 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/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +79 -19
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +27 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +80 -8
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- 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 +61 -34
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- 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/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +38 -14
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +68 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +38 -9
- data/lib/rigor/language_server.rb +2 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +195 -47
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +21 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- data/sig/rigor/scope.rbs +8 -0
- metadata +23 -3
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
340
|
+
insert_namespace_chain(state, qualified, kinds, supers, [])
|
|
317
341
|
end
|
|
318
342
|
end
|
|
319
343
|
|
|
320
|
-
|
|
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 ?
|
|
325
|
-
snippet =
|
|
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
|
-
#
|
|
343
|
-
# Pre-existing members might be missing (an empty `class Foo; end`)
|
|
344
|
-
# signal.
|
|
345
|
-
def
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
@@ -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` —
|
|
252
|
-
# `time`, which
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|