rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # Runtime CI-environment detection (ADR-51 WD7), modelled on
6
+ # `OndraM/ci-detector` (the library PHPStan uses). Reads the well-known
7
+ # environment variables a CI provider sets and returns the matching
8
+ # {Platform}, classifying it into a **tier** that decides how
9
+ # `rigor check` surfaces diagnostics there:
10
+ #
11
+ # :native_stdout — Rigor has a native format that renders purely from
12
+ # stdout, so it is auto-emitted on top of the human
13
+ # output (GitHub Actions → `github`, TeamCity →
14
+ # `teamcity`). These are the first-class platforms.
15
+ # :native_artifact — Rigor has a native format but it needs a CI-wired
16
+ # report artifact, not stdout (GitLab CI → `gitlab`).
17
+ # First-class, but Rigor only *hints* the format.
18
+ # :reviewdog — no native Rigor format; second-class, routed through
19
+ # reviewdog (`checkstyle`/`sarif`) or `junit`. Hint
20
+ # only.
21
+ #
22
+ # Detection is a pure function of the environment hash, so it is fully
23
+ # testable; the CLI passes `ENV`. `RIGOR_CI_DETECT=0` (or `false`/`no`)
24
+ # disables it globally — the seam the spec suite uses for determinism.
25
+ module CiDetector
26
+ Platform = Struct.new(:id, :name, :format, :tier, keyword_init: true) do
27
+ def native_stdout? = tier == :native_stdout
28
+ def native_artifact? = tier == :native_artifact
29
+ def reviewdog? = tier == :reviewdog
30
+ end
31
+
32
+ # The detection table, ordered most-specific first so the generic
33
+ # `CI=true` catch-all is last (a provider that also sets `CI` is still
34
+ # recognised by its own variable). `match` is `:truthy` (value in
35
+ # 1/true/yes/on), `:present` (variable set non-empty), or `:equals`.
36
+ PROVIDERS = [
37
+ { id: "github-actions", name: "GitHub Actions", format: "github", tier: :native_stdout,
38
+ var: "GITHUB_ACTIONS", match: :truthy },
39
+ { id: "gitlab", name: "GitLab CI", format: "gitlab", tier: :native_artifact,
40
+ var: "GITLAB_CI", match: :truthy },
41
+ { id: "teamcity", name: "TeamCity", format: "teamcity", tier: :native_stdout,
42
+ var: "TEAMCITY_VERSION", match: :present },
43
+ { id: "circleci", name: "CircleCI", format: nil, tier: :reviewdog,
44
+ var: "CIRCLECI", match: :truthy },
45
+ { id: "jenkins", name: "Jenkins", format: nil, tier: :reviewdog,
46
+ var: "JENKINS_URL", match: :present },
47
+ { id: "travis", name: "Travis CI", format: nil, tier: :reviewdog,
48
+ var: "TRAVIS", match: :truthy },
49
+ { id: "appveyor", name: "AppVeyor", format: nil, tier: :reviewdog,
50
+ var: "APPVEYOR", match: :truthy },
51
+ { id: "azure-pipelines", name: "Azure Pipelines", format: nil, tier: :reviewdog,
52
+ var: "TF_BUILD", match: :present },
53
+ { id: "bitbucket", name: "Bitbucket Pipelines", format: nil, tier: :reviewdog,
54
+ var: "BITBUCKET_BUILD_NUMBER", match: :present },
55
+ { id: "buildkite", name: "Buildkite", format: nil, tier: :reviewdog,
56
+ var: "BUILDKITE", match: :truthy },
57
+ { id: "drone", name: "Drone CI", format: nil, tier: :reviewdog,
58
+ var: "DRONE", match: :truthy },
59
+ { id: "semaphore", name: "Semaphore", format: nil, tier: :reviewdog,
60
+ var: "SEMAPHORE", match: :truthy },
61
+ { id: "codeship", name: "Codeship", format: nil, tier: :reviewdog,
62
+ var: "CI_NAME", match: :equals, value: "codeship" },
63
+ { id: "ci", name: "CI", format: nil, tier: :reviewdog,
64
+ var: "CI", match: :truthy }
65
+ ].freeze
66
+
67
+ module_function
68
+
69
+ # Returns the detected {Platform}, or nil when no CI is recognised or
70
+ # detection is disabled via `RIGOR_CI_DETECT`.
71
+ def detect(env = ENV)
72
+ return nil if disabled?(env)
73
+
74
+ row = PROVIDERS.find { |provider| matches?(env, provider) }
75
+ return nil if row.nil?
76
+
77
+ Platform.new(id: row[:id], name: row[:name], format: row[:format], tier: row[:tier])
78
+ end
79
+
80
+ def disabled?(env)
81
+ %w[0 false no off].include?(env["RIGOR_CI_DETECT"].to_s.strip.downcase)
82
+ end
83
+
84
+ def matches?(env, provider)
85
+ value = env[provider[:var]].to_s.strip
86
+ case provider[:match]
87
+ when :truthy then %w[1 true yes on].include?(value.downcase)
88
+ when :present then !value.empty?
89
+ when :equals then value.downcase == provider[:value]
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "../version"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # CI-native diagnostic output formats (ADR-51). Each renders an
10
+ # `Analysis::Result` to a string a CI platform consumes to surface
11
+ # diagnostics inline in a pull / merge request, rather than only in the
12
+ # job log. They read the same `Diagnostic` fields as `--format json`
13
+ # (path / line / column / severity / qualified rule / message) and add
14
+ # no new information — only a platform-native rendering of it.
15
+ #
16
+ # sarif — SARIF 2.1.0 (cross-platform; GitHub code-scanning, any
17
+ # other SARIF tool, and reviewdog `-f=sarif`)
18
+ # github — GitHub Actions workflow commands (`::error file=…,line=…::`)
19
+ # that the runner turns into inline PR annotations
20
+ # gitlab — GitLab Code Quality report JSON (the CodeClimate subset)
21
+ # that drives the merge-request Code Quality widget
22
+ # checkstyle — Checkstyle XML, the broad lint-interchange format that
23
+ # reviewdog (`-f=checkstyle`) and Jenkins/etc. consume
24
+ # junit — JUnit XML, the test-report format many CI systems render
25
+ #
26
+ # Severity maps once per format from Rigor's `:error` / `:warning` /
27
+ # `:info`; the qualified rule (`<source_family>.<rule>` or the bare rule
28
+ # for the builtin family) is the stable identifier, nil only for the
29
+ # ruleless producers (parse / internal errors), which each format
30
+ # degrades gracefully.
31
+ module DiagnosticFormats
32
+ FORMATS = %w[sarif github gitlab checkstyle junit teamcity].freeze
33
+
34
+ module_function
35
+
36
+ def supports?(format)
37
+ FORMATS.include?(format)
38
+ end
39
+
40
+ # Renders `result` in the named CI format. Callers gate on
41
+ # {.supports?} first; an unrecognised format returns nil.
42
+ def render(result, format)
43
+ case format
44
+ when "sarif" then Sarif.new(result).render
45
+ when "github" then GithubActions.new(result).render
46
+ when "gitlab" then GitlabCodeQuality.new(result).render
47
+ when "checkstyle" then Checkstyle.new(result).render
48
+ when "junit" then Junit.new(result).render
49
+ when "teamcity" then Teamcity.new(result).render
50
+ end
51
+ end
52
+
53
+ # XML attribute / text escaping shared by the Checkstyle and JUnit
54
+ # formatters. Covers the five predefined XML entities so a diagnostic
55
+ # message carrying `<`, `&`, or a quote can't break the document.
56
+ module XmlEscaping
57
+ ENTITIES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
58
+ '"' => "&quot;", "'" => "&apos;" }.freeze
59
+
60
+ def xml_escape(value)
61
+ value.to_s.gsub(/[&<>"']/, ENTITIES)
62
+ end
63
+ end
64
+
65
+ # SARIF 2.1.0 — the OASIS static-analysis interchange format. GitHub's
66
+ # `codeql-action/upload-sarif` ingests it to render findings on the PR
67
+ # diff and in the Security tab; it is the cross-platform anchor format.
68
+ class Sarif
69
+ SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
70
+ INFORMATION_URI = "https://github.com/rigortype/rigor"
71
+
72
+ # SARIF defines exactly three result levels; Rigor's `:info` is a
73
+ # `note` (the SARIF spelling for advisory findings).
74
+ LEVELS = { error: "error", warning: "warning", info: "note" }.freeze
75
+
76
+ def initialize(result)
77
+ @result = result
78
+ end
79
+
80
+ def render
81
+ JSON.pretty_generate(document)
82
+ end
83
+
84
+ private
85
+
86
+ def document
87
+ { "version" => "2.1.0", "$schema" => SCHEMA, "runs" => [run] }
88
+ end
89
+
90
+ def run
91
+ {
92
+ "tool" => { "driver" => driver },
93
+ "results" => @result.diagnostics.map { |diagnostic| result_for(diagnostic) }
94
+ }
95
+ end
96
+
97
+ def driver
98
+ {
99
+ "name" => "Rigor",
100
+ "informationUri" => INFORMATION_URI,
101
+ "version" => Rigor::VERSION,
102
+ "rules" => rules
103
+ }
104
+ end
105
+
106
+ # The distinct rule ids seen in this run, declared so consumers can
107
+ # cross-reference each result's `ruleId`. Id-only is a valid minimal
108
+ # SARIF rule object; richer per-rule metadata is a later enrichment.
109
+ def rules
110
+ @result.diagnostics.filter_map(&:qualified_rule).uniq.map { |id| { "id" => id } }
111
+ end
112
+
113
+ def result_for(diagnostic)
114
+ entry = {
115
+ "level" => LEVELS.fetch(diagnostic.severity, "warning"),
116
+ "message" => { "text" => diagnostic.message },
117
+ "locations" => [location_for(diagnostic)]
118
+ }
119
+ rule_id = diagnostic.qualified_rule
120
+ entry["ruleId"] = rule_id if rule_id
121
+ entry
122
+ end
123
+
124
+ # Rigor lines / columns are already 1-based, matching SARIF's
125
+ # 1-based `startLine` / `startColumn`. Paths are project-relative;
126
+ # SARIF URIs use forward slashes on every platform.
127
+ def location_for(diagnostic)
128
+ {
129
+ "physicalLocation" => {
130
+ "artifactLocation" => { "uri" => diagnostic.path.to_s.tr("\\", "/") },
131
+ "region" => { "startLine" => diagnostic.line, "startColumn" => diagnostic.column }
132
+ }
133
+ }
134
+ end
135
+ end
136
+
137
+ # GitHub Actions workflow commands — `::<level> file=…,line=…,col=…,
138
+ # title=…::<message>` lines the runner parses out of stdout and turns
139
+ # into inline PR annotations, with no separate upload step.
140
+ class GithubActions
141
+ # GitHub's annotation levels; Rigor's `:info` is a `notice`.
142
+ LEVELS = { error: "error", warning: "warning", info: "notice" }.freeze
143
+
144
+ def initialize(result)
145
+ @result = result
146
+ end
147
+
148
+ def render
149
+ @result.diagnostics.map { |diagnostic| line_for(diagnostic) }.join("\n")
150
+ end
151
+
152
+ private
153
+
154
+ def line_for(diagnostic)
155
+ level = LEVELS.fetch(diagnostic.severity, "warning")
156
+ props = ["file=#{escape_property(diagnostic.path)}",
157
+ "line=#{diagnostic.line}", "col=#{diagnostic.column}"]
158
+ rule_id = diagnostic.qualified_rule
159
+ props << "title=#{escape_property(rule_id)}" if rule_id
160
+ "::#{level} #{props.join(',')}::#{escape_data(diagnostic.message)}"
161
+ end
162
+
163
+ # GitHub's documented workflow-command escaping: `%` and the CR / LF
164
+ # that would otherwise terminate the command line, for message data.
165
+ def escape_data(value)
166
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
167
+ end
168
+
169
+ # Property values additionally escape the `,` (property separator)
170
+ # and `:` (command terminator) so a path or rule id can carry them.
171
+ def escape_property(value)
172
+ escape_data(value).gsub(",", "%2C").gsub(":", "%3A")
173
+ end
174
+ end
175
+
176
+ # GitLab Code Quality report — the CodeClimate-subset JSON array GitLab
177
+ # reads from a `codequality` CI artifact to populate the merge-request
178
+ # Code Quality widget.
179
+ class GitlabCodeQuality
180
+ # GitLab's severity vocabulary (a CodeClimate subset). Rigor maps
181
+ # error → major, warning → minor, info → info; `critical` / `blocker`
182
+ # are left for a louder future tier.
183
+ SEVERITIES = { error: "major", warning: "minor", info: "info" }.freeze
184
+
185
+ def initialize(result)
186
+ @result = result
187
+ end
188
+
189
+ def render
190
+ JSON.pretty_generate(@result.diagnostics.map { |diagnostic| entry_for(diagnostic) })
191
+ end
192
+
193
+ private
194
+
195
+ def entry_for(diagnostic)
196
+ {
197
+ "description" => description(diagnostic),
198
+ "check_name" => diagnostic.qualified_rule || "rigor",
199
+ "fingerprint" => fingerprint(diagnostic),
200
+ "severity" => SEVERITIES.fetch(diagnostic.severity, "minor"),
201
+ "location" => {
202
+ "path" => diagnostic.path.to_s,
203
+ "lines" => { "begin" => diagnostic.line }
204
+ }
205
+ }
206
+ end
207
+
208
+ # The rule id is folded into the description (the widget shows it)
209
+ # because Code Quality has no dedicated rule field.
210
+ def description(diagnostic)
211
+ rule_id = diagnostic.qualified_rule
212
+ rule_id ? "#{diagnostic.message} [#{rule_id}]" : diagnostic.message
213
+ end
214
+
215
+ # GitLab dedups findings by fingerprint and tracks them across runs
216
+ # by it, so it must be stable for an unchanged finding and unique per
217
+ # finding. Hashing the locating tuple (path, rule, line, column,
218
+ # message) satisfies both — order-independent, no run-volatile input.
219
+ def fingerprint(diagnostic)
220
+ payload = [diagnostic.path, diagnostic.qualified_rule, diagnostic.line,
221
+ diagnostic.column, diagnostic.message].join("")
222
+ Digest::SHA256.hexdigest(payload)
223
+ end
224
+ end
225
+
226
+ # Checkstyle XML — the lint-interchange format a wide range of tools
227
+ # read, most usefully reviewdog (`-f=checkstyle`), which then posts to
228
+ # any of its reporters (GitHub PR review, GitLab MR, Gerrit, …). Errors
229
+ # are grouped by file; the qualified rule rides in `source` (the rule
230
+ # code reviewdog surfaces). Checkstyle's native severities are
231
+ # `error` / `warning` / `info`, so Rigor's map through unchanged.
232
+ class Checkstyle
233
+ include XmlEscaping
234
+
235
+ def initialize(result)
236
+ @result = result
237
+ end
238
+
239
+ def render
240
+ lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<checkstyle>"]
241
+ @result.diagnostics.group_by(&:path).each do |path, diagnostics|
242
+ lines << %( <file name="#{xml_escape(path)}">)
243
+ diagnostics.each { |diagnostic| lines << error_element(diagnostic) }
244
+ lines << " </file>"
245
+ end
246
+ lines << "</checkstyle>"
247
+ lines.join("\n")
248
+ end
249
+
250
+ private
251
+
252
+ def error_element(diagnostic)
253
+ attrs = %(line="#{diagnostic.line}" column="#{diagnostic.column}" ) +
254
+ %(severity="#{diagnostic.severity}" message="#{xml_escape(diagnostic.message)}")
255
+ rule_id = diagnostic.qualified_rule
256
+ attrs += %( source="#{xml_escape(rule_id)}") if rule_id
257
+ " <error #{attrs} />"
258
+ end
259
+ end
260
+
261
+ # JUnit XML — the test-report format GitHub's test reporting, GitLab,
262
+ # Jenkins, and CircleCI render. Following the established linter
263
+ # convention (rubocop / eslint / PHPStan): every diagnostic is a
264
+ # `testcase` carrying a `failure` typed by its severity, so all of them
265
+ # are visible in the report. The exit code (errors only) remains the
266
+ # gate; this view is for surfacing, not gating.
267
+ class Junit
268
+ include XmlEscaping
269
+
270
+ def initialize(result)
271
+ @result = result
272
+ end
273
+
274
+ def render
275
+ diagnostics = @result.diagnostics
276
+ # JUnit wants at least one test; a clean run reports one passing case.
277
+ tests = diagnostics.empty? ? 1 : diagnostics.size
278
+ lines = ['<?xml version="1.0" encoding="UTF-8"?>',
279
+ %(<testsuite name="rigor" tests="#{tests}" failures="#{diagnostics.size}">)]
280
+ if diagnostics.empty?
281
+ lines << ' <testcase name="rigor" />'
282
+ else
283
+ diagnostics.each { |diagnostic| lines.concat(testcase(diagnostic)) }
284
+ end
285
+ lines << "</testsuite>"
286
+ lines.join("\n")
287
+ end
288
+
289
+ private
290
+
291
+ def testcase(diagnostic)
292
+ name = "#{diagnostic.path}:#{diagnostic.line}:#{diagnostic.column}"
293
+ classname = diagnostic.qualified_rule || "rigor"
294
+ [
295
+ %( <testcase name="#{xml_escape(name)}" classname="#{xml_escape(classname)}">),
296
+ %( <failure type="#{diagnostic.severity}" message="#{xml_escape(diagnostic.message)}" />),
297
+ " </testcase>"
298
+ ]
299
+ end
300
+ end
301
+
302
+ # TeamCity inspection service messages — the `##teamcity[…]` lines a
303
+ # TeamCity build agent parses out of the build log into its Inspections
304
+ # view. The one stdout-native format (besides `github`) that
305
+ # CI-detection auto-emits. One `inspectionType` declares the category;
306
+ # each diagnostic is an `inspection` typed by severity.
307
+ class Teamcity
308
+ SEVERITIES = { error: "ERROR", warning: "WARNING", info: "INFO" }.freeze
309
+
310
+ def initialize(result)
311
+ @result = result
312
+ end
313
+
314
+ def render
315
+ return "" if @result.diagnostics.empty?
316
+
317
+ lines = [message("inspectionType", id: "rigor", name: "rigor",
318
+ category: "rigor", description: "Rigor inspection")]
319
+ @result.diagnostics.each { |diagnostic| lines << inspection(diagnostic) }
320
+ lines.join("\n")
321
+ end
322
+
323
+ private
324
+
325
+ def inspection(diagnostic)
326
+ rule_id = diagnostic.qualified_rule
327
+ text = rule_id ? "#{diagnostic.message} [#{rule_id}]" : diagnostic.message
328
+ message("inspection", typeId: "rigor", message: text, file: diagnostic.path,
329
+ line: diagnostic.line, SEVERITY: SEVERITIES.fetch(diagnostic.severity, "WARNING"))
330
+ end
331
+
332
+ def message(name, attrs)
333
+ pairs = attrs.map { |key, value| "#{key}='#{escape(value)}'" }.join(" ")
334
+ "##teamcity[#{name} #{pairs}]"
335
+ end
336
+
337
+ # TeamCity's documented service-message escaping.
338
+ def escape(value)
339
+ value.to_s.gsub("|", "||").gsub("'", "|'").gsub("\n", "|n")
340
+ .gsub("\r", "|r").gsub("[", "|[").gsub("]", "|]")
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -30,7 +30,7 @@ module Rigor
30
30
  # - every manifest-declared extension surface
31
31
  # (`open_receivers:` / `owns_receivers:` / `produces:` /
32
32
  # `consumes:` / `block_as_methods:` / `heredoc_templates:` /
33
- # `trait_registries:` / `external_files:` /
33
+ # `trait_registries:` /
34
34
  # `type_node_resolvers:` / `hkt_registrations:` /
35
35
  # `hkt_definitions:` / `protocol_contracts:` /
36
36
  # `source_rbs_synthesizer:`);
@@ -233,7 +233,6 @@ module Rigor
233
233
  block_as_methods: manifest.block_as_methods.size,
234
234
  heredoc_templates: manifest.heredoc_templates.size,
235
235
  trait_registries: manifest.trait_registries.size,
236
- external_files: manifest.external_files.size,
237
236
  type_node_resolvers: manifest.type_node_resolvers.size,
238
237
  hkt_registrations: manifest.hkt_registrations.size,
239
238
  hkt_definitions: manifest.hkt_definitions.size,
@@ -257,7 +256,6 @@ module Rigor
257
256
  block_as_methods: 0,
258
257
  heredoc_templates: 0,
259
258
  trait_registries: 0,
260
- external_files: 0,
261
259
  type_node_resolvers: 0,
262
260
  hkt_registrations: 0,
263
261
  hkt_definitions: 0,
@@ -337,7 +335,7 @@ module Rigor
337
335
  signature_paths: [],
338
336
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
339
337
  block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
340
- external_files: 0, type_node_resolvers: 0,
338
+ type_node_resolvers: 0,
341
339
  hkt_registrations: 0, hkt_definitions: 0,
342
340
  protocol_contracts: 0, source_rbs_synthesizer: false,
343
341
  node_rule_types: [], dynamic_return_receivers: [], type_specifier_methods: [],
@@ -209,7 +209,6 @@ module Rigor
209
209
  parts << "block_as_methods=#{row[:block_as_methods]}" if row[:block_as_methods].positive?
210
210
  parts << "heredoc_templates=#{row[:heredoc_templates]}" if row[:heredoc_templates].positive?
211
211
  parts << "trait_registries=#{row[:trait_registries]}" if row[:trait_registries].positive?
212
- parts << "external_files=#{row[:external_files]}" if row[:external_files].positive?
213
212
  return [] if parts.empty?
214
213
 
215
214
  [" macro substrate: #{parts.join(', ')}"]
@@ -233,7 +232,6 @@ module Rigor
233
232
  "block_as_methods" => row[:block_as_methods],
234
233
  "heredoc_templates" => row[:heredoc_templates],
235
234
  "trait_registries" => row[:trait_registries],
236
- "external_files" => row[:external_files],
237
235
  "type_node_resolvers" => row[:type_node_resolvers],
238
236
  "hkt_registrations" => row[:hkt_registrations],
239
237
  "hkt_definitions" => row[:hkt_definitions],
@@ -41,12 +41,19 @@ module Rigor
41
41
  # @return [String] the source with ANSI colour escapes, or
42
42
  # the input unchanged when lexing surfaces an error.
43
43
  def colorize(source)
44
+ # Sources read under a POSIX locale arrive tagged US-ASCII even
45
+ # when they carry UTF-8 bytes; retag so the token regexes below
46
+ # do not raise on multibyte comments.
47
+ source = source.dup.force_encoding(Encoding::UTF_8) unless source.encoding == Encoding::UTF_8
44
48
  result = Prism.lex(source)
45
49
  return source unless result.errors.empty?
46
50
 
47
51
  render(source, result.value)
48
52
  end
49
53
 
54
+ # Prism token offsets are BYTE offsets — slice with byteslice, or
55
+ # any multibyte character earlier in the source shifts every
56
+ # subsequent token boundary.
50
57
  def render(source, lexed)
51
58
  out = +""
52
59
  offset = 0
@@ -54,15 +61,15 @@ module Rigor
54
61
  lexed.each do |entry|
55
62
  token = entry.first
56
63
  location = token.location
57
- out << source[offset...location.start_offset]
64
+ out << (source.byteslice(offset, [location.start_offset - offset, 0].max) || "")
58
65
  break if token.type == :EOF
59
66
 
60
- text = source[location.start_offset...location.end_offset]
67
+ text = source.byteslice(location.start_offset, location.end_offset - location.start_offset) || ""
61
68
  out << paint(text, effective_category(token.type, previous_type))
62
69
  offset = location.end_offset
63
70
  previous_type = token.type
64
71
  end
65
- out << (source[offset..] || "")
72
+ out << (source.byteslice(offset, source.bytesize - offset) || "")
66
73
  out
67
74
  end
68
75
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ require_relative "../bleeding_edge"
7
+ require_relative "../configuration"
8
+ require_relative "command"
9
+
10
+ module Rigor
11
+ class CLI
12
+ # Executes `rigor show-bleedingedge` (ADR-50 § WD2).
13
+ #
14
+ # Prints the bleeding-edge overlay — the Rigor-maintained set of the
15
+ # next major's queued changes ({Rigor::BleedingEdge}) — as an
16
+ # explicit list, and reports which of them the project's
17
+ # `bleeding_edge:` configuration adopts. The overlay is empty in this
18
+ # release, so the command currently reports an empty set; it becomes
19
+ # the inspection surface ADR-50 describes once a feature is queued.
20
+ #
21
+ # Read-only: it loads `.rigor.yml` to resolve the active selection
22
+ # but runs no analysis.
23
+ class ShowBleedingedgeCommand < Command
24
+ USAGE = "Usage: rigor show-bleedingedge [options]"
25
+
26
+ # @return [Integer] CLI exit status.
27
+ def run
28
+ options = parse_options
29
+ configuration = load_configuration(options)
30
+ return CLI::EXIT_USAGE if configuration.nil?
31
+
32
+ case options.fetch(:format)
33
+ when "json" then render_json(configuration)
34
+ else render_text(configuration)
35
+ end
36
+ 0
37
+ end
38
+
39
+ private
40
+
41
+ def parse_options
42
+ options = { format: "text", config: nil }
43
+ OptionParser.new do |opt|
44
+ opt.banner = USAGE
45
+ opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
46
+ options[:format] = fmt
47
+ end
48
+ opt.on("--config=PATH", "Path to a .rigor.yml (default: auto-discovery).") do |path|
49
+ options[:config] = path
50
+ end
51
+ end.parse!(@argv)
52
+ options
53
+ end
54
+
55
+ def load_configuration(options)
56
+ Configuration.load(options.fetch(:config))
57
+ rescue StandardError => e
58
+ @err.puts("show-bleedingedge: could not load configuration: #{e.message}")
59
+ nil
60
+ end
61
+
62
+ def render_json(configuration)
63
+ selector = configuration.bleeding_edge
64
+ @out.puts(JSON.pretty_generate(
65
+ "overlay" => BleedingEdge.features.map(&:to_h),
66
+ "selector" => configuration.to_h.fetch("bleeding_edge"),
67
+ "active" => BleedingEdge.active_features(selector).map(&:id),
68
+ "unknown_selected" => BleedingEdge.unknown_selected_ids(selector)
69
+ ))
70
+ end
71
+
72
+ def render_text(configuration)
73
+ @out.puts("Bleeding-edge overlay (ADR-50 § WD2)")
74
+ @out.puts("")
75
+ if BleedingEdge.features.empty?
76
+ render_empty_overlay
77
+ else
78
+ render_overlay
79
+ end
80
+ @out.puts("")
81
+ render_selection(configuration)
82
+ end
83
+
84
+ def render_empty_overlay
85
+ @out.puts("The overlay is empty in this release — no features are queued for")
86
+ @out.puts("the next major. The `bleeding_edge:` mechanism is wired and ready;")
87
+ @out.puts("there is simply nothing to adopt yet.")
88
+ end
89
+
90
+ def render_overlay
91
+ @out.puts("#{BleedingEdge.features.length} feature(s) queued for the next major:")
92
+ @out.puts("")
93
+ BleedingEdge.features.each do |feature|
94
+ @out.puts(" #{feature.id}")
95
+ @out.puts(" #{feature.summary}")
96
+ feature.severity_overrides.each do |rule, severity|
97
+ @out.puts(" severity: #{rule} → :#{severity}")
98
+ end
99
+ end
100
+ end
101
+
102
+ def render_selection(configuration)
103
+ selector = configuration.bleeding_edge
104
+ active = BleedingEdge.active_features(selector).map(&:id)
105
+ @out.puts("Your configuration adopts: #{active.empty? ? '(none)' : active.join(', ')}")
106
+
107
+ unknown = BleedingEdge.unknown_selected_ids(selector)
108
+ return if unknown.empty?
109
+
110
+ @out.puts("Selected but not in this overlay (ignored): #{unknown.join(', ')}")
111
+ end
112
+ end
113
+ end
114
+ end