rigortype 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/check_rules.rb +96 -3
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli/skill_command.rb +170 -0
- data/lib/rigor/cli.rb +37 -1
- data/lib/rigor/configuration/severity_profile.rb +3 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +195 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +23 -0
- data/skills/rigor-baseline-reduce/SKILL.md +100 -0
- data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
- data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
- data/skills/rigor-plugin-author/SKILL.md +95 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
- data/skills/rigor-project-init/SKILL.md +129 -0
- data/skills/rigor-project-init/references/01-detect.md +101 -0
- data/skills/rigor-project-init/references/02-configure.md +185 -0
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
- data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
- metadata +22 -1
|
@@ -4,6 +4,7 @@ require "rigor/plugin"
|
|
|
4
4
|
|
|
5
5
|
require_relative "rails_routes/helper_table"
|
|
6
6
|
require_relative "rails_routes/routes_parser"
|
|
7
|
+
require_relative "rails_routes/helper_discoverer"
|
|
7
8
|
require_relative "rails_routes/analyzer"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
@@ -52,32 +53,104 @@ module Rigor
|
|
|
52
53
|
class RailsRoutes < Rigor::Plugin::Base
|
|
53
54
|
manifest(
|
|
54
55
|
id: "rails-routes",
|
|
55
|
-
|
|
56
|
+
# Bumped 2026-05-28 — GitLab FOSS sweep adds: (a)
|
|
57
|
+
# `draw_all :name` support (action_dispatch-draw_all
|
|
58
|
+
# gem; single-file load semantics matching `draw :name`);
|
|
59
|
+
# (b) keyword-style `scope(path: ':project_id',
|
|
60
|
+
# as: :project)` — path read from the `:path` keyword,
|
|
61
|
+
# not only from the positional first arg; (c) detection
|
|
62
|
+
# of iterative `direct(name.sub(FROM, TO)) do ... end`
|
|
63
|
+
# alias-generation idiom — generates `<TO>_*` aliases
|
|
64
|
+
# for every registered `<FROM>_*` helper. GitLab uses
|
|
65
|
+
# this to shorten `namespace_project_*` → `project_*`.
|
|
66
|
+
version: "0.27.0",
|
|
56
67
|
description: "Validates Rails route-helper calls against `config/routes.rb`.",
|
|
57
68
|
config_schema: {
|
|
58
|
-
"routes_file" => :string
|
|
69
|
+
"routes_file" => :string,
|
|
70
|
+
"helper_paths" => :array
|
|
59
71
|
},
|
|
60
72
|
produces: [:helper_table]
|
|
61
73
|
)
|
|
62
74
|
|
|
63
75
|
DEFAULT_ROUTES_FILE = "config/routes.rb"
|
|
64
76
|
|
|
77
|
+
# The directories `HelperDiscoverer` walks for project-
|
|
78
|
+
# defined `*_path` / `*_url` methods. Default to the whole
|
|
79
|
+
# `app/` tree — the suffix filter inside the discoverer
|
|
80
|
+
# keeps the registered set tight, and real-world Rails
|
|
81
|
+
# apps routinely keep URL builders under `app/controllers`
|
|
82
|
+
# (private `def page_url`, `def callback_url` shapes),
|
|
83
|
+
# `app/lib` (Mastodon's `TranslationService::DeepL#base_url`),
|
|
84
|
+
# `app/services` (`SoftwareUpdateCheckService#api_url`),
|
|
85
|
+
# `app/serializers`, `app/presenters`, `app/decorators`,
|
|
86
|
+
# not only `app/helpers/`. Walking the whole tree is the
|
|
87
|
+
# honest answer to "does this `_path` / `_url` name exist
|
|
88
|
+
# anywhere in the project?"; the cost is a one-time Prism
|
|
89
|
+
# parse per file at startup, which is bounded.
|
|
90
|
+
DEFAULT_HELPER_PATHS = ["app"].freeze
|
|
91
|
+
|
|
65
92
|
# Cached producer — reads `config/routes.rb` through
|
|
66
93
|
# the trusted `IoBoundary` and parses through
|
|
67
94
|
# {RoutesParser}. The descriptor's auto-collected
|
|
68
95
|
# `FileEntry` digest invalidates the cache on routes-
|
|
69
96
|
# file edits.
|
|
97
|
+
#
|
|
98
|
+
# Passes a `file_reader` lambda so the parser can follow
|
|
99
|
+
# `draw(:admin)` → `config/routes/admin.rb` partials.
|
|
70
100
|
producer :helper_table do |_params|
|
|
101
|
+
routes_dir = "#{File.dirname(@routes_file)}/routes"
|
|
102
|
+
file_reader = lambda do |name|
|
|
103
|
+
io_boundary.read_file("#{routes_dir}/#{name}")
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
71
107
|
contents = io_boundary.read_file(@routes_file)
|
|
72
|
-
|
|
108
|
+
custom_helpers = discover_custom_helpers
|
|
109
|
+
RoutesParser.parse(contents, file_reader: file_reader, custom_helpers: custom_helpers)
|
|
73
110
|
end
|
|
74
111
|
|
|
75
112
|
def init(_services)
|
|
76
113
|
@routes_file = config.fetch("routes_file", DEFAULT_ROUTES_FILE)
|
|
114
|
+
@helper_paths = Array(config.fetch("helper_paths", DEFAULT_HELPER_PATHS)).map(&:to_s)
|
|
77
115
|
@helper_table = nil
|
|
78
116
|
@load_error = nil
|
|
79
117
|
end
|
|
80
118
|
|
|
119
|
+
# Walks every configured `helper_paths:` directory
|
|
120
|
+
# through the trusted `IoBoundary` and returns the set
|
|
121
|
+
# of project-defined `*_path` / `*_url` method names
|
|
122
|
+
# for {HelperDiscoverer}. Each file digest is captured
|
|
123
|
+
# by the boundary so editing a helper file invalidates
|
|
124
|
+
# the `:helper_table` cache automatically. Returns the
|
|
125
|
+
# empty set when nothing under `helper_paths:` exists —
|
|
126
|
+
# the routes table still works.
|
|
127
|
+
def discover_custom_helpers
|
|
128
|
+
contents_per_path = {}
|
|
129
|
+
each_helper_file do |path|
|
|
130
|
+
contents_per_path[path] = io_boundary.read_file(path)
|
|
131
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
HelperDiscoverer.discover(contents_per_path)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def pre_read_helper_files
|
|
138
|
+
each_helper_file do |path|
|
|
139
|
+
io_boundary.read_file(path)
|
|
140
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def each_helper_file(&)
|
|
146
|
+
@helper_paths.each do |dir|
|
|
147
|
+
absolute = File.expand_path(dir)
|
|
148
|
+
next unless File.directory?(absolute)
|
|
149
|
+
|
|
150
|
+
Dir.glob(File.join(absolute, "**", "*.rb")).each(&)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
81
154
|
# Publishes the parsed table to the cross-plugin fact
|
|
82
155
|
# store so future Tier 2 plugins (rigor-actionpack
|
|
83
156
|
# Phase 4) can read it via `services.fact_store.read`.
|
|
@@ -122,8 +195,12 @@ module Rigor
|
|
|
122
195
|
# Read first so the IoBoundary's FileEntry digest
|
|
123
196
|
# captures into the descriptor before `cache_for`
|
|
124
197
|
# snapshots it (the same pattern documented in
|
|
125
|
-
# rigor-routes / rigor-activerecord).
|
|
198
|
+
# rigor-routes / rigor-activerecord). Helper files are
|
|
199
|
+
# pre-read for the same reason — editing a file under
|
|
200
|
+
# `app/helpers/` MUST invalidate the helper_table cache
|
|
201
|
+
# so the new custom-helper set is picked up.
|
|
126
202
|
io_boundary.read_file(@routes_file)
|
|
203
|
+
pre_read_helper_files
|
|
127
204
|
@helper_table = cache_for(:helper_table, params: {}).call
|
|
128
205
|
rescue Plugin::AccessDeniedError => e
|
|
129
206
|
@load_error = "rigor-rails-routes: #{e.message}"
|
data/sig/rigor/scope.rbs
CHANGED
|
@@ -18,8 +18,22 @@ module Rigor
|
|
|
18
18
|
attr_reader discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]]
|
|
19
19
|
attr_reader discovered_superclasses: Hash[String, String]
|
|
20
20
|
attr_reader discovered_includes: Hash[String, Array[String]]
|
|
21
|
+
attr_reader indexed_narrowings: Hash[IndexedKey, Type::t]
|
|
22
|
+
attr_reader method_chain_narrowings: Hash[ChainKey, Type::t]
|
|
21
23
|
attr_reader source_path: String?
|
|
22
24
|
|
|
25
|
+
class IndexedKey
|
|
26
|
+
attr_reader receiver_kind: Symbol
|
|
27
|
+
attr_reader receiver_name: Symbol
|
|
28
|
+
attr_reader key: untyped
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class ChainKey
|
|
32
|
+
attr_reader receiver_kind: Symbol
|
|
33
|
+
attr_reader receiver_name: Symbol
|
|
34
|
+
attr_reader method_name: Symbol
|
|
35
|
+
end
|
|
36
|
+
|
|
23
37
|
def self.empty: (?environment: Environment, ?source_path: String?) -> Scope
|
|
24
38
|
|
|
25
39
|
def initialize: (environment: Environment, locals: Hash[Symbol, Type::t], ?fact_store: Analysis::FactStore, ?self_type: Type::t?, ?declared_types: Hash[untyped, Type::t], ?ivars: Hash[Symbol, Type::t], ?cvars: Hash[Symbol, Type::t], ?globals: Hash[Symbol, Type::t], ?source_path: String?) -> void
|
|
@@ -44,12 +58,21 @@ module Rigor
|
|
|
44
58
|
def with_discovered_def_nodes: (Hash[String, Hash[Symbol, untyped]] table) -> Scope
|
|
45
59
|
def user_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
|
|
46
60
|
def top_level_def_for: (String | Symbol method_name) -> untyped?
|
|
61
|
+
def toplevel?: () -> bool
|
|
47
62
|
def with_discovered_method_visibilities: (Hash[String, Hash[Symbol, Symbol]] table) -> Scope
|
|
48
63
|
def discovered_method_visibility: (String | Symbol class_name, String | Symbol method_name) -> Symbol?
|
|
49
64
|
def superclass_of: (String | Symbol class_name) -> String?
|
|
50
65
|
def with_discovered_superclasses: (Hash[String, String] table) -> Scope
|
|
51
66
|
def includes_of: (String | Symbol class_name) -> Array[String]
|
|
52
67
|
def with_discovered_includes: (Hash[String, Array[String]] table) -> Scope
|
|
68
|
+
def indexed_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, untyped key) -> Type::t?
|
|
69
|
+
def with_indexed_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, untyped key, Type::t type) -> Scope
|
|
70
|
+
def without_indexed_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, untyped key) -> Scope
|
|
71
|
+
def without_indexed_narrowings_for: (Symbol receiver_kind, String | Symbol receiver_name) -> Scope
|
|
72
|
+
def method_chain_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, String | Symbol method_name) -> Type::t?
|
|
73
|
+
def with_method_chain_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, String | Symbol method_name, Type::t type) -> Scope
|
|
74
|
+
def without_method_chain_narrowing: (Symbol receiver_kind, String | Symbol receiver_name, String | Symbol method_name) -> Scope
|
|
75
|
+
def without_method_chain_narrowings_for: (Symbol receiver_kind, String | Symbol receiver_name) -> Scope
|
|
53
76
|
def with_fact: (Analysis::FactStore::Fact fact) -> Scope
|
|
54
77
|
def with_self_type: (Type::t? type) -> Scope
|
|
55
78
|
def with_declared_types: (Hash[untyped, Type::t] table) -> Scope
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rigor-baseline-reduce
|
|
3
|
+
description: |
|
|
4
|
+
Work a Rigor project's `.rigor-baseline.yml` down rule by rule: prioritise with `rigor triage`, sample call sites, classify each as real bug / stylistic-safe / false positive, then fix, `# rigor:disable`, or open a Rigor issue — and regenerate the baseline. Triggers: "reduce the rigor baseline", "fix some baseline diagnostics", "what rigor issue should I fix next?". NOT for first-time setup (use rigor-project-init) or authoring a plugin (use rigor-plugin-author).
|
|
5
|
+
license: MPL-2.0
|
|
6
|
+
metadata:
|
|
7
|
+
version: 0.1.0
|
|
8
|
+
homepage: https://github.com/rigortype/rigor
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Rigor Baseline Reduce
|
|
12
|
+
|
|
13
|
+
Opportunistic, session-bounded quality improvement for a project
|
|
14
|
+
that already has a `.rigor-baseline.yml`. Each session picks a rule,
|
|
15
|
+
walks its sites, and lands a mix of fixes, intentional suppressions,
|
|
16
|
+
and issue reports — then refreshes the baseline so the gains stick.
|
|
17
|
+
|
|
18
|
+
This skill is for **users improving their own project**. It uses the
|
|
19
|
+
published `rigor` executable on `PATH` and references only public CLI
|
|
20
|
+
flags and config keys.
|
|
21
|
+
|
|
22
|
+
## Phase 0 — When to use this skill
|
|
23
|
+
|
|
24
|
+
Trigger when the user says "reduce the rigor baseline", "fix some
|
|
25
|
+
baseline diagnostics", "what should I fix next in rigor?", or asks to
|
|
26
|
+
work down the diagnostics a previous onboarding parenthesised.
|
|
27
|
+
|
|
28
|
+
**Precondition: the project is in acknowledge mode.** This skill
|
|
29
|
+
operates on a `.rigor-baseline.yml` that `.rigor.yml` /
|
|
30
|
+
`.rigor.dist.yml` declares via `baseline:`. A strict-mode project
|
|
31
|
+
(`severity_profile: strict`, no baseline) has nothing for this skill
|
|
32
|
+
to reduce — its diagnostics are already all live; fix them as
|
|
33
|
+
ordinary `rigor check` output. If no baseline exists, the user wants
|
|
34
|
+
`rigor-project-init`, not this skill.
|
|
35
|
+
|
|
36
|
+
Do NOT trigger for:
|
|
37
|
+
|
|
38
|
+
- **First-time onboarding** — no `.rigor.yml` yet → `rigor-project-init`.
|
|
39
|
+
- **Writing a plugin** for the project's DSL → `rigor-plugin-author`.
|
|
40
|
+
|
|
41
|
+
## What baseline reduction is
|
|
42
|
+
|
|
43
|
+
`.rigor-baseline.yml` records `(file, rule, count)` buckets — the
|
|
44
|
+
diagnostics that existed when the project adopted Rigor. They are
|
|
45
|
+
suppressed while their count holds. Reduction means: go through them
|
|
46
|
+
deliberately, decide what each one really is, and shrink the file.
|
|
47
|
+
Three outcomes per site:
|
|
48
|
+
|
|
49
|
+
- **Real bug** — Rigor caught a genuine defect. Fix the code.
|
|
50
|
+
- **Stylistic / safe** — the static reading is worst-case-sound but
|
|
51
|
+
the site is fine in practice (an idiom the analyzer's narrowing
|
|
52
|
+
doesn't follow, a slot the project always initialises). Mark it
|
|
53
|
+
`# rigor:disable <rule>` with intent.
|
|
54
|
+
- **False positive** — Rigor is wrong; the rule should narrow
|
|
55
|
+
further. Leave it baselined and open a Rigor issue.
|
|
56
|
+
|
|
57
|
+
Shrinking the baseline is progress whichever outcome applies — the
|
|
58
|
+
file gets smaller, and "reduce the baseline by N this sprint" is a
|
|
59
|
+
trackable goal.
|
|
60
|
+
|
|
61
|
+
## Phase outline
|
|
62
|
+
|
|
63
|
+
| Phase | What | Reference |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| 1 | Prioritise — `rigor triage --format json` + `rigor baseline dump` pick the rule to work. | [`references/01-classify.md`](references/01-classify.md) |
|
|
66
|
+
| 2 | Per rule: sample 3–5 sites, classify each (real bug / stylistic / FP). | [`references/01-classify.md`](references/01-classify.md) |
|
|
67
|
+
| 3 | Act — fix, `# rigor:disable`, or open a Rigor issue. | [`references/02-fix-or-suppress.md`](references/02-fix-or-suppress.md) |
|
|
68
|
+
| 4 | Refresh — `rigor baseline drift`, then `rigor baseline regenerate`. | [`references/02-fix-or-suppress.md`](references/02-fix-or-suppress.md) |
|
|
69
|
+
|
|
70
|
+
Phases 2–4 repeat per rule until a stop condition (below) fires.
|
|
71
|
+
|
|
72
|
+
## Reading order — modules
|
|
73
|
+
|
|
74
|
+
| Module | Read | Covers |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| 1 | [`references/01-classify.md`](references/01-classify.md) | **Phases 1–2.** Priority order from the `rigor triage` hints + ascending bucket count. The sample-and-classify protocol; the three-way real-bug / stylistic / FP triage and how to tell them apart. |
|
|
77
|
+
| 2 | [`references/02-fix-or-suppress.md`](references/02-fix-or-suppress.md) | **Phases 3–4.** Acting on each classification — fix patterns, `# rigor:disable` placement (per-line vs per-file), FP escalation as a Rigor GitHub issue. Refreshing the baseline with `drift` + `regenerate`. |
|
|
78
|
+
|
|
79
|
+
## Stop conditions
|
|
80
|
+
|
|
81
|
+
End the session — do not grind indefinitely — when any of these hit:
|
|
82
|
+
|
|
83
|
+
- The user signals halt.
|
|
84
|
+
- The next rule's site count exceeds the session budget (default:
|
|
85
|
+
~20 sites). A 200-site rule is systemic; escalate it as a decision
|
|
86
|
+
(below), do not walk it site by site.
|
|
87
|
+
- A wall-time budget is reached (default: ~60 minutes).
|
|
88
|
+
|
|
89
|
+
Always finish with Phase 4 (refresh the baseline) before stopping, so
|
|
90
|
+
the landed work is recorded.
|
|
91
|
+
|
|
92
|
+
## Decision points to escalate to the user
|
|
93
|
+
|
|
94
|
+
- "This rule has 200 sites across 14 files — it looks systemic. A
|
|
95
|
+
plugin or an engine fix would clear them in bulk; want me to
|
|
96
|
+
investigate that instead of walking sites one by one?"
|
|
97
|
+
- "This file's diagnostics would be cleaner under one per-file
|
|
98
|
+
`# rigor:disable-file <rule>` than 30 per-line comments — switch?"
|
|
99
|
+
- "The diagnostic wording changed between Rigor versions and the
|
|
100
|
+
baseline no longer matches cleanly — regenerate now?"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 01 — Prioritise and classify
|
|
2
|
+
|
|
3
|
+
Covers **Phase 1** (pick the rule) and **Phase 2** (sample and
|
|
4
|
+
classify its sites).
|
|
5
|
+
|
|
6
|
+
## Phase 1 — Pick the rule to work
|
|
7
|
+
|
|
8
|
+
Do not walk the baseline top to bottom. Two commands decide the
|
|
9
|
+
order; run both.
|
|
10
|
+
|
|
11
|
+
### `rigor triage --format json` — the priority signal
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
rigor triage --format json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`rigor triage` runs the analysis and returns a structured summary —
|
|
18
|
+
a rule `distribution`, file `hotspots`, and a `hints` catalogue. It
|
|
19
|
+
is the deterministic diagnosis layer; use it rather than counting the
|
|
20
|
+
raw `rigor check` stream by hand. The `hints` array is the priority
|
|
21
|
+
signal — each hint has an `id`:
|
|
22
|
+
|
|
23
|
+
| Hint `id` | What it means for reduction | Priority |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `genuine-bugs` | Low-count, scattered rules — the localised bugs Rigor caught. | **Work these first.** Highest value per fix. |
|
|
26
|
+
| `systemic-file-cluster` | One file × one rule, large count. | One fix may clear many — high leverage. Or escalate as systemic. |
|
|
27
|
+
| `activesupport-core-ext`, `gem-without-rbs` | Config gaps, not code bugs. | **Not reduction work** — these are `rigor-project-init` territory (wire the RBS bundle). Flag to the user; do not walk site by site. |
|
|
28
|
+
| `project-monkey-patch` | A DSL / monkey-patch Rigor can't see. | Escalate — a `pre_eval:` entry or a plugin clears the whole cluster. |
|
|
29
|
+
| `activerecord-relation-misinference` | Likely an engine gap. | Treat sites as candidate false positives (Phase 2). |
|
|
30
|
+
|
|
31
|
+
### `rigor baseline dump --format json` — the bucket list
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
rigor baseline dump --format json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This is the authoritative list of `(file, rule, count)` buckets in
|
|
38
|
+
`.rigor-baseline.yml`. Within the priority tier the triage hints set,
|
|
39
|
+
**sort rules by ascending total count** — the smallest rules first.
|
|
40
|
+
A rule with 3 sites is either a quick win or a contained pattern;
|
|
41
|
+
either way it is finishable in one session, and finishing a rule is
|
|
42
|
+
more motivating than half-clearing a 200-site one.
|
|
43
|
+
|
|
44
|
+
So the working order is: rules flagged `genuine-bugs` first, then the
|
|
45
|
+
rest by ascending count, with config-gap hints handed back to the
|
|
46
|
+
user instead of walked.
|
|
47
|
+
|
|
48
|
+
## Phase 2 — Sample and classify
|
|
49
|
+
|
|
50
|
+
Pick the chosen rule. Surface its actual diagnostics — run
|
|
51
|
+
`rigor check` scoped to the affected files so the user sees real
|
|
52
|
+
messages, not baseline rows:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
rigor check app/models/account.rb app/services/feed_service.rb
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(The baseline still suppresses these in a full run; naming the files
|
|
59
|
+
and reading the stream shows them. `--no-baseline` also works if you
|
|
60
|
+
want the whole project's live stream.)
|
|
61
|
+
|
|
62
|
+
From the rule's sites, **sample 3–5 distinct ones** — different
|
|
63
|
+
files, different shapes, not five copies of the same line. For each
|
|
64
|
+
sampled site, read the code and classify it. Ask the user to confirm
|
|
65
|
+
when the call is not clear-cut.
|
|
66
|
+
|
|
67
|
+
### The three classifications
|
|
68
|
+
|
|
69
|
+
**Real bug** — Rigor found a genuine defect.
|
|
70
|
+
|
|
71
|
+
- A `possible-nil-receiver` where the value genuinely can be `nil` on
|
|
72
|
+
some path and the code would crash.
|
|
73
|
+
- An `undefined-method` that is a real typo or a removed method.
|
|
74
|
+
- An argument-type mismatch that would raise or misbehave.
|
|
75
|
+
- Tell: trace the value's origin and you find a path that actually
|
|
76
|
+
produces the bad case.
|
|
77
|
+
|
|
78
|
+
**Stylistic / safe** — the static reading is worst-case-sound, but
|
|
79
|
+
the site is fine in practice.
|
|
80
|
+
|
|
81
|
+
- `T | nil` where the slot is always initialised before this point
|
|
82
|
+
by code Rigor's narrowing doesn't follow (a memoised getter, a
|
|
83
|
+
framework lifecycle guarantee).
|
|
84
|
+
- A dynamic `send` over a known-finite, known-safe tag set.
|
|
85
|
+
- An idiom repeated across dozens of files — at that scale the
|
|
86
|
+
pattern *is* the project's style.
|
|
87
|
+
- Tell: you can articulate *why* the bad case never reaches this
|
|
88
|
+
line, and that reason is a real invariant, not a hope.
|
|
89
|
+
|
|
90
|
+
**False positive** — Rigor is simply wrong; a correct analyzer would
|
|
91
|
+
not flag this site.
|
|
92
|
+
|
|
93
|
+
- The narrowing should have followed an `&.` chain or an early
|
|
94
|
+
`return`/`raise` guard and didn't.
|
|
95
|
+
- The receiver type is misinferred (e.g. an ActiveRecord relation
|
|
96
|
+
typed as `Array`).
|
|
97
|
+
- Tell: the code is correct *and* a reasonable type checker should
|
|
98
|
+
see that it is correct — the gap is in Rigor, not the code.
|
|
99
|
+
|
|
100
|
+
The distinction between "stylistic / safe" and "false positive"
|
|
101
|
+
matters: stylistic-safe sites get a `# rigor:disable` (the code stays
|
|
102
|
+
as is, the suppression is intentional); false positives get a Rigor
|
|
103
|
+
issue (the analyzer should improve). Both stay out of the count, but
|
|
104
|
+
only one of them is feedback Rigor's maintainers can act on.
|
|
105
|
+
|
|
106
|
+
Carry the per-site classifications into Phase 3
|
|
107
|
+
([`02-fix-or-suppress.md`](02-fix-or-suppress.md)).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# 02 — Act on the classification, then refresh
|
|
2
|
+
|
|
3
|
+
Covers **Phase 3** (act per site) and **Phase 4** (refresh the
|
|
4
|
+
baseline). Input: the per-site classifications from
|
|
5
|
+
[`01-classify.md`](01-classify.md).
|
|
6
|
+
|
|
7
|
+
## Phase 3 — Act
|
|
8
|
+
|
|
9
|
+
### Real bug → fix the code
|
|
10
|
+
|
|
11
|
+
Propose the fix and offer to apply it. The fix is ordinary code work
|
|
12
|
+
— the value is that Rigor surfaced a defect that was latent because
|
|
13
|
+
the line is rarely exercised. Common shapes:
|
|
14
|
+
|
|
15
|
+
- `possible-nil-receiver` → add the missing guard (`return unless x`,
|
|
16
|
+
`x&.method`, an early raise), or fix the upstream code so the value
|
|
17
|
+
is never `nil` here.
|
|
18
|
+
- `undefined-method` typo → correct the method name.
|
|
19
|
+
- argument-type mismatch → pass the right type, or fix the signature.
|
|
20
|
+
|
|
21
|
+
After a real-bug fix the diagnostic is *gone*, not suppressed — the
|
|
22
|
+
bucket count drops on its own.
|
|
23
|
+
|
|
24
|
+
### Stylistic / safe → `# rigor:disable` with intent
|
|
25
|
+
|
|
26
|
+
The code is staying as it is; the suppression is a deliberate,
|
|
27
|
+
recorded decision. Place a per-line comment at the end of the
|
|
28
|
+
offending line:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
config.fetch(:timeout) # rigor:disable call.possible-nil-receiver — set in initializer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Placement rules:
|
|
35
|
+
|
|
36
|
+
- **Per-line `# rigor:disable <rule>`** is the default. It keeps the
|
|
37
|
+
suppression visible exactly where it applies, and a future reader
|
|
38
|
+
sees the intent. Always name the specific rule, never `all`.
|
|
39
|
+
- Add a short reason after the rule — *why* the site is safe. A bare
|
|
40
|
+
`# rigor:disable` rots; `# rigor:disable … — set in initializer`
|
|
41
|
+
survives review.
|
|
42
|
+
- **Per-file `# rigor:disable-file <rule>`** only when one file has
|
|
43
|
+
many sites of the same rule and they are all the same safe idiom.
|
|
44
|
+
Escalate this to the user as a decision (it is coarser — it also
|
|
45
|
+
silences *future* sites of that rule in the file).
|
|
46
|
+
|
|
47
|
+
A `# rigor:disable`d diagnostic is suppressed *before* the baseline
|
|
48
|
+
filter, so once the comment is in place the site no longer counts
|
|
49
|
+
toward its bucket.
|
|
50
|
+
|
|
51
|
+
### False positive → open a Rigor issue
|
|
52
|
+
|
|
53
|
+
Leave the site baselined (do not `# rigor:disable` it — that would
|
|
54
|
+
imply the code is the thing to live with, when actually Rigor is).
|
|
55
|
+
Open an issue on the Rigor project:
|
|
56
|
+
|
|
57
|
+
<https://github.com/rigortype/rigor/issues>
|
|
58
|
+
|
|
59
|
+
A useful issue includes:
|
|
60
|
+
|
|
61
|
+
- The rule id and the exact diagnostic message.
|
|
62
|
+
- A **minimal** code snippet that reproduces the false positive —
|
|
63
|
+
reduce it to the smallest shape that still mis-fires.
|
|
64
|
+
- What the correct inference should be, and why.
|
|
65
|
+
- The `rigor version` output.
|
|
66
|
+
|
|
67
|
+
This is the feedback loop that makes the analyzer better — a false
|
|
68
|
+
positive reported with a minimal repro is far more actionable than
|
|
69
|
+
one buried in a baseline. While the issue is open the baseline keeps
|
|
70
|
+
the site quiet.
|
|
71
|
+
|
|
72
|
+
## Phase 4 — Refresh the baseline
|
|
73
|
+
|
|
74
|
+
After working a rule (fixes applied, `# rigor:disable` comments
|
|
75
|
+
added, issues filed), the live diagnostic count for the touched
|
|
76
|
+
buckets has dropped below the recorded count. Make the baseline
|
|
77
|
+
reflect reality.
|
|
78
|
+
|
|
79
|
+
First, inspect the drift:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
rigor baseline drift
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This reports each bucket as `within` / `over` / `cleared` /
|
|
86
|
+
`reducible`. After a reduction session you expect `cleared` (the
|
|
87
|
+
bucket is now empty) and `reducible` (the bucket shrank but is not
|
|
88
|
+
empty) rows.
|
|
89
|
+
|
|
90
|
+
Then refresh:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
rigor baseline regenerate
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`regenerate` rewrites `.rigor-baseline.yml` from a fresh `rigor
|
|
97
|
+
check` run — cleared buckets disappear, reducible buckets get their
|
|
98
|
+
new lower counts. This is the command that *banks* the session's
|
|
99
|
+
gains: until you regenerate, the baseline still records the old,
|
|
100
|
+
higher numbers.
|
|
101
|
+
|
|
102
|
+
`rigor baseline prune` is the narrower tool — it drops only the
|
|
103
|
+
fully-`cleared` buckets and leaves reducible ones at their old count.
|
|
104
|
+
Prefer `regenerate` at the end of a reduction session; it captures
|
|
105
|
+
both kinds of progress in one step.
|
|
106
|
+
|
|
107
|
+
Commit the updated `.rigor-baseline.yml` together with the code
|
|
108
|
+
fixes and `# rigor:disable` comments, so the smaller baseline and the
|
|
109
|
+
work that earned it land together.
|
|
110
|
+
|
|
111
|
+
## Verifying the session
|
|
112
|
+
|
|
113
|
+
Confirm the gains held:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
rigor baseline drift # expect "No drift detected" after regenerate
|
|
117
|
+
rigor check # the project's gate — should still pass
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If the project's CI uses `rigor check --baseline-strict`, the run
|
|
121
|
+
after `regenerate` should be clean — a stale baseline (numbers not
|
|
122
|
+
refreshed) is exactly the deficit drift that gate fails on.
|
|
123
|
+
|
|
124
|
+
## Output of this module — session complete
|
|
125
|
+
|
|
126
|
+
- Code fixes for the real bugs.
|
|
127
|
+
- Intentional, reasoned `# rigor:disable` comments for the
|
|
128
|
+
stylistic-safe sites.
|
|
129
|
+
- Rigor issues filed for the false positives.
|
|
130
|
+
- A regenerated, smaller `.rigor-baseline.yml`, committed with the
|
|
131
|
+
work.
|
|
132
|
+
|
|
133
|
+
Run the skill again next session to take the next rule.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rigor-plugin-author
|
|
3
|
+
description: |
|
|
4
|
+
Author a Rigor plugin in your own repository — a standalone rigor-prefixed gem or a project-private plugin — to teach Rigor about an application DSL, framework, or metaprogramming pattern. Covers gemspec / Gemfile wiring, the plugin class and AST walker, return-type contributions, fixture-based testing (RSpec or Minitest), and version pinning against the pre-1.0 plugin contract. Triggers: "write a Rigor plugin for our DSL", "extend Rigor for X in this project", "make Rigor understand our macro". NOT for onboarding a project (use rigor-project-init) or reducing a baseline (use rigor-baseline-reduce).
|
|
5
|
+
license: MPL-2.0
|
|
6
|
+
metadata:
|
|
7
|
+
version: 0.1.0
|
|
8
|
+
homepage: https://github.com/rigortype/rigor
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Rigor Plugin Author (external)
|
|
12
|
+
|
|
13
|
+
Author a Rigor plugin **in your own repository** — to teach Rigor a
|
|
14
|
+
DSL, framework convention, or metaprogramming pattern its core
|
|
15
|
+
analyzer cannot follow. The result is either a standalone
|
|
16
|
+
`rigor-<id>` gem or a project-private plugin.
|
|
17
|
+
|
|
18
|
+
This skill targets **external authors**: you depend on the published
|
|
19
|
+
`rigortype` gem (`bundle add rigortype`) and use the public plugin
|
|
20
|
+
API surface — `Rigor::Plugin::Base` and friends. It does not assume
|
|
21
|
+
the rigor monorepo's `Makefile`, `spec/integration/` helpers, or Nix
|
|
22
|
+
environment.
|
|
23
|
+
|
|
24
|
+
> **Authoring inside the rigor monorepo instead?** If you are adding
|
|
25
|
+
> a plugin to rigor's own `plugins/` or `examples/` tree, use the
|
|
26
|
+
> contributor `rigor-plugin-author` skill bundled in that repo — it
|
|
27
|
+
> covers the in-repo layout, `plugin_helpers.rb`, and `make verify`.
|
|
28
|
+
> This skill is for plugins that live in *your* project.
|
|
29
|
+
|
|
30
|
+
## Important — the plugin contract is a preview (pre-1.0)
|
|
31
|
+
|
|
32
|
+
Rigor's plugin contract (ADR-2) is **not yet frozen**. It stabilises
|
|
33
|
+
at `rigortype` v0.2.0. Until then, treat each `rigortype` minor
|
|
34
|
+
release as potentially contract-changing:
|
|
35
|
+
|
|
36
|
+
- **Pin `rigortype` tightly** — `>= 0.1.0, < 0.2.0` in your gemspec
|
|
37
|
+
or Gemfile. Do not float across the v0.2.0 boundary blind.
|
|
38
|
+
- Expect to **revisit your plugin** when you bump `rigortype` to the
|
|
39
|
+
next minor. The walker hook signature, the `Diagnostic` shape, and
|
|
40
|
+
the type carriers may shift.
|
|
41
|
+
- After v0.2.0 the contract is stable and ordinary semver applies.
|
|
42
|
+
|
|
43
|
+
Tell the user this up front. A plugin written today is a preview
|
|
44
|
+
artefact, valuable but not yet on a stable foundation.
|
|
45
|
+
|
|
46
|
+
## Phase 0 — Standalone gem or project-private?
|
|
47
|
+
|
|
48
|
+
Decide where the plugin lives before scaffolding anything.
|
|
49
|
+
|
|
50
|
+
| Signal | Build it as |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| The DSL/library is reusable across projects, or you want to publish it | **Standalone `rigor-<id>` gem** — own repo, own gemspec, RubyGems-publishable |
|
|
53
|
+
| The pattern is specific to *one* application's own code / in-house DSL | **Project-private plugin** — lives inside the app repo, never published |
|
|
54
|
+
|
|
55
|
+
Both produce the same plugin class and walker; they differ only in
|
|
56
|
+
packaging and activation (Phase 1). When unsure, default to
|
|
57
|
+
**project-private** — it is less ceremony, and a plugin can always
|
|
58
|
+
be extracted into a gem later.
|
|
59
|
+
|
|
60
|
+
Do NOT use this skill for:
|
|
61
|
+
|
|
62
|
+
- **Onboarding a project to Rigor** (writing `.rigor.yml`, choosing
|
|
63
|
+
plugins) → `rigor-project-init`.
|
|
64
|
+
- **Reducing a baseline** → `rigor-baseline-reduce`.
|
|
65
|
+
- **Editing an existing, already-working plugin** — that is ordinary
|
|
66
|
+
code work; modify the plugin class directly.
|
|
67
|
+
|
|
68
|
+
## How a plugin works — the one-paragraph model
|
|
69
|
+
|
|
70
|
+
A Rigor plugin is a Ruby class that subclasses `Rigor::Plugin::Base`,
|
|
71
|
+
declares a `manifest(id:, version:, …)`, and calls
|
|
72
|
+
`Rigor::Plugin.register(self)` at load time. When `.rigor.yml` lists
|
|
73
|
+
the plugin under `plugins:`, Rigor `require`s it and, for every
|
|
74
|
+
analysed file, calls the plugin's `#diagnostics_for_file(path:,
|
|
75
|
+
scope:, root:)` — handing it the file's Prism AST (`root`) and a
|
|
76
|
+
`scope` it can query for inferred types. The plugin walks the AST and
|
|
77
|
+
returns an array of `Rigor::Analysis::Diagnostic`. Optionally it also
|
|
78
|
+
implements `#flow_contribution_for(call_node:, scope:)` to *supply* a
|
|
79
|
+
return type for call sites the core analyzer types as `Dynamic`.
|
|
80
|
+
|
|
81
|
+
## Phase outline
|
|
82
|
+
|
|
83
|
+
| Phase | What | Reference |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| 1 | Package and scaffold — gem vs project-private layout, gemspec / Gemfile, the plugin class skeleton, `.rigor.yml` activation. | [`references/01-plan-and-scaffold.md`](references/01-plan-and-scaffold.md) |
|
|
86
|
+
| 2 | The walker — `diagnostics_for_file`, building `Diagnostic`s, querying `scope.type_of`, optional `flow_contribution_for`, RBS for the DSL. | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) |
|
|
87
|
+
| 3 | Test and ship — fixture-based tests (RSpec / Minitest, no rigor internals), version pinning, README, publish or keep private. | [`references/03-test-and-ship.md`](references/03-test-and-ship.md) |
|
|
88
|
+
|
|
89
|
+
## Reading order — modules
|
|
90
|
+
|
|
91
|
+
| Module | Read | Covers |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| 1 | [`references/01-plan-and-scaffold.md`](references/01-plan-and-scaffold.md) | **Phase 1.** The gem vs project-private packaging split, directory trees for both, gemspec template, project-private path-gem / `RUBYLIB` activation, the `Rigor::Plugin::Base` skeleton, `.rigor.yml` `plugins:` wiring. |
|
|
94
|
+
| 2 | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) | **Phase 2.** The `diagnostics_for_file` AST walk over Prism nodes, the `Diagnostic` constructor shape, asking the analyzer for inferred types via `scope.type_of`, the optional `flow_contribution_for` return-type hook, and shipping `sig/*.rbs` so the DSL's types are visible. |
|
|
95
|
+
| 3 | [`references/03-test-and-ship.md`](references/03-test-and-ship.md) | **Phase 3.** Testing a plugin from outside the monorepo — fixture projects driven through `rigor check --format json`, plus pure unit tests of dispatch tables — with RSpec or Minitest. Version pinning against the pre-1.0 contract. README. Publishing to RubyGems or keeping the plugin private. |
|