Skip to content

Instantly share code, notes, and snippets.

@leolanese
Last active July 26, 2026 11:37
Show Gist options
  • Select an option

  • Save leolanese/8443691d4973634ca2188ab42c9ce42a to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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