rigortype 0.3.1 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +10 -10
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- 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/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +8 -0
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +107 -28
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_cache_key.rb +17 -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 +78 -7
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- 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/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- 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/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +62 -4
- data/lib/rigor/inference/fork_map.rb +47 -4
- 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/rbs_dispatch.rb +158 -12
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
- 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/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +123 -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 +197 -55
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +55 -4
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- 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 +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +190 -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 +114 -10
- 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 +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -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/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +12 -0
- data/sig/rigor.rbs +4 -0
- metadata +21 -1
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require "rigor/plugin"
|
|
6
6
|
|
|
7
7
|
require_relative "dry_schema/schema_scanner"
|
|
8
|
+
require_relative "dry_schema/result_shape"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
10
11
|
module Plugin
|
|
@@ -58,14 +59,22 @@ module Rigor
|
|
|
58
59
|
# above.
|
|
59
60
|
# - Publishes the table; no user-facing diagnostics yet.
|
|
60
61
|
#
|
|
61
|
-
#
|
|
62
|
+
# Landed since: typed `result.to_h` returns, via the {.dynamic_return} rule below rather than the
|
|
63
|
+
# ADR-16 Tier C substrate the README originally projected — Tier C synthesises methods on a class
|
|
64
|
+
# from a class-level DSL call, and this is a return type for a call chain off a constant.
|
|
62
65
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# Landed since (issue #137): `each do ... end` element-type recursion ({SchemaScanner#each_block_type_info})
|
|
67
|
+
# and the `dry-schema.unknown-type` `:info` diagnostic ({SchemaScanner#unknown_type_issues}), wired
|
|
68
|
+
# below via {.node_file_context} + {.node_rule}.
|
|
69
|
+
#
|
|
70
|
+
# The **ceiling** (still open):
|
|
71
|
+
#
|
|
72
|
+
# - Nested schemas OUTSIDE an `each` (`required(:x).schema do ... end` with no `each` in the chain) —
|
|
73
|
+
# the key is still kept as untyped; only the `each`-wrapped nested form recurses.
|
|
74
|
+
# - `predicates(:size?)` / other fine-grained predicate constraint walks.
|
|
75
|
+
# - `dry-schema.unknown-predicate` — deliberately NOT shipped; see {SchemaScanner.unknown_type_issues}
|
|
76
|
+
# for why (no reliable static signal without a full predicate registry; risks flagging correct code).
|
|
77
|
+
# - `rigor-dry-validation` integration — landed separately in that plugin's own slices 2/3 (issue #137).
|
|
69
78
|
class DrySchema < Rigor::Plugin::Base
|
|
70
79
|
manifest(
|
|
71
80
|
id: "dry-schema",
|
|
@@ -76,6 +85,46 @@ module Rigor
|
|
|
76
85
|
consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }]
|
|
77
86
|
)
|
|
78
87
|
|
|
88
|
+
# ADR-37 dynamic return — `NewUserSchema.call(input).to_h` yields the schema's own
|
|
89
|
+
# `HashShape[{email: String, ?nickname: String}]` instead of the untyped hash the DSL's boundary
|
|
90
|
+
# otherwise leaves behind. The shape's required/optional split and its open extra-key policy are
|
|
91
|
+
# argued in {ResultShape}.
|
|
92
|
+
#
|
|
93
|
+
# Gated on the method name alone, not on a `Dry::Schema::Result` receiver: this plugin ships no RBS
|
|
94
|
+
# overlay for dry-schema, so in an ordinary project the receiver of `.to_h` is untyped and a
|
|
95
|
+
# `receivers:` gate would never fire. The cost of the wider gate is bounded — the engine compiles
|
|
96
|
+
# `methods:` into its registry name gate, so the block is consulted only for `.to_h` calls, and its
|
|
97
|
+
# first act rejects any chain that is not `<Const>.call(...).to_h` before the table is touched.
|
|
98
|
+
dynamic_return methods: [:to_h] do |call_node, _scope|
|
|
99
|
+
result_shape_for(call_node)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ADR-37 slice 1c two-pass: the "collect" half of `dry-schema.unknown-type` (issue #137's ceiling
|
|
103
|
+
# diagnostic). Walks the already-parsed file root once via {SchemaScanner.unknown_type_issues}; the
|
|
104
|
+
# `node_rule` below is the "validate" half that turns each collected issue into a Diagnostic.
|
|
105
|
+
node_file_context do |root, _scope|
|
|
106
|
+
SchemaScanner.unknown_type_issues(root)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Fires exactly once per file: `Prism::ProgramNode` is the AST root, and the engine's node walker
|
|
110
|
+
# visits the root itself before descending (see `Source::NodeWalker#walk_with_ancestors`). Cheaper
|
|
111
|
+
# than a `Prism::CallNode` rule re-matching every call site in the file — the collect pass already
|
|
112
|
+
# did the real walk.
|
|
113
|
+
node_rule Prism::ProgramNode do |_node, _scope, path, issues|
|
|
114
|
+
next [] if issues.nil? || issues.empty?
|
|
115
|
+
|
|
116
|
+
issues.map do |issue|
|
|
117
|
+
diagnostic(
|
|
118
|
+
issue[:node],
|
|
119
|
+
path: path,
|
|
120
|
+
message: "`#{issue[:key]}` uses `:#{issue[:symbol]}`, which is not a recognised dry-schema " \
|
|
121
|
+
"canonical type symbol (see the plugin README's predicate type table)",
|
|
122
|
+
severity: :info,
|
|
123
|
+
rule: "dry-schema.unknown-type"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
79
128
|
# Walks every project file once during `prepare(services)` to build the schema table, then publishes
|
|
80
129
|
# via the ADR-9 fact store. Mirrors the rigor-dry-types `#prepare` shape — the walk is bounded by
|
|
81
130
|
# `paths:`, parse errors degrade silently.
|
|
@@ -93,10 +142,28 @@ module Rigor
|
|
|
93
142
|
|
|
94
143
|
def init(_services)
|
|
95
144
|
@scannable_paths = nil
|
|
145
|
+
@result_shapes = {}
|
|
96
146
|
end
|
|
97
147
|
|
|
98
148
|
private
|
|
99
149
|
|
|
150
|
+
# The HashShape for a `<Const>.call(...).to_h` chain, or nil when the chain isn't that shape, names
|
|
151
|
+
# no schema in the table, or names one that declares no key.
|
|
152
|
+
# Memoised per schema name INCLUDING nil: a carrier is a value object, and the miss is the common
|
|
153
|
+
# case on any project that calls `to_h` on something that isn't a schema result.
|
|
154
|
+
def result_shape_for(call_node)
|
|
155
|
+
name = ResultShape.schema_name(call_node)
|
|
156
|
+
return nil if name.nil?
|
|
157
|
+
|
|
158
|
+
# `||=` rather than a plain read of an `init`-assigned ivar: `dynamic_return_type` rescues every
|
|
159
|
+
# StandardError to nil, so a NoMethodError here would turn the whole feature off in silence.
|
|
160
|
+
shapes = (@result_shapes ||= {})
|
|
161
|
+
return shapes[name] if shapes.key?(name)
|
|
162
|
+
|
|
163
|
+
entry = read_fact(plugin_id: manifest.id, name: :dry_schema_table)&.[](name)
|
|
164
|
+
shapes[name] = entry.nil? ? nil : ResultShape.build(entry)
|
|
165
|
+
end
|
|
166
|
+
|
|
100
167
|
def scannable_paths(services)
|
|
101
168
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
102
169
|
if File.directory?(entry)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "rigor/source/literals"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Plugin
|
|
@@ -41,6 +42,262 @@ module Rigor
|
|
|
41
42
|
end
|
|
42
43
|
private_class_method :scan_file
|
|
43
44
|
|
|
45
|
+
# Issue #137 slices 2/3 — per-Contract `params { ... }` / `json { ... }` shapes, delegating the
|
|
46
|
+
# actual required/optional walk to `rigor-dry-schema`'s {DrySchema::SchemaScanner.collect_schema_shape}
|
|
47
|
+
# (the SAME dry-schema DSL, just under a bare `params`/`json` call instead of
|
|
48
|
+
# `Dry::Schema.Params { ... }`). Returns `{}` immediately, without touching a single file, when
|
|
49
|
+
# `rigor-dry-schema` isn't loaded — the companion plugin's absence is a hard precondition here,
|
|
50
|
+
# not a degrade-gracefully fallback, because this plugin ships no required/optional walker of
|
|
51
|
+
# its own.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash{String => Hash{Symbol => Hash}}] contract FQN => `{params: <shape>}` and/or
|
|
54
|
+
# `{json: <shape>}` (only the recognised key(s) are present; a contract with neither present
|
|
55
|
+
# contributes nothing). Each `<shape>` is exactly {DrySchema::SchemaScanner.collect_schema_shape}'s
|
|
56
|
+
# `{required:, optional:, unmodelled:}` return.
|
|
57
|
+
def scan_schema_blocks(paths:, type_aliases: {})
|
|
58
|
+
return {} unless Rigor::Plugin.registered_for("dry-schema")
|
|
59
|
+
|
|
60
|
+
table = {}
|
|
61
|
+
paths.each do |path|
|
|
62
|
+
scan_file_schema_blocks(path, type_aliases).each do |fqn, shapes|
|
|
63
|
+
table[fqn] ||= shapes
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
table.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scan_file_schema_blocks(path, type_aliases)
|
|
70
|
+
source = File.read(path)
|
|
71
|
+
parse_result = Prism.parse(source, filepath: path)
|
|
72
|
+
return {} unless parse_result.errors.empty?
|
|
73
|
+
|
|
74
|
+
collect_params_blocks(parse_result.value, [], type_aliases)
|
|
75
|
+
rescue StandardError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
private_class_method :scan_file_schema_blocks
|
|
79
|
+
|
|
80
|
+
# Mirrors {#collect_contracts}'s recursive shape, additionally capturing each recognised
|
|
81
|
+
# Contract's own `params { ... }` / `json { ... }` shape.
|
|
82
|
+
def collect_params_blocks(node, qualified_prefix, type_aliases)
|
|
83
|
+
return {} if node.nil?
|
|
84
|
+
|
|
85
|
+
case node
|
|
86
|
+
when Prism::ClassNode
|
|
87
|
+
collect_class_params_blocks(node, qualified_prefix, type_aliases)
|
|
88
|
+
when Prism::ModuleNode
|
|
89
|
+
inner_name = constant_name_for(node.constant_path)
|
|
90
|
+
return {} if inner_name.nil?
|
|
91
|
+
|
|
92
|
+
collect_params_blocks(node.body, qualified_prefix + [inner_name], type_aliases)
|
|
93
|
+
else
|
|
94
|
+
node.compact_child_nodes.each_with_object({}) do |child, acc|
|
|
95
|
+
collect_params_blocks(child, qualified_prefix, type_aliases).each { |k, v| acc[k] ||= v }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_class_method :collect_params_blocks
|
|
100
|
+
|
|
101
|
+
def collect_class_params_blocks(node, qualified_prefix, type_aliases)
|
|
102
|
+
inner_name = constant_name_for(node.constant_path)
|
|
103
|
+
return {} if inner_name.nil?
|
|
104
|
+
|
|
105
|
+
new_prefix = qualified_prefix + [inner_name]
|
|
106
|
+
inner = collect_params_blocks(node.body, new_prefix, type_aliases)
|
|
107
|
+
|
|
108
|
+
if contract_subclass?(node)
|
|
109
|
+
shapes = class_body_schema_shapes(node.body, type_aliases)
|
|
110
|
+
inner[new_prefix.join("::")] = shapes unless shapes.empty?
|
|
111
|
+
end
|
|
112
|
+
inner
|
|
113
|
+
end
|
|
114
|
+
private_class_method :collect_class_params_blocks
|
|
115
|
+
|
|
116
|
+
# ONLY a top-level `params do ... end` / `json do ... end` call directly inside the Contract's
|
|
117
|
+
# class body — a bare call, no receiver, no positional/keyword arguments, exactly one block.
|
|
118
|
+
# A call nested inside a conditional, a method def, or wrapped in any other construct is NOT
|
|
119
|
+
# this floor; it's indistinguishable here from a dynamically-built or externally-shared schema,
|
|
120
|
+
# and the conservative read is to contribute nothing rather than guess.
|
|
121
|
+
def class_body_schema_shapes(body, type_aliases)
|
|
122
|
+
return {} if body.nil?
|
|
123
|
+
|
|
124
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
125
|
+
shapes = {}
|
|
126
|
+
children.each do |child|
|
|
127
|
+
next unless bare_block_call?(child)
|
|
128
|
+
|
|
129
|
+
case child.name
|
|
130
|
+
when :params then shapes[:params] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
|
|
131
|
+
when :json then shapes[:json] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
shapes
|
|
135
|
+
end
|
|
136
|
+
private_class_method :class_body_schema_shapes
|
|
137
|
+
|
|
138
|
+
def bare_block_call?(node)
|
|
139
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
|
|
140
|
+
node.block.is_a?(Prism::BlockNode)
|
|
141
|
+
end
|
|
142
|
+
private_class_method :bare_block_call?
|
|
143
|
+
|
|
144
|
+
# `dry-validation.rule-key-mismatch` (issue #137's remaining ceiling checkbox) — walks an
|
|
145
|
+
# ALREADY-PARSED file's root node collecting `{node:, key:, contract_fqn:}` issues for a
|
|
146
|
+
# `rule(:sym, ...) do ... end` call whose Symbol argument names a key that is NOT in the
|
|
147
|
+
# Contract's OWN, cleanly-recognised `params`/`json` key set.
|
|
148
|
+
#
|
|
149
|
+
# FP-gated hard, per two independent all-or-nothing checks:
|
|
150
|
+
#
|
|
151
|
+
# - {#clean_known_keys} — the Contract's key universe is trusted ONLY when EVERY top-level
|
|
152
|
+
# statement in EVERY `params`/`json` block is a recognised `required`/`optional` row (typed or
|
|
153
|
+
# not — an unmodelled row is still a DECLARED key) or the inert `config.<x> = <literal>` idiom.
|
|
154
|
+
# A loop building keys dynamically, a splat, an `include`, a nested schema merge, or a
|
|
155
|
+
# `params`/`json(SomeSchema)` delegating to an externally-shared schema — ANY of these makes
|
|
156
|
+
# the WHOLE Contract's key universe untrustworthy, not just the offending row, and the
|
|
157
|
+
# Contract is skipped outright (no diagnostic against any of its `rule()` calls).
|
|
158
|
+
# - {#rule_arguments_or_nil} — a `rule(...)` call is checked ONLY when EVERY argument is a
|
|
159
|
+
# literal Symbol. A splat (`rule(*keys)`), a String, a local variable, or a Hash (the
|
|
160
|
+
# nested-key form `rule(user: [:name])`) makes the WHOLE call unresolvable, not just that one
|
|
161
|
+
# argument, and it is skipped rather than partially validated.
|
|
162
|
+
#
|
|
163
|
+
# Returns `[]` immediately, without touching a single file, when `rigor-dry-schema` isn't loaded
|
|
164
|
+
# — there is no key universe to check a `rule()` call against without it.
|
|
165
|
+
def rule_key_issues(root, type_aliases)
|
|
166
|
+
return [] unless Rigor::Plugin.registered_for("dry-schema")
|
|
167
|
+
|
|
168
|
+
issues = []
|
|
169
|
+
walk_for_rule_issues(root, [], type_aliases, issues)
|
|
170
|
+
issues
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def walk_for_rule_issues(node, qualified_prefix, type_aliases, issues)
|
|
174
|
+
return if node.nil?
|
|
175
|
+
|
|
176
|
+
case node
|
|
177
|
+
when Prism::ClassNode
|
|
178
|
+
inner_name = constant_name_for(node.constant_path)
|
|
179
|
+
return if inner_name.nil?
|
|
180
|
+
|
|
181
|
+
new_prefix = qualified_prefix + [inner_name]
|
|
182
|
+
check_contract_rules(node, new_prefix.join("::"), type_aliases, issues) if contract_subclass?(node)
|
|
183
|
+
walk_for_rule_issues(node.body, new_prefix, type_aliases, issues)
|
|
184
|
+
when Prism::ModuleNode
|
|
185
|
+
inner_name = constant_name_for(node.constant_path)
|
|
186
|
+
return if inner_name.nil?
|
|
187
|
+
|
|
188
|
+
walk_for_rule_issues(node.body, qualified_prefix + [inner_name], type_aliases, issues)
|
|
189
|
+
else
|
|
190
|
+
node.compact_child_nodes.each { |child| walk_for_rule_issues(child, qualified_prefix, type_aliases, issues) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_class_method :walk_for_rule_issues
|
|
194
|
+
|
|
195
|
+
def check_contract_rules(class_node, contract_fqn, type_aliases, issues)
|
|
196
|
+
body = class_node.body
|
|
197
|
+
return if body.nil?
|
|
198
|
+
|
|
199
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
200
|
+
known_keys = clean_known_keys(children, type_aliases)
|
|
201
|
+
return if known_keys.nil?
|
|
202
|
+
|
|
203
|
+
children.each do |child|
|
|
204
|
+
next unless rule_call?(child)
|
|
205
|
+
|
|
206
|
+
check_rule_call(child, contract_fqn, known_keys, issues)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
private_class_method :check_contract_rules
|
|
210
|
+
|
|
211
|
+
# The Contract's full declared key set (required + optional + unmodelled, across BOTH `params`
|
|
212
|
+
# and `json` if both are present — a Contract having both is unusual, so the conservative read
|
|
213
|
+
# is the union rather than guessing which one a given `rule()` targets), or nil when the
|
|
214
|
+
# Contract has no `params`/`json` block at all OR either block fails {#clean_schema_block?}.
|
|
215
|
+
def clean_known_keys(children, type_aliases)
|
|
216
|
+
blocks = children.select { |c| bare_block_call?(c) && %i[params json].include?(c.name) }
|
|
217
|
+
return nil if blocks.empty?
|
|
218
|
+
|
|
219
|
+
keys = Set.new
|
|
220
|
+
blocks.each do |block_call|
|
|
221
|
+
return nil unless clean_schema_block?(block_call.block)
|
|
222
|
+
|
|
223
|
+
shape = DrySchema::SchemaScanner.collect_schema_shape(block_call.block, type_aliases, nested: false)
|
|
224
|
+
keys.merge(shape[:required].keys)
|
|
225
|
+
keys.merge(shape[:optional].keys)
|
|
226
|
+
keys.merge(shape[:unmodelled][:required])
|
|
227
|
+
keys.merge(shape[:unmodelled][:optional])
|
|
228
|
+
end
|
|
229
|
+
keys
|
|
230
|
+
end
|
|
231
|
+
private_class_method :clean_known_keys
|
|
232
|
+
|
|
233
|
+
def clean_schema_block?(block_node)
|
|
234
|
+
body = block_node.body
|
|
235
|
+
return true if body.nil? # empty block — vacuously clean, contributes no keys
|
|
236
|
+
|
|
237
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
238
|
+
children.all? { |child| required_or_optional_row?(child) || inert_config_assignment?(child) }
|
|
239
|
+
end
|
|
240
|
+
private_class_method :clean_schema_block?
|
|
241
|
+
|
|
242
|
+
# A `required(:key)...` / `optional(:key)...` chain, ANY predicate suffix (even one this
|
|
243
|
+
# scanner can't type — an unmodelled row is still a row that DECLARES the key, which is all
|
|
244
|
+
# {#clean_known_keys} needs).
|
|
245
|
+
def required_or_optional_row?(node)
|
|
246
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
247
|
+
|
|
248
|
+
current = node
|
|
249
|
+
while current.is_a?(Prism::CallNode)
|
|
250
|
+
if %i[required optional].include?(current.name)
|
|
251
|
+
return !Rigor::Source::Literals.symbol(current.arguments&.arguments&.first).nil?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
current = current.receiver
|
|
255
|
+
end
|
|
256
|
+
false
|
|
257
|
+
end
|
|
258
|
+
private_class_method :required_or_optional_row?
|
|
259
|
+
|
|
260
|
+
# `config.validate_keys = true` and similar — dry-schema's own `config.<x> = <literal>` idiom.
|
|
261
|
+
# Declares no key, so it's safe to ignore rather than treat as "unrecognised" (over-declining a
|
|
262
|
+
# perfectly common line would silently drop valid diagnostics, not risk a false positive, but
|
|
263
|
+
# there's no reason to pay that cost).
|
|
264
|
+
def inert_config_assignment?(node)
|
|
265
|
+
node.is_a?(Prism::CallNode) && node.name.end_with?("=") &&
|
|
266
|
+
node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :config && node.receiver.receiver.nil?
|
|
267
|
+
end
|
|
268
|
+
private_class_method :inert_config_assignment?
|
|
269
|
+
|
|
270
|
+
def rule_call?(node)
|
|
271
|
+
node.is_a?(Prism::CallNode) && node.name == :rule && node.receiver.nil? && node.block.is_a?(Prism::BlockNode)
|
|
272
|
+
end
|
|
273
|
+
private_class_method :rule_call?
|
|
274
|
+
|
|
275
|
+
def check_rule_call(rule_node, contract_fqn, known_keys, issues)
|
|
276
|
+
symbols = rule_arguments_or_nil(rule_node)
|
|
277
|
+
return if symbols.nil?
|
|
278
|
+
|
|
279
|
+
symbols.each do |arg|
|
|
280
|
+
key = arg.unescaped.to_sym
|
|
281
|
+
next if known_keys.include?(key)
|
|
282
|
+
|
|
283
|
+
issues << { node: arg, key: key, contract_fqn: contract_fqn, known_keys: known_keys }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
private_class_method :check_rule_call
|
|
287
|
+
|
|
288
|
+
# Every positional argument of `rule(...)`, as `Prism::SymbolNode`s, or nil when there are none,
|
|
289
|
+
# or ANY of them isn't a literal Symbol (a splat, a String, a local variable, or a Hash — the
|
|
290
|
+
# `rule(user: [:name])` nested-key form). One non-Symbol argument makes the WHOLE call
|
|
291
|
+
# unresolvable, not just that argument.
|
|
292
|
+
def rule_arguments_or_nil(rule_node)
|
|
293
|
+
args = rule_node.arguments&.arguments
|
|
294
|
+
return nil if args.nil? || args.empty?
|
|
295
|
+
return nil unless args.all?(Prism::SymbolNode)
|
|
296
|
+
|
|
297
|
+
args
|
|
298
|
+
end
|
|
299
|
+
private_class_method :rule_arguments_or_nil
|
|
300
|
+
|
|
44
301
|
def collect_contracts(node, qualified_prefix)
|
|
45
302
|
return [] if node.nil?
|
|
46
303
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class DryValidation < Rigor::Plugin::Base
|
|
8
|
+
# Issue #137 slices 2/3 — recognises the `<Const>.new.call(...).to_h` call chain and builds the
|
|
9
|
+
# per-contract `HashShape` from the entry {ContractScanner.scan_schema_blocks} collected.
|
|
10
|
+
module ParamsShape
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# The Contract constant `to_h_node`'s receiver chain, or nil when the chain isn't the recognised
|
|
14
|
+
# `<Const>.new.call(...).to_h` form.
|
|
15
|
+
#
|
|
16
|
+
# Mirrors rigor-dry-schema's `ResultShape.schema_name` with one extra hop: a schema's `.call` is a
|
|
17
|
+
# bare class method on the schema constant itself, but a Contract's `.call` is an INSTANCE method
|
|
18
|
+
# (`NewUserContract.new.call(input)`), so the chain has to walk through `.new` first. Floor,
|
|
19
|
+
# matching dry-schema's own posture: the contract must be named by a constant as written at the
|
|
20
|
+
# call site. Reached through a local (`c = NewUserContract.new; c.call(x).to_h`) or a relative
|
|
21
|
+
# constant path, the chain contributes nothing and `to_h` types per the RBS overlay's generic
|
|
22
|
+
# `Hash[Symbol, untyped]` as it did before.
|
|
23
|
+
def contract_name(to_h_node)
|
|
24
|
+
return nil unless to_h_node.is_a?(Prism::CallNode) && to_h_node.name == :to_h
|
|
25
|
+
return nil unless to_h_node.arguments.nil? && to_h_node.block.nil?
|
|
26
|
+
|
|
27
|
+
call_node = to_h_node.receiver
|
|
28
|
+
return nil unless call_node.is_a?(Prism::CallNode) && call_node.name == :call
|
|
29
|
+
|
|
30
|
+
new_node = call_node.receiver
|
|
31
|
+
return nil unless new_node.is_a?(Prism::CallNode) && new_node.name == :new
|
|
32
|
+
return nil unless new_node.arguments.nil? && new_node.block.nil?
|
|
33
|
+
|
|
34
|
+
constant_name(new_node.receiver)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Delegates the entry -> HashShape build to rigor-dry-schema's own
|
|
38
|
+
# {DrySchema::ResultShape.build} — the identical algorithm the entry was collected with (via
|
|
39
|
+
# {ContractScanner.scan_schema_blocks}, which itself delegates to
|
|
40
|
+
# {DrySchema::SchemaScanner.collect_schema_shape}). Only ever called once the caller has already
|
|
41
|
+
# confirmed rigor-dry-schema is loaded.
|
|
42
|
+
def build(entry)
|
|
43
|
+
DrySchema::ResultShape.build(entry)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders `Foo` / `Foo::Bar` / `::Foo::Bar` as the `::`-joined String {ContractScanner}'s table is
|
|
47
|
+
# keyed by. Duplicated from rigor-dry-schema's identical helper rather than shared — five lines,
|
|
48
|
+
# no dependency on anything ELSE in that plugin, and reusing it would need a `registered_for`
|
|
49
|
+
# guard here too even though this leaf never touches dry-schema's own type/alias machinery.
|
|
50
|
+
def constant_name(node)
|
|
51
|
+
case node
|
|
52
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
53
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def constant_path_name(node)
|
|
58
|
+
parent = node.parent
|
|
59
|
+
name = node.name&.to_s
|
|
60
|
+
return nil if name.nil?
|
|
61
|
+
return name if parent.nil? # `::Foo` — the table keys are unrooted, so drop the leading `::`
|
|
62
|
+
|
|
63
|
+
prefix = constant_name(parent)
|
|
64
|
+
prefix.nil? ? nil : "#{prefix}::#{name}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require "rigor/plugin"
|
|
6
6
|
|
|
7
7
|
require_relative "dry_validation/contract_scanner"
|
|
8
|
+
require_relative "dry_validation/params_shape"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
10
11
|
module Plugin
|
|
@@ -21,13 +22,21 @@ module Rigor
|
|
|
21
22
|
# Result) and `Dry::Validation::Result#{success?, failure?, to_h}`. The manifest's
|
|
22
23
|
# `signature_paths: ["sig"]` auto-contributes the overlay (ADR-25) — no project-side wiring needed.
|
|
23
24
|
#
|
|
24
|
-
#
|
|
25
|
+
# Landed since (issue #137):
|
|
25
26
|
#
|
|
26
|
-
# -
|
|
27
|
-
#
|
|
28
|
-
# `
|
|
27
|
+
# - **Slice 2** — when `rigor-dry-schema` is loaded, a Contract's `params { ... }` block (the SAME
|
|
28
|
+
# dry-schema DSL a top-level `Dry::Schema.X { ... }` body uses) refines
|
|
29
|
+
# `NewUserContract.new.call(input).to_h` from the RBS overlay's generic `Hash[Symbol, untyped]` to
|
|
30
|
+
# the schema-typed `HashShape`. See {ContractScanner.scan_schema_blocks} + {ParamsShape}.
|
|
31
|
+
# - **Slice 3** — `json { ... }` adapter parity with `params { ... }`. Same mechanism, same fact.
|
|
32
|
+
# - **`dry-validation.rule-key-mismatch`** — a `rule(:key)` referencing a key absent from the
|
|
33
|
+
# Contract's own `params`/`json` block. Hard-gated: see {ContractScanner.rule_key_issues}'s doc for
|
|
34
|
+
# the two all-or-nothing checks that keep it silent on anything not statically resolvable.
|
|
29
35
|
#
|
|
30
|
-
#
|
|
36
|
+
# Without `rigor-dry-schema` loaded, `result.to_h` still types per the RBS overlay's generic
|
|
37
|
+
# `Hash[Symbol, untyped]` and `rule-key-mismatch` never fires — this plugin ships no required/optional
|
|
38
|
+
# walker of its own; it delegates to dry-schema's (see {ContractScanner}'s module doc for why that is
|
|
39
|
+
# a deliberate hard precondition, not a degrade-gracefully fallback).
|
|
31
40
|
#
|
|
32
41
|
# No ADR-3 amendment is needed for the validation surface itself; `Dry::Validation::Result` is a
|
|
33
42
|
# generic class, not a sum type (the `success?` / `failure?` predicates narrow via existing bool flow
|
|
@@ -36,31 +45,98 @@ module Rigor
|
|
|
36
45
|
manifest(
|
|
37
46
|
id: "dry-validation",
|
|
38
47
|
version: "0.1.0",
|
|
39
|
-
description: "Recognises `class T < Dry::Validation::Contract` subclasses
|
|
40
|
-
"
|
|
41
|
-
|
|
48
|
+
description: "Recognises `class T < Dry::Validation::Contract` subclasses, publishes the " \
|
|
49
|
+
"contract FQN set, and (with rigor-dry-schema loaded) refines each contract's " \
|
|
50
|
+
"`result.to_h` to its `params`/`json` schema shape.",
|
|
51
|
+
produces: %i[dry_validation_contracts dry_validation_params],
|
|
52
|
+
consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }],
|
|
42
53
|
# Auto-contribute the bundled RBS overlay (Contract#call -> Result, Result#success?/#to_h/...)
|
|
43
54
|
# per ADR-25, so no project-side signature_paths wiring is needed.
|
|
44
55
|
signature_paths: ["sig"]
|
|
45
56
|
)
|
|
46
57
|
|
|
58
|
+
# ADR-37 dynamic return — refines `NewUserContract.new.call(input).to_h` to the per-contract
|
|
59
|
+
# `HashShape` when {#result_shape_for} finds one. Gated on the method name alone (mirrors
|
|
60
|
+
# rigor-dry-schema's own `.to_h` rule): the RBS overlay types `Contract#call`'s return as the
|
|
61
|
+
# generic `Result`, but that alone can't select a PER-CONTRACT shape, so the block does its own
|
|
62
|
+
# syntactic chain match via {ParamsShape.contract_name} before touching the table. When
|
|
63
|
+
# rigor-dry-schema isn't loaded, or the chain isn't a Contract call, this returns nil and the RBS
|
|
64
|
+
# overlay's generic `Hash[Symbol, untyped]` stands.
|
|
65
|
+
dynamic_return methods: [:to_h] do |call_node, _scope|
|
|
66
|
+
result_shape_for(call_node)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ADR-37 slice 1c two-pass — the "collect" half of `dry-validation.rule-key-mismatch`. Reads the
|
|
70
|
+
# SAME `:dry_type_aliases` fact `#prepare` does (a plain `services.fact_store.read`, not
|
|
71
|
+
# `#read_fact`'s memoised wrapper — `services` at collect time is the run-wide instance, so an
|
|
72
|
+
# in-block memo isn't needed here the way it is across many per-call `dynamic_return` invocations).
|
|
73
|
+
node_file_context do |root, _scope|
|
|
74
|
+
type_aliases = services.fact_store.read(plugin_id: "dry-types", name: :dry_type_aliases) || {}
|
|
75
|
+
ContractScanner.rule_key_issues(root, type_aliases)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fires once per file (see rigor-dry-schema's identical `Prism::ProgramNode` rule for why).
|
|
79
|
+
node_rule Prism::ProgramNode do |_node, _scope, path, issues|
|
|
80
|
+
next [] if issues.nil? || issues.empty?
|
|
81
|
+
|
|
82
|
+
issues.map do |issue|
|
|
83
|
+
hint = Rigor::Plugin::Base.suggest(issue[:key], issue[:known_keys])
|
|
84
|
+
suffix = hint ? " (did you mean `:#{hint}`?)" : ""
|
|
85
|
+
diagnostic(
|
|
86
|
+
issue[:node],
|
|
87
|
+
path: path,
|
|
88
|
+
message: "rule(:#{issue[:key]}) references a key not declared by " \
|
|
89
|
+
"`#{issue[:contract_fqn]}`'s params/json schema#{suffix}",
|
|
90
|
+
severity: :error,
|
|
91
|
+
rule: "dry-validation.rule-key-mismatch"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
47
96
|
def prepare(services)
|
|
48
97
|
contracts = ContractScanner.scan(paths: scannable_paths(services))
|
|
49
|
-
|
|
98
|
+
unless contracts.empty?
|
|
99
|
+
services.fact_store.publish(
|
|
100
|
+
plugin_id: manifest.id,
|
|
101
|
+
name: :dry_validation_contracts,
|
|
102
|
+
value: contracts
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
type_aliases = services.fact_store.read(plugin_id: "dry-types", name: :dry_type_aliases) || {}
|
|
107
|
+
params_table = ContractScanner.scan_schema_blocks(paths: scannable_paths(services), type_aliases: type_aliases)
|
|
108
|
+
return if params_table.empty?
|
|
50
109
|
|
|
51
110
|
services.fact_store.publish(
|
|
52
111
|
plugin_id: manifest.id,
|
|
53
|
-
name: :
|
|
54
|
-
value:
|
|
112
|
+
name: :dry_validation_params,
|
|
113
|
+
value: params_table
|
|
55
114
|
)
|
|
56
115
|
end
|
|
57
116
|
|
|
58
117
|
def init(_services)
|
|
59
118
|
@scannable_paths = nil
|
|
119
|
+
@params_shapes = {}
|
|
60
120
|
end
|
|
61
121
|
|
|
62
122
|
private
|
|
63
123
|
|
|
124
|
+
# The HashShape for a `<Const>.new.call(...).to_h` chain, or nil when the chain isn't that shape,
|
|
125
|
+
# names no contract in the table, or the contract's `params`/`json` shape declares no key.
|
|
126
|
+
# `params` wins over `json` when (unusually) a Contract declares both — mirrors dry-schema's own
|
|
127
|
+
# per-name memoisation, including nil, for the same reason: the miss is the common case.
|
|
128
|
+
def result_shape_for(call_node)
|
|
129
|
+
name = ParamsShape.contract_name(call_node)
|
|
130
|
+
return nil if name.nil?
|
|
131
|
+
|
|
132
|
+
shapes = (@params_shapes ||= {})
|
|
133
|
+
return shapes[name] if shapes.key?(name)
|
|
134
|
+
|
|
135
|
+
entry = read_fact(plugin_id: manifest.id, name: :dry_validation_params)&.[](name)
|
|
136
|
+
row = entry && (entry[:params] || entry[:json])
|
|
137
|
+
shapes[name] = row.nil? ? nil : ParamsShape.build(row)
|
|
138
|
+
end
|
|
139
|
+
|
|
64
140
|
def scannable_paths(services)
|
|
65
141
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
66
142
|
if File.directory?(entry)
|
|
@@ -60,9 +60,11 @@ module Rigor
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Return value contract:
|
|
63
|
-
# - `String` (non-empty)
|
|
64
|
-
# - `nil`
|
|
65
|
-
# - `[:error, message_string]`
|
|
63
|
+
# - `String` (non-empty) → successful synthesis
|
|
64
|
+
# - `nil` → no contribution
|
|
65
|
+
# - `[:error, message_string]` → parse failed, surface info diagnostic per ADR-32 WD6
|
|
66
|
+
# - `[:ok, source, [message, …]]` → synthesis succeeded, but an annotation was parsed and NOT
|
|
67
|
+
# honoured; surface info diagnostics per ADR-32 WD12
|
|
66
68
|
def call(source_file_path)
|
|
67
69
|
return nil unless RBS_INLINE_AVAILABLE
|
|
68
70
|
return nil unless File.file?(source_file_path)
|
|
@@ -83,7 +85,8 @@ module Rigor
|
|
|
83
85
|
rendered = ::RBS::Inline::Writer.write(uses, decls, rbs_decls)
|
|
84
86
|
return nil if rendered.nil? || rendered.strip.empty?
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
notices = unhonoured_annotations(result)
|
|
89
|
+
notices.empty? ? rendered : [:ok, rendered, notices]
|
|
87
90
|
rescue ::StandardError => e
|
|
88
91
|
# WD6 fail-soft — surface a structured error tuple so the engine's `Environment.for_project` can
|
|
89
92
|
# emit a `source-rbs-synthesis-failed` info diagnostic naming the file + the upstream error
|
|
@@ -113,6 +116,33 @@ module Rigor
|
|
|
113
116
|
.any? { |parsed| parsed.each_annotation.any? }
|
|
114
117
|
end
|
|
115
118
|
|
|
119
|
+
# ADR-32 WD12 — annotations upstream's parser ACCEPTS and its writer then contributes nothing from.
|
|
120
|
+
# These are invisible without a report: synthesis succeeds, and the annotation comment is even echoed
|
|
121
|
+
# into the generated RBS, so the omission shows up neither in the output nor at runtime.
|
|
122
|
+
#
|
|
123
|
+
# The one case today is `module-self`, where the two inline-RBS dialects disagree on spelling. rbs's
|
|
124
|
+
# own `docs/inline.md` documents `# @rbs module-self: Foo`; the rbs-inline gem's grammar is
|
|
125
|
+
# `# @rbs module-self Foo`, without the colon. Handed the colon form the gem still builds a
|
|
126
|
+
# `ModuleSelf` annotation but extracts no types from it, so an empty `self_types` on a parsed
|
|
127
|
+
# annotation is a precise signature for "the author asked for a constraint we did not apply".
|
|
128
|
+
# Measured both ways in `docs/notes/20260730-inline-rbs-parser-grammar-diff.md`.
|
|
129
|
+
#
|
|
130
|
+
# Deliberately narrow. A construct the gem's parser REJECTS already routes through WD6's error path,
|
|
131
|
+
# and one it never recognised at all is upstream's grammar to define (WD3) — guessing at those would
|
|
132
|
+
# make this a lint on comment prose, which is exactly the false-positive cost ADR-5 ranks first.
|
|
133
|
+
def unhonoured_annotations(prism_result)
|
|
134
|
+
::RBS::Inline::AnnotationParser.parse(prism_result.comments).flat_map do |parsed|
|
|
135
|
+
parsed.each_annotation.filter_map do |annotation|
|
|
136
|
+
next unless annotation.is_a?(::RBS::Inline::AST::Annotations::ModuleSelf)
|
|
137
|
+
next unless annotation.self_types.empty?
|
|
138
|
+
|
|
139
|
+
"`@rbs module-self` contributed no self-type constraint. Rigor reads the " \
|
|
140
|
+
"`# @rbs module-self Foo` spelling; `# @rbs module-self: Foo` (the spelling in rbs's own " \
|
|
141
|
+
"inline documentation) is not honoured here."
|
|
142
|
+
end
|
|
143
|
+
end.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
116
146
|
# Rewrite every RDoc directive comment to its spaced spelling (`#:nodoc:` -> `# :nodoc:`) so
|
|
117
147
|
# upstream's annotation grammar never sees it, and re-parse. Two reasons this happens here rather
|
|
118
148
|
# than in {#annotated?}: the directive must not gate a file in, AND it must not reach the synthesis,
|