#!/usr/bin/env bash # scripts/ci-guards/bundle-8-M-009-bare-usemutation.sh # # Audit M-009 + M-029 Pass 1 closure: # # Pre-Bundle-8 the codebase had 56 bare useMutation sites with # discretionary invalidation. Bundle 8 shipped the useTrackedMutation # wrapper (web/src/hooks/useTrackedMutation.ts) that requires every # caller to declare `invalidates: QueryKey[] | 'noop'`. M-029 Pass 1 # then migrated all 56 sites to the wrapper across 6 batches. # # This guard pins the contract going forward: every useMutation call # in src/ MUST be inside useTrackedMutation.ts (the wrapper itself # is the only legitimate caller of useMutation). Any bare useMutation # call elsewhere is a regression — adding a new mutation site means # going through the wrapper so the invalidates contract is enforced # per-site, not by a soft budget guard. # # If you genuinely need raw useMutation (extremely unlikely — the # wrapper supports invalidates: 'noop' for fire-and-forget mutations), # update this guard's exclusion list and document the carve-out. set -e # Test files (web/src/**/*.test.{ts,tsx}) are excluded so existing # useMutation-mocking test patterns and the wrapper's own unit # tests don't trip the production guard — symmetric with L-015 # and L-019 above. # # Sprint 5 ARCH-001-A carve-out (2026-05-16): web/src/api/generated/** # is the Orval-generated React Query layer (TanStack Query client # mode, tags-split). Orval's mutation template calls bare useMutation # directly — by design, because the generated layer is one # abstraction below the wrapper. The Composition pattern is: hand- # written feature code consumes the generated hooks AND wraps any # mutation through useTrackedMutation at the call site (which routes # through the generated hook's mutationFn under the hood). Adding the # wrapper inside the generated tree would be overwritten on every # regenerate, so the contract is "wrapper at the feature layer, not # the codegen layer." The drift guard (openapi-codegen-drift.sh) # already pins the generated tree against the canonical openapi.yaml, # so this exclusion can't be abused to smuggle in a hand-edit. BARE=$(grep -rnE '\buseMutation\(' web/src/ 2>/dev/null \ | grep -v 'web/src/hooks/useTrackedMutation\.ts' \ | grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \ | grep -v '^web/src/api/generated/' \ || true) if [ -n "$BARE" ]; then echo "::error::M-009 hard-zero regression: bare useMutation() call(s) outside the wrapper:" echo "$BARE" echo echo "Every mutation must go through useTrackedMutation" echo "(web/src/hooks/useTrackedMutation.ts) with explicit" echo "invalidates: QueryKey[] | 'noop'. See file header for usage." exit 1 fi # Sanity counts (informational, not a gate). TRACKED=$(grep -rcE '\buseTrackedMutation\(' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}') INVALIDATIONS=$(grep -rcE 'invalidateQueries|setQueryData|removeQueries|invalidates:' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}') echo "M-009 bare-usemutation: clean (wrapper-internal call + test files excluded)." echo "M-009 informational: useTrackedMutation sites = $TRACKED; invalidation surface = $INVALIDATIONS."