rigortype 0.2.8 → 0.3.0
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 +2 -2
- data/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +18 -9
- data/docs/handbook/08-understanding-errors.md +4 -3
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +99 -4
- data/docs/manual/02-cli-reference.md +65 -19
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +23 -0
- data/docs/manual/07-plugins.md +5 -5
- data/docs/manual/09-editor-integration.md +4 -4
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
- data/lib/rigor/analysis/check_rules.rb +557 -95
- data/lib/rigor/analysis/dependency_recorder.rb +93 -9
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental_session.rb +456 -51
- data/lib/rigor/analysis/path_expansion.rb +42 -0
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
- data/lib/rigor/analysis/rule_catalog.rb +226 -1
- data/lib/rigor/analysis/run_cache_key.rb +68 -0
- data/lib/rigor/analysis/run_cache_probe.rb +72 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
- data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
- data/lib/rigor/analysis/runner.rb +343 -68
- data/lib/rigor/analysis/severity_stamp.rb +43 -0
- data/lib/rigor/analysis/worker_session.rb +30 -2
- data/lib/rigor/bleeding_edge.rb +37 -8
- data/lib/rigor/builtins/regex_refinement.rb +36 -1
- data/lib/rigor/cache/descriptor.rb +88 -28
- data/lib/rigor/cache/file_digest.rb +158 -0
- data/lib/rigor/cache/incremental_snapshot.rb +41 -5
- data/lib/rigor/cache/rbs_descriptor.rb +56 -10
- data/lib/rigor/cache/store.rb +207 -60
- data/lib/rigor/ci_detector.rb +90 -0
- data/lib/rigor/cli/annotate_command.rb +15 -9
- data/lib/rigor/cli/baseline_command.rb +4 -1
- data/lib/rigor/cli/check_command.rb +212 -27
- data/lib/rigor/cli/coverage_command.rb +56 -30
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +163 -23
- data/lib/rigor/cli/lsp_command.rb +5 -0
- data/lib/rigor/cli/mcp_command.rb +5 -0
- data/lib/rigor/cli/plugins_command.rb +36 -6
- data/lib/rigor/cli/plugins_renderer.rb +44 -12
- data/lib/rigor/cli/probe_environment.rb +85 -0
- data/lib/rigor/cli/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +1 -32
- data/lib/rigor/cli/trace_command.rb +5 -6
- data/lib/rigor/cli/type_of_command.rb +9 -8
- data/lib/rigor/cli/type_scan_command.rb +8 -8
- data/lib/rigor/cli.rb +15 -4
- data/lib/rigor/config_audit.rb +48 -2
- data/lib/rigor/configuration/severity_profile.rb +31 -0
- data/lib/rigor/configuration.rb +156 -8
- data/lib/rigor/environment/default_libraries.rb +35 -0
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +71 -43
- data/lib/rigor/inference/budget_trace.rb +77 -2
- data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
- data/lib/rigor/inference/def_handle.rb +23 -0
- data/lib/rigor/inference/def_node_resolver.rb +90 -0
- data/lib/rigor/inference/def_return_typer.rb +2 -1
- data/lib/rigor/inference/expression_typer.rb +379 -113
- data/lib/rigor/inference/fork_map.rb +87 -0
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
- data/lib/rigor/inference/method_dispatcher.rb +57 -1
- data/lib/rigor/inference/mutation_widening.rb +42 -8
- data/lib/rigor/inference/narrowing.rb +245 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +8 -1
- data/lib/rigor/inference/scope_indexer.rb +460 -49
- data/lib/rigor/inference/statement_evaluator.rb +111 -17
- data/lib/rigor/inference/struct_fold_safety.rb +48 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
- data/lib/rigor/inference/void_origin.rb +25 -0
- data/lib/rigor/inference/void_tail_summary.rb +220 -0
- data/lib/rigor/language_server/completion_provider.rb +2 -1
- data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
- data/lib/rigor/language_server/folding_range_provider.rb +2 -1
- data/lib/rigor/language_server/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/plugin/base.rb +9 -22
- data/lib/rigor/plugin/inflector.rb +12 -3
- data/lib/rigor/plugin/io_boundary.rb +5 -1
- data/lib/rigor/plugin/isolation.rb +81 -11
- data/lib/rigor/plugin/load_error.rb +10 -1
- data/lib/rigor/plugin/loader.rb +96 -14
- data/lib/rigor/plugin/node_rule_walk.rb +5 -3
- data/lib/rigor/plugin/registry.rb +32 -23
- data/lib/rigor/plugin.rb +26 -0
- data/lib/rigor/protection/mutator.rb +3 -2
- data/lib/rigor/reflection.rb +64 -0
- data/lib/rigor/runtime/jit.rb +128 -0
- data/lib/rigor/scope/discovery_index.rb +12 -2
- data/lib/rigor/scope.rb +122 -28
- data/lib/rigor/sig_gen/classification.rb +5 -1
- data/lib/rigor/sig_gen/generator.rb +38 -6
- data/lib/rigor/sig_gen/observation_collector.rb +4 -3
- data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
- data/lib/rigor/sig_gen/renderer.rb +10 -0
- data/lib/rigor/sig_gen/write_result.rb +9 -4
- data/lib/rigor/sig_gen/writer.rb +24 -2
- data/lib/rigor/source/node_children.rb +116 -0
- data/lib/rigor/source/node_locator.rb +3 -1
- data/lib/rigor/source/node_walker.rb +4 -2
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/difference.rb +28 -24
- data/lib/rigor/type/hash_shape.rb +34 -10
- data/lib/rigor/version.rb +1 -1
- data/lib/rigortype.rb +24 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
- data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
- data/sig/prism_node_children.rbs +9 -0
- data/sig/rigor/environment.rbs +1 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +2 -3
- data/sig/rigor/reflection.rbs +1 -0
- data/sig/rigor/scope.rbs +12 -1
- data/skills/rigor-ci-setup/SKILL.md +10 -0
- data/skills/rigor-editor-setup/SKILL.md +11 -0
- data/skills/rigor-next-steps/SKILL.md +23 -2
- data/skills/rigor-plugin-author/SKILL.md +3 -3
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
- data/skills/rigor-plugin-review/SKILL.md +2 -2
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
- data/skills/rigor-project-init/SKILL.md +29 -3
- data/skills/rigor-project-init/references/01-detect.md +1 -1
- data/skills/rigor-project-init/references/02-configure.md +39 -5
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
- data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
- metadata +49 -24
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
require "rigor/source/literals"
|
|
5
7
|
|
|
@@ -33,12 +35,25 @@ module Rigor
|
|
|
33
35
|
# @param io_boundary [Rigor::Plugin::IoBoundary]
|
|
34
36
|
# @param search_paths [Array<String>] absolute or project-relative paths.
|
|
35
37
|
# @param base_classes [Array<String>] superclass names that identify a class as an AR model.
|
|
38
|
+
# Declaration macros whose column's runtime value is a rich object, not the SQL scalar. Their column
|
|
39
|
+
# must NOT be narrowed to the schema type (see {ModelIndex.build}'s type-override remap).
|
|
40
|
+
TYPE_OVERRIDE_METHODS = %i[serialize mount_uploader mount_uploaders].freeze
|
|
41
|
+
|
|
36
42
|
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
37
43
|
@io_boundary = io_boundary
|
|
38
44
|
@search_paths = search_paths
|
|
39
45
|
@base_classes = base_classes.to_set
|
|
46
|
+
# Global set of column names whose declared runtime type overrides the schema scalar
|
|
47
|
+
# (`serialize` / `mount_uploader` / `attribute :x, CustomType`). Collected across every class AND
|
|
48
|
+
# concern module walked — a serialize inside a concern's `included do … end` is invisible to the
|
|
49
|
+
# per-model view (the discoverer doesn't follow `include`), so overrides are tracked globally by
|
|
50
|
+
# column name and applied wherever that column appears. Over-suppresses a same-named scalar
|
|
51
|
+
# column elsewhere (a precision cost, never a false positive).
|
|
52
|
+
@type_override_columns = Set.new
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
attr_reader :type_override_columns
|
|
56
|
+
|
|
42
57
|
# @return [Array<Hash>] rows of { class_name:, table_name_override:, sti_parent:, ... }
|
|
43
58
|
def discover
|
|
44
59
|
candidates = []
|
|
@@ -127,7 +142,7 @@ module Rigor
|
|
|
127
142
|
when Prism::ModuleNode
|
|
128
143
|
visit_module(node, lexical_path, &)
|
|
129
144
|
else
|
|
130
|
-
node.
|
|
145
|
+
node.rigor_each_child { |child| walk_for_classes(child, lexical_path, &) }
|
|
131
146
|
end
|
|
132
147
|
end
|
|
133
148
|
|
|
@@ -141,6 +156,8 @@ module Rigor
|
|
|
141
156
|
full_name = (lexical_path + [class_local_name]).join("::")
|
|
142
157
|
superclass = constant_path_name(node.superclass) if node.superclass
|
|
143
158
|
|
|
159
|
+
collect_type_overrides(node.body)
|
|
160
|
+
|
|
144
161
|
yield({
|
|
145
162
|
class_name: full_name,
|
|
146
163
|
superclass_name: superclass,
|
|
@@ -162,10 +179,56 @@ module Rigor
|
|
|
162
179
|
module_local_name = constant_path_name(node.constant_path)
|
|
163
180
|
return if module_local_name.nil?
|
|
164
181
|
|
|
182
|
+
# Concerns (`module DiffPositionableNote`) carry `serialize` / `mount_uploader` inside an
|
|
183
|
+
# `included do … end` block; collect their overrides even though the module itself is not a model.
|
|
184
|
+
collect_type_overrides(node.body)
|
|
185
|
+
|
|
165
186
|
inner_path = lexical_path + [module_local_name]
|
|
166
187
|
walk_for_classes(node.body, inner_path, &) if node.body
|
|
167
188
|
end
|
|
168
189
|
|
|
190
|
+
# Records the column name of every `serialize :col` / `mount_uploader(s) :col` / `attribute :col,
|
|
191
|
+
# CustomType` in `body` (descending into `with_options` and concern `included do` blocks) into the
|
|
192
|
+
# global {#type_override_columns} set. `attribute :col, :symbol_type` (a built-in scalar type) is
|
|
193
|
+
# NOT an override — only a custom type CONSTANT is.
|
|
194
|
+
def collect_type_overrides(body)
|
|
195
|
+
type_override_declaration_calls(body).each do |node|
|
|
196
|
+
next if node.receiver
|
|
197
|
+
|
|
198
|
+
column = Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
|
|
199
|
+
next if column.nil?
|
|
200
|
+
|
|
201
|
+
if TYPE_OVERRIDE_METHODS.include?(node.name)
|
|
202
|
+
@type_override_columns << column
|
|
203
|
+
elsif node.name == :attribute && custom_type_attribute?(node)
|
|
204
|
+
@type_override_columns << column
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# `declaration_calls` variant that also descends into a concern's `included do … end` block (the
|
|
210
|
+
# ActiveSupport::Concern idiom where models' shared `serialize` declarations live).
|
|
211
|
+
def type_override_declaration_calls(body)
|
|
212
|
+
return [] if body.nil?
|
|
213
|
+
|
|
214
|
+
body.compact_child_nodes.flat_map do |node|
|
|
215
|
+
next [] unless node.is_a?(Prism::CallNode)
|
|
216
|
+
|
|
217
|
+
if %i[with_options included].include?(node.name) && node.block.is_a?(Prism::BlockNode)
|
|
218
|
+
type_override_declaration_calls(node.block.body)
|
|
219
|
+
else
|
|
220
|
+
[node]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# True when an `attribute :col, <type>` call's type argument is a custom type CLASS (a constant),
|
|
226
|
+
# whose runtime value is a rich object — not a built-in `:symbol` type, which stays a scalar.
|
|
227
|
+
def custom_type_attribute?(node)
|
|
228
|
+
type_arg = node.arguments&.arguments&.[](1)
|
|
229
|
+
type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
|
|
230
|
+
end
|
|
231
|
+
|
|
169
232
|
# Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String. Returns nil for
|
|
170
233
|
# shapes the discoverer chooses not to handle.
|
|
171
234
|
def constant_path_name(node)
|
|
@@ -195,7 +258,7 @@ module Rigor
|
|
|
195
258
|
def lookup_table_name_override(body)
|
|
196
259
|
return nil if body.nil?
|
|
197
260
|
|
|
198
|
-
body.
|
|
261
|
+
body.rigor_each_child do |node|
|
|
199
262
|
next unless node.is_a?(Prism::CallNode) && node.name == :table_name=
|
|
200
263
|
next unless node.receiver.is_a?(Prism::SelfNode)
|
|
201
264
|
|
|
@@ -88,15 +88,16 @@ module Rigor
|
|
|
88
88
|
def class_names = entries.keys
|
|
89
89
|
def empty? = entries.empty?
|
|
90
90
|
|
|
91
|
-
def self.build(model_rows:, schema_table:)
|
|
91
|
+
def self.build(model_rows:, schema_table:, type_override_columns: nil)
|
|
92
92
|
rows_by_name = model_rows.to_h { |row| [row.fetch(:class_name), row] }
|
|
93
|
+
overrides = type_override_columns || []
|
|
93
94
|
|
|
94
95
|
entries = model_rows.each_with_object({}) do |row, acc|
|
|
95
96
|
class_name = row.fetch(:class_name)
|
|
96
97
|
# The STI ancestry chain, root → self. For a plain (non-STI) model this is just `[row]`.
|
|
97
98
|
chain = sti_chain(row, rows_by_name)
|
|
98
99
|
table_name = sti_table_name(chain)
|
|
99
|
-
columns = schema_table.columns_for(table_name) || []
|
|
100
|
+
columns = apply_type_overrides(schema_table.columns_for(table_name) || [], overrides)
|
|
100
101
|
|
|
101
102
|
# STI children inherit their ancestors' declared associations / enums / aliases / scopes /
|
|
102
103
|
# validations / callbacks. Without the merge a `where(<parent-association>: ...)` on the
|
|
@@ -116,6 +117,23 @@ module Rigor
|
|
|
116
117
|
new(entries.freeze)
|
|
117
118
|
end
|
|
118
119
|
|
|
120
|
+
# Remaps every type-overridden column's `ruby_type` to `"Object"` so instance-side column narrowing
|
|
121
|
+
# declines to narrow it (the `"Object"` case in `ruby_type_to_type`). A `serialize` / `mount_uploader`
|
|
122
|
+
# / custom-`attribute` column reads as a rich object at runtime, not its SQL scalar, so narrowing it
|
|
123
|
+
# to e.g. `String` false-positives on the object's methods (`note.position.diff_refs`). The column
|
|
124
|
+
# stays in the set, so `where(col: ...)` existence validation is unaffected — only its value type.
|
|
125
|
+
def self.apply_type_overrides(columns, overrides)
|
|
126
|
+
return columns if overrides.empty?
|
|
127
|
+
|
|
128
|
+
columns.map do |column|
|
|
129
|
+
next column unless overrides.include?(column.name)
|
|
130
|
+
|
|
131
|
+
SchemaTable::Column.new(
|
|
132
|
+
name: column.name, type: column.type, ruby_type: "Object", array: column.array
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
119
137
|
# The STI ancestry chain for a row, ordered root → self. Walks `sti_parent` pointers, guarding
|
|
120
138
|
# against a cycle.
|
|
121
139
|
def self.sti_chain(row, rows_by_name, seen = [])
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
|
|
5
7
|
module Rigor
|
|
@@ -66,7 +68,7 @@ module Rigor
|
|
|
66
68
|
|
|
67
69
|
yield node if node.is_a?(Prism::CallNode) && node.name == :create_table && node.receiver.nil?
|
|
68
70
|
|
|
69
|
-
node.
|
|
71
|
+
node.rigor_each_child { |child| collect_create_table_calls(child, &) }
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def parse_create_table(call_node)
|
|
@@ -118,7 +120,7 @@ module Rigor
|
|
|
118
120
|
return
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
node.
|
|
123
|
+
node.rigor_each_child { |child| collect_column_calls(child, &) }
|
|
122
124
|
end
|
|
123
125
|
|
|
124
126
|
def parse_column(call_node)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schema_table"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activerecord < Rigor::Plugin::Base
|
|
8
|
+
# Parses a PostgreSQL `db/structure.sql` (the `schema_format = :sql` schema dump) into the same
|
|
9
|
+
# {SchemaTable} the Ruby-DSL {SchemaParser} produces, so a project that commits `structure.sql`
|
|
10
|
+
# instead of `schema.rb` is no longer inert (GitLab-class apps use `structure.sql`; without this the
|
|
11
|
+
# plugin skips every AR call check and — worse — degrades ordinary relation chains to `Array`,
|
|
12
|
+
# cascading false diagnostics).
|
|
13
|
+
#
|
|
14
|
+
# The dump is regular enough to read line-by-line without a SQL parser. `pg_dump` emits, per table:
|
|
15
|
+
#
|
|
16
|
+
# CREATE TABLE namespaces (
|
|
17
|
+
# id bigint NOT NULL,
|
|
18
|
+
# name character varying NOT NULL,
|
|
19
|
+
# visibility_level integer DEFAULT 20 NOT NULL,
|
|
20
|
+
# tag_ids bigint[],
|
|
21
|
+
# created_at timestamp without time zone
|
|
22
|
+
# );
|
|
23
|
+
#
|
|
24
|
+
# Load-bearing regularities this relies on: column names / types are lowercase, column modifiers
|
|
25
|
+
# (`NOT NULL`, `DEFAULT …`, `GENERATED …`, `COLLATE …`) are UPPERCASE, one column per line, the body
|
|
26
|
+
# closes on a line starting with `)`. So the type is the run of tokens before the first
|
|
27
|
+
# uppercase-initial token, and a table-level constraint line (`CONSTRAINT …`, `PRIMARY KEY …`) is
|
|
28
|
+
# recognised by its uppercase leading keyword and skipped. Anything it cannot map (custom enum,
|
|
29
|
+
# `tsvector`, `ltree`) degrades to `Object` via {SchemaTable.ruby_type_for} — never a dropped column,
|
|
30
|
+
# which would turn every query on it into a false `unknown-column`.
|
|
31
|
+
class StructureSqlParser
|
|
32
|
+
# Normalised PostgreSQL type (lowercased, length/precision + `[]` stripped) → the Rails column-type
|
|
33
|
+
# symbol {SchemaTable.ruby_type_for} already understands. Unmapped types fall through to their own
|
|
34
|
+
# symbol, which `ruby_type_for` maps to `Object` (the safe "do not narrow" default).
|
|
35
|
+
PG_TYPE_TO_RAILS = {
|
|
36
|
+
"bigint" => :bigint, "int8" => :bigint, "bigserial" => :bigint,
|
|
37
|
+
"integer" => :integer, "int" => :integer, "int4" => :integer, "serial" => :integer,
|
|
38
|
+
"smallint" => :integer, "int2" => :integer, "smallserial" => :integer,
|
|
39
|
+
"boolean" => :boolean, "bool" => :boolean,
|
|
40
|
+
"text" => :text,
|
|
41
|
+
"character varying" => :string, "varchar" => :string,
|
|
42
|
+
"character" => :string, "char" => :string, "name" => :string, "citext" => :citext,
|
|
43
|
+
"timestamp without time zone" => :datetime, "timestamp with time zone" => :datetime,
|
|
44
|
+
"timestamp" => :datetime, "timestamptz" => :datetime,
|
|
45
|
+
"date" => :date,
|
|
46
|
+
"time without time zone" => :time, "time with time zone" => :time, "time" => :time,
|
|
47
|
+
"numeric" => :decimal, "decimal" => :decimal, "money" => :decimal,
|
|
48
|
+
"double precision" => :float, "real" => :float, "float8" => :float, "float4" => :float,
|
|
49
|
+
"bytea" => :binary,
|
|
50
|
+
"json" => :json, "jsonb" => :jsonb,
|
|
51
|
+
"uuid" => :uuid, "inet" => :inet, "cidr" => :string, "macaddr" => :string
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Uppercase leading keywords that mark a table-level constraint line rather than a column.
|
|
55
|
+
CONSTRAINT_KEYWORDS = %w[CONSTRAINT PRIMARY UNIQUE CHECK FOREIGN EXCLUDE LIKE PARTITION].freeze
|
|
56
|
+
|
|
57
|
+
# @param source [String] contents of `db/structure.sql`
|
|
58
|
+
# @return [SchemaTable]
|
|
59
|
+
def self.parse(source)
|
|
60
|
+
new.parse(source)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse(source)
|
|
64
|
+
tables = {}
|
|
65
|
+
each_create_table(source.to_s) do |table_name, body_lines|
|
|
66
|
+
columns = parse_columns(body_lines)
|
|
67
|
+
tables[table_name] = columns unless columns.empty?
|
|
68
|
+
end
|
|
69
|
+
SchemaTable.new(tables.freeze)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Yields `[table_name, body_lines]` for every `CREATE TABLE … (` … `)` block. Body lines are the raw
|
|
75
|
+
# lines between the opening `(` line and the closing `)` line.
|
|
76
|
+
def each_create_table(source)
|
|
77
|
+
lines = source.lines
|
|
78
|
+
index = 0
|
|
79
|
+
while index < lines.length
|
|
80
|
+
match = lines[index].match(/\ACREATE (?:UNLOGGED |TEMPORARY )?TABLE (?:IF NOT EXISTS )?(\S+)\s*\(/)
|
|
81
|
+
unless match
|
|
82
|
+
index += 1
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
table_name = normalize_table_name(match[1])
|
|
87
|
+
index += 1
|
|
88
|
+
body = []
|
|
89
|
+
while index < lines.length && lines[index] !~ /\A\s*\)/
|
|
90
|
+
body << lines[index]
|
|
91
|
+
index += 1
|
|
92
|
+
end
|
|
93
|
+
yield(table_name, body) if table_name
|
|
94
|
+
index += 1 # step past the closing `)` line
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Strips quotes and a leading schema qualifier. Only the default `public` schema (or an unqualified
|
|
99
|
+
# name) is accepted; other schemas (`gitlab_partitions_dynamic.*`, etc.) are partitions of a base
|
|
100
|
+
# table already emitted unqualified, so returning nil skips them without losing a real table.
|
|
101
|
+
def normalize_table_name(raw)
|
|
102
|
+
name = raw.delete('"')
|
|
103
|
+
if name.include?(".")
|
|
104
|
+
schema, table = name.split(".", 2)
|
|
105
|
+
return nil unless schema == "public"
|
|
106
|
+
|
|
107
|
+
name = table
|
|
108
|
+
end
|
|
109
|
+
name.empty? ? nil : name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_columns(body_lines)
|
|
113
|
+
columns = {}
|
|
114
|
+
# A column's `DEFAULT` may carry a multi-line single-quoted string (a serialized YAML default,
|
|
115
|
+
# `DEFAULT '--- []\n'::character varying`). The column name + type sit before the `DEFAULT`, so
|
|
116
|
+
# the first physical line still parses correctly; the continuation lines must be skipped or they
|
|
117
|
+
# read as bogus columns (`'::character varying`). A line with an ODD count of single quotes opens
|
|
118
|
+
# (or closes) such a string — PG doubles a literal quote (`''`), so escapes stay even and only a
|
|
119
|
+
# genuinely unterminated string toggles the state.
|
|
120
|
+
in_string = false
|
|
121
|
+
body_lines.each do |raw|
|
|
122
|
+
if in_string
|
|
123
|
+
in_string = false if raw.count("'").odd?
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
in_string = true if raw.count("'").odd?
|
|
127
|
+
|
|
128
|
+
line = raw.strip.chomp(",")
|
|
129
|
+
next if line.empty?
|
|
130
|
+
|
|
131
|
+
name, rest = line.split(/\s+/, 2)
|
|
132
|
+
next if name.nil? || rest.nil?
|
|
133
|
+
next if CONSTRAINT_KEYWORDS.include?(name) # a table-level constraint, not a column
|
|
134
|
+
|
|
135
|
+
column = build_column(name.delete('"'), rest)
|
|
136
|
+
columns[column.name] = column if column
|
|
137
|
+
end
|
|
138
|
+
columns.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_column(name, rest)
|
|
142
|
+
type_str, array = extract_type(rest)
|
|
143
|
+
return nil if type_str.empty?
|
|
144
|
+
|
|
145
|
+
rails_type = PG_TYPE_TO_RAILS.fetch(type_str, type_str.to_sym)
|
|
146
|
+
SchemaTable::Column.new(
|
|
147
|
+
name: name,
|
|
148
|
+
type: rails_type,
|
|
149
|
+
ruby_type: SchemaTable.ruby_type_for(rails_type),
|
|
150
|
+
array: array
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The type is every token before the first uppercase-initial token (a column modifier such as
|
|
155
|
+
# `NOT`, `NULL`, `DEFAULT`, `GENERATED`, `COLLATE`). A trailing `[]` marks a Postgres array column;
|
|
156
|
+
# a `(length[,scale])` specifier is dropped. Returns `[normalized_type, array?]`.
|
|
157
|
+
def extract_type(rest)
|
|
158
|
+
type_tokens = []
|
|
159
|
+
rest.split(/\s+/).each do |token|
|
|
160
|
+
break if token.match?(/\A[A-Z]/)
|
|
161
|
+
|
|
162
|
+
type_tokens << token
|
|
163
|
+
end
|
|
164
|
+
type_str = type_tokens.join(" ")
|
|
165
|
+
array = type_str.include?("[]")
|
|
166
|
+
type_str = type_str.gsub("[]", "").gsub(/\([0-9,\s]*\)/, "").strip.downcase
|
|
167
|
+
[type_str, array]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -4,6 +4,7 @@ require "rigor/plugin"
|
|
|
4
4
|
|
|
5
5
|
require_relative "activerecord/schema_table"
|
|
6
6
|
require_relative "activerecord/schema_parser"
|
|
7
|
+
require_relative "activerecord/structure_sql_parser"
|
|
7
8
|
require_relative "activerecord/model_index"
|
|
8
9
|
require_relative "activerecord/model_discoverer"
|
|
9
10
|
require_relative "activerecord/analyzer"
|
|
@@ -18,7 +19,8 @@ module Rigor
|
|
|
18
19
|
# Two cached producers per plugin run:
|
|
19
20
|
#
|
|
20
21
|
# 1. `:schema_table` reads `db/schema.rb` via the `IoBoundary` and parses it through {SchemaParser} into
|
|
21
|
-
# a {SchemaTable} mapping `table_name → { column_name → Column }`.
|
|
22
|
+
# a {SchemaTable} mapping `table_name → { column_name → Column }`. When `db/schema.rb` is absent it
|
|
23
|
+
# falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) via {StructureSqlParser}.
|
|
22
24
|
# 2. `:model_index` walks every `.rb` file under the configured `model_search_paths`, finds class
|
|
23
25
|
# declarations whose direct superclass is in `model_base_classes`, and composes them with the schema
|
|
24
26
|
# table into a {ModelIndex}.
|
|
@@ -57,6 +59,9 @@ module Rigor
|
|
|
57
59
|
description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
|
|
58
60
|
config_schema: {
|
|
59
61
|
"schema_file" => { kind: :string, default: "db/schema.rb" },
|
|
62
|
+
# `schema_format = :sql` projects (GitLab-class apps) commit a PostgreSQL `db/structure.sql`
|
|
63
|
+
# instead of `db/schema.rb`. Used as the fallback schema source when `schema_file` is absent.
|
|
64
|
+
"structure_sql_file" => { kind: :string, default: "db/structure.sql" },
|
|
60
65
|
"model_search_paths" => { kind: :array, default: ["app/models"] },
|
|
61
66
|
"model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
|
|
62
67
|
},
|
|
@@ -76,26 +81,34 @@ module Rigor
|
|
|
76
81
|
RELATION_CLASS_NAME = "ActiveRecord::Relation"
|
|
77
82
|
|
|
78
83
|
# Cached: parsed schema table. The producer reads `@schema_file` via `io_boundary.read_file` so the
|
|
79
|
-
# descriptor picks up the digest, then parses through {SchemaParser}.
|
|
84
|
+
# descriptor picks up the digest, then parses through {SchemaParser}. When `db/schema.rb` is absent
|
|
85
|
+
# the producer falls back to a PostgreSQL `db/structure.sql` (`schema_format = :sql` apps) parsed
|
|
86
|
+
# through {StructureSqlParser} — both reads are captured into the record-and-validate descriptor.
|
|
80
87
|
producer :schema_table do |_params|
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
SchemaParser.parse(io_boundary.read_file(@schema_file))
|
|
89
|
+
rescue Errno::ENOENT
|
|
90
|
+
StructureSqlParser.parse(io_boundary.read_file(@structure_sql_file))
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
# Cached: model index. Walks every model file, then composes the rows with the cached schema table.
|
|
86
94
|
# `watch:` (ADR-60 WD3) covers model-file additions; the discoverer's in-block reads are captured
|
|
87
95
|
# into the record-and-validate dependency descriptor after the block runs.
|
|
88
96
|
producer :model_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
|
|
89
|
-
|
|
97
|
+
discoverer = ModelDiscoverer.new(
|
|
90
98
|
io_boundary: io_boundary,
|
|
91
99
|
search_paths: @model_search_paths,
|
|
92
100
|
base_classes: @model_base_classes
|
|
93
|
-
)
|
|
94
|
-
|
|
101
|
+
)
|
|
102
|
+
rows = discoverer.discover
|
|
103
|
+
ModelIndex.build(
|
|
104
|
+
model_rows: rows, schema_table: schema_table_or_nil,
|
|
105
|
+
type_override_columns: discoverer.type_override_columns
|
|
106
|
+
)
|
|
95
107
|
end
|
|
96
108
|
|
|
97
109
|
def init(_services)
|
|
98
110
|
@schema_file = config.fetch("schema_file")
|
|
111
|
+
@structure_sql_file = config.fetch("structure_sql_file")
|
|
99
112
|
@model_search_paths = Array(config.fetch("model_search_paths")).map(&:to_s)
|
|
100
113
|
@model_base_classes = Array(config.fetch("model_base_classes")).map(&:to_s)
|
|
101
114
|
@schema_table = nil
|
|
@@ -518,7 +531,8 @@ module Rigor
|
|
|
518
531
|
@load_errors << "rigor-activerecord: #{e.message}"
|
|
519
532
|
nil
|
|
520
533
|
rescue Errno::ENOENT
|
|
521
|
-
@load_errors << "rigor-activerecord: schema file `#{@schema_file}`
|
|
534
|
+
@load_errors << "rigor-activerecord: schema file `#{@schema_file}` (or `#{@structure_sql_file}`) " \
|
|
535
|
+
"not found; AR call checks skipped"
|
|
522
536
|
nil
|
|
523
537
|
rescue StandardError => e
|
|
524
538
|
@load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
|
|
5
7
|
module Rigor
|
|
@@ -32,7 +34,7 @@ module Rigor
|
|
|
32
34
|
return if node.nil?
|
|
33
35
|
|
|
34
36
|
yield node
|
|
35
|
-
node.
|
|
37
|
+
node.rigor_each_child { |child| walk(child, &) }
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def visit_call(node)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
require "rigor/source/literals"
|
|
5
7
|
|
|
@@ -77,7 +79,7 @@ module Rigor
|
|
|
77
79
|
when Prism::ModuleNode
|
|
78
80
|
visit_module(node, lexical_path, &)
|
|
79
81
|
else
|
|
80
|
-
node.
|
|
82
|
+
node.rigor_each_child { |child| walk(child, lexical_path, &) }
|
|
81
83
|
end
|
|
82
84
|
end
|
|
83
85
|
|
|
@@ -105,7 +107,7 @@ module Rigor
|
|
|
105
107
|
return [] if body.nil?
|
|
106
108
|
|
|
107
109
|
rows = []
|
|
108
|
-
body.
|
|
110
|
+
body.rigor_each_child do |node|
|
|
109
111
|
next unless node.is_a?(Prism::CallNode)
|
|
110
112
|
|
|
111
113
|
kind = ATTACHMENT_METHODS[node.name]
|
|
@@ -62,6 +62,9 @@ class Object
|
|
|
62
62
|
# `acts_like?(:string)` / `acts_like?(:date)` / `acts_like?(:time)`
|
|
63
63
|
# is ActiveSupport's "duck-typing helper" predicate.
|
|
64
64
|
def acts_like?: (Symbol | String) -> bool
|
|
65
|
+
|
|
66
|
+
# `core_ext/object/inclusion` — `x.in?([a, b])` / `x.in?(1..10)`.
|
|
67
|
+
def in?: (untyped) -> bool
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# `nil.blank?` / `nil.present?` / `nil.try` are the most frequent
|
|
@@ -99,6 +102,9 @@ class String
|
|
|
99
102
|
def demodulize: () -> String
|
|
100
103
|
def deconstantize: () -> String
|
|
101
104
|
def titleize: () -> String
|
|
105
|
+
def titlecase: () -> String
|
|
106
|
+
def dasherize: () -> String
|
|
107
|
+
def upcase_first: () -> String
|
|
102
108
|
def parameterize: (?separator: String, ?preserve_case: bool, ?locale: Symbol?) -> String
|
|
103
109
|
def tableize: () -> String
|
|
104
110
|
def foreign_key: (?bool separate_class_name_and_id_with_underscore) -> String
|
|
@@ -112,6 +118,9 @@ class String
|
|
|
112
118
|
def truncate: (Integer truncate_at, ?omission: String, ?separator: String | Regexp | nil) -> String
|
|
113
119
|
def truncate_bytes: (Integer truncate_at, ?omission: String) -> String
|
|
114
120
|
def truncate_words: (Integer words_count, ?omission: String, ?separator: String | Regexp | nil) -> String
|
|
121
|
+
# `remove` / `remove!` delete every occurrence of the given patterns.
|
|
122
|
+
def remove: (*Regexp | String patterns) -> String
|
|
123
|
+
def remove!: (*Regexp | String patterns) -> String
|
|
115
124
|
|
|
116
125
|
# `core_ext/string/output_safety` — `html_safe` returns an
|
|
117
126
|
# `ActiveSupport::SafeBuffer` (a String subclass). The closest
|
|
@@ -274,6 +283,9 @@ class Time
|
|
|
274
283
|
def noon: () -> Time
|
|
275
284
|
def utc?: () -> bool
|
|
276
285
|
def acts_like_time?: () -> true
|
|
286
|
+
# `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
|
|
287
|
+
def advance: (untyped options) -> Time
|
|
288
|
+
def all_day: () -> Range[Time]
|
|
277
289
|
end
|
|
278
290
|
|
|
279
291
|
# ---------------------------------------------------------------
|
|
@@ -314,6 +326,11 @@ class Date
|
|
|
314
326
|
def at_beginning_of_day: () -> Time
|
|
315
327
|
def end_of_day: () -> Time
|
|
316
328
|
def at_end_of_day: () -> Time
|
|
329
|
+
# `advance(days: 1)`, `all_day` (a Time Range), and `to_time(form)` — the
|
|
330
|
+
# `:local` / `:utc` form arg core RBS' `Date#to_time` (arity 0) omits.
|
|
331
|
+
def advance: (untyped options) -> Date
|
|
332
|
+
def all_day: () -> Range[Time]
|
|
333
|
+
def to_time: (?Symbol form) -> Time
|
|
317
334
|
end
|
|
318
335
|
|
|
319
336
|
# ---------------------------------------------------------------
|
|
@@ -476,3 +493,18 @@ end
|
|
|
476
493
|
class String
|
|
477
494
|
def exclude?: (String) -> bool
|
|
478
495
|
end
|
|
496
|
+
|
|
497
|
+
# ---------------------------------------------------------------
|
|
498
|
+
# ERB::Util — ActionView extends it with `html_escape_once`
|
|
499
|
+
# ---------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
# `ERB` is a CLASS in Ruby / RBS (not a module), so reopen it as a class —
|
|
502
|
+
# a `module ERB` wrapper collides with upstream `class ERB` once the `erb`
|
|
503
|
+
# stdlib is in scope. `ERB::Util` itself is a module.
|
|
504
|
+
class ERB
|
|
505
|
+
module Util
|
|
506
|
+
# `ERB::Util.html_escape_once(s)` escapes HTML without double-escaping
|
|
507
|
+
# an already-escaped entity. ActionView adds it on top of stdlib ERB.
|
|
508
|
+
def self.html_escape_once: (untyped) -> String
|
|
509
|
+
end
|
|
510
|
+
end
|
|
@@ -63,12 +63,28 @@ module Rigor
|
|
|
63
63
|
produces: [:dry_type_aliases]
|
|
64
64
|
)
|
|
65
65
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# Cached alias-table producer (ADR-60 WD3 record-and-validate). The scan Prism-parses every `.rb` file
|
|
67
|
+
# under the project's `paths:` tree to find `include Dry.Types()` declarations — an expensive pass that
|
|
68
|
+
# ran on EVERY invocation (cold and warm) because `#prepare` called {AliasScanner.scan} directly, with
|
|
69
|
+
# no cache. It now rides `cache_for`: a warm run re-globs + re-digests the watched tree (cheap SHA over
|
|
70
|
+
# file bytes, no AST build) and reuses the cached table instead of re-parsing. This is the dominant win
|
|
71
|
+
# even for a project that ships no dry-types module — the parse still has to run to prove that, and now
|
|
72
|
+
# the empty result is cached too.
|
|
73
|
+
#
|
|
74
|
+
# `watch:` covers exactly the files the scan reads: one glob per project `paths:` entry, co-extensive
|
|
75
|
+
# with {#scannable_paths}. A {Cache::Descriptor::GlobEntry} digests every file matching its glob, so a
|
|
76
|
+
# content edit, a file addition, and a file removal anywhere under those paths all move the digest and
|
|
77
|
+
# invalidate the entry. The scan reads no file individually through the `IoBoundary`, so the evaluated
|
|
78
|
+
# `watch:` globs are the entire dependency descriptor — and they fully cover the scan's input.
|
|
79
|
+
producer :dry_type_aliases, watch: -> { alias_watch_globs } do |_params|
|
|
80
|
+
AliasScanner.scan(paths: scannable_paths)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Builds the (cached) alias table and publishes it via the ADR-9 fact store. `producer_value` runs the
|
|
84
|
+
# producer through `cache_for` and memoises the result — including a rescued nil — for the run.
|
|
69
85
|
def prepare(services)
|
|
70
|
-
aliases =
|
|
71
|
-
return if aliases.empty?
|
|
86
|
+
aliases = producer_value(:dry_type_aliases)
|
|
87
|
+
return if aliases.nil? || aliases.empty?
|
|
72
88
|
|
|
73
89
|
services.fact_store.publish(
|
|
74
90
|
plugin_id: manifest.id,
|
|
@@ -86,7 +102,7 @@ module Rigor
|
|
|
86
102
|
# Resolves the project's `paths:` to a flat list of `.rb` files the scanner walks. Mirrors
|
|
87
103
|
# `Analysis::Runner`'s `expand_paths` floor; we don't need the runner's full exclude/sort surface
|
|
88
104
|
# because the alias table is a union — any duplicate scan is a no-op.
|
|
89
|
-
def scannable_paths
|
|
105
|
+
def scannable_paths
|
|
90
106
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
91
107
|
if File.directory?(entry)
|
|
92
108
|
Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
|
|
@@ -97,6 +113,22 @@ module Rigor
|
|
|
97
113
|
end
|
|
98
114
|
end.uniq.freeze
|
|
99
115
|
end
|
|
116
|
+
|
|
117
|
+
# The `watch:` glob tuples covering the scan's entire input — one `[root, pattern]` per project `paths:`
|
|
118
|
+
# entry. A directory entry watches its whole `**/*.rb` subtree; a single-file `.rb` entry watches
|
|
119
|
+
# exactly that file (`[dir, basename]`). Roots are expanded to absolute paths by
|
|
120
|
+
# `Plugin::Base#watch_glob_entries`, so a relative CLI path (`app/models`) re-validates against the same
|
|
121
|
+
# tree from the project root on the next run. Co-extensive with {#scannable_paths}: every `.rb` the scan
|
|
122
|
+
# would read is watched, so any edit / addition / removal under those paths invalidates the cache.
|
|
123
|
+
def alias_watch_globs
|
|
124
|
+
services.configuration.paths.filter_map do |entry|
|
|
125
|
+
if File.directory?(entry)
|
|
126
|
+
[entry, "**/*.rb"]
|
|
127
|
+
elsif File.file?(entry) && entry.end_with?(".rb")
|
|
128
|
+
[File.dirname(entry), File.basename(entry)]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
100
132
|
end
|
|
101
133
|
|
|
102
134
|
Rigor::Plugin.register(DryTypes)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
require "rigor/source/literals"
|
|
5
7
|
|
|
@@ -86,7 +88,7 @@ module Rigor
|
|
|
86
88
|
visit_factory(node, &)
|
|
87
89
|
return
|
|
88
90
|
end
|
|
89
|
-
node.
|
|
91
|
+
node.rigor_each_child { |child| walk_for_factories(child, &) }
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def factory_call?(node)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
3
5
|
require "prism"
|
|
4
6
|
|
|
5
7
|
module Rigor
|
|
@@ -65,7 +67,7 @@ module Rigor
|
|
|
65
67
|
case node
|
|
66
68
|
when Prism::DefNode then defs << node
|
|
67
69
|
when Prism::ClassNode, Prism::ModuleNode then nil # nested scopes own their own defs
|
|
68
|
-
else node.
|
|
70
|
+
else node.rigor_each_child { |child| collect_direct_defs(child, defs) }
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
|