Closer/scripts/nav-scan.sh

121 lines
5.2 KiB
Bash
Raw Normal View History

test(qa): add nav-scan.sh — prove navigation dead ends statically QA had no net for navigation. Pass C already mandated "from every screen confirm there's always a way out", and C-NAV-004 still shipped: the pack library drew no app bar and no bottom bar, stranding the user on the system Back gesture. A human walking ~50 routes misses one. A dead end is statically provable, so it should not be hunted by eye. The shell draws chrome from two hand-kept sets — topLevelRoutes (bottom bar) and shellBackRoutes (app bar + back). A route in neither, whose screen also draws no back of its own, has no exit by construction. nav-scan.sh cross-references both sets against each route's screen composable and reports those as CRITICAL, exiting 1 so it can gate. It follows the existing scanner family (theme-scan, painter-xml-scan, wiring-scan) and is wired into Pass C as a pre-check, run before the manual sweep like the theme scan. Proven both ways rather than assumed: it flags QUESTION_PACKS when the fix is reverted, and reports 0 with the fix in place. Scanning the whole route table found no other dead ends. Excludes the entry flow (C-NAV-001: back out of Home must not resurface onboarding) and two self-contained flows whose own CTAs are the exit (PAIR_PROMPT, RECOVERY). Also records the durable substance where it belongs: - ERM landmine C-NAV-004 — the exact inverse of C-NAV-003 (wrongly *added* to shellBackRoutes = double app bar; wrongly *omitted* = no bar at all). The two failure modes point in opposite directions, so there is no safe default; the scanner is the guard. - Future.md — the Tier 3 screenshot-diff idea is no longer hypothetical. Five visual defects surfaced in one day (star glyph fallback, "Chat Bubble Outline" chip leak, mid-word truncation, double back arrow, hard-edged art) and every one was caught only by a human reading a screenshot. They share a shape: the UI stays renderable and shows the wrong thing, so compile/unit/theme-scan all stay green. That is the class the tooling is structurally blind to. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:46:24 -05:00
#!/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