Files
ironclaw/scripts/test-pre-commit-safety.sh
Illia Polosukhin 22ff4957c9 feat(safety): projection-exempt lint for gateway event sources (#2840)
* feat(safety): projection-exempt lint for gateway event sources

Phase 1 of the gateway state-convergence epic (#2792): add check #9 to
`scripts/pre-commit-safety.sh` that flags newly-added
`sse.broadcast(` / `sse.broadcast_for_user(` calls without a
`// projection-exempt: <reason>` annotation on the same line.

The invariant is documented in the new `.claude/rules/gateway-events.md`:

- Every `AppEvent` must project from a typed source log (engine
  `EventKind`, sandbox `JobEvent`, or a channel-lifecycle log).
- A short transport-only allowlist (`Heartbeat`, `StreamChunk`) covers
  the ephemeral variants with no state backing them.
- Direct emits are the root cause of the state-drift class — UI stream
  and replayable source end up with different stories. Four recent
  incidents (#2654, #2534, #2731, #2079) share this shape.

The lint is diff-based, so pre-existing unannotated call sites aren't
broken. Baseline annotation of the ~20 existing emit sites is the next
PR under Phase 1 — this one establishes the gate.

Suppressions require a named category (`bridge dispatcher`,
`channel-lifecycle`, `sandbox JobEvent`, `transport-only, heartbeat`,
or `migrate in #NNNN`). An unnamed `legacy` reason is rejected by
review, not by the lint itself.

Tested locally:
- Fires on unannotated `sse.broadcast(...)` in a new file.
- Suppressed by `// projection-exempt: transport-only, heartbeat`.
- Does not match `Channel::broadcast` (different trait).
- Does not match calls inside `#[cfg(test)] mod tests` blocks (via
  the shared `strip_test_mod_lines` filter).

Refs: #2792, #2654

* refactor(safety): address review feedback on projection-exempt check

Four review comments from Copilot and Gemini on #2840:

1. **Match rustfmt's method-chain wrapping.** The original regex only
   caught same-line `sse.broadcast(...)`. Long calls like
   `state\n    .sse\n    .broadcast_for_user(...)` — produced by
   rustfmt and already in-tree at
   `src/channels/web/features/extensions/mod.rs:645` — would bypass the
   check. New matcher adds a dangling-method alternation that catches
   `.broadcast_for_user(` at line start. Only the `_for_user` suffix
   (SseManager-unique) is matched in dangling form; bare
   `.broadcast(` can be `Channel::broadcast` trait, which is
   intentionally out of scope.

2. **Enforce the documented annotation format.** The check previously
   accepted any `// projection-exempt:` comment, including bare
   `// projection-exempt: legacy` that the rule doc explicitly forbids.
   Negative filter now requires `<category>, <detail>` — presence of a
   comma separating the category from the detail.

3. **Point at the real path in the warning.** Replace
   `bridge::thread_event_to_app_events` with `thread_event_to_app_events`
   in `src/bridge/router.rs` — the actual file location.

4. **Update suppression hint** to show the `<category>, <detail>`
   format rather than the generic `<reason>`.

Verified against a 6-case fixture (same-line fire + suppress,
dangling-chain fire + suppress, unnamed-category fire,
`Channel::broadcast` silent).

Refs: #2792, #2840 review

* fix(safety): match header exclusion against grep -n prefixed output

After `grep -nE '^\+'`, every line is prefixed with `N:`, so the
`^\+\+\+` anchor for filtering diff header lines (`+++ b/file.rs`)
never fires. The positive patterns already exclude header lines by
shape, so today this is harmless — but the dead branch masks future
defense-in-depth failures if the template is reused with a less
specific positive match.

Replace `^\+\+\+` with `:\+\+\+ ` in DISPATCH, CREDNAME, and PROJECTION
checks so the exclusion works against the `grep -n` output shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(safety): regression for grep-n-prefixed header exclusion

Covers PROJECTION / DISPATCH / CREDNAME pipelines:
- diff header lines (`+++ b/path`) are filtered after `grep -n`
- real broadcast/state/CredentialName lines are still flagged
- `// projection-exempt: <category>, <detail>` exempts
- bare `// projection-exempt: legacy` (no comma) is not exempt

Locks in that `:\+\+\+ ` (matches the `grep -n` prefixed shape)
behaves as intended, where the prior `^\+\+\+` anchor silently
never fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deny): ignore RUSTSEC-2026-0104 (rustls-webpki CRL panic)

Same transitive pin as 0049/0098/0099 — rustls-webpki 0.102.8 is
held by libsql 0.6.0 → rustls 0.22 → hyper-rustls 0.25. The
advisory explicitly notes that applications not parsing CRLs are
unaffected; we do not parse CRLs.

[skip-regression-check] — deny.toml-only config change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safety): portable grep boundary + broadened broadcast_for_user match

Two PROJECTION bypass paths flagged in review:

1. `\b` is a GNU-grep extension (works in grep 3.x, not portable to BSD
   grep on macOS dev envs) — replace with `(^|[^[:alnum:]_])sse\.` so
   the check fires uniformly across `grep -E` implementations.

2. `broadcast_for_user(...)` on a non-`sse` receiver (e.g.
   `manager.broadcast_for_user(...)`) previously slipped through. The
   method is defined only on `SseManager`
   (`src/channels/web/platform/sse.rs:144`), so matching
   `\.broadcast_for_user\(` on any receiver is safe and makes the
   enforcement match the documented rule.

Regression tests extended: chained-receiver, non-`sse` receiver, bare
`sse.broadcast(`, and a portable-boundary negative case (identifier
ending in `sse`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(gateway-events): align matcher description with broadened check

Update the enforcement section to describe the two current PROJECTION
matcher shapes after the review follow-up in the preceding commit:

1. Any-receiver `.broadcast_for_user(...)` — catches the non-`sse`
   receiver bypass and rustfmt wraps alike.
2. `<word-boundary>sse.broadcast(...)` with a portable boundary
   (`(^|[^[:alnum:]_])`), which is needed because `grep -E`'s `\b`
   is a GNU extension and not available on BSD grep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safety): tighten CREDNAME + projection-exempt lints, sync header

Three follow-ups from the review:

1. CREDNAME portability — `\bCredentialName\b` used GNU-grep `\b`,
   which BSD grep does not recognise. Replace with the same
   `(^|[^[:alnum:]_])…([^[:alnum:]_]|$)` boundary used for
   PROJECTION and matches cleanly across GNU and BSD `grep -E`.

2. Empty-detail suppression bypass — `// projection-exempt: [^,]+,`
   accepted `// projection-exempt: foo,` (empty detail) as exempt
   even though `.claude/rules/gateway-events.md` requires a
   non-empty detail. Tighten to `[^,]+,[[:space:]]*[^[:space:]]`
   so a comma without a trailing token still fires the check.

3. Header suppression hint (`#24`) said
   `// projection-exempt: <reason>` — update to
   `<category>, <detail>` to match what the check actually accepts
   so contributors don't copy an unsupported format.

Regression tests extended: `PROJECTION: empty detail after comma
still flagged`, `PROJECTION: comma + whitespace-only detail still
flagged`, `CREDNAME: CredentialNameExt (different type) is not
flagged`. All 16 cases pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:36:19 +09:00

171 lines
6.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression tests for the grep pipelines in `pre-commit-safety.sh`.
#
# The PROJECTION / DISPATCH / CREDNAME checks all pipe through
# `grep -nE '^\+' | grep -E <positive> | grep -vE <exclusions>`.
# A previous version of the exclusion regex used `^\+\+\+` to filter
# diff header lines (`+++ b/file.rs`), which silently never fired —
# `grep -n` prepends a `N:` line-number prefix, so `^` no longer
# anchors against the `+++` bytes. This test locks in the corrected
# `:\+\+\+ ` shape.
set -euo pipefail
cd "$(dirname "$0")/.."
PASS=0
FAIL=0
assert_filtered() {
local label="$1" input="$2" positive="$3" exclusions="$4"
# Emulate the production pipeline: `grep -n '^+'` adds the line-number
# prefix, then positive/negative filters run against that shape.
local result
if result=$(printf '%s\n' "$input" \
| grep -nE '^\+' \
| grep -E "$positive" \
| grep -vE "$exclusions" \
| head -5 || true); then :; fi
if [ -z "${result:-}" ]; then
echo "OK: $label (correctly filtered)"
PASS=$((PASS + 1))
else
echo "FAIL: $label — line leaked past exclusions:"
echo "$result" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
}
assert_flagged() {
local label="$1" input="$2" positive="$3" exclusions="$4"
local result
if result=$(printf '%s\n' "$input" \
| grep -nE '^\+' \
| grep -E "$positive" \
| grep -vE "$exclusions" \
| head -5 || true); then :; fi
if [ -n "${result:-}" ]; then
echo "OK: $label (correctly flagged)"
PASS=$((PASS + 1))
else
echo "FAIL: $label — line not flagged by positive pattern"
FAIL=$((FAIL + 1))
fi
}
# ── PROJECTION ────────────────────────────────────────────────
# Positive: any `.broadcast_for_user(` (SseManager-unique method) or
# `sse.broadcast(` with a portable word boundary.
# Exclusions: `// projection-exempt: <category>, <detail>`, `// safety:`,
# and diff-header lines (`+++ b/path`) via `:\+\+\+ `.
PROJ_POS='(\.broadcast_for_user|(^|[^[:alnum:]_])sse\.broadcast)[[:space:]]*\('
PROJ_NEG='// projection-exempt: [^,]+,[[:space:]]*[^[:space:]]|// safety:|:\+\+\+ '
# Diff header lines must be filtered.
assert_filtered "PROJECTION: diff header line is filtered" \
"+++ b/src/bridge/router.rs" \
"$PROJ_POS" \
"$PROJ_NEG"
# A real broadcast call is flagged.
assert_flagged "PROJECTION: bare sse.broadcast_for_user is flagged" \
"+ sse.broadcast_for_user(&user, event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# Chained receiver (state.sse.broadcast_for_user) is flagged.
assert_flagged "PROJECTION: chained state.sse.broadcast_for_user is flagged" \
"+ state.sse.broadcast_for_user(&user, event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# A rustfmt-wrapped call is flagged.
assert_flagged "PROJECTION: rustfmt-wrapped .broadcast_for_user is flagged" \
"+ .broadcast_for_user(&user, event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# Non-`sse` receiver must still fire — `broadcast_for_user` is unique to
# SseManager, so the method name alone is authoritative.
assert_flagged "PROJECTION: non-sse receiver .broadcast_for_user is flagged" \
"+ manager.broadcast_for_user(&user, event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# Plain sse.broadcast call is flagged via the portable word boundary.
assert_flagged "PROJECTION: bare sse.broadcast is flagged" \
"+ sse.broadcast(event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# The portable boundary must not fire on a longer identifier that ends
# in 'sse' (e.g. `usse.broadcast(...)` — not a real SseManager).
assert_filtered "PROJECTION: identifier ending in sse is not flagged" \
"+ usse.broadcast(event);" \
"$PROJ_POS" \
"$PROJ_NEG"
# Correctly annotated call is exempted.
assert_filtered "PROJECTION: annotated call with category+detail is exempt" \
"+ sse.broadcast_for_user(&user, event); // projection-exempt: bridge dispatcher, auth gate" \
"$PROJ_POS" \
"$PROJ_NEG"
# Bare `// projection-exempt: legacy` (no comma, no detail) does NOT exempt.
assert_flagged "PROJECTION: unnamed 'legacy' suppression still flagged" \
"+ sse.broadcast_for_user(&user, event); // projection-exempt: legacy" \
"$PROJ_POS" \
"$PROJ_NEG"
# Empty detail after the comma (`// projection-exempt: foo,`) does NOT
# exempt — the documented format requires a non-empty detail.
assert_flagged "PROJECTION: empty detail after comma still flagged" \
"+ sse.broadcast_for_user(&user, event); // projection-exempt: foo," \
"$PROJ_POS" \
"$PROJ_NEG"
# Trailing whitespace after the comma without a detail also does NOT exempt.
assert_flagged "PROJECTION: comma + whitespace-only detail still flagged" \
"+ sse.broadcast_for_user(&user, event); // projection-exempt: foo, " \
"$PROJ_POS" \
"$PROJ_NEG"
# ── DISPATCH ──────────────────────────────────────────────────
DISPATCH_POS='state\.(store|workspace|workspace_pool|extension_manager|skill_registry|session_manager)\.'
DISPATCH_NEG='// dispatch-exempt:|// safety:|:\+\+\+ '
assert_filtered "DISPATCH: diff header line is filtered" \
"+++ b/src/channels/web/handlers/foo.rs" \
"$DISPATCH_POS" \
"$DISPATCH_NEG"
assert_flagged "DISPATCH: direct state.store touch is flagged" \
"+ state.store.create_project(...)" \
"$DISPATCH_POS" \
"$DISPATCH_NEG"
# ── CREDNAME ──────────────────────────────────────────────────
# Portable word boundary: `(^|[^[:alnum:]_])` / `([^[:alnum:]_]|$)` —
# `grep -E`'s `\b` is a GNU extension and not recognised by BSD grep.
CREDNAME_POS='(^|[^[:alnum:]_])CredentialName([^[:alnum:]_]|$)'
CREDNAME_NEG='// web-identity-exempt:|// safety:|:\+\+\+ '
assert_filtered "CREDNAME: diff header line is filtered" \
"+++ b/src/channels/web/features/settings.rs" \
"$CREDNAME_POS" \
"$CREDNAME_NEG"
# A similarly-named but distinct identifier must not fire.
assert_filtered "CREDNAME: CredentialNameExt (different type) is not flagged" \
"+ let ext: CredentialNameExt = ...;" \
"$CREDNAME_POS" \
"$CREDNAME_NEG"
assert_flagged "CREDNAME: bare CredentialName reference is flagged" \
"+ let name: CredentialName = ...;" \
"$CREDNAME_POS" \
"$CREDNAME_NEG"
echo ""
echo "Passed: $PASS, Failed: $FAIL"
[ "$FAIL" -eq 0 ]