Sdlc/Structural/RescueSite: record is_a?, ===, and when constants only when the name ends in Error or Exception #1376

Closed
opened 2026-10-25 21:23:27 +00:00 by jared · 2 comments
Owner

Problem

Sdlc/Structural/RescueSite is a recording cop. Its record claims that a constant is an error family that a site handles. For the is_a?, ===, and when forms it records any constant, so most of those records name a class that is not an error. ErrorLineage::RescueIndex reads the records, and bin/lint-sweep shows each record as an offense.

Found by the #1325 zero-false-positive audit (docs/plans/class-length-family/sdlc-cop-audit.md). Shapes measured on main 3ccd7a22, 2026-10-26, over every plugins/**/*.rb file (446 records):

shape records verdict example
rescue C (any constant) 321 true positive: Ruby rescues only an exception class or module plugins/os/lib/handoff/cli.rb:24 rescue Error => e
is_a?/===/when with a constant whose last name segment ends in Error or Exception 9 true positive plugins/os-sdlc/lib/os_sdlc/project.rb:14 raise if e.is_a?(OsSdlc::Project::TargetSelectionError)
is_a?/===/when with any other constant 116 false positive: not an error plugins/os-aidd-lint/lib/aidd_lint/hook_input.rb:12 data = {} unless data.is_a?(Hash); also when Array at plugins/os-adr/lib/adr/record/template.rb:21. Most common: is_a?(Hash) 42, is_a?(String) 15, Prism node classes

In the #1317 sweep of os-backlog, os, os-adr, os-aidd-lint, and os-me (163 RescueSite offenses in 93 files), 33 are this false-positive shape and 130 are rescue sites. Example: value.is_a?(String) is recorded as an error family.

Approach

Fix by shape in the cop matcher. Never use a config Exclude list or a test-path exclusion.

Rule: for the is_a?, ===, and when forms, the cop records a constant only when the last segment of its name ends in Error or Exception. The rescue form does not change: a rescued constant is always an exception class or module.

Measured effect (scratch simulation on main 3ccd7a22): records over plugins/**/*.rb go from 446 to 330; the 116 removed records are all non-errors; the 9 kept non-rescue records are all errors. plugins/os-sdlc/bin/detector error-lineage --target os-sdlc prints the same bytes before and after. Two rejected rules changed 8 rows of that output: record a non-rescue constant only when the same file rescues it, or drop the non-rescue forms.

A non-rescue check on an error whose name does not end in Error or Exception (for example Gate::Failed) is no longer recorded. This miss is accepted, as in ruling R2 (docs/plans/class-length-family/HANDOFF.md): the cop gives up recall to keep every record true. No such site exists in plugins/ today.

Required work:

  1. In plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb, record an is_a? argument, a === receiver, or a when condition only when the last segment of its constant name ends in Error or Exception. Gate the rule on the form, not inside the shared site path, so that the rescue form stays unchanged. Add must-pass fixtures (value.is_a?(String), Hash === value, case v with when Array) and must-fire fixtures (e.is_a?(Map::InvalidError), Psych::Exception === e) to plugins/os-sdlc/tests/cops/rescue_site_test.rb.
    check: present is_a?(String) in plugins/os-sdlc/tests/cops/rescue_site_test.rb
  2. Update test_a_when_on_a_local_variable_names_its_constants (plugins/os-sdlc/tests/cops/rescue_site_test.rb:48-51): its fixture when Failed, Loud::Error records only Loud::Error under the rule. Rename the fixture constant Failed to an Error-suffixed name so that the test keeps two when records.
    check: absent when Failed, Loud::Error in plugins/os-sdlc/tests/cops/rescue_site_test.rb
  3. Update the fixtures in plugins/os-sdlc/tests/detector/error_lineage_test.rb that check the error Root with is_a?, ===, and when (:13, :28). Rename Root to RootError in every fixture and assertion of that file, so that the family counts stay 3, 3, 1 and 1.
    check: absent is_a?(Root) in plugins/os-sdlc/tests/detector/error_lineage_test.rb
  4. In the lesson plugins/os-sdlc/lint-lessons/sdlc-structural-rescue-site.yml, state in signal that an is_a?, ===, or when site counts only when the constant name ends in Error or Exception.
    check: present ends in Error or Exception in plugins/os-sdlc/lint-lessons/sdlc-structural-rescue-site.yml
  5. Add one History row for this change to docs/implementation-status/os-sdlc.md.
    check: present #1376 in docs/implementation-status/os-sdlc.md

Acceptance criteria

  1. Given value.is_a?(String), Hash === value, or case v with when Array, when the cop runs, then it records nothing.
  2. Given e.is_a?(Map::InvalidError), Psych::Exception === e, or case error with when Gate::FailedError, when the cop runs, then it records the constant with its form.
  3. Given rescue Gate::Failed => e, when the cop runs, then it records Gate::Failed with form rescue, as today.
  4. Given plugins/os-sdlc/bin/detector error-lineage --target os-sdlc, when it runs after the change, then its stdout is byte-identical to its stdout on the base commit.
  5. Given plugins/os-sdlc/bin/lint-sweep plugins/<p> for os-backlog, os, os-adr, os-aidd-lint, and os-me, when it runs, then every Sdlc/Structural/RescueSite offense is the rescue form or names a constant whose last segment ends in Error or Exception (163 offenses on 3ccd7a22 become 130).

Owner sketch

  • RuboCop::Cop::Sdlc::Structural::RescueSite (existing) owns the decision: it already routes all four forms through site. It asks for the constant's name parts (RescueSite::ConstantParts, existing) and, for a non-rescue form, reports only when the last part names an error. No new object.

In scope / out of scope

  • In scope: the record rule for the is_a?, ===, and when forms, its tests, the two pinning tests above, and the lesson signal.
  • Out of scope (cut on 2026-10-26, no site in plugins/): recording rescue *CONST with a literal constant array, kind_of? and instance_of? arguments, and case @ivar subjects. rescue_site_test.rb:57-60 (an unresolved splat is not a site) stays. The only case @ivar in plugins/ (plugins/os-sdlc/lib/os_sdlc/constant_path.rb:27) checks Prism node classes, so recording it would add a false positive.
  • Out of scope: ErrorLineage::RescueIndex and the other detector code; their output does not change.

Blocking edges

None. One implement run.

Origin

  • Trigger: the #1325 zero-false-positive audit of the Sdlc cops (docs/plans/class-length-family/sdlc-cop-audit.md)
  • Improvised this session: none
  • Chain: wrong and missing records ← matcher accepts any constant and a narrow set of forms ← DESIGN (plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb)
  • Root candidate: none found; searched RescueSite, rescue_site, rescue, kind_of, splat
  • Where: plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb
  • Session: eb98cdcd-6783-46a0-b7df-cf18a8fe2a56
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-ticket-1325/eb98cdcd-6783-46a0-b7df-cf18a8fe2a56.jsonl

VERDICT: CREATE

## Problem `Sdlc/Structural/RescueSite` is a recording cop. Its record claims that a constant is an error family that a site handles. For the `is_a?`, `===`, and `when` forms it records any constant, so most of those records name a class that is not an error. `ErrorLineage::RescueIndex` reads the records, and `bin/lint-sweep` shows each record as an offense. Found by the #1325 zero-false-positive audit (docs/plans/class-length-family/sdlc-cop-audit.md). Shapes measured on main 3ccd7a22, 2026-10-26, over every `plugins/**/*.rb` file (446 records): | shape | records | verdict | example | |---|---|---|---| | `rescue C` (any constant) | 321 | true positive: Ruby rescues only an exception class or module | `plugins/os/lib/handoff/cli.rb:24` `rescue Error => e` | | `is_a?`/`===`/`when` with a constant whose last name segment ends in `Error` or `Exception` | 9 | true positive | `plugins/os-sdlc/lib/os_sdlc/project.rb:14` `raise if e.is_a?(OsSdlc::Project::TargetSelectionError)` | | `is_a?`/`===`/`when` with any other constant | 116 | false positive: not an error | `plugins/os-aidd-lint/lib/aidd_lint/hook_input.rb:12` `data = {} unless data.is_a?(Hash)`; also `when Array` at `plugins/os-adr/lib/adr/record/template.rb:21`. Most common: `is_a?(Hash)` 42, `is_a?(String)` 15, Prism node classes | In the #1317 sweep of os-backlog, os, os-adr, os-aidd-lint, and os-me (163 RescueSite offenses in 93 files), 33 are this false-positive shape and 130 are `rescue` sites. Example: `value.is_a?(String)` is recorded as an error family. ## Approach Fix by shape in the cop matcher. Never use a config Exclude list or a test-path exclusion. Rule: for the `is_a?`, `===`, and `when` forms, the cop records a constant only when the last segment of its name ends in `Error` or `Exception`. The `rescue` form does not change: a rescued constant is always an exception class or module. Measured effect (scratch simulation on main 3ccd7a22): records over `plugins/**/*.rb` go from 446 to 330; the 116 removed records are all non-errors; the 9 kept non-rescue records are all errors. `plugins/os-sdlc/bin/detector error-lineage --target os-sdlc` prints the same bytes before and after. Two rejected rules changed 8 rows of that output: record a non-rescue constant only when the same file rescues it, or drop the non-rescue forms. A non-rescue check on an error whose name does not end in `Error` or `Exception` (for example `Gate::Failed`) is no longer recorded. This miss is accepted, as in ruling R2 (docs/plans/class-length-family/HANDOFF.md): the cop gives up recall to keep every record true. No such site exists in `plugins/` today. Required work: 1. In `plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb`, record an `is_a?` argument, a `===` receiver, or a `when` condition only when the last segment of its constant name ends in `Error` or `Exception`. Gate the rule on the form, not inside the shared `site` path, so that the `rescue` form stays unchanged. Add must-pass fixtures (`value.is_a?(String)`, `Hash === value`, `case v` with `when Array`) and must-fire fixtures (`e.is_a?(Map::InvalidError)`, `Psych::Exception === e`) to `plugins/os-sdlc/tests/cops/rescue_site_test.rb`. check: present `is_a?(String)` in `plugins/os-sdlc/tests/cops/rescue_site_test.rb` 2. Update `test_a_when_on_a_local_variable_names_its_constants` (`plugins/os-sdlc/tests/cops/rescue_site_test.rb:48-51`): its fixture `when Failed, Loud::Error` records only `Loud::Error` under the rule. Rename the fixture constant `Failed` to an `Error`-suffixed name so that the test keeps two `when` records. check: absent `when Failed, Loud::Error` in `plugins/os-sdlc/tests/cops/rescue_site_test.rb` 3. Update the fixtures in `plugins/os-sdlc/tests/detector/error_lineage_test.rb` that check the error `Root` with `is_a?`, `===`, and `when` (`:13`, `:28`). Rename `Root` to `RootError` in every fixture and assertion of that file, so that the family counts stay 3, 3, 1 and 1. check: absent `is_a?(Root)` in `plugins/os-sdlc/tests/detector/error_lineage_test.rb` 4. In the lesson `plugins/os-sdlc/lint-lessons/sdlc-structural-rescue-site.yml`, state in `signal` that an `is_a?`, `===`, or `when` site counts only when the constant name ends in Error or Exception. check: present `ends in Error or Exception` in `plugins/os-sdlc/lint-lessons/sdlc-structural-rescue-site.yml` 5. Add one History row for this change to `docs/implementation-status/os-sdlc.md`. check: present `#1376` in `docs/implementation-status/os-sdlc.md` ## Acceptance criteria 1. Given `value.is_a?(String)`, `Hash === value`, or `case v` with `when Array`, when the cop runs, then it records nothing. 2. Given `e.is_a?(Map::InvalidError)`, `Psych::Exception === e`, or `case error` with `when Gate::FailedError`, when the cop runs, then it records the constant with its form. 3. Given `rescue Gate::Failed => e`, when the cop runs, then it records `Gate::Failed` with form `rescue`, as today. 4. Given `plugins/os-sdlc/bin/detector error-lineage --target os-sdlc`, when it runs after the change, then its stdout is byte-identical to its stdout on the base commit. 5. Given `plugins/os-sdlc/bin/lint-sweep plugins/<p>` for os-backlog, os, os-adr, os-aidd-lint, and os-me, when it runs, then every `Sdlc/Structural/RescueSite` offense is the `rescue` form or names a constant whose last segment ends in `Error` or `Exception` (163 offenses on 3ccd7a22 become 130). ## Owner sketch - `RuboCop::Cop::Sdlc::Structural::RescueSite` (existing) owns the decision: it already routes all four forms through `site`. It asks for the constant's name parts (`RescueSite::ConstantParts`, existing) and, for a non-rescue form, reports only when the last part names an error. No new object. ## In scope / out of scope - In scope: the record rule for the `is_a?`, `===`, and `when` forms, its tests, the two pinning tests above, and the lesson signal. - Out of scope (cut on 2026-10-26, no site in `plugins/`): recording `rescue *CONST` with a literal constant array, `kind_of?` and `instance_of?` arguments, and `case @ivar` subjects. `rescue_site_test.rb:57-60` (an unresolved splat is not a site) stays. The only `case @ivar` in `plugins/` (`plugins/os-sdlc/lib/os_sdlc/constant_path.rb:27`) checks Prism node classes, so recording it would add a false positive. - Out of scope: `ErrorLineage::RescueIndex` and the other detector code; their output does not change. ## Blocking edges None. One implement run. ## Origin - Trigger: the #1325 zero-false-positive audit of the Sdlc cops (docs/plans/class-length-family/sdlc-cop-audit.md) - Improvised this session: none - Chain: wrong and missing records ← matcher accepts any constant and a narrow set of forms ← DESIGN (plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb) - Root candidate: none found; searched RescueSite, rescue_site, rescue, kind_of, splat - Where: plugins/os-sdlc/lib/os_sdlc/cops/structural/rescue_site.rb - Session: eb98cdcd-6783-46a0-b7df-cf18a8fe2a56 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-ticket-1325/eb98cdcd-6783-46a0-b7df-cf18a8fe2a56.jsonl VERDICT: CREATE
jared changed title from Sdlc/Structural/RescueSite: record constant-array splats, ivar case subjects, kind_of?; skip non-errors to Sdlc/Structural/RescueSite: record is_a?, ===, and when constants only when the name ends in Error or Exception 2026-10-26 15:15:14 +00:00
Author
Owner

Work started on branch ticket-1376 via /os-sdlc:implement.

Work started on branch ticket-1376 via /os-sdlc:implement.
Author
Owner

Resolution

Done: RescueSite records is_a?/===/when constants only when the last name segment ends in Error or Exception; rescue sites unchanged. Tests, lesson signal and History row updated.

Evidence: Implementation 170, commit 50c917b8 on ticket-1376; all 8 pipeline gates passed, including full suite (70 s) and contract-auditor.

Follow-ups: none: the sdlc-cop-audit.md RescueSite row and the #1317 count note (163 -> 130) are carried in handoff 2026-10-26-1527 for the #1317 sweep step.

## Resolution **Done:** RescueSite records is_a?/===/when constants only when the last name segment ends in Error or Exception; rescue sites unchanged. Tests, lesson signal and History row updated. **Evidence:** Implementation 170, commit 50c917b8 on ticket-1376; all 8 pipeline gates passed, including full suite (70 s) and contract-auditor. **Follow-ups:** none: the sdlc-cop-audit.md RescueSite row and the #1317 count note (163 -> 130) are carried in handoff 2026-10-26-1527 for the #1317 sweep step.
jared closed this issue 2026-10-26 15:46:11 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#1376
No description provided.