Closer/scripts/nav-scan.sh

121 lines
5.2 KiB
Bash
Executable File

#!/bin/bash
#
# CloserApp — automated navigation dead-end scanner (Pass C pre-check)
#
# Finds screens a user can reach but cannot leave.
#
# The shell in AppNavigation.kt draws chrome from two hand-maintained sets:
# * topLevelRoutes (AppRoute.kt) -> gets the bottom nav bar
# * shellBackRoutes (AppNavigation.kt) -> gets the app bar + its back arrow
# A route in NEITHER set gets no bar at all. That is correct only if the screen
# draws its own back affordance (several deliberately do — see the exclusion
# comments in shellBackRoutes). If it does neither, the system Back gesture is
# the only way off the screen: a dead end.
#
# That is exactly how C-NAV-004 shipped — QUESTION_PACKS is drilled into from
# four places, drew no header of its own, and was missing from shellBackRoutes.
# The Pass C manual sweep already said "from every screen confirm there's always
# a way out" and still missed it, because it depends on a human walking ~50
# routes. This check is static, so it cannot be skipped by fatigue.
#
# ⛔ CLAUDE: You may improve this script whenever you find a new way a route can
# strand the user. Keep it self-contained, runnable from the project root, and
# write findings to stdout. Update this header with any new patterns/exclusions.
#
# Exclusions:
# - onboardingAuthRoutes — the pre-app entry flow deliberately has no back
# (C-NAV-001: back out of Home must not resurface onboarding/auth).
# - SELF_EXIT_ROUTES below — self-contained flows whose own CTAs are the exit
# (PAIR_PROMPT has invite + skip buttons; RECOVERY is an unlock flow).
#
# Usage:
# ./scripts/nav-scan.sh > /tmp/claude-nav-scan-$(date +%Y%m%d).md
#
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
ROUTE_FILE="app/src/main/java/app/closer/core/navigation/AppRoute.kt"
NAV_FILE="app/src/main/java/app/closer/core/navigation/AppNavigation.kt"
# Routes whose screen intentionally owns its exit rather than the shell's back arrow.
SELF_EXIT_ROUTES="PAIR_PROMPT RECOVERY"
python3 - "$ROUTE_FILE" "$NAV_FILE" "$SELF_EXIT_ROUTES" <<'PY'
import re, subprocess, sys
from pathlib import Path
route_file, nav_file, self_exit = sys.argv[1], sys.argv[2], set(sys.argv[3].split())
route_src = Path(route_file).read_text()
nav_src = Path(nav_file).read_text()
def set_of(src, name):
m = re.search(rf'{name}\s*=\s*setOf\((.*?)\n\s*\)', src, re.S)
if not m: return set()
return set(re.findall(r'(?:AppRoute\.)?([A-Z_][A-Z0-9_]*)', m.group(1)))
defs = dict(re.findall(r'Definition\((\w+),\s*"([^"]+)"', route_src))
top_level = set_of(route_src, "val topLevelRoutes")
entry = set_of(route_src, "val onboardingAuthRoutes")
shell_back = set_of(nav_src, "shellBackRoutes")
# route const -> the *Screen composable the NavHost renders for it
route_screen = {}
for r, body in re.findall(r'composable\(\s*(?:route\s*=\s*)?AppRoute\.(\w+).*?\)\s*\{(.*?)\n \}', nav_src, re.S):
m = re.search(r'(\w+Screen)\s*\(', body)
if m: route_screen[r] = m.group(1)
def draws_own_exit(screen):
"""Does the screen render its own back/close affordance?"""
hits = subprocess.run(["grep", "-rl", f"fun {screen}", "app/src/main/java"],
capture_output=True, text=True).stdout.strip().splitlines()
if not hits or not hits[0]:
return None # unknown — report so a human looks
src = Path(hits[0]).read_text()
return bool(re.search(r'TopAppBar|CloserGlyphs\.Back|navigationIcon|popBackStack|onBack\b|"back"', src))
print("# CloserApp — navigation dead-end scan\n")
print("Routes that render no shell chrome (no bottom bar, no app bar) and no back")
print("affordance of their own. Reaching one strands the user on the system Back gesture.\n")
crit, review = [], []
for route, title in sorted(defs.items()):
if route in top_level or route in shell_back or route in entry or route in self_exit:
continue
screen = route_screen.get(route)
if not screen:
review.append((route, title, "(no screen resolved from NavHost)"))
continue
own = draws_own_exit(screen)
if own is None:
review.append((route, title, f"{screen} (source not found)"))
elif not own:
crit.append((route, title, screen))
print("## Tier 1 — Unreachable exit (CRITICAL)\n")
if crit:
for r, t, s in crit:
print(f"🔴 CRITICAL {r} \"{t}\" -> {s}")
print(f" no bottom bar, no app bar, no own back. Add AppRoute.{r} to")
print(f" shellBackRoutes in {nav_file}, or give the screen its own back.")
else:
print("none — every reachable route has a bottom bar, an app bar, or its own back.")
print("\n## Tier 2 — Could not verify (REVIEW)\n")
if review:
for r, t, s in review:
print(f"🟡 REVIEW {r} \"{t}\" {s}")
else:
print("none")
print("\n## Summary\n")
print(f"- routes defined: {len(defs)}")
print(f"- bottom-nav tabs: {len(top_level)}")
print(f"- shell back routes: {len(shell_back)}")
print(f"- entry flow (no back): {len(entry)}")
print(f"- self-exit exclusions: {len(self_exit)} ({', '.join(sorted(self_exit))})")
print(f"- 🔴 CRITICAL dead ends: {len(crit)}")
print(f"- 🟡 REVIEW: {len(review)}")
sys.exit(1 if crit else 0)
PY