Skip to content

proposal: SYN006 — warn on process.exit() calls in fn bodies (?bs 0.7+) #146

@marcelofarias

Description

@marcelofarias

Problem

SYN002–SYN005 catch specific capability-model bypasses: native throws, console.* output, eval/Function(), and process.env access. But none of them catch process.exit().

A fn that calls process.exit() or process.exit(code):

  • Terminates the entire host process — not just the fn, not just the bot, but the runtime
  • Produces no return value and never runs caller code after it
  • Completely bypasses botscript's Result<T, E> error contract — callers relying on ?, match, or throws {} propagation will never see this
  • Has no capability declaration, no throws {}, nothing in the fn header to signal the kill
  • Leaves the calling chain with no evidence anything went wrong — no error, no diagnostic, just silence

It is the most severe silent-exit pattern botscript currently misses.

Real risk patterns

Code-generation models commonly produce process.exit() for:

  • CLI-style guard clauses: if (!cfg.valid) process.exit(1)
  • Error handlers that want to "bail out": catch (e) { process.exit(1) }
  • Config-load failures: if (!env) { console.error(...); process.exit(1) }

None of these are caught by any current diagnostic.

Proposed detection

Extends passSynCheck following the SYN005 (process.env) pattern exactly:

Fires on:

  • process.exit(...) — token process not preceded by . or ?., followed by ./?., then exit, then (
  • process?.exit(...) — optional chain form (same risk, should be flagged)

Suppressed by:

  • unsafe "reason" { ... } blocks
  • unsafe "reason" fn bodies

Not fired for:

  • obj.process.exit(...)process preceded by ./?. (member access on a local named process)

Severity: warning (non-blocking), same as SYN002–SYN005. There are edge cases where a toplevel bootstrap script genuinely needs process.exit, and unsafe "exits on invalid config" { process.exit(1) } is the right escape.

Implementation sketch

Detect immediately after the process.env detection block in passSynCheck:

// --- process.exit(...) detection ---
// Look for: `process` not preceded by `.`/`?.`, followed by `.`/`?.`, then `exit`, then `(`
if (tok.text === "process") {
  const prevProcIdx = prevSignificant(tokens, i - 1);
  const prevProc = tokens[prevProcIdx];
  if (prevProc && ((prevProc.kind === "punct" && prevProc.text === ".") || prevProc.kind === "questionDot"))
    continue;

  const nextDotIdx = nextSignificant(tokens, i + 1);
  const nextDot = tokens[nextDotIdx];
  const isDot = nextDot && nextDot.kind === "punct" && nextDot.text === ".";
  const isOptDot = nextDot && nextDot.kind === "questionDot";
  if (!isDot && !isOptDot) continue;

  const memberIdx = nextSignificant(tokens, nextDotIdx + 1);
  const memberTok = tokens[memberIdx];
  if (!memberTok || memberTok.kind !== "ident" || memberTok.text !== "exit") continue;

  const parenIdx = nextSignificant(tokens, memberIdx + 1);
  const parenTok = tokens[parenIdx];
  if (!parenTok || !(parenTok.kind === "open" && parenTok.text === "(")) continue;

  if (isInsideRange(tok.start, unsafeRanges)) continue;

  // emit SYN006
}

Relation to SYN002–SYN005

Code Pattern Impact
SYN002 throw Bypasses Result contract
SYN003 console.* Bypasses stdout/stderr capability
SYN004 eval/Function() Bypasses all static checks at once
SYN005 process.env Hides deployment dependencies
SYN006 process.exit() Terminates host process; bypasses Result, throws, all recovery logic

Note on timing

Should be implemented after PR #145 (SYN004) and PR #143 (SYN005) merge, since the next available code on main is SYN004 — implementing before those PRs land would create a conflicting code assignment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalRFC or design proposal

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions