Last active
July 26, 2026 11:37
-
-
Save leolanese/8443691d4973634ca2188ab42c9ce42a to your computer and use it in GitHub Desktop.
Uses a custom AWK script to track parenthesis depth, ensuring we accurately scan entire effect() or toSignal() blocks without being tricked by multi-line formatting.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Angular Signal health checks | |
| # | |
| # Section 1 is a hard gate: direct/in-place mutation of signal state | |
| # (push/splice/etc., property assignment, or index assignment on a signal | |
| # read) bypasses change detection because the signal's reference never | |
| # changes - this is always a bug, so it fails the script. | |
| # | |
| # Sections 2-8 are advisory: each surfaces candidates that need a human to | |
| # judge whether they're a real problem (e.g. an effect() that fetches data | |
| # and sets several signals is normal; an effect() that only re-derives one | |
| # signal from another should probably be a computed() instead). They print | |
| # findings but never fail the script on their own. | |
| set -uo pipefail | |
| SRC="src/app" | |
| fail=0 | |
| section() { | |
| echo | |
| echo "=== $1 ===" | |
| } | |
| # --- 1. Signal mutated in place (hard gate) --------------------------------- | |
| section "1. In-place signal mutation (hard gate)" | |
| MUTATION_PATTERNS=( | |
| '\(\)\.(push|pop|shift|unshift|splice|sort|reverse|fill)\(' | |
| '\(\)\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]' | |
| '\(\)\[[^\]]*\]\s*=[^=]' | |
| ) | |
| mutation_found=0 | |
| for pattern in "${MUTATION_PATTERNS[@]}"; do | |
| if grep -rnE "$pattern" --include=*.ts "$SRC"; then | |
| mutation_found=1 | |
| fi | |
| done | |
| if [ "$mutation_found" -eq 1 ]; then | |
| echo "FAIL: signal mutation anti-pattern detected (see matches above)." | |
| fail=1 | |
| else | |
| echo "OK: no in-place signal mutation found." | |
| fi | |
| # --- 2. effect() that writes signals directly (candidate for computed()) --- | |
| # Scans each effect(...) call as a whole block (paren-balanced, not a fixed | |
| # line window) so multi-line effect bodies are captured correctly, then | |
| # flags ones containing a direct .set(/.update( call. Side-effecting | |
| # effects (HTTP loads, DOM/timer work) are expected to hit this - review, | |
| # don't assume it's wrong. | |
| section "2. effect() writing signals directly (review candidates)" | |
| effect_files=$(grep -rlE 'effect\(' --include=*.ts "$SRC" | grep -v spec.ts || true) | |
| if [ -n "$effect_files" ]; then | |
| # shellcheck disable=SC2086 | |
| awk ' | |
| FNR==1 { in_block=0; depth=0; buf=""; startline=0 } | |
| { | |
| line=$0 | |
| if (!in_block) { | |
| if (line ~ /effect\(/) { in_block=1; depth=0; buf=""; startline=FNR; startcontent=line } | |
| else next | |
| } | |
| buf = buf "\n" line | |
| n=length(line) | |
| for (i=1;i<=n;i++){ | |
| c=substr(line,i,1) | |
| if (c=="(") depth++ | |
| else if (c==")") depth-- | |
| } | |
| if (depth<=0){ | |
| if (buf ~ /\.set\(|\.update\(/) { | |
| gsub(/^[ \t]+/,"",startcontent) | |
| print FILENAME ":" startline ": " startcontent | |
| } | |
| in_block=0 | |
| } | |
| } | |
| ' $effect_files | |
| else | |
| echo "(no effect() calls found)" | |
| fi | |
| # --- 3. signal()/computed() declared inside a function instead of a class field | |
| section "3. signal()/computed() declared as a local variable (not a class field)" | |
| grep -rnE '^\s*(const|let)\s+\w+\s*=\s*(signal|computed)\(' --include=*.ts "$SRC" | grep -v spec.ts || echo "(none found)" | |
| # --- 4. signal<Array|Object> - candidates that may need a custom equal comparator | |
| section "4. signal<Array|Object> (eyeball for reference-vs-value equality needs)" | |
| grep -rnE 'signal<[^>]*(\[\]|\{)' --include=*.ts "$SRC" || echo "(none found)" | |
| # --- 5 & 6. effect() / onCleanup / untracked() usage stats ----------------- | |
| section "5-6. effect()/onCleanup/untracked() usage stats" | |
| effect_count=$(grep -rcE 'effect\(' --include=*.ts "$SRC" | awk -F: '{s+=$2} END{print s+0}') | |
| cleanup_count=$(grep -rcE 'onCleanup' --include=*.ts "$SRC" | awk -F: '{s+=$2} END{print s+0}') | |
| untracked_count=$(grep -rcE 'untracked\(' --include=*.ts "$SRC" | awk -F: '{s+=$2} END{print s+0}') | |
| echo "effect() calls: $effect_count" | |
| echo "onCleanup uses: $cleanup_count" | |
| echo "untracked() uses: $untracked_count" | |
| echo "(most effects legitimately need neither cleanup nor untracked() - a gap" | |
| echo " here is only worth investigating if you have effects doing subscriptions/" | |
| echo " timers/listeners with no visible teardown, not as a ratio to hit.)" | |
| # --- 7. toSignal() without initialValue ------------------------------------- | |
| # Same paren-balanced block scan as section 2, applied to toSignal(...) calls. | |
| section "7. toSignal() without initialValue" | |
| # Word-boundary pre-filter avoids matching inside longer identifiers, e.g. | |
| # "fetchListIntoSignal(" contains the literal substring "toSignal(". | |
| tosignal_files=$(grep -rlE '\btoSignal\(' --include=*.ts "$SRC" || true) | |
| if [ -n "$tosignal_files" ]; then | |
| # shellcheck disable=SC2086 | |
| awk ' | |
| FNR==1 { in_block=0; depth=0; buf=""; startline=0 } | |
| { | |
| line=$0 | |
| if (!in_block) { | |
| if (line ~ /toSignal\(/) { in_block=1; depth=0; buf=""; startline=FNR; startcontent=line } | |
| else next | |
| } | |
| buf = buf "\n" line | |
| n=length(line) | |
| for (i=1;i<=n;i++){ | |
| c=substr(line,i,1) | |
| if (c=="(") depth++ | |
| else if (c==")") depth-- | |
| } | |
| if (depth<=0){ | |
| if (buf !~ /initialValue/) { | |
| gsub(/^[ \t]+/,"",startcontent) | |
| print FILENAME ":" startline ": " startcontent | |
| } | |
| in_block=0 | |
| } | |
| } | |
| ' $tosignal_files | |
| echo "(no output above this line = every toSignal() call has an initialValue)" | |
| else | |
| echo "(no toSignal() calls found)" | |
| fi | |
| # --- 8. Async fetching inside effect() (candidate for resource()) ----------- | |
| # Same paren-balanced block scan as section 2, but looks for async patterns | |
| # like RxJS subscriptions, switchMap, Promises, or HttpClient calls inside | |
| # an effect. These are prime candidates to be refactored into resource(). | |
| section "8. Async fetching inside effect() (review candidates for resource())" | |
| # Re-use the effect files list, but fallback safely if empty | |
| if [ -n "$effect_files" ]; then | |
| # shellcheck disable=SC2086 | |
| awk ' | |
| FNR==1 { in_block=0; depth=0; buf=""; startline=0 } | |
| { | |
| line=$0 | |
| if (!in_block) { | |
| if (line ~ /effect\(/) { in_block=1; depth=0; buf=""; startline=FNR; startcontent=line } | |
| else next | |
| } | |
| buf = buf "\n" line | |
| n=length(line) | |
| for (i=1;i<=n;i++){ | |
| c=substr(line,i,1) | |
| if (c=="(") depth++ | |
| else if (c==")") depth-- | |
| } | |
| if (depth<=0){ | |
| if (buf ~ /\.subscribe\(|switchMap\(|\.then\(|http\.|fetch\(/) { | |
| gsub(/^[ \t]+/,"",startcontent) | |
| print FILENAME ":" startline ": " startcontent | |
| } | |
| in_block=0 | |
| } | |
| } | |
| ' $effect_files | |
| echo "(no output above this line = no async fetching found inside effects)" | |
| else | |
| echo "(no effect() calls found)" | |
| fi | |
| echo | |
| if [ "$fail" -eq 1 ]; then | |
| exit 1 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment