121 lines
5.2 KiB
Bash
121 lines
5.2 KiB
Bash
|
|
#!/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
|