Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions engine/hooks/explicit-failures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Python (`.py`):

JS/TS (`.js .jsx .ts .tsx .mjs .cjs .vue .svelte`):

- `catch {}` / `catch (e) {}` with an empty or comment-only body, or a body
that only `continue`s / `break`s / returns bare.
- `catch {}` / `catch (e) {}` with an empty or comment-only body, a body
whose only statements are `void err`, or a body that only `continue`s /
`break`s / returns bare.
- `.catch(() => {})`, `.catch(e => null)`, `.catch(function () {})`.
- `if (!x)` / `if (x == null)` / `if (x === undefined)` / `if (x.length === 0)`
whose only statement is `continue`, `break`, or a bare / `null` / `[]` return.
Expand All @@ -32,9 +33,9 @@ Bash: every heredoc body in the command is scanned; the redirect target
(`cat > build.py <<'EOF'`) picks the grammar, a heredoc with no target
(`python3 - <<EOF`) runs both.

A hit is suppressed when the block contains any of `log`, `raise`, `throw`,
`warn`, `print(`, `status`, or `reason` (so a status row or a log line with
context already satisfies it), or when the substring `explicit-failures`
A hit is suppressed when the block contains any of `log`, `console.error`,
`raise`, `throw`, `warn`, `print(`, `status`, or `reason` (so a status row
or a log line with context already satisfies it), or when the substring `explicit-failures`
appears on the header line, the line before it, or inside the block:
`# explicit-failures: allow`, `# pragma: explicit-failures: allow`,
`// eslint-disable-next-line explicit-failures`.
Expand Down
13 changes: 9 additions & 4 deletions engine/hooks/explicit-failures/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
guard whose last statement is such an exit. In both cases the block must
contain nothing that says the failure happened: no log, raise, warn, print,
status or reason assignment. JS/TS shapes: `catch {}` with an empty or
comment-only body (or a body that only continues / returns bare), a
`.catch(() => {})` no-op, and an `if (!x)` / `if (x == null)` guard whose
only statement is a bare exit. The substring `explicit-failures` on the
comment-only body, a body whose only statements are `void err`, a body that
only continues / returns bare, a `.catch(() => {})` no-op, and an
`if (!x)` / `if (x == null)` guard whose only statement is a bare exit. The substring `explicit-failures` on the
header line, the line before it, or inside the block suppresses a hit
(`# explicit-failures: allow`, `# pragma: explicit-failures: allow`,
`// eslint-disable-next-line explicit-failures`).
Expand All @@ -35,7 +35,10 @@
PY_SUFFIXES = (".py", ".pyi")
JS_SUFFIXES = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte")

ALLOW_TOKEN_RE = re.compile(r"(?i)(?:\blog|_log\b|\braise\b|\bthrow\b|status|reason|warn|\bprint\s*\()")
ALLOW_TOKEN_RE = re.compile(
r"(?i)(?:\blog|_log\b|\braise\b|\bthrow\b|status|reason|warn|\bprint\s*\(|console\.error)"
)
JS_VOID_ONLY_RE = re.compile(r"(?:void\s+\S+\s*;\s*)*void\s+\S+")
ALLOW_MARK = "explicit-failures"

PY_EXCEPT_RE = re.compile(r"^(\s*)except\b[^:]*:\s*(\S.*)?$")
Expand Down Expand Up @@ -189,6 +192,8 @@ def scan_js(text: str) -> list[tuple[int, str]]:
continue
if stripped == "":
hits.append((_line_of(text, m.start()), "`catch {}` with an empty body"))
elif JS_VOID_ONLY_RE.fullmatch(stripped) and not ALLOW_TOKEN_RE.search(body):
hits.append((_line_of(text, m.start()), "catch block that only discards the error with `void`"))
elif re.fullmatch(JS_EXIT, stripped) and not ALLOW_TOKEN_RE.search(body):
hits.append((_line_of(text, m.start()), f"catch block that only `{stripped}`s"))
for m in JS_PROMISE_CATCH_RE.finditer(text):
Expand Down
11 changes: 11 additions & 0 deletions engine/hooks/explicit-failures/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ def test_fires_on_js_comment_only_catch_body(self):
hits = detect.scan_js(text)
self.assertEqual(hits, [(1, "`catch {}` with an empty body")])

def test_fires_on_void_discard_in_catch(self):
text = "try { a() } catch (reportingFailure) {\n void reportingFailure;\n}\n"
hits = detect.scan_js(text)
self.assertEqual(hits, [(1, "catch block that only discards the error with `void`")])

def test_silent_on_console_error_and_void_console_error(self):
logged = "try { a() } catch (e) {\n console.error('metric failed', e);\n}\n"
discarded_return = "try { a() } catch (e) {\n void console.error('metric failed', e);\n}\n"
self.assertEqual(detect.scan_js(logged), [])
self.assertEqual(detect.scan_js(discarded_return), [])

def test_fires_on_promise_catch_noop_fixture(self):
hits = lines_for(write_payload("promise_catch_noop_fires.js"))
self.assertEqual([h.split(": ", 1)[1].split(" — ")[0] for h in hits], ["`.catch(() => {})` no-op rejection handler"] * 2)
Expand Down
Loading