rigortype 0.1.11 → 0.1.13

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c5853d88c57eb0ded87f9fbf801e3cfbbc7672be2a16bcc3171bd7e3f33122c
4
- data.tar.gz: d3f3b9d936dd4aab4a10c93b7879b34bee26aa1313007619baadb1381b87310c
3
+ metadata.gz: 6f7d66cf2c7a6c883fbbab59a805b1d1bca62867022e42d9b68d0b788525e831
4
+ data.tar.gz: c327399cf8c239da46f383de404cd15843d8ce220b5538f1905d72f4e082a59f
5
5
  SHA512:
6
- metadata.gz: bebba3258c508b893a7ca22e98b17838bcc7267399882956a4ced9c214e87947754f5a15ecf80029cf601eed58c93fc53ffcb50636002df9f75c00d498a0585b
7
- data.tar.gz: 4ac6679d930144ffc5a675a9189ed7ce20d500c5ae9d61820b44fcfbf3e02149b615b3b11a44fa48760c17a75b57ec6d6b5ee3e4a80a4b9139514c742c9c612a
6
+ metadata.gz: a69935a6c878eba22f3050041d044855b9f98eefa8dc3ab55bd33ca2ecce12efc5abffb2dd0fbc57edfd7736fe5ee4eb985694e4fbcddbc63f2e231bf48483a4
7
+ data.tar.gz: 58839858c8a5adcf8db74ef9d8c5ed2999c4459854a85c330b107ce3a933d08cb024cacc6c1303c638ab12ae1f5bc6559c14922ac13d17f41a5609631220fc28
@@ -57,6 +57,7 @@ module Rigor
57
57
  # system; new rules MUST register here so user configuration
58
58
  # can refer to them.
59
59
  RULE_UNDEFINED_METHOD = "call.undefined-method"
60
+ RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
60
61
  RULE_WRONG_ARITY = "call.wrong-arity"
61
62
  RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
62
63
  RULE_NIL_RECEIVER = "call.possible-nil-receiver"
@@ -72,6 +73,7 @@ module Rigor
72
73
 
73
74
  ALL_RULES = [
74
75
  RULE_UNDEFINED_METHOD,
76
+ RULE_UNRESOLVED_TOPLEVEL,
75
77
  RULE_WRONG_ARITY,
76
78
  RULE_ARGUMENT_TYPE,
77
79
  RULE_NIL_RECEIVER,
@@ -162,6 +164,7 @@ module Rigor
162
164
  def call_node_diagnostics(path, node, scope_index)
163
165
  [
164
166
  undefined_method_diagnostic(path, node, scope_index),
167
+ unresolved_toplevel_diagnostic(path, node, scope_index),
165
168
  wrong_arity_diagnostic(path, node, scope_index),
166
169
  argument_type_diagnostic(path, node, scope_index),
167
170
  nil_receiver_diagnostic(path, node, scope_index),
@@ -365,10 +368,14 @@ module Rigor
365
368
  return nil if open_receiver?(class_name, scope)
366
369
 
367
370
  # Slice 7 phase 12 — suppress when the user has
368
- # declared the method in source (instance `def`,
369
- # `def self.foo`, or recognised `define_method`).
371
+ # declared the method in source (`def` /
372
+ # `define_method`) OR in a `pre_eval:` monkey-patch
373
+ # file (ADR-17). Both paths are project-side method
374
+ # contributions the dispatcher already resolved; the
375
+ # rule must not surface a false `undefined-method`
376
+ # for them.
370
377
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
371
- return nil if scope.discovered_method?(class_name, call_node.name, kind)
378
+ return nil if source_declared_method?(scope, class_name, call_node.name, kind)
372
379
 
373
380
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
374
381
 
@@ -404,6 +411,92 @@ module Rigor
404
411
  scope.environment.rbs_module?(receiver_type.class_name)
405
412
  end
406
413
 
414
+ # Combined suppression probe for `undefined-method` /
415
+ # `unresolved-toplevel`. Returns true when the method is
416
+ # declared by any project-side contributor the dispatcher
417
+ # already resolves: an in-source `def` / `define_method`
418
+ # (`scope.discovered_method?`) OR an ADR-17 `pre_eval:`
419
+ # monkey-patch (`Environment#project_patched_methods`).
420
+ # Both paths sit at the same dispatcher precedence; the
421
+ # check must hold them together so neither rule fires a
422
+ # false positive.
423
+ def source_declared_method?(scope, class_name, method_name, kind)
424
+ return true if scope.discovered_method?(class_name, method_name, kind)
425
+
426
+ project_patched_method?(scope, class_name, method_name, kind)
427
+ end
428
+
429
+ # ADR-17 § "Inference contract" — consults
430
+ # `Environment#project_patched_methods` so a `def` declared
431
+ # in a `pre_eval:` file suppresses the diagnostic at the
432
+ # same dispatcher precedence the registry holds for type
433
+ # inference (between plugins and dependency-source).
434
+ # Returns false when the environment carries no registry
435
+ # (legacy path) or the lookup misses.
436
+ def project_patched_method?(scope, class_name, method_name, kind)
437
+ environment = scope.environment
438
+ registry = environment&.project_patched_methods
439
+ return false if registry.nil? || registry.empty?
440
+
441
+ !registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind).nil?
442
+ end
443
+
444
+ # ADR-34 — `call.unresolved-toplevel`. Fires on an
445
+ # implicit-self call (no explicit receiver) at toplevel
446
+ # scope (`scope.toplevel?`, i.e. outside any class /
447
+ # module body) whose name does not resolve against:
448
+ #
449
+ # 1. A same-file toplevel `def` via
450
+ # {Scope#top_level_def_for}.
451
+ # 2. The ADR-17 `ProjectPatchedMethods` registry under
452
+ # `(Object, name, :instance)` — projects declare
453
+ # their toplevel-injecting monkey-patches in
454
+ # `.rigor.yml`'s `pre_eval:` array as the canonical
455
+ # opt-out per ADR-34 WD2.
456
+ # 3. The standard `Kernel` / `Object` private-method
457
+ # surface (`puts`, `p`, `require`, `loop`, `raise`,
458
+ # …) drawn from the loaded RBS environment.
459
+ #
460
+ # The rule deliberately does NOT generalise to
461
+ # implicit-self calls inside `def` / `class` / `module`
462
+ # bodies — ADR-24 WD3's lenient-on-unresolved default
463
+ # stays in force there. ADR-24 WD4's gated class-body
464
+ # diagnostic is a separate decision this ADR does not
465
+ # open.
466
+ #
467
+ # Authored severity is `:warning`; the severity profile
468
+ # remaps it (`strict` → `:error`, `balanced` →
469
+ # `:warning`, `lenient` → `:off` / suppressed).
470
+ def unresolved_toplevel_diagnostic(path, call_node, scope_index)
471
+ return nil unless call_node.receiver.nil?
472
+
473
+ scope = scope_index[call_node]
474
+ return nil if scope.nil?
475
+ return nil unless scope.toplevel?
476
+
477
+ name = call_node.name
478
+ return nil if scope.top_level_def_for(name)
479
+ return nil if source_declared_method?(scope, "Object", name, :instance)
480
+ return nil if Rigor::Reflection.instance_method_definition("Object", name, scope: scope)
481
+
482
+ build_unresolved_toplevel_diagnostic(path, call_node)
483
+ end
484
+
485
+ def build_unresolved_toplevel_diagnostic(path, call_node)
486
+ location = call_node.message_loc || call_node.location
487
+ Diagnostic.new(
488
+ path: path,
489
+ line: location.start_line,
490
+ column: location.start_column + 1,
491
+ message: "unresolved toplevel call to `#{call_node.name}`. " \
492
+ "If a project file defines `#{call_node.name}` via a toplevel " \
493
+ "`def` or a monkey-patch on Object/Kernel, list that file in " \
494
+ "`.rigor.yml`'s `pre_eval:` (ADR-17) so the analyzer sees it.",
495
+ severity: :warning,
496
+ rule: RULE_UNRESOLVED_TOPLEVEL
497
+ )
498
+ end
499
+
407
500
  # Returns a qualified class name for the in-scope check.
408
501
  # Nominal / Singleton carry a single-class identity
409
502
  # directly. Constant projects to its value's class.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Detects when a `.rb` file is actually an ERB template — a Rails
6
+ # generator `templates/foo.rb` shape that uses `<%= ... %>` to
7
+ # interpolate identifiers. Prism rejects the template as invalid
8
+ # Ruby and the analyzer emits one parse-error diagnostic per scrap
9
+ # the parser tripped over (≈ 20 on Redmine's
10
+ # `lib/generators/redmine_plugin_model/templates/migration.rb`),
11
+ # all of them noise from the user's perspective. The runner / worker
12
+ # consults this detector before turning parse errors into
13
+ # diagnostics; an ERB-shaped source is silently skipped.
14
+ #
15
+ # Detection is byte-level on the raw source: any occurrence of the
16
+ # `%>` closing marker proves the file is an ERB template. `%>`
17
+ # cannot appear in valid Ruby — `%` is a binary operator that
18
+ # requires an operand on its right side, never `>`. The opening
19
+ # `<%` is ambiguous in principle (`x<%y` parses as `x < %y`, a
20
+ # comparison against a `%`-literal) but a real Ruby file with that
21
+ # shape would still not contain the closing `%>`.
22
+ module ErbTemplateDetector
23
+ ERB_CLOSING_MARKER = /%>/
24
+
25
+ module_function
26
+
27
+ # @param parse_result [Prism::ParseResult]
28
+ # @return [Boolean] true when the parsed source looks like an
29
+ # ERB template (parse errors expected; analysis should skip).
30
+ def template?(parse_result)
31
+ source = parse_result.source.source
32
+ return false unless source.is_a?(String) && !source.empty?
33
+
34
+ ERB_CLOSING_MARKER.match?(source)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -20,6 +20,7 @@ require_relative "buffer_binding"
20
20
  require_relative "check_rules"
21
21
  require_relative "dependency_source_inference"
22
22
  require_relative "diagnostic"
23
+ require_relative "erb_template_detector"
23
24
  require_relative "project_scan"
24
25
  require_relative "result"
25
26
  require_relative "run_stats"
@@ -1457,7 +1458,11 @@ module Rigor
1457
1458
 
1458
1459
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
1459
1460
  parse_result = parse_source(path)
1460
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
1461
+ unless parse_result.errors.empty?
1462
+ return [] if ErbTemplateDetector.template?(parse_result)
1463
+
1464
+ return parse_diagnostics(path, parse_result)
1465
+ end
1461
1466
 
1462
1467
  scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1463
1468
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
@@ -15,6 +15,7 @@ require_relative "../inference/method_dispatcher/file_folding"
15
15
  require_relative "check_rules"
16
16
  require_relative "dependency_source_inference"
17
17
  require_relative "diagnostic"
18
+ require_relative "erb_template_detector"
18
19
 
19
20
  module Rigor
20
21
  module Analysis
@@ -158,7 +159,11 @@ module Rigor
158
159
  # is a per-run aggregate concern handled by the caller.
159
160
  def analyze(path)
160
161
  parse_result = parse_source(path)
161
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
162
+ unless parse_result.errors.empty?
163
+ return [] if ErbTemplateDetector.template?(parse_result)
164
+
165
+ return parse_diagnostics(path, parse_result)
166
+ end
162
167
 
163
168
  scope = Scope.empty(environment: @environment, source_path: path)
164
169
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../plugin"
7
+ require_relative "../plugin/loader"
8
+ require_relative "../plugin/services"
9
+ require_relative "../reflection"
10
+ require_relative "../type/combinator"
11
+ require_relative "plugins_renderer"
12
+
13
+ module Rigor
14
+ class CLI
15
+ # `rigor plugins` — reports the activation status of every
16
+ # plugin entry in `.rigor.yml` so users (and the
17
+ # `rigor-project-init` SKILL, CI gates, `rigor init`) can
18
+ # verify their plugin configuration is actually doing what
19
+ # they think.
20
+ #
21
+ # The command is read-only and idempotent: it loads the
22
+ # project's `.rigor.yml` (same discovery as `rigor check`),
23
+ # runs `Plugin::Loader.load` to attempt instantiation, then
24
+ # prints a table of:
25
+ #
26
+ # - load status (`loaded` / `load-error` with reason);
27
+ # - resolved manifest id, version, description;
28
+ # - `signature_paths:` (absolute paths + per-dir `.rbs` count);
29
+ # - every manifest-declared extension surface
30
+ # (`open_receivers:` / `owns_receivers:` / `produces:` /
31
+ # `consumes:` / `block_as_methods:` / `heredoc_templates:` /
32
+ # `trait_registries:` / `external_files:` /
33
+ # `type_node_resolvers:` / `hkt_registrations:` /
34
+ # `hkt_definitions:` / `protocol_contracts:` /
35
+ # `source_rbs_synthesizer:`).
36
+ #
37
+ # Output formats: `text` (default, human-readable table) and
38
+ # `json` (for tooling — SKILLs, CI gates, editor integrations).
39
+ #
40
+ # Exit codes:
41
+ # - `0` — every configured plugin loaded.
42
+ # - `1` — at least one plugin failed to load AND `--strict`
43
+ # was passed. Without `--strict` the command always exits 0;
44
+ # load errors are reported but not treated as a gate failure
45
+ # (matching `rigor triage`'s advisory shape).
46
+ #
47
+ # Future expansion (not in this slice):
48
+ # - Per-plugin diagnostic counts (would require running the
49
+ # full analysis pipeline; out of scope for an inspection
50
+ # command).
51
+ # - Verification that `signature_paths` actually merged into
52
+ # the RBS environment without conflict (requires constructing
53
+ # the Environment, which is heavier than the loader-only
54
+ # pass this slice does).
55
+ class PluginsCommand
56
+ USAGE = "Usage: rigor plugins [options]"
57
+
58
+ def initialize(argv:, out: $stdout, err: $stderr)
59
+ @argv = argv
60
+ @out = out
61
+ @err = err
62
+ end
63
+
64
+ # @return [Integer] CLI exit status.
65
+ def run
66
+ options = parse_options
67
+ config_path = options.fetch(:config) || Configuration.discover
68
+ configuration = Configuration.load(options.fetch(:config))
69
+ rows = build_rows(configuration)
70
+
71
+ renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path)
72
+ @out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
73
+
74
+ any_load_errors = rows.any? { |row| row.fetch(:status) == :load_error }
75
+ return 1 if any_load_errors && options.fetch(:strict)
76
+
77
+ 0
78
+ end
79
+
80
+ private
81
+
82
+ def parse_options
83
+ options = { config: nil, format: "text", strict: false }
84
+ OptionParser.new do |opts|
85
+ opts.banner = USAGE
86
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
87
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
88
+ opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
89
+ end.parse!(@argv)
90
+ validate!(options)
91
+ options
92
+ end
93
+
94
+ def validate!(options)
95
+ return if %w[text json].include?(options.fetch(:format))
96
+
97
+ raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
98
+ end
99
+
100
+ # Runs the plugin loader against the project configuration
101
+ # and returns a row per declared plugin entry. Each row is
102
+ # a plain Hash with the fields enumerated in the class
103
+ # docstring so the renderer (text + JSON) reads from a
104
+ # single shape.
105
+ #
106
+ # The loader catches require / init failures and surfaces
107
+ # them through `registry.load_errors`; we merge those back
108
+ # into the per-entry rows by matching on `plugin_ref`.
109
+ def build_rows(configuration)
110
+ services = build_services(configuration)
111
+ registry = Plugin::Loader.load(configuration: configuration, services: services)
112
+
113
+ rows = configuration.plugins.map { |entry| row_for_entry(entry, registry) }
114
+ # Surface registry-level errors that did not bind to an
115
+ # entry (e.g. dependency-cycle errors that name multiple
116
+ # plugins). The renderer treats these as "orphan" errors.
117
+ orphan_errors = orphan_load_errors(registry, rows)
118
+ rows + orphan_errors
119
+ end
120
+
121
+ def build_services(configuration)
122
+ Plugin::Services.new(
123
+ reflection: Reflection,
124
+ type: Type::Combinator,
125
+ configuration: configuration,
126
+ cache_store: nil
127
+ )
128
+ end
129
+
130
+ def row_for_entry(entry, registry)
131
+ gem_name = entry_gem_name(entry)
132
+ config = entry_config(entry)
133
+ plugin = registry.plugins.find do |p|
134
+ # The loader has already matched the entry to a plugin
135
+ # class; we can identify it by either the gem name (when
136
+ # the entry was a String) or the explicit id (when the
137
+ # entry was a Hash with `id:`).
138
+ plugin_matches_entry?(p, gem_name, entry_id(entry))
139
+ end
140
+
141
+ if plugin
142
+ loaded_row(plugin, gem_name, config)
143
+ else
144
+ # Find the load error whose plugin_ref names this entry
145
+ # (the ref is set by Loader to the gem name on require
146
+ # failures and to the manifest id on later failures).
147
+ error = registry.load_errors.find do |e|
148
+ ref = e.plugin_ref.to_s
149
+ ref == gem_name || ref == entry_id(entry).to_s
150
+ end
151
+ load_error_row(gem_name, entry_id(entry), config, error)
152
+ end
153
+ end
154
+
155
+ def plugin_matches_entry?(plugin, gem_name, entry_id)
156
+ return true if entry_id && plugin.manifest.id == entry_id
157
+
158
+ # A bare gem entry conventionally has manifest.id equal to
159
+ # the gem name with the `rigor-` prefix stripped.
160
+ derived_id = gem_name.delete_prefix("rigor-")
161
+ [derived_id, gem_name].include?(plugin.manifest.id)
162
+ end
163
+
164
+ def loaded_row(plugin, gem_name, config)
165
+ manifest = plugin.manifest
166
+ identity_fields(gem_name, manifest, config)
167
+ .merge(extension_fields(plugin, manifest))
168
+ .merge(load_error: nil)
169
+ end
170
+
171
+ def identity_fields(gem_name, manifest, config)
172
+ {
173
+ gem: gem_name,
174
+ status: :loaded,
175
+ id: manifest.id,
176
+ version: manifest.version,
177
+ description: manifest.description,
178
+ config: config
179
+ }
180
+ end
181
+
182
+ def extension_fields(plugin, manifest)
183
+ {
184
+ signature_paths: signature_path_rows(plugin),
185
+ open_receivers: manifest.open_receivers.dup,
186
+ owns_receivers: manifest.owns_receivers.dup,
187
+ produces: manifest.produces.map(&:to_s),
188
+ consumes: manifest.consumes.map { |c| "#{c.plugin_id}/#{c.name}#{'?' if c.optional}" },
189
+ source_rbs_synthesizer: !manifest.source_rbs_synthesizer.nil?
190
+ }.merge(extension_count_fields(manifest))
191
+ end
192
+
193
+ def extension_count_fields(manifest)
194
+ {
195
+ block_as_methods: manifest.block_as_methods.size,
196
+ heredoc_templates: manifest.heredoc_templates.size,
197
+ trait_registries: manifest.trait_registries.size,
198
+ external_files: manifest.external_files.size,
199
+ type_node_resolvers: manifest.type_node_resolvers.size,
200
+ hkt_registrations: manifest.hkt_registrations.size,
201
+ hkt_definitions: manifest.hkt_definitions.size,
202
+ protocol_contracts: manifest.protocol_contracts.size
203
+ }
204
+ end
205
+
206
+ def load_error_row(gem_name, entry_id, config, error)
207
+ {
208
+ gem: gem_name,
209
+ status: :load_error,
210
+ id: entry_id,
211
+ version: nil,
212
+ description: nil,
213
+ config: config,
214
+ signature_paths: [],
215
+ open_receivers: [],
216
+ owns_receivers: [],
217
+ produces: [],
218
+ consumes: [],
219
+ block_as_methods: 0,
220
+ heredoc_templates: 0,
221
+ trait_registries: 0,
222
+ external_files: 0,
223
+ type_node_resolvers: 0,
224
+ hkt_registrations: 0,
225
+ hkt_definitions: 0,
226
+ protocol_contracts: 0,
227
+ source_rbs_synthesizer: false,
228
+ load_error: error&.message || "plugin did not register or could not be matched to a registered class"
229
+ }
230
+ end
231
+
232
+ # For each `signature_paths:` directory the plugin resolves
233
+ # against its gem root, report the absolute path and a
234
+ # quick `.rbs`-file count. The count is a sanity signal:
235
+ # an empty bundle directory loads silently today but
236
+ # contributes nothing.
237
+ def signature_path_rows(plugin)
238
+ plugin.signature_paths.map do |abs|
239
+ {
240
+ path: abs,
241
+ exists: File.directory?(abs),
242
+ rbs_files: rbs_file_count(abs)
243
+ }
244
+ end
245
+ end
246
+
247
+ def rbs_file_count(dir)
248
+ return 0 unless File.directory?(dir)
249
+
250
+ Dir.glob(File.join(dir, "**", "*.rbs")).size
251
+ end
252
+
253
+ def entry_gem_name(entry)
254
+ case entry
255
+ when String then entry
256
+ when Hash
257
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
258
+ (string_keyed["gem"] || string_keyed["id"]).to_s
259
+ else entry.to_s
260
+ end
261
+ end
262
+
263
+ def entry_id(entry)
264
+ case entry
265
+ when Hash
266
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
267
+ string_keyed["id"]
268
+ end
269
+ end
270
+
271
+ def entry_config(entry)
272
+ case entry
273
+ when Hash
274
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
275
+ string_keyed["config"] || {}
276
+ else {}
277
+ end
278
+ end
279
+
280
+ # Build orphan-error rows for load errors whose `plugin_ref`
281
+ # did not bind to any configured plugin entry (e.g. a
282
+ # dependency-cycle naming a plugin we already accounted for
283
+ # but reported alongside another). De-duplicates against
284
+ # errors we already attached.
285
+ def orphan_load_errors(registry, rows)
286
+ attached_refs = rows.compact.flat_map { |row| [row[:gem], row[:id]].compact }.to_set
287
+ unattached = registry.load_errors.reject { |error| attached_refs.include?(error.plugin_ref.to_s) }
288
+ unattached.map do |error|
289
+ {
290
+ gem: error.plugin_ref.to_s,
291
+ status: :load_error,
292
+ id: nil,
293
+ version: nil,
294
+ description: nil,
295
+ config: {},
296
+ signature_paths: [],
297
+ open_receivers: [], owns_receivers: [], produces: [], consumes: [],
298
+ block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
299
+ external_files: 0, type_node_resolvers: 0,
300
+ hkt_registrations: 0, hkt_definitions: 0,
301
+ protocol_contracts: 0, source_rbs_synthesizer: false,
302
+ load_error: error.message
303
+ }
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end