rigortype 0.1.17 → 0.1.18

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -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
@@ -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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+ require "prism"
6
+
7
+ require_relative "../configuration"
8
+ require_relative "../environment"
9
+ require_relative "../scope"
10
+ require_relative "../inference/flow_tracer"
11
+ require_relative "../inference/scope_indexer"
12
+ require_relative "command"
13
+ require_relative "trace_renderer"
14
+
15
+ module Rigor
16
+ class CLI
17
+ # Executes the `rigor trace` command: re-runs the inference engine
18
+ # over one file under {Rigor::Inference::FlowTracer} and replays the
19
+ # recorded event stream — scope binds, union formation, method
20
+ # dispatch, and (with `--verbose`) every expression enter/result —
21
+ # as a step-through terminal animation. A teaching probe: it shows
22
+ # HOW Rigor arrives at a type, where `rigor type-of` shows only the
23
+ # answer.
24
+ #
25
+ # The tracer is observational; this command never changes what
26
+ # `rigor check` would infer for the same file.
27
+ class TraceCommand < Command
28
+ USAGE = "Usage: rigor trace [options] FILE"
29
+
30
+ # Default frame kinds: the three teachable moments. :enter/:result
31
+ # add one frame per literal and drown the signal; `--verbose`
32
+ # opts into them.
33
+ DEFAULT_KINDS = %i[bind union dispatch].freeze
34
+ VERBOSE_KINDS = (%i[enter result] + DEFAULT_KINDS).freeze
35
+
36
+ # @return [Integer] CLI exit status.
37
+ def run
38
+ options = parse_options
39
+ file = @argv.first
40
+ if file.nil? || @argv.size != 1
41
+ @err.puts(USAGE)
42
+ return CLI::EXIT_USAGE
43
+ end
44
+ return 1 unless file_exists?(file)
45
+
46
+ execute(file: file, options: options)
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options
52
+ options = { format: "text", delay: nil, verbose: false, line: nil, config: nil }
53
+ parser = OptionParser.new do |opts|
54
+ opts.banner = USAGE
55
+ opts.on("--format=FORMAT", "Output format: text (animation) or json (raw event stream)") do |value|
56
+ options[:format] = value
57
+ end
58
+ opts.on("--delay=SECONDS", Float,
59
+ "Autoplay with SECONDS between frames (default: step on key press)") do |value|
60
+ options[:delay] = value
61
+ end
62
+ opts.on("--line=N", Integer, "Only replay events whose source range starts on line N") do |value|
63
+ options[:line] = value
64
+ end
65
+ opts.on("--verbose", "Include every expression enter/result frame") { options[:verbose] = true }
66
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
67
+ end
68
+ parser.parse!(@argv)
69
+ options
70
+ end
71
+
72
+ def execute(file:, options:)
73
+ configuration = Configuration.load(options.fetch(:config))
74
+ source = File.read(file, encoding: Encoding::UTF_8)
75
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
76
+ return 1 if parse_errors?(parse_result, file)
77
+
78
+ events = record_events(parse_result.value, file, configuration)
79
+ frames = filter(events, options)
80
+
81
+ if options.fetch(:format) == "json"
82
+ @out.puts(JSON.pretty_generate(frames.map { |event| event_to_h(event) }))
83
+ return 0
84
+ end
85
+
86
+ if frames.empty?
87
+ @out.puts("trace: no events to replay (try --verbose)")
88
+ return 0
89
+ end
90
+
91
+ interactive = @out.respond_to?(:tty?) && @out.tty?
92
+ TraceRenderer.new(out: @out, source: source, file: file)
93
+ .play(frames, delay: options.fetch(:delay), interactive: interactive)
94
+ 0
95
+ end
96
+
97
+ # Mirrors the single-file path `rigor type-of` takes: a
98
+ # project-aware environment, an empty seed scope, one
99
+ # statement-level evaluation of the whole program — but recorded
100
+ # under the FlowTracer.
101
+ def record_events(root, file, configuration)
102
+ environment = Environment.for_project(
103
+ libraries: configuration.libraries,
104
+ signature_paths: configuration.signature_paths
105
+ )
106
+ scope = Scope.empty(environment: environment, source_path: file)
107
+ Inference::FlowTracer.record { scope.evaluate(root) }
108
+ end
109
+
110
+ def filter(events, options)
111
+ kinds = options.fetch(:verbose) ? VERBOSE_KINDS : DEFAULT_KINDS
112
+ frames = events.select { |event| kinds.include?(event.kind) }
113
+ line = options.fetch(:line)
114
+ frames = frames.select { |event| event.location && event.location[:start_line] == line } if line
115
+ frames
116
+ end
117
+
118
+ def event_to_h(event)
119
+ {
120
+ kind: event.kind,
121
+ depth: event.depth,
122
+ location: event.location,
123
+ stack: event.stack,
124
+ data: event.data
125
+ }
126
+ end
127
+
128
+ def file_exists?(file)
129
+ return true if File.file?(file)
130
+
131
+ @err.puts("trace: file not found: #{file}")
132
+ false
133
+ end
134
+
135
+ def parse_errors?(result, file)
136
+ return false if result.errors.empty?
137
+
138
+ result.errors.each { |error| @err.puts("#{file}:#{error.location.start_line}: #{error.message}") }
139
+ true
140
+ end
141
+ end
142
+ end
143
+ end