rigortype 0.1.16 → 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 (180) 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/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # Base class for the `rigor <subcommand>` command objects.
6
+ #
7
+ # Every subcommand captured the same invariant wiring — the argument
8
+ # vector plus the output and error streams — in an identical
9
+ # `initialize(argv:, out:, err:)`, and defaulted the streams
10
+ # inconsistently (some to `$stdout` / `$stderr`, most not at all).
11
+ # Centralising it here gives one consistent shape and lets a test
12
+ # instantiate a command with just `argv:` (the streams default so a
13
+ # spec can pass a `StringIO` for one and ignore the other).
14
+ #
15
+ # Subclasses read the `@argv` / `@out` / `@err` ivars directly, as
16
+ # they did before.
17
+ class Command
18
+ def initialize(argv:, out: $stdout, err: $stderr)
19
+ @argv = argv
20
+ @out = out
21
+ @err = err
22
+ end
23
+
24
+ private
25
+
26
+ # Expands `args` (a mix of files and directories) into a unique
27
+ # list of `.rb` paths, recursing into directories. Returns nil —
28
+ # after writing `<command_name>: not a file or directory: <arg>` to
29
+ # `@err` — on the first arg that is neither. Shared by the
30
+ # path-walking commands (`type-scan`, `coverage`).
31
+ def collect_paths(args, command_name:)
32
+ paths = []
33
+ args.each do |arg|
34
+ if File.directory?(arg)
35
+ paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
36
+ elsif File.file?(arg)
37
+ paths << arg
38
+ else
39
+ @err.puts("#{command_name}: not a file or directory: #{arg}")
40
+ return nil
41
+ end
42
+ end
43
+ paths.uniq
44
+ end
45
+ end
46
+ end
47
+ end
@@ -9,6 +9,7 @@ require_relative "../inference/precision_scanner"
9
9
  require_relative "../scope"
10
10
  require_relative "coverage_report"
11
11
  require_relative "coverage_renderer"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -25,19 +26,13 @@ module Rigor
25
26
  # 0 — scan complete, precision ratio ≥ threshold (or no threshold given)
26
27
  # 1 — precision ratio < threshold, or parse errors encountered
27
28
  # 64 — usage error
28
- class CoverageCommand
29
+ class CoverageCommand < Command
29
30
  USAGE = "Usage: rigor coverage [options] PATH..."
30
31
 
31
- def initialize(argv:, out:, err:)
32
- @argv = argv
33
- @out = out
34
- @err = err
35
- end
36
-
37
32
  # @return [Integer] CLI exit status.
38
33
  def run
39
34
  options = parse_options
40
- paths = collect_paths(@argv)
35
+ paths = collect_paths(@argv, command_name: "coverage")
41
36
  return CLI::EXIT_USAGE if paths.nil?
42
37
  return usage_error if paths.empty?
43
38
 
@@ -64,21 +59,6 @@ module Rigor
64
59
  options
65
60
  end
66
61
 
67
- def collect_paths(args)
68
- paths = []
69
- args.each do |arg|
70
- if File.directory?(arg)
71
- paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
72
- elsif File.file?(arg)
73
- paths << arg
74
- else
75
- @err.puts("coverage: not a file or directory: #{arg}")
76
- return nil
77
- end
78
- end
79
- paths.uniq
80
- end
81
-
82
62
  def usage_error
83
63
  @err.puts("coverage: at least one path is required")
84
64
  @err.puts(USAGE)
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "renderable"
4
5
 
5
6
  module Rigor
6
7
  class CLI
7
8
  # Renders a `CoverageReport` as terminal-friendly text or JSON.
8
9
  class CoverageRenderer
10
+ include Renderable
11
+
9
12
  TIER_LABELS = {
10
13
  constant: "constant",
11
14
  nominal: "nominal",
@@ -21,14 +24,6 @@ module Rigor
21
24
  @out = out
22
25
  end
23
26
 
24
- def render(report, format:)
25
- case format
26
- when "text" then render_text(report)
27
- when "json" then render_json(report)
28
- else raise OptionParser::InvalidArgument, "unsupported format: #{format}"
29
- end
30
- end
31
-
32
27
  private
33
28
 
34
29
  def render_text(report)
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "json"
4
6
  require "optionparser"
5
7
 
@@ -29,15 +31,9 @@ module Rigor
29
31
  # is `1` when any new diagnostic appears, `0` otherwise —
30
32
  # so adding new errors fails CI but legacy errors recorded
31
33
  # in the baseline don't.
32
- class DiffCommand
34
+ class DiffCommand < Command
33
35
  USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
34
36
 
35
- def initialize(argv:, out:, err:)
36
- @argv = argv
37
- @out = out
38
- @err = err
39
- end
40
-
41
37
  # @return [Integer] CLI exit status.
42
38
  def run
43
39
  options = parse_options
@@ -4,6 +4,7 @@ require "json"
4
4
  require "optionparser"
5
5
 
6
6
  require_relative "../analysis/rule_catalog"
7
+ require_relative "command"
7
8
 
8
9
  module Rigor
9
10
  class CLI
@@ -17,15 +18,9 @@ module Rigor
17
18
  # beyond the rendered catalog. Useful when a user sees a
18
19
  # diagnostic in the editor and wants to know what the rule
19
20
  # means without leaving the terminal.
20
- class ExplainCommand
21
+ class ExplainCommand < Command
21
22
  USAGE = "Usage: rigor explain [options] [<rule>]"
22
23
 
23
- def initialize(argv:, out:, err:)
24
- @argv = argv
25
- @out = out
26
- @err = err
27
- end
28
-
29
24
  # @return [Integer] CLI exit status.
30
25
  def run
31
26
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optionparser"
4
6
 
5
7
  module Rigor
@@ -11,15 +13,9 @@ module Rigor
11
13
  # The actual stdio JSON-RPC reader / writer is queued for slice 2;
12
14
  # invoking `rigor lsp` at slice 1 returns immediately after
13
15
  # validating the transport flag.
14
- class LspCommand
16
+ class LspCommand < Command
15
17
  USAGE = "Usage: rigor lsp [options]"
16
18
 
17
- def initialize(argv:, out:, err:)
18
- @argv = argv
19
- @out = out
20
- @err = err
21
- end
22
-
23
19
  # @return [Integer] CLI exit status.
24
20
  def run
25
21
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optionparser"
4
6
 
5
7
  module Rigor
@@ -13,15 +15,9 @@ module Rigor
13
15
  # Slice 1 ships the stdio transport with seven read-only tools:
14
16
  # rigor_check, rigor_type_of, rigor_triage, rigor_annotate,
15
17
  # rigor_sig_gen, rigor_explain, rigor_coverage.
16
- class McpCommand
18
+ class McpCommand < Command
17
19
  USAGE = "Usage: rigor mcp [options]"
18
20
 
19
- def initialize(argv:, out:, err:)
20
- @argv = argv
21
- @out = out
22
- @err = err
23
- end
24
-
25
21
  # @return [Integer] CLI exit status.
26
22
  def run
27
23
  options = parse_options