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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules.rb +34 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +160 -1190
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli.rb +15 -614
- data/lib/rigor/configuration.rb +9 -6
- data/lib/rigor/environment/rbs_loader.rb +53 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/expression_typer.rb +28 -62
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher.rb +115 -54
- data/lib/rigor/inference/narrowing.rb +60 -0
- data/lib/rigor/inference/scope_indexer.rb +75 -15
- data/lib/rigor/inference/statement_evaluator.rb +35 -52
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +282 -41
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +263 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +67 -198
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/combinator.rb +5 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/scope.rbs +41 -29
- data/sig/rigor/source.rbs +1 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +15 -2
- 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 = { "&" => "&", "<" => "<", ">" => ">",
|
|
58
|
+
'"' => """, "'" => "'" }.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[
|
|
64
|
+
out << (source.byteslice(offset, [location.start_offset - offset, 0].max) || "")
|
|
58
65
|
break if token.type == :EOF
|
|
59
66
|
|
|
60
|
-
text = source
|
|
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
|
|
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
|