diff --git a/.github/workflows/nav-contract.yml b/.github/workflows/nav-contract.yml new file mode 100644 index 0000000..add1274 --- /dev/null +++ b/.github/workflows/nav-contract.yml @@ -0,0 +1,86 @@ +# The nav-contract audit as a script is a suggestion; as a workflow it is a +# gate. This is what turns scripts/ci/nav-contract-audit.sh from something +# that exists into something that runs. +# +# It answers the half of the nav contract ui-defect-audit.mjs structurally +# cannot: that audit renders each site's PUBLIC entry page, so a sidebar +# behind a login — exactly where the bug that started this sweep lived — is +# invisible to it. This one reads source on every repo's default branch, +# authed surfaces included. +name: Nav contract audit + +on: + schedule: + # Weekly, same cadence as the hosted-Supabase and UI-defect audits. This + # class of regression arrives with a new nav surface, not with every + # commit — daily would be noise nobody reads twice. + - cron: '13 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nav-contract-audit + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + # Runs BEFORE the sweep, deliberately — same reasoning as the + # hosted-Supabase and UI-defect audits. A detector that has silently + # stopped catching anything reports a clean fleet, and a clean report + # from a broken detector is worse than no report: it is an absent check + # that produces a tick. This self-test already found and fixed one real + # bug in the audit (an unanchored SKIP_FILE pattern) before this + # workflow's first run. + - name: The audit still detects, and still stays quiet + run: bash scripts/ci/test-nav-contract-audit.sh + + # Same shallow-clone-the-fleet pattern as hosted-supabase.yml: this repo + # deliberately has no package.json, GitHub code search returns nothing + # for these repos, and the audit reads `origin/`, never + # a working tree. + - name: Clone the fleet + env: + # Same gap as the shared inventory and the hosted-Supabase audit, + # stated rather than hidden: the default token reads public repos + # only, so private ones are silently omitted. The audit prints how + # many repos it swept — a drop in that number is the tell. Set + # FLEET_READ_TOKEN to cover all of them. + GH_TOKEN: ${{ secrets.FLEET_READ_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/dev" + names=$(gh repo list bitbaum --limit 200 --no-archived --json name --jq '.[].name') + [ -n "$names" ] || { echo "::error::no repos listed — the token cannot see the fleet"; exit 1; } + n=0 + for name in $names; do + if gh repo clone "bitbaum/$name" "$RUNNER_TEMP/dev/$name" -- \ + --depth 1 --filter=blob:none --quiet 2>/dev/null; then + n=$((n + 1)) + else + echo "::warning::could not clone $name — it will not be audited" + fi + done + echo "cloned $n repo(s)" + echo "DEV_ROOT=$RUNNER_TEMP/dev" >> "$GITHUB_ENV" + + - name: Every repo announces its current page + run: | + set -uo pipefail + { + echo '## Nav contract audit' + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + set +e + bash scripts/ci/nav-contract-audit.sh 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" + rc=${PIPESTATUS[0]} + set -e + echo '```' >> "$GITHUB_STEP_SUMMARY" + exit "$rc" diff --git a/SHARED.md b/SHARED.md index 70a4dac..c180449 100644 --- a/SHARED.md +++ b/SHARED.md @@ -205,14 +205,30 @@ checkable. This is the nav answer to "centralize the rule, assert it locally". 5. Persisted UI state distinguishes `null` from empty. 6. Every internal href comes from a routes constant. -**Enforced in two places, because they cover disjoint surfaces.** +**Enforced in three places, because they cover disjoint surfaces.** `scripts/ci/ui-defect-audit.mjs` checks 1, 3 and 4 by **rendering** each live site — which is the only thing that spans Next apps, CSS modules, Tailwind and wild-spirit's no-framework generator alike. But it renders **public entry pages -only**, so it structurally cannot see a sidebar behind a login. Authed surfaces -need a source-level check the repo runs in its own `verify`: orangecat's -`check:dead-labels` (rule 4) and fleetcrown's `check_paired` in -`check-design-system.sh` (rule 1) are the two working examples. +only**, so it structurally cannot see a sidebar behind a login. `nav-contract.yml` +/ `scripts/ci/nav-contract-audit.sh` closes exactly that gap for rule 1: a +weekly, central, source-level sweep of every repo's default branch (public and +authed alike), self-tested before it runs. It found and fixed six repos — +botsmann, datacat, petvity, printcraft, s-ink, surf-your-life — on its first +sweep, 2026-08-31. A per-repo `verify` check can still be worth adding +alongside it: orangecat's `check:dead-labels` (rule 4) and fleetcrown's +`check_paired` in `check-design-system.sh` (rule 1) block the *commit*, +where the fleet sweep only reports weekly. + +**Open gap the central sweeps share: `orangecat.ch` isn't the whole fleet.** +`ui-defect-audit.mjs` discovers sites from `FLEET_SITES` in fleetcrown's +public footer — a deliberately hand-maintained editorial list, "each site's +own words," not something to auto-expand. As of 2026-08-31 four public sites +are outside it: s-ink (sinktattoo.com, genuinely off the `orangecat.ch` +pattern) and substrata / camille-boulangerie / wild-spirit (all on +`*.orangecat.ch`, but not yet linked from the footer that drives discovery). +`nav-contract-audit.sh` still sees all four, because it reads source rather +than a curated link list. Adding the missing three to `FLEET_SITES` is a +one-line-each product decision for a human, not folded into this sweep. **Deliberately NOT on the ratchet.** The ratchet counts concerns that should converge on ONE implementation, and nav is the opposite: every repo is supposed diff --git a/scripts/ci/nav-contract-audit.sh b/scripts/ci/nav-contract-audit.sh new file mode 100755 index 0000000..61bfcff --- /dev/null +++ b/scripts/ci/nav-contract-audit.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Fleet audit: a navigation that STYLES the current page but never ANNOUNCES it. +# +# The rendered audit (ui-defect-audit.mjs) already checks this, but only where +# it can reach: the public entry page of each orangecat.ch site. Every sidebar +# behind a login is invisible to it by construction — and the bug that started +# this was on exactly such a sidebar. This is the other half: a source sweep +# that sees authed surfaces, every framework, and the sites on their own +# domains the renderer never visits. +# +# THE RULE IS REPO-LEVEL, DELIBERATELY. +# +# The first draft asserted this per FILE: a file that renders nav links and +# computes which one is current must contain `aria-current`. Run across the +# fleet it produced 19 findings of which roughly a quarter were wrong, because +# the active state and the announcement legitimately live in different files: +# +# - evig spreads `{...navLinkProps(...)}`, a helper that supplies the +# attribute — the literal string never appears in the component. +# - fleetcrown's SidebarNav passes `current={...}` to SidebarNavItem, and the +# child is what announces. +# - a tab list correctly uses aria-selected, not aria-current. +# +# A gate that is wrong a quarter of the time gets ignored, and an ignored gate +# is worse than none because it manufactures confidence. So the assertion is +# the one that cannot be wrong: a repo whose navigation computes an active +# state must announce it SOMEWHERE. That catches the real class — a whole app +# where the highlight exists only for people who can see it — with no false +# positives. Per-surface stragglers are the rendered audit's job, because only +# a real DOM can tell which link actually got the attribute. +# +# CENTRAL, NOT A COPY PER REPO — the rule this repo already lives by. +# +# READS THE DEFAULT BRANCH, never the working tree: a local checkout sits on a +# feature branch as often as not. It reads the LAST-FETCHED origin ref and does +# not fetch, so a stale clone reports stale facts — the workflow clones fresh. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +BASELINE="${NAV_CONTRACT_BASELINE:-$HERE/nav-contract.baseline}" + +NAV_PATH='(nav|sidebar|header|footer|menu)[^/]*\.(tsx|jsx|mjs|html)$' +ACTIVE='isActive|isCurrent|pathname *===|pathname\.startsWith|currentView|-active\b|data-active' +LINKS=']*href|/dev/null || echo "local:$name")" + remote="${remote%.git}" + if [ -n "${seen_remote[$remote]:-}" ]; then continue; fi + seen_remote[$remote]=1 + + # `|| true`: origin/HEAD is unset in many clones. A question, not an error. + branch="$(git -C "$repo" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true)" + [ -n "$branch" ] || branch=main + git -C "$repo" rev-parse --verify -q "origin/$branch" >/dev/null 2>&1 || branch=master + ref="origin/$branch" + git -C "$repo" rev-parse --verify -q "$ref" >/dev/null 2>&1 || continue + repos_scanned=$((repos_scanned + 1)) + + mapfile -t candidates < <( + { + git -C "$repo" ls-tree -r --name-only "$ref" 2>/dev/null | grep -iE "$NAV_PATH" || true + git -C "$repo" grep -l -I -E ']|role="navigation"' "$ref" \ + -- '*.tsx' '*.jsx' '*.mjs' '*.html' 2>/dev/null | sed "s|^$ref:||" || true + } | grep -viE 'node_modules|/dist/|\.next/|__tests__|\.test\.|\.spec\.' \ + | grep -viE "$SKIP_FILE" | sort -u + ) + + hits=() + for f in "${candidates[@]}"; do + [ -n "$f" ] || continue + body="$(git -C "$repo" show "$ref:$f" 2>/dev/null || true)" + [ -n "$body" ] || continue + navfiles_scanned=$((navfiles_scanned + 1)) + if printf '%s' "$body" | grep -qE "$TABS"; then continue; fi + if ! printf '%s' "$body" | grep -qE "$LINKS"; then continue; fi + if ! printf '%s' "$body" | grep -qE "$ACTIVE"; then continue; fi + hits+=("$f") + done + + # No nav computes an active state here — nothing to announce. + if [ "${#hits[@]}" -eq 0 ]; then continue; fi + + # THE ASSERTION: somewhere in this repo, the current page is announced. + if git -C "$repo" grep -q -I 'aria-current' "$ref" 2>/dev/null; then continue; fi + if allowed "$name"; then continue; fi + + offenders+=("$name") + evidence+=("$name|${hits[0]}|${#hits[@]}") +done + +if [ "$repos_scanned" -eq 0 ] || [ "$navfiles_scanned" -eq 0 ]; then + echo "⊘ nav-contract audit SKIPPED — no fleet checkout under $DEV_ROOT" >&2 + echo " (scanned $navfiles_scanned nav file(s) across $repos_scanned repo(s))" >&2 + exit 2 +fi + +if [ "${#offenders[@]}" -gt 0 ]; then + echo "nav-contract audit FAILED — ${#offenders[@]} repo(s) paint a current page they never announce:" + echo + for e in "${evidence[@]}"; do + IFS='|' read -r r f n <<< "$e" + echo " $r — $n nav file(s) compute an active state, 0 aria-current in the repo" + echo " e.g. $f" + done + echo + echo "The highlight exists only for people who can see it. Set aria-current=\"page\"" + echo "on the active link. If a repo's nav genuinely has no current page (a" + echo "single-page tool whose nav is anchors), add it to $(basename "$BASELINE") with a reason." + exit 1 +fi + +echo "nav-contract audit: ok — $navfiles_scanned nav file(s) across $repos_scanned repo(s), every repo announces its current page." diff --git a/scripts/ci/nav-contract.baseline b/scripts/ci/nav-contract.baseline new file mode 100644 index 0000000..5e4992a --- /dev/null +++ b/scripts/ci/nav-contract.baseline @@ -0,0 +1,11 @@ +# Repos where "no aria-current anywhere" is legitimate, not a defect. +# +# Format: # why this one is exempt, decided in the PR that added it +# +# Empty on purpose. The 2026-08-31 sweep found six repos genuinely missing the +# announcement (botsmann, datacat, petvity, printcraft, s-ink, surf-your-life) +# and all six were real defects, fixed rather than exempted — s-ink's active +# state IS a real navigable state (a section in view), so it got +# aria-current="location", not a baseline entry. If a future repo's nav truly +# has no current page to mark, add it here with a reason instead of letting it +# fail silently or patching the audit to ignore it. diff --git a/scripts/ci/test-nav-contract-audit.sh b/scripts/ci/test-nav-contract-audit.sh new file mode 100755 index 0000000..0a19646 --- /dev/null +++ b/scripts/ci/test-nav-contract-audit.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# +# Self-test for nav-contract-audit.sh, run BEFORE the real sweep for the same +# reason test-hosted-supabase-audit.sh runs before that one: a detector that +# has silently stopped catching anything reports a clean fleet, and a clean +# report from a broken detector is worse than no report — it is an absent +# check that produces a tick. +# +# Builds tiny synthetic git repos under a scratch DEV_ROOT and points the +# audit at them, rather than sourcing internals — the script under test is a +# straight-through sweep, not a library of functions. Each fixture pins one +# side of a judgement the real sweep had to make on 2026-08-31: +# +# 1. catches a real defect (styles isActive, never announces it) +# 2. stays quiet on a repo with no nav computing an active state at all +# 3. stays quiet on a repo that already announces correctly +# 4. stays quiet on a tab list correctly using aria-selected +# (this exact false positive fired on evig's AnalyseTabs before the +# audit was narrowed to a repo-level assertion) +# 5. the baseline suppresses a named repo +# 6. two directories with the same origin remote count as one repo +# +# Pure: no network, no real fleet checkout. +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/nav-contract-audit.sh" +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT + +PASS=0; FAIL=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } + +# make_repo [origin_url] +make_repo() { + local root="$1" name="$2" file="$3" content="$4" origin="${5:-}" + local dir="$root/$name" + mkdir -p "$dir/$(dirname "$file")" + printf '%s' "$content" > "$dir/$file" + git -C "$dir" init -q -b main + git -C "$dir" -c user.email=t@test -c user.name=t -c commit.gpgsign=false \ + add -A + git -C "$dir" -c user.email=t@test -c user.name=t -c commit.gpgsign=false \ + -c core.hooksPath=/dev/null commit -q -m init + # The audit reads a REMOTE-TRACKING ref (origin/), never a local + # branch — a bare `git init` fixture has no such ref until something fetches + # it. Fake the ref directly rather than standing up a real remote. + git -C "$dir" update-ref refs/remotes/origin/main "$(git -C "$dir" rev-parse HEAD)" + if [ -n "$origin" ]; then + git -C "$dir" remote add origin "$origin" + fi +} + +run_audit() { + local root="$1" + DEV_ROOT="$root" bash "$SCRIPT" 2>&1 + echo "EXIT:$?" +} + +# ── 1. catches a real defect ──────────────────────────────────────────────── +d="$SCRATCH/case1" +make_repo "$d" broken-repo "components/Nav.tsx" ' +export function Nav({ pathname }) { + return ( + + ); +} +' +out="$(run_audit "$d")" +if echo "$out" | grep -q "EXIT:1" && echo "$out" | grep -q "broken-repo"; then + ok "catches a repo that styles the current page and never announces it" +else + no "should have failed and named broken-repo, got: $out" +fi + +# ── 2. quiet when nothing computes an active state ────────────────────────── +d="$SCRATCH/case2" +make_repo "$d" static-repo "components/Footer.tsx" ' +export function Footer() { + return ( + + ); +} +' +out="$(run_audit "$d")" +if echo "$out" | grep -q "EXIT:0"; then + ok "stays quiet on a nav with no active-state computation (nothing to announce)" +else + no "should have passed, got: $out" +fi + +# ── 3. quiet when it already announces correctly ──────────────────────────── +d="$SCRATCH/case3" +make_repo "$d" correct-repo "components/Nav.tsx" ' +export function Nav({ pathname }) { + return ( + + ); +} +' +out="$(run_audit "$d")" +if echo "$out" | grep -q "EXIT:0"; then + ok "stays quiet on a repo that already sets aria-current" +else + no "should have passed, got: $out" +fi + +# ── 4. quiet on a tab list (aria-selected is correct there, not aria-current) ─ +# This exact shape fired on evig's AnalyseTabs before the rule excluded it. +d="$SCRATCH/case4" +make_repo "$d" tabs-repo "components/nav/AnalyseTabs.tsx" ' +export function AnalyseTabs({ activeTab }) { + return ( + + ); +} +' +out="$(run_audit "$d")" +if echo "$out" | grep -q "EXIT:0"; then + ok "does NOT demand aria-current from a tab list using aria-selected" +else + no "should have passed (tab list is exempt), got: $out" +fi + +# ── 5. baseline suppresses a named repo ───────────────────────────────────── +d="$SCRATCH/case5" +make_repo "$d" exempt-repo "components/Nav.tsx" ' +export function Nav({ pathname }) { + return ( + + ); +} +' +bad="$(NAV_CONTRACT_BASELINE=/nonexistent run_audit "$d")" +if echo "$bad" | grep -q "EXIT:1"; then + ok "fails without a baseline entry (sanity check before testing suppression)" +else + no "sanity check itself failed, got: $bad" +fi + +baseline="$SCRATCH/case5.baseline" +printf 'exempt-repo # test fixture\n' > "$baseline" +good="$(NAV_CONTRACT_BASELINE="$baseline" run_audit "$d")" +if echo "$good" | grep -q "EXIT:0"; then + ok "a baseline entry suppresses a named repo" +else + no "baseline should have suppressed exempt-repo, got: $good" +fi + +# ── 6. two directories, one origin remote → counted once ─────────────────── +d="$SCRATCH/case6" +make_repo "$d" repo-a "components/Nav.tsx" ' +export function Nav({ pathname }) { + return ( + + ); +} +' "https://github.com/example/same-repo.git" +make_repo "$d" repo-b "components/Nav.tsx" ' +export function Nav({ pathname }) { + return ( + + ); +} +' "https://github.com/example/same-repo.git" +out="$(run_audit "$d")" +# Should report exactly ONE offending repo, not two, despite two directories. +n=$(echo "$out" | grep -cE "^ (repo-a|repo-b) —") +if [ "$n" = "1" ]; then + ok "two clones of the same origin remote are audited once, not twice" +else + no "expected exactly 1 finding for the deduped repo, got $n: $out" +fi + +echo +echo "$PASS/$((PASS + FAIL)) nav-contract-audit self-tests passed" +[ "$FAIL" -eq 0 ]