Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/nav-contract.yml
Original file line number Diff line number Diff line change
@@ -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/<default-branch>`, 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"
26 changes: 21 additions & 5 deletions SHARED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions scripts/ci/nav-contract-audit.sh
Original file line number Diff line number Diff line change
@@ -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='<Link|<a [^>]*href|<NavLink'
TABS='aria-selected|role="tab"'
SKIP_FILE='(^|/)page\.(tsx|jsx)$|(^|/)[A-Za-z-]*sortable[A-Za-z-]*\.(tsx|jsx)$|(^|/)[A-Za-z-]*filter[A-Za-z-]*\.(tsx|jsx)$|(^|/)tabs?\.(tsx|jsx)$'
# Every alternative is anchored to a path boundary ((^|/)) so it matches a
# whole basename, never a substring. Unanchored, `tabs?\.(tsx|jsx)$` matched
# ANY file merely ending in "Tabs.tsx" — evig's AnalyseTabs.tsx included —
# and skipped it silently, which is exactly the kind of miss this script
# exists to prevent elsewhere.

offenders=()
evidence=()
repos_scanned=0
navfiles_scanned=0

allowed() {
[ -f "$BASELINE" ] || return 1
grep -vE '^\s*(#|$)' "$BASELINE" | awk '{print $1}' | grep -qxF "$1"
}

declare -A seen_remote=()
for gitdir in "$DEV_ROOT"/*/.git; do
[ -e "$gitdir" ] || continue
repo="$(dirname "$gitdir")"
name="$(basename "$repo")"

# Two directories are not two repos: fleetcrown/fleetcrown-scripts and
# hirnli/revamp-info are each ONE repo cloned twice.
remote="$(git -C "$repo" remote get-url origin 2>/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 '<nav[ >]|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."
11 changes: 11 additions & 0 deletions scripts/ci/nav-contract.baseline
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Repos where "no aria-current anywhere" is legitimate, not a defect.
#
# Format: <repo> # 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.
Loading