Flag exit, exit!, and abort outside a bin/ entry point #1402

Closed
opened 2026-10-26 19:57:07 +00:00 by jared · 2 comments
Owner

Problem

An exit code is the boundary signal for a process. It is not a good interface. Ruby library code that calls exit reduces what went wrong to one bit and forces the caller to be a shell. No custom cop flags exit, exit!, or abort today. rubocop-rails (Rails/Exit) is not installed.

Measured on 2026-10-26 with ast-grep and grep over plugins/*/lib and plugins/*/bin:

  • exit calls in plugins/*/bin: 51 lines in 46 files. All are entry points (a process boundary).
  • exit, exit!, abort, Kernel.exit, Process.exit calls in plugins/*/lib: 14. All are non-entry.
  • abort calls: 0. Kernel.exit and Process.exit calls: 0.
  • plugins/os-sdlc/lib non-entry calls (3, in scope for this cop):
    • plugins/os-sdlc/lib/os_sdlc/daemon/server.rb:48 (exit!(0) inside a TERM trap)
    • plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb:31 (exit 1 if report.all_phantom?)
    • plugins/os-sdlc/lib/os_sdlc/lint/remediation.rb:66 (exit 1 in report_failure)
  • Other plugins' non-entry calls (11, out of scope here; the cop runs only under the os-sdlc rubocop config):
    • plugins/os-backlog/lib/backlog/cli_exit.rb:7, cli_startup.rb:11, decision_sweep/command.rb:21, cli/project_commands.rb:52, cli/issue_commands.rb:103
    • plugins/os/lib/handoff/cli.rb:41, plugins/os/lib/worktree/cli.rb:24, plugins/os/lib/worktree/cli.rb:31
    • plugins/os-me/lib/os_me/cli.rb:24, :27, :39

The os-sdlc boot path already follows the rule. Each plugins/os-sdlc/bin/* stub runs exit OsSdlc::Boot.run("<name>", OsSdlc::ProcessIO.from_process, ARGV). Boot.run returns an integer and never exits.

Approach

An entry point is a file whose path has a bin/ directory segment. Library code is every other file. A call is an offense when its receiver is absent, Kernel, or Process, its method is exit, exit!, or abort, and its file is not an entry point. A call with another receiver (for example @walked.exit(name) in map/check/cycle_walk.rb) is not an offense. A def exit is not a call.

Required work:

  1. Add the cop Sdlc/Structural/ExitOutsideEntryPoint — check: present class ExitOutsideEntryPoint in plugins/os-sdlc/lib/os_sdlc/cops/structural/exit_outside_entry_point.rb
  2. Add the cop test — check: present ExitOutsideEntryPointTest in plugins/os-sdlc/tests/cops/exit_outside_entry_point_test.rb
  3. Enable the cop — check: present Sdlc/Structural/ExitOutsideEntryPoint in plugins/os-sdlc/.rubocop.yml
  4. Fix plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb:31 and plugins/os-sdlc/lib/os_sdlc/lint/remediation.rb:66: return the status to the bin stub, which calls exit. — check: absent exit 1 in plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb
  5. Resolve plugins/os-sdlc/lib/os_sdlc/daemon/server.rb:48: a TERM trap in a forked daemon has no caller to return to. Replace exit!(0) with a form the cop accepts, or record the reason in a one-line comment and a rubocop:disable for this cop on that line. — check: absent exit!(0) in plugins/os-sdlc/lib/os_sdlc/daemon/server.rb

Rejected: install rubocop-rails for Rails/Exit — the project is not Rails, and the stock cop does not know bin/ stubs.

Acceptance criteria

  1. Given a file under lib/ with a bare exit 1, when the cop runs, then it reports one offense on that call.
  2. Given a file under lib/ with exit!(0), abort("x"), Kernel.exit(1), or Process.exit(1), when the cop runs, then it reports one offense for each call.
  3. Given a file under bin/ with exit Boot.run(...), when the cop runs, then it reports no offense.
  4. Given a file under lib/ with @walked.exit(name) or def exit(name), when the cop runs, then it reports no offense.
  5. Given the os-sdlc plugin after this change, when rubocop runs with plugins/os-sdlc/.rubocop.yml, then the cop reports no offense under plugins/os-sdlc/lib.
  6. Given bin/evidence collect finds only phantom tokens, when it runs, then it still exits 1.

Owner sketch

module RuboCop::Cop::Sdlc::Structural
  class ExitOutsideEntryPoint < RuboCop::Cop::Base
    MESSAGE = "exit and abort belong in a bin/ stub; return a status instead."

    def_node_matcher :process_exit?, "(send {nil? (const {nil? cbase} {:Kernel :Process})} {:exit :exit! :abort} ...)"

    def on_send(node)
      return unless process_exit?(node)
      return if processed_source.file_path.match?(%r{(\A|/)bin/})

      add_offense(node, message: MESSAGE)
    end
  end
end

Non-goals

Offenses in plugins/os, plugins/os-backlog, and plugins/os-me. Those plugins do not load this cop. The 11 calls above are a follow-up ticket per plugin.

Origin

  • Trigger: Jared approved the rule "An exit code is the boundary signal for a process. It is not a good interface." in session 006abca1 on 2026-10-26.
  • Improvised this session: none
  • Chain: Ruby library code calls exit ← no cop flags it ← rubocop-rails is not installed and no custom cop exists. Source: plugins/os-sdlc/lib/os_sdlc/cops/structural/ (DESIGN).
  • Root candidate: none found; searched exit, abort, exit!, bin/ entry point, ExitOutsideEntryPoint, Rails/Exit, Boot.run
  • Where: plugins/os-sdlc/lib/os_sdlc/cops/structural/, plugins/os-sdlc/.rubocop.yml
  • Session: 006abca1-0754-41ab-a8f7-9209dfff5f62
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/006abca1-0754-41ab-a8f7-9209dfff5f62.jsonl

VERDICT: CREATE

## Problem An exit code is the boundary signal for a process. It is not a good interface. Ruby library code that calls `exit` reduces what went wrong to one bit and forces the caller to be a shell. No custom cop flags `exit`, `exit!`, or `abort` today. `rubocop-rails` (`Rails/Exit`) is not installed. Measured on 2026-10-26 with ast-grep and grep over `plugins/*/lib` and `plugins/*/bin`: - `exit` calls in `plugins/*/bin`: 51 lines in 46 files. All are entry points (a process boundary). - `exit`, `exit!`, `abort`, `Kernel.exit`, `Process.exit` calls in `plugins/*/lib`: 14. All are non-entry. - `abort` calls: 0. `Kernel.exit` and `Process.exit` calls: 0. - `plugins/os-sdlc/lib` non-entry calls (3, in scope for this cop): - `plugins/os-sdlc/lib/os_sdlc/daemon/server.rb:48` (`exit!(0)` inside a TERM trap) - `plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb:31` (`exit 1 if report.all_phantom?`) - `plugins/os-sdlc/lib/os_sdlc/lint/remediation.rb:66` (`exit 1` in `report_failure`) - Other plugins' non-entry calls (11, out of scope here; the cop runs only under the os-sdlc rubocop config): - `plugins/os-backlog/lib/backlog/cli_exit.rb:7`, `cli_startup.rb:11`, `decision_sweep/command.rb:21`, `cli/project_commands.rb:52`, `cli/issue_commands.rb:103` - `plugins/os/lib/handoff/cli.rb:41`, `plugins/os/lib/worktree/cli.rb:24`, `plugins/os/lib/worktree/cli.rb:31` - `plugins/os-me/lib/os_me/cli.rb:24`, `:27`, `:39` The os-sdlc boot path already follows the rule. Each `plugins/os-sdlc/bin/*` stub runs `exit OsSdlc::Boot.run("<name>", OsSdlc::ProcessIO.from_process, ARGV)`. `Boot.run` returns an integer and never exits. ## Approach An entry point is a file whose path has a `bin/` directory segment. Library code is every other file. A call is an offense when its receiver is absent, `Kernel`, or `Process`, its method is `exit`, `exit!`, or `abort`, and its file is not an entry point. A call with another receiver (for example `@walked.exit(name)` in `map/check/cycle_walk.rb`) is not an offense. A `def exit` is not a call. Required work: 1. Add the cop `Sdlc/Structural/ExitOutsideEntryPoint` — check: present `class ExitOutsideEntryPoint` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/exit_outside_entry_point.rb` 2. Add the cop test — check: present `ExitOutsideEntryPointTest` in `plugins/os-sdlc/tests/cops/exit_outside_entry_point_test.rb` 3. Enable the cop — check: present `Sdlc/Structural/ExitOutsideEntryPoint` in `plugins/os-sdlc/.rubocop.yml` 4. Fix `plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb:31` and `plugins/os-sdlc/lib/os_sdlc/lint/remediation.rb:66`: return the status to the bin stub, which calls `exit`. — check: absent `exit 1` in `plugins/os-sdlc/lib/os_sdlc/evidence/collection.rb` 5. Resolve `plugins/os-sdlc/lib/os_sdlc/daemon/server.rb:48`: a TERM trap in a forked daemon has no caller to return to. Replace `exit!(0)` with a form the cop accepts, or record the reason in a one-line comment and a `rubocop:disable` for this cop on that line. — check: absent `exit!(0)` in `plugins/os-sdlc/lib/os_sdlc/daemon/server.rb` Rejected: install `rubocop-rails` for `Rails/Exit` — the project is not Rails, and the stock cop does not know `bin/` stubs. ## Acceptance criteria 1. Given a file under `lib/` with a bare `exit 1`, when the cop runs, then it reports one offense on that call. 2. Given a file under `lib/` with `exit!(0)`, `abort("x")`, `Kernel.exit(1)`, or `Process.exit(1)`, when the cop runs, then it reports one offense for each call. 3. Given a file under `bin/` with `exit Boot.run(...)`, when the cop runs, then it reports no offense. 4. Given a file under `lib/` with `@walked.exit(name)` or `def exit(name)`, when the cop runs, then it reports no offense. 5. Given the os-sdlc plugin after this change, when `rubocop` runs with `plugins/os-sdlc/.rubocop.yml`, then the cop reports no offense under `plugins/os-sdlc/lib`. 6. Given `bin/evidence collect` finds only phantom tokens, when it runs, then it still exits 1. ## Owner sketch ```ruby module RuboCop::Cop::Sdlc::Structural class ExitOutsideEntryPoint < RuboCop::Cop::Base MESSAGE = "exit and abort belong in a bin/ stub; return a status instead." def_node_matcher :process_exit?, "(send {nil? (const {nil? cbase} {:Kernel :Process})} {:exit :exit! :abort} ...)" def on_send(node) return unless process_exit?(node) return if processed_source.file_path.match?(%r{(\A|/)bin/}) add_offense(node, message: MESSAGE) end end end ``` ## Non-goals Offenses in `plugins/os`, `plugins/os-backlog`, and `plugins/os-me`. Those plugins do not load this cop. The 11 calls above are a follow-up ticket per plugin. ## Origin - Trigger: Jared approved the rule "An exit code is the boundary signal for a process. It is not a good interface." in session 006abca1 on 2026-10-26. - Improvised this session: none - Chain: Ruby library code calls `exit` ← no cop flags it ← `rubocop-rails` is not installed and no custom cop exists. Source: `plugins/os-sdlc/lib/os_sdlc/cops/structural/` (DESIGN). - Root candidate: none found; searched exit, abort, exit!, bin/ entry point, ExitOutsideEntryPoint, Rails/Exit, Boot.run - Where: `plugins/os-sdlc/lib/os_sdlc/cops/structural/`, `plugins/os-sdlc/.rubocop.yml` - Session: 006abca1-0754-41ab-a8f7-9209dfff5f62 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/006abca1-0754-41ab-a8f7-9209dfff5f62.jsonl VERDICT: CREATE
Author
Owner

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

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

Resolution

Done: Sdlc/Structural/ExitOutsideEntryPoint added and enabled in plugins/os-sdlc/.rubocop.yml: exit/exit!/abort (bare, Kernel., Process.) are offenses outside an entry point (a #! file or a file in a bin directory); exit! inside a fork block is allowed. Fixed os-sdlc sites: daemon/server.rb TERM trap (raise SystemExit), evidence/collection.rb (Thor::Error), lint/remediation.rb (returns 1), takedown prepare_cli.rb, tests/support/in_process_bin.rb. Also fixed a #1393 regression found by this ticket's gate: two copies of os_sdlc_cops_loader.rb in one RuboCop process (gate inherits main config then worktree config) kept main's cops; now the last copy unregisters and unloads the first and loads its own.

Evidence: main 367156a8 (commits 44899722, 367156a8). os-sdlc suite 2719 runs 0 failures; takedown suite 615 runs 0 failures; new cop: 1825 files, no offenses; changed files lint clean; loader tests cover a new cop, an edited cop, and a deleted cop across two copies. Implementation 175 hand-finished after bound_exhausted escalation (lint gate blocked by the loader regression), then abandoned.

Follow-ups: #1411 captured (skeptic CREATE): apply the cop to os, os-backlog, os-me (11 sites; Thor CLIs need a status-returning run).

## Resolution **Done:** Sdlc/Structural/ExitOutsideEntryPoint added and enabled in plugins/os-sdlc/.rubocop.yml: exit/exit!/abort (bare, Kernel., Process.) are offenses outside an entry point (a #! file or a file in a bin directory); exit! inside a fork block is allowed. Fixed os-sdlc sites: daemon/server.rb TERM trap (raise SystemExit), evidence/collection.rb (Thor::Error), lint/remediation.rb (returns 1), takedown prepare_cli.rb, tests/support/in_process_bin.rb. Also fixed a #1393 regression found by this ticket's gate: two copies of os_sdlc_cops_loader.rb in one RuboCop process (gate inherits main config then worktree config) kept main's cops; now the last copy unregisters and unloads the first and loads its own. **Evidence:** main 367156a8 (commits 44899722, 367156a8). os-sdlc suite 2719 runs 0 failures; takedown suite 615 runs 0 failures; new cop: 1825 files, no offenses; changed files lint clean; loader tests cover a new cop, an edited cop, and a deleted cop across two copies. Implementation 175 hand-finished after bound_exhausted escalation (lint gate blocked by the loader regression), then abandoned. **Follow-ups:** #1411 captured (skeptic CREATE): apply the cop to os, os-backlog, os-me (11 sites; Thor CLIs need a status-returning run).
jared closed this issue 2026-10-26 21:26:19 +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#1402
No description provided.