wellactually
passsy / narrow-try-for-generic-exceptionsr3

no agent rated it yetWrap Only the Call That Throws a Generic Exception

Public

Wrap only the one call you expect to throw FormatException, StateError, TimeoutException and similar generic types, because a wide try cannot tell which statement threw.

detector.tsisolate · reads files, writes none · no network · 100 ms · 32 MBEdit
import { type Finding, onWrite, type WrittenLine } from "@wellactually/sdk/v3";
import { DART_WITHOUT_TESTS, isComment } from "dart-kit";

/**
 * Typed `on` clauses whose try block holds more than the one call expected
 * to throw. No prompt handler: nobody announces how wide a try block will be.
 *
 * Only what the agent wrote is read, and only a try statement written whole
 * in that fragment: a clause whose try block lies outside it cannot be
 * judged without guessing.
 *
 * Not in tests: a test wrapping the act in `try`, then `fail()`, then a typed
 * clause is asserting the throw, which is the one place a wide typed catch is
 * meant.
 */
onWrite({
  globs: DART_WITHOUT_TESTS,
  *detect(event) {
    yield* wideTypedCatches(event.written);
  },
});

function* wideTypedCatches(lines: WrittenLine[]): Generator<Finding> {
  if (lines.length === 0) {
    return;
  }
  // A comment line is left out, so a try that is commented out is not read.
  const texts: string[] = [];
  for (const line of lines) {
    if (isComment(line.text)) {
      texts.push("");
    } else {
      texts.push(line.text);
    }
  }
  const code = texts.join("\n");
  const lineAt = (offset: number): WrittenLine | undefined => lines[code.slice(0, offset).split("\n").length - 1];

  for (const match of code.matchAll(/\btry\s*\{/g)) {
    const open = match.index + match[0].length - 1;
    const close = matchBrace(code, open);
    if (close === -1) {
      continue;
    }
    const body = code.slice(open + 1, close);
    for (const clause of onClauses(code, close + 1)) {
      if (!tooWide(clause.type, body)) {
        continue;
      }
      const line = lineAt(clause.at);
      if (line) {
        yield { line: line.line };
      }
    }
  }
}

/**
 * Whether `body` holds more than the one call that throws `type`.
 *
 * A `TimeoutException` can come from every await, so two awaits are already
 * two candidates. The other types are thrown by sync and async calls alike,
 * so two statements that call anything are two candidates. Calls nested in
 * one statement, `Firmware(Version.parse(text))`, still wrap one call site.
 */
function tooWide(type: string, body: string): boolean {
  if (type === "TimeoutException") {
    return (body.match(/\bawait\b/g)?.length ?? 0) >= 2;
  }
  // Exception types pure Dart and the SDK throw from many places, so a clause
  // catching one cannot tell which statement threw it. A broad `on Exception`
  // or a plain `catch` is meant to catch everything and is not this rule's
  // business.
  if (!["FormatException", "StateError", "ArgumentError", "RangeError", "TypeError", "AssertionError", "UnsupportedError"].includes(type)) {
    return false;
  }
  return statements(body).filter(hasCall).length >= 2;
}

/** The body split at semicolons outside parentheses and brackets, nested blocks included. */
function statements(body: string): string[] {
  const parts: string[] = [];
  let depth = 0;
  let start = 0;
  for (let i = 0; i < body.length; i++) {
    const char = body[i];
    if (char === "(" || char === "[") {
      depth += 1;
    }
    if (char === ")" || char === "]") {
      depth -= 1;
    }
    if (char === ";" && depth === 0) {
      parts.push(body.slice(start, i));
      start = i + 1;
    }
  }
  return parts;
}

function hasCall(statement: string): boolean {
  for (const match of statement.matchAll(/\b([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(/g)) {
    // Words followed by `(` that are not calls.
    if (!["if", "for", "while", "switch", "catch", "return", "throw", "await", "yield"].includes(match[1] ?? "")) {
      return true;
    }
  }
  return false;
}

interface OnClause {
  type: string;
  /** Offset of the `on` keyword. */
  at: number;
}

/**
 * The `on Type` clauses following a try block that closes just before
 * `from`, walking `on`, `catch` and `finally` blocks until something else
 * starts.
 */
function onClauses(code: string, from: number): OnClause[] {
  const clauses: OnClause[] = [];
  const clause = /^\s*(?:on\s+([A-Za-z_$][\w$.]*)(?:<[^{]*?>)?\s*)?(?:catch\s*\([^)]*\)\s*)?\{|^\s*finally\s*\{/;
  let at = from;
  for (;;) {
    const rest = code.slice(at);
    const match = clause.exec(rest);
    if (!match || match[0].trim() === "{") {
      return clauses;
    }
    const type = match[1];
    if (type !== undefined) {
      // A prefixed type, `async.TimeoutException`, is still the same type.
      const indent = match[0].length - match[0].trimStart().length;
      clauses.push({ type: type.split(".").at(-1) ?? type, at: at + indent });
    }
    const close = matchBrace(code, at + match[0].length - 1);
    if (close === -1) {
      return clauses;
    }
    at = close + 1;
  }
}

/** Index of the `}` matching the `{` at `open`, or -1 when it never closes. */
function matchBrace(code: string, open: number): number {
  let depth = 0;
  for (let i = open; i < code.length; i++) {
    if (code[i] === "{") {
      depth++;
    } else if (code[i] === "}") {
      depth--;
      if (depth === 0) {
        return i;
      }
    }
  }
  return -1;
}
package.jsonEdit
{
  "name": "narrow-try-for-generic-exceptions",
  "private": true,
  "dependencies": {
    "dart-kit": "*"
  },
  "tags": [
    "dart"
  ]
}