rigortype 0.1.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 182bad9de02b3b4579fe1c385fa740e30f2df85cad36d8c21647dfb09004b9eb
4
- data.tar.gz: 398d4ebc670530522696122117592bd59b60d7f899ae0a8cd58b11c8506ec608
3
+ metadata.gz: 6f7d66cf2c7a6c883fbbab59a805b1d1bca62867022e42d9b68d0b788525e831
4
+ data.tar.gz: c327399cf8c239da46f383de404cd15843d8ce220b5538f1905d72f4e082a59f
5
5
  SHA512:
6
- metadata.gz: abd25775ea4973023dc7ef7132669a0bf556604c7378caef60aa3c2fbae4ef79bc2651cd3499cecb8046507214dbdc4cb56bdb953c301ec42b0ebb86291202b6
7
- data.tar.gz: 9281f16a4b2d39847aaaf408844920dfb9f2e6a914df3544182d6d3832f28f03ca63d8a19aa54ca80977f3431351717a59d7ba704f3361ee92697be21d6193ab
6
+ metadata.gz: a69935a6c878eba22f3050041d044855b9f98eefa8dc3ab55bd33ca2ecce12efc5abffb2dd0fbc57edfd7736fe5ee4eb985694e4fbcddbc63f2e231bf48483a4
7
+ data.tar.gz: 58839858c8a5adcf8db74ef9d8c5ed2999c4459854a85c330b107ce3a933d08cb024cacc6c1303c638ab12ae1f5bc6559c14922ac13d17f41a5609631220fc28
@@ -57,6 +57,7 @@ module Rigor
57
57
  # system; new rules MUST register here so user configuration
58
58
  # can refer to them.
59
59
  RULE_UNDEFINED_METHOD = "call.undefined-method"
60
+ RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
60
61
  RULE_WRONG_ARITY = "call.wrong-arity"
61
62
  RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
62
63
  RULE_NIL_RECEIVER = "call.possible-nil-receiver"
@@ -72,6 +73,7 @@ module Rigor
72
73
 
73
74
  ALL_RULES = [
74
75
  RULE_UNDEFINED_METHOD,
76
+ RULE_UNRESOLVED_TOPLEVEL,
75
77
  RULE_WRONG_ARITY,
76
78
  RULE_ARGUMENT_TYPE,
77
79
  RULE_NIL_RECEIVER,
@@ -162,6 +164,7 @@ module Rigor
162
164
  def call_node_diagnostics(path, node, scope_index)
163
165
  [
164
166
  undefined_method_diagnostic(path, node, scope_index),
167
+ unresolved_toplevel_diagnostic(path, node, scope_index),
165
168
  wrong_arity_diagnostic(path, node, scope_index),
166
169
  argument_type_diagnostic(path, node, scope_index),
167
170
  nil_receiver_diagnostic(path, node, scope_index),
@@ -365,10 +368,14 @@ module Rigor
365
368
  return nil if open_receiver?(class_name, scope)
366
369
 
367
370
  # Slice 7 phase 12 — suppress when the user has
368
- # declared the method in source (instance `def`,
369
- # `def self.foo`, or recognised `define_method`).
371
+ # declared the method in source (`def` /
372
+ # `define_method`) OR in a `pre_eval:` monkey-patch
373
+ # file (ADR-17). Both paths are project-side method
374
+ # contributions the dispatcher already resolved; the
375
+ # rule must not surface a false `undefined-method`
376
+ # for them.
370
377
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
371
- return nil if scope.discovered_method?(class_name, call_node.name, kind)
378
+ return nil if source_declared_method?(scope, class_name, call_node.name, kind)
372
379
 
373
380
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
374
381
 
@@ -404,6 +411,92 @@ module Rigor
404
411
  scope.environment.rbs_module?(receiver_type.class_name)
405
412
  end
406
413
 
414
+ # Combined suppression probe for `undefined-method` /
415
+ # `unresolved-toplevel`. Returns true when the method is
416
+ # declared by any project-side contributor the dispatcher
417
+ # already resolves: an in-source `def` / `define_method`
418
+ # (`scope.discovered_method?`) OR an ADR-17 `pre_eval:`
419
+ # monkey-patch (`Environment#project_patched_methods`).
420
+ # Both paths sit at the same dispatcher precedence; the
421
+ # check must hold them together so neither rule fires a
422
+ # false positive.
423
+ def source_declared_method?(scope, class_name, method_name, kind)
424
+ return true if scope.discovered_method?(class_name, method_name, kind)
425
+
426
+ project_patched_method?(scope, class_name, method_name, kind)
427
+ end
428
+
429
+ # ADR-17 § "Inference contract" — consults
430
+ # `Environment#project_patched_methods` so a `def` declared
431
+ # in a `pre_eval:` file suppresses the diagnostic at the
432
+ # same dispatcher precedence the registry holds for type
433
+ # inference (between plugins and dependency-source).
434
+ # Returns false when the environment carries no registry
435
+ # (legacy path) or the lookup misses.
436
+ def project_patched_method?(scope, class_name, method_name, kind)
437
+ environment = scope.environment
438
+ registry = environment&.project_patched_methods
439
+ return false if registry.nil? || registry.empty?
440
+
441
+ !registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind).nil?
442
+ end
443
+
444
+ # ADR-34 — `call.unresolved-toplevel`. Fires on an
445
+ # implicit-self call (no explicit receiver) at toplevel
446
+ # scope (`scope.toplevel?`, i.e. outside any class /
447
+ # module body) whose name does not resolve against:
448
+ #
449
+ # 1. A same-file toplevel `def` via
450
+ # {Scope#top_level_def_for}.
451
+ # 2. The ADR-17 `ProjectPatchedMethods` registry under
452
+ # `(Object, name, :instance)` — projects declare
453
+ # their toplevel-injecting monkey-patches in
454
+ # `.rigor.yml`'s `pre_eval:` array as the canonical
455
+ # opt-out per ADR-34 WD2.
456
+ # 3. The standard `Kernel` / `Object` private-method
457
+ # surface (`puts`, `p`, `require`, `loop`, `raise`,
458
+ # …) drawn from the loaded RBS environment.
459
+ #
460
+ # The rule deliberately does NOT generalise to
461
+ # implicit-self calls inside `def` / `class` / `module`
462
+ # bodies — ADR-24 WD3's lenient-on-unresolved default
463
+ # stays in force there. ADR-24 WD4's gated class-body
464
+ # diagnostic is a separate decision this ADR does not
465
+ # open.
466
+ #
467
+ # Authored severity is `:warning`; the severity profile
468
+ # remaps it (`strict` → `:error`, `balanced` →
469
+ # `:warning`, `lenient` → `:off` / suppressed).
470
+ def unresolved_toplevel_diagnostic(path, call_node, scope_index)
471
+ return nil unless call_node.receiver.nil?
472
+
473
+ scope = scope_index[call_node]
474
+ return nil if scope.nil?
475
+ return nil unless scope.toplevel?
476
+
477
+ name = call_node.name
478
+ return nil if scope.top_level_def_for(name)
479
+ return nil if source_declared_method?(scope, "Object", name, :instance)
480
+ return nil if Rigor::Reflection.instance_method_definition("Object", name, scope: scope)
481
+
482
+ build_unresolved_toplevel_diagnostic(path, call_node)
483
+ end
484
+
485
+ def build_unresolved_toplevel_diagnostic(path, call_node)
486
+ location = call_node.message_loc || call_node.location
487
+ Diagnostic.new(
488
+ path: path,
489
+ line: location.start_line,
490
+ column: location.start_column + 1,
491
+ message: "unresolved toplevel call to `#{call_node.name}`. " \
492
+ "If a project file defines `#{call_node.name}` via a toplevel " \
493
+ "`def` or a monkey-patch on Object/Kernel, list that file in " \
494
+ "`.rigor.yml`'s `pre_eval:` (ADR-17) so the analyzer sees it.",
495
+ severity: :warning,
496
+ rule: RULE_UNRESOLVED_TOPLEVEL
497
+ )
498
+ end
499
+
407
500
  # Returns a qualified class name for the in-scope check.
408
501
  # Nominal / Singleton carry a single-class identity
409
502
  # directly. Constant projects to its value's class.
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # `rigor skill` — discover and print the SKILL.md files
8
+ # bundled with the `rigortype` gem.
9
+ #
10
+ # Rigor ships a small set of Agent Skills under `skills/` that
11
+ # walk an AI coding agent through onboarding (`rigor-project-init`),
12
+ # baseline reduction (`rigor-baseline-reduce`), and authoring a
13
+ # plugin (`rigor-plugin-author`). When Rigor is installed via
14
+ # `mise` / `gem install` / etc. the SKILL files live inside the
15
+ # gem checkout — the project being analysed has no copy, so an
16
+ # AI agent has no a priori way to find them.
17
+ #
18
+ # This command exposes the bundled skills via three subcommands:
19
+ #
20
+ # - `rigor skill list` — table of name + absolute path.
21
+ # - `rigor skill print <name>` — short header (paths + how to use)
22
+ # followed by the SKILL.md body. This
23
+ # is the form AI agents should call;
24
+ # the inline body plus the header's
25
+ # absolute paths together let the
26
+ # agent act with or without a file
27
+ # reading tool.
28
+ # - `rigor skill path <name>` — one-line absolute path, suitable
29
+ # as input to a Read tool.
30
+ #
31
+ # `rigor skill` with no subcommand is an alias for `list`.
32
+ class SkillCommand
33
+ USAGE = <<~USAGE
34
+ Usage: rigor skill <subcommand> [args]
35
+
36
+ Subcommands:
37
+ list List bundled skills (default when no subcommand given)
38
+ print <name> Print the SKILL.md body for <name> to stdout, with a header
39
+ path <name> Print the absolute path of the SKILL.md file for <name>
40
+
41
+ Examples:
42
+ rigor skill list
43
+ rigor skill print rigor-project-init
44
+ rigor skill path rigor-baseline-reduce
45
+ USAGE
46
+
47
+ # The bundled skills live at `<gem_root>/skills/`. From
48
+ # `lib/rigor/cli/skill_command.rb` that is three directories up.
49
+ SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
50
+
51
+ def initialize(argv:, out: $stdout, err: $stderr)
52
+ @argv = argv
53
+ @out = out
54
+ @err = err
55
+ end
56
+
57
+ # @return [Integer] CLI exit status.
58
+ def run
59
+ subcommand = @argv.shift || "list"
60
+
61
+ case subcommand
62
+ when "list" then run_list
63
+ when "print" then run_print
64
+ when "path" then run_path
65
+ when "-h", "--help", "help"
66
+ print_usage(@out)
67
+ 0
68
+ else
69
+ @err.puts("Unknown subcommand: #{subcommand}")
70
+ print_usage(@err)
71
+ Rigor::CLI::EXIT_USAGE
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def run_list
78
+ skills = discover_skills
79
+ if skills.empty?
80
+ @err.puts("No bundled skills found under #{SKILLS_ROOT}")
81
+ return 1
82
+ end
83
+
84
+ width = skills.map { |s| s.fetch(:name).length }.max
85
+ skills.each do |skill|
86
+ @out.puts(format("%-#{width}s %s", skill.fetch(:name), skill.fetch(:path)))
87
+ end
88
+ 0
89
+ end
90
+
91
+ def run_print
92
+ name = @argv.shift
93
+ return usage_error("`print` requires a skill name") if name.nil?
94
+
95
+ skill = find_skill(name)
96
+ return name_error(name) if skill.nil?
97
+
98
+ @out.puts(render_print_header(skill))
99
+ @out.puts
100
+ @out.write(File.read(skill.fetch(:path)))
101
+ 0
102
+ end
103
+
104
+ def run_path
105
+ name = @argv.shift
106
+ return usage_error("`path` requires a skill name") if name.nil?
107
+
108
+ skill = find_skill(name)
109
+ return name_error(name) if skill.nil?
110
+
111
+ @out.puts(skill.fetch(:path))
112
+ 0
113
+ end
114
+
115
+ # The header that precedes the SKILL.md body when an agent
116
+ # runs `rigor skill print <name>`. Kept as `# `-prefixed
117
+ # comment lines so the combined output remains parseable as
118
+ # markdown — anything below `---` (the SKILL frontmatter
119
+ # marker) is unchanged.
120
+ def render_print_header(skill)
121
+ references_dir = File.join(File.dirname(skill.fetch(:path)), "references")
122
+ ref_line = if File.directory?(references_dir)
123
+ "# References: #{references_dir}/ (read referenced `references/NN-*.md` files from here)"
124
+ else
125
+ "# References: (none)"
126
+ end
127
+ <<~HEADER.chomp
128
+ # Rigor skill: #{skill.fetch(:name)}
129
+ # Source: #{skill.fetch(:path)}
130
+ #{ref_line}
131
+ #
132
+ # The body below is the canonical SKILL definition shipped with
133
+ # rigortype #{Rigor::VERSION}. Follow its instructions.
134
+ HEADER
135
+ end
136
+
137
+ def discover_skills
138
+ return [] unless File.directory?(SKILLS_ROOT)
139
+
140
+ Dir.children(SKILLS_ROOT).sort.filter_map do |name|
141
+ skill_md = File.join(SKILLS_ROOT, name, "SKILL.md")
142
+ next unless File.file?(skill_md)
143
+
144
+ { name: name, path: skill_md }
145
+ end
146
+ end
147
+
148
+ def find_skill(name)
149
+ discover_skills.find { |s| s.fetch(:name) == name }
150
+ end
151
+
152
+ def name_error(name)
153
+ @err.puts("Unknown skill: #{name}")
154
+ @err.puts("Available skills:")
155
+ discover_skills.each { |s| @err.puts(" #{s.fetch(:name)}") }
156
+ 1
157
+ end
158
+
159
+ def usage_error(message)
160
+ @err.puts(message)
161
+ print_usage(@err)
162
+ Rigor::CLI::EXIT_USAGE
163
+ end
164
+
165
+ def print_usage(io)
166
+ io.puts(USAGE)
167
+ end
168
+ end
169
+ end
170
+ end
data/lib/rigor/cli.rb CHANGED
@@ -33,7 +33,8 @@ module Rigor
33
33
  "triage" => :run_triage,
34
34
  "coverage" => :run_coverage,
35
35
  "plugins" => :run_plugins,
36
- "playground" => :run_playground
36
+ "playground" => :run_playground,
37
+ "skill" => :run_skill
37
38
  }.freeze
38
39
 
39
40
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -635,6 +636,12 @@ module Rigor
635
636
  Rigor::CLI::PlaygroundCommand.new(@argv[1..], @out, @err).run
636
637
  end
637
638
 
639
+ def run_skill
640
+ require_relative "cli/skill_command"
641
+
642
+ CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
643
+ end
644
+
638
645
  def write_result(result, format)
639
646
  case format
640
647
  when "json"
@@ -682,6 +689,7 @@ module Rigor
682
689
  coverage Report type-precision coverage (precise vs Dynamic ratio)
683
690
  plugins Report activation status of every configured plugin
684
691
  playground Start the browser playground (requires rigor-playground gem)
692
+ skill List or print bundled Agent Skills (rigor-project-init, ...)
685
693
  version Print the Rigor version
686
694
  help Print this help
687
695
  HELP
@@ -39,6 +39,7 @@ module Rigor
39
39
  PROFILES = {
40
40
  lenient: {
41
41
  "call.undefined-method" => :error,
42
+ "call.unresolved-toplevel" => :off,
42
43
  "call.wrong-arity" => :error,
43
44
  "call.argument-type-mismatch" => :warning,
44
45
  "call.possible-nil-receiver" => :warning,
@@ -54,6 +55,7 @@ module Rigor
54
55
  }.freeze,
55
56
  balanced: {
56
57
  "call.undefined-method" => :error,
58
+ "call.unresolved-toplevel" => :warning,
57
59
  "call.wrong-arity" => :error,
58
60
  "call.argument-type-mismatch" => :error,
59
61
  "call.possible-nil-receiver" => :error,
@@ -69,6 +71,7 @@ module Rigor
69
71
  }.freeze,
70
72
  strict: {
71
73
  "call.undefined-method" => :error,
74
+ "call.unresolved-toplevel" => :error,
72
75
  "call.wrong-arity" => :error,
73
76
  "call.argument-type-mismatch" => :error,
74
77
  "call.possible-nil-receiver" => :error,
data/lib/rigor/scope.rb CHANGED
@@ -309,6 +309,20 @@ module Rigor
309
309
  table[method_name.to_sym] == kind
310
310
  end
311
311
 
312
+ # ADR-34 § "Decision" — predicate identifying a toplevel-shaped
313
+ # scope (no enclosing `class` / `module` body). True at the top
314
+ # of a file AND inside a top-level `def` body (since toplevel
315
+ # defs leave `self_type` nil per the existing scope-construction
316
+ # contract, mirroring how ADR-24's `adoptable_self_call_result?`
317
+ # also keys on `self_type.nil?` for the same context). Used by
318
+ # `CheckRules#unresolved_toplevel_diagnostic` to gate the
319
+ # `call.unresolved-toplevel` rule so it fires only outside
320
+ # class / module bodies, where Rails-DSL metaprogramming
321
+ # leniency (ADR-24 WD3 → WD4) does not apply.
322
+ def toplevel?
323
+ @self_type.nil?
324
+ end
325
+
312
326
  def with_discovered_methods(table)
313
327
  rebuild(discovered_methods: table)
314
328
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.12"
4
+ VERSION = "0.1.13"
5
5
  end
data/sig/rigor/scope.rbs CHANGED
@@ -58,6 +58,7 @@ module Rigor
58
58
  def with_discovered_def_nodes: (Hash[String, Hash[Symbol, untyped]] table) -> Scope
59
59
  def user_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
60
60
  def top_level_def_for: (String | Symbol method_name) -> untyped?
61
+ def toplevel?: () -> bool
61
62
  def with_discovered_method_visibilities: (Hash[String, Hash[Symbol, Symbol]] table) -> Scope
62
63
  def discovered_method_visibility: (String | Symbol class_name, String | Symbol method_name) -> Symbol?
63
64
  def superclass_of: (String | Symbol class_name) -> String?
@@ -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)).