rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hashing/xxh3"
4
+
5
+ module Rigor
6
+ module Lens
7
+ # A site's anchor and contract-comment span (docs/internal-spec/lens.md § Anchors). The hashed material is the
8
+ # contract comment lines above the declaration (`#:`, `@rbs`, `@extrbs` and their continuation lines), the
9
+ # declaration's own bytes, and a `#:` comment trailing its last line. Documentation prose is not part of it.
10
+ class Anchor
11
+ Result = Data.define(:token, :line, :contract_comments)
12
+
13
+ # rbs-inline's annotation grammar (`#:`, `#[`, `#\s*@rbs`) plus `@extrbs`, matched from the `#`.
14
+ CONTRACT_COMMENT = /\A#(?::|\[|\s*@rbs\b|\s*@extrbs\b)/
15
+ # A `#:nodoc:`-style RDoc directive, which the rbs-inline plugin neutralises rather than reads.
16
+ RDOC_DIRECTIVE = /\A#:[a-z_][\w-]*:/
17
+ # A kept line that later comment lines may continue, with the spaces between its `#` and the tag.
18
+ CONTINUED_TAG = /\A#( *)@(?:rbs|extrbs)\b/
19
+
20
+ # @param source — the file's bytes (binary String)
21
+ # @param comments — the file's `Prism::Comment` list
22
+ def initialize(source, comments)
23
+ @source = source.b
24
+ @line_starts = [0]
25
+ @source.each_byte.with_index { |byte, index| @line_starts << (index + 1) if byte == 10 }
26
+ @comment_by_line = {}
27
+ comments.each do |comment|
28
+ next unless comment.is_a?(Prism::InlineComment)
29
+
30
+ @comment_by_line[comment.location.start_line] = comment
31
+ end
32
+ end
33
+
34
+ # The anchor of the declaration spanning bytes `start_offset...end_offset` and lines `start_line..end_line`.
35
+ def for_span(start_offset, end_offset, start_line, end_line)
36
+ leading = leading_contract_comments(start_line)
37
+ trailing = trailing_contract_comment(end_line, end_offset)
38
+ material = +"".b
39
+ leading.each { |comment| material << line_text_from(comment.location.start_offset) << "\n" }
40
+ material << @source.byteslice(start_offset, end_offset - start_offset)
41
+ material << "\n" << line_text_from(trailing.location.start_offset) if trailing
42
+ line = leading.empty? ? start_line : leading.first.location.start_line
43
+ Result.new(token: "#{line}:#{Hashing::XXH3.anchor_hash(material)}", line: line,
44
+ contract_comments: leading + [trailing].compact)
45
+ end
46
+
47
+ private
48
+
49
+ # The contract comments among the contiguous comment-only lines directly above `start_line`, in file order,
50
+ # with rbs-inline's continuation lines below a kept `@rbs` / `@extrbs` line.
51
+ def leading_contract_comments(start_line)
52
+ run = []
53
+ line = start_line - 1
54
+ while line >= 1 && (comment = comment_only_line(line))
55
+ run.unshift(comment)
56
+ line -= 1
57
+ end
58
+ kept = []
59
+ tag_indent = nil
60
+ run.each do |comment|
61
+ text = comment_text(comment)
62
+ if tag_indent && hash_indent(text) > tag_indent
63
+ kept << comment
64
+ next
65
+ end
66
+
67
+ tag_indent = nil
68
+ next unless contract_comment?(text)
69
+
70
+ kept << comment
71
+ continued = text.match(CONTINUED_TAG)
72
+ tag_indent = continued && continued[1].size
73
+ end
74
+ kept
75
+ end
76
+
77
+ def trailing_contract_comment(end_line, end_offset)
78
+ comment = @comment_by_line[end_line]
79
+ return nil if comment.nil? || comment.location.start_offset < end_offset
80
+
81
+ text = comment_text(comment)
82
+ contract_comment?(text) && text.start_with?("#:") ? comment : nil
83
+ end
84
+
85
+ def contract_comment?(text)
86
+ text.match?(CONTRACT_COMMENT) && !text.match?(RDOC_DIRECTIVE)
87
+ end
88
+
89
+ # The spaces between a comment's `#` and its first non-space byte.
90
+ def hash_indent(text)
91
+ text[/\A#( *)/, 1].size
92
+ end
93
+
94
+ # The comment on `line` when nothing but whitespace precedes it.
95
+ def comment_only_line(line)
96
+ comment = @comment_by_line[line]
97
+ return nil if comment.nil?
98
+
99
+ prefix = @source.byteslice(@line_starts[line - 1], comment.location.start_offset - @line_starts[line - 1])
100
+ prefix.match?(/\A[ \t]*\z/) ? comment : nil
101
+ end
102
+
103
+ # The comment's text from its `#`, without a line terminator.
104
+ def comment_text(comment)
105
+ comment.location.slice.b.chomp.delete_suffix("\r")
106
+ end
107
+
108
+ # The bytes from `offset` to the end of its line, without the line terminator.
109
+ def line_text_from(offset)
110
+ line_end = @source.index("\n", offset) || @source.bytesize
111
+ @source.byteslice(offset, line_end - offset).delete_suffix("\r")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot"
4
+ require_relative "../reflection"
5
+
6
+ module Rigor
7
+ module Lens
8
+ # Reads a member's own RBS declaration into overloads with per-slot provenance (docs/internal-spec/lens.md
9
+ # § Members). A declaration the member inherits is not its contract; one written for its own class is, whether
10
+ # it comes from `sig/`, an inline comment, a plugin or a library.
11
+ class ContractReader
12
+ INFERRED_RETURN = "rigor:v1:inferred-return"
13
+ INFERRED_SIGNATURE = "rigor:v1:inferred-signature"
14
+
15
+ # `overloads` hold `{rbs, params, block, return}`; a return the contract leaves to inference is nil there,
16
+ # with `return_inferred` set. `buffers` are the declaring buffer names.
17
+ Contract = Data.define(:overloads, :provenance, :path, :return_inferred, :buffers)
18
+
19
+ def initialize(provenance)
20
+ @provenance = provenance
21
+ end
22
+
23
+ # The member's contract, or nil when it has none of its own. `name` is the Ruby method name.
24
+ def read(owner, scope, name, lookup_scope)
25
+ method = lookup(owner, scope, name, lookup_scope)
26
+ defs = method ? method.defs.select { |definition| definition.defined_in.to_s.delete_prefix("::") == owner } : []
27
+ return nil if defs.empty?
28
+
29
+ annotations = annotations_of(method, defs)
30
+ return nil if annotations.include?(INFERRED_SIGNATURE)
31
+
32
+ contract(defs, annotations)
33
+ rescue StandardError
34
+ unreadable("definition_build_failed", [])
35
+ end
36
+
37
+ private
38
+
39
+ def contract(defs, annotations)
40
+ buffers = defs.map { |definition| buffer_name(definition) }
41
+ classified = buffers.map { |buffer| @provenance.classify(buffer) }
42
+ return unreadable("not_computed", buffers) if classified.include?(nil)
43
+
44
+ return_inferred = annotations.include?(INFERRED_RETURN)
45
+ authored = annotations.select do |string|
46
+ string.start_with?("rigor:v1:") && !string.start_with?(INFERRED_RETURN)
47
+ end
48
+ # Each overload carries the provenance of the declaration it came from: a `sig/` overload extending a
49
+ # library declaration reads `sig`, the library one `library`.
50
+ overloads = defs.zip(classified).map do |definition, (provenance, path)|
51
+ overload(definition.type, provenance, path, return_inferred, authored)
52
+ end
53
+ provenance, path = classified.first
54
+ Contract.new(overloads: overloads, provenance: provenance, path: path,
55
+ return_inferred: return_inferred, buffers: buffers)
56
+ end
57
+
58
+ # The method's and its own declarations' annotation strings: the plugin's defaulted-slot marks and authored
59
+ # `%a{…}`.
60
+ def annotations_of(method, defs)
61
+ strings = method.annotations.map(&:string) + defs.flat_map { |definition| definition.annotations.map(&:string) }
62
+ strings.map(&:strip).uniq
63
+ end
64
+
65
+ def buffer_name(definition)
66
+ location = definition.member&.location
67
+ location ? location.buffer&.name.to_s : ""
68
+ end
69
+
70
+ def lookup(owner, scope, name, lookup_scope)
71
+ if scope == :singleton
72
+ Reflection.singleton_method_definition(owner, name.to_sym, scope: lookup_scope)
73
+ else
74
+ Reflection.instance_method_definition(owner, name.to_sym, scope: lookup_scope)
75
+ end
76
+ end
77
+
78
+ # A declaration Rigor cannot read: its buffer name did not survive the environment cache
79
+ # (`not_computed`), or building the member's definition failed (`definition_build_failed`). The member has a
80
+ # contract, so it is not inferred; every slot is unavailable, and so are its parameters (`params: null`).
81
+ def unreadable(reason, buffers)
82
+ Contract.new(overloads: [{ "rbs" => nil, "params" => nil, "block" => nil,
83
+ "return" => Slot.unavailable(reason) }],
84
+ provenance: nil, path: nil, return_inferred: false, buffers: buffers)
85
+ end
86
+
87
+ def overload(method_type, provenance, path, return_inferred, annotations)
88
+ function = method_type.type
89
+ params = function.respond_to?(:required_positionals) ? params_of(function, provenance, path) : nil
90
+ block = method_type.block && { "required" => method_type.block.required,
91
+ "type" => Slot.from_rbs(method_type.block.type.to_s, provenance, path) }
92
+ prefix = annotations.map { |string| "%a{#{string}} " }.join
93
+ return_slot = return_inferred ? nil : Slot.from_rbs(function.return_type.to_s, provenance, path)
94
+ { "rbs" => "#{prefix}#{method_type}", "params" => params, "block" => block, "return" => return_slot,
95
+ "return_rbs" => function.return_type.to_s }
96
+ end
97
+
98
+ def params_of(function, provenance, path)
99
+ (positional_params(function) + keyword_params(function)).map do |kind, name, param|
100
+ { "name" => name&.to_s, "kind" => kind, "type" => Slot.from_rbs(param.type.to_s, provenance, path) }
101
+ end
102
+ end
103
+
104
+ def positional_params(function)
105
+ function.required_positionals.map { |param| ["required", param.name, param] } +
106
+ function.optional_positionals.map { |param| ["optional", param.name, param] } +
107
+ [function.rest_positionals].compact.map { |param| ["rest", param.name, param] } +
108
+ function.trailing_positionals.map { |param| ["post", param.name, param] }
109
+ end
110
+
111
+ def keyword_params(function)
112
+ function.required_keywords.map { |name, param| ["keyreq", name, param] } +
113
+ function.optional_keywords.map { |name, param| ["key", name, param] } +
114
+ [function.rest_keywords].compact.map { |param| ["keyrest", param.name, param] }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../version"
6
+ require_relative "../cache/engine_source"
7
+ require_relative "../analysis/run_cache_key"
8
+
9
+ module Rigor
10
+ module Lens
11
+ # Assembles the `lens/v0` document (docs/internal-spec/lens.md § Document) from a {Run::Outcome}.
12
+ class Document
13
+ SITE_FIELDS = %w[key kind name owner scope attribute members ordinal span contract_span anchor visibility
14
+ contract params block body_return].freeze
15
+
16
+ def initialize(outcome:, configuration:, root:, config_path:)
17
+ @outcome = outcome
18
+ @configuration = configuration
19
+ @root = root
20
+ @config_path = config_path
21
+ end
22
+
23
+ def to_h
24
+ { "schema" => SCHEMA, "revision" => REVISION, "producer" => producer, "context" => context,
25
+ "capabilities" => CAPABILITIES, "files" => @outcome.targets.map { |target| file(target) },
26
+ "members" => @outcome.member_rows }
27
+ end
28
+
29
+ private
30
+
31
+ def producer
32
+ identity = begin
33
+ Cache::EngineSource.process_identity
34
+ rescue StandardError
35
+ "unknown"
36
+ end
37
+ { "name" => "rigor", "version" => Rigor::VERSION, "engine_source" => identity }
38
+ end
39
+
40
+ def context
41
+ fields = {
42
+ "config" => @config_path && relative(File.expand_path(@config_path)),
43
+ "target_ruby" => @configuration.target_ruby,
44
+ "rbs_version" => rbs_version,
45
+ "signature_roots" => signature_roots.map { |root| relative(root) },
46
+ "signature_digest" => signature_digest,
47
+ "plugins" => @outcome.plugins,
48
+ "lockfiles" => lockfiles
49
+ }
50
+ fields.merge("digest" => Lens.digest(JSON.generate(fields)))
51
+ end
52
+
53
+ def rbs_version
54
+ require "rbs"
55
+ RBS::VERSION
56
+ end
57
+
58
+ def signature_roots
59
+ Environment.project_signature_roots(@configuration.signature_paths).map { |path| File.expand_path(path.to_s) }
60
+ end
61
+
62
+ def signature_digest
63
+ listing = signature_roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.rbs")) }.sort.map do |path|
64
+ "#{relative(path)}\t#{Lens.digest(File.binread(path))}\n"
65
+ end
66
+ Lens.digest(listing.join)
67
+ end
68
+
69
+ def lockfiles
70
+ Analysis::RunCacheKey.resolved_lockfile_paths(@configuration).compact.filter_map do |path|
71
+ absolute = File.expand_path(path.to_s)
72
+ next unless File.file?(absolute)
73
+
74
+ { "path" => relative(absolute), "digest" => Lens.digest(File.binread(absolute)) }
75
+ end
76
+ end
77
+
78
+ # Every field is present; those that apply only to some statuses are null elsewhere (lens.md § Files).
79
+ def file(target)
80
+ read = %w[ok parse_error].include?(target.status)
81
+ head = { "argument" => target.argument, "path" => target.status == "not_found" ? nil : target.path,
82
+ "status" => target.status, "reason" => target.status == "ok" ? nil : target.reason,
83
+ "digest" => file_digest(target, read) }
84
+ head.merge(analysed_fields(target.status == "ok" ? target : nil))
85
+ .merge("diagnostics" => read ? target.diagnostics.map { |diagnostic| diagnostic_row(diagnostic) } : nil)
86
+ .merge("inputs" => target.status == "ok" ? target.inputs : nil)
87
+ end
88
+
89
+ # The digest of the bytes the analysis read; a file that did not parse is read once more.
90
+ def file_digest(target, read)
91
+ return nil unless read
92
+ return target.reader.digest if target.reader
93
+
94
+ Lens.digest(File.binread(target.absolute))
95
+ end
96
+
97
+ def analysed_fields(target)
98
+ reader = target&.reader
99
+ { "complete" => reader && reader.omissions.empty?, "omissions" => reader&.omissions,
100
+ "sites" => reader&.sites&.map { |site| site.data.slice(*SITE_FIELDS) } }
101
+ end
102
+
103
+ def diagnostic_row(diagnostic)
104
+ { "rule" => diagnostic.qualified_rule, "severity" => diagnostic.severity.to_s,
105
+ "message" => diagnostic.message, "line" => diagnostic.line, "column" => diagnostic.column }
106
+ end
107
+
108
+ def relative(absolute)
109
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
110
+ end
111
+ end
112
+ end
113
+ end