Files
worldmonitor/.husky/pre-push
Elie Habib a04c53fe26 fix(build): pin sebuf plugin via PATH in make generate (#3371)
* fix(build): pin sebuf plugin via PATH in `make generate`

Without this, developers who have an older sebuf protoc-gen-openapiv3
binary installed via a package manager (Homebrew ships v0.7.0 at
/opt/homebrew/bin) hit this failure on a fresh `make generate`:

    Failure: file ".../AviationService.openapi.yaml" was generated
    multiple times: once by plugin "protoc-gen-openapiv3" and again
    by plugin "protoc-gen-openapiv3"

Root cause: `buf.gen.yaml` declares three `protoc-gen-openapiv3`
invocations — the default yaml, a `format=json` variant, and a
`bundle_only=true, strategy: all` unified bundle. Sebuf v0.11.x
honors both `format=json` (emits .json extension) and `bundle_only=true`
(skips per-service emission), so the three invocations write distinct
files. Sebuf v0.7.x does NOT honor either option — it silently emits
the same per-service .yaml filenames from all three plugins and buf
rejects the collision.

`Makefile: install-plugins` installs v0.11.1 (SEBUF_VERSION) to
$HOME/go/bin. But the `generate` target doesn't prepend that to PATH,
so `which protoc-gen-openapiv3` resolves to the stale Homebrew binary
for anyone with both installed.

Verified by `go version -m`:
    /opt/homebrew/bin/protoc-gen-openapiv3 — mod sebuf v0.7.0
    /Users/eliehabib/go/bin/protoc-gen-openapiv3 — mod sebuf v0.11.1

Fix: prepend $$HOME/go/bin to PATH in the `generate` recipe. Matches
what .husky/pre-push:151-153 already does before invoking this target,
so CI and local behavior converge. No sebuf upstream bug.

* fix(build): follow GOBIN-then-GOPATH/bin for plugin PATH prefix

Reviewer (Codex) on PR #3371: the previous patch hardcoded
\$HOME/go/bin, which is only the default fallback when GOBIN is unset
AND GOPATH defaults to \$HOME/go. On machines with a custom GOBIN or
a non-default GOPATH, `go install` targets a different directory — so
hardcoding \$HOME/go/bin can force a stale binary from there to win
over the freshly-installed SEBUF_VERSION sitting at the actual install
location.

Fix: resolve the install dir the same way `go install` does:
  GOBIN first, then GOPATH/bin.

Shell expression: `go env GOBIN` returns an empty string (exit 0) when
unset, so `||` alone doesn't cascade. Using explicit `[ -n "$gobin" ]`
instead.

Also dropped the misleading comment that claimed the pre-push hook
used the same rule — it still hardcodes \$HOME/go/bin. Called that out
in a note, but left the hook alone because its PATH prepend is
belt-and-suspenders (only matters for locating `buf` itself; the
Makefile's own recipe-level prepend decides plugin resolution).

Verified on a machine with empty GOBIN:
    resolved → /Users/eliehabib/go/bin
And \`make generate\` succeeds without manual PATH overrides.

* fix(build): use first GOPATH entry for plugin PATH prefix

Reviewer (Codex) on commit 6db0b53c2: the GOBIN-empty fallback used
`$(go env GOPATH)/bin`, which silently breaks on setups where GOPATH
is a colon-separated list. Example:

    GOPATH=/p1:/p2
    previous code → "/p1:/p2/bin"
       ^ two PATH entries; neither is the actual install target /p1/bin

`go install` writes binaries only into the first GOPATH entry's bin,
so the stale-plugin case this PR is trying to fix can still bite.

Fix: extract the first entry via `cut -d: -f1`. Matches Go's own
behavior in cmd/go/internal/modload/init.go:gobin(), which uses
filepath.SplitList + [0].

Verified:
- Single-entry GOPATH (this machine) → /Users/eliehabib/go/bin ✓
- Simulated GOPATH=/fake/path1:/fake/path2 → /fake/path1/bin ✓
- make generate succeeds in both cases.

* fix(build): resolve buf via caller PATH; prepend plugin dir only (review P1/P3)

Codex P1: the previous recipe prepended the Go install dir to PATH
before invoking `buf generate`, which also changed which `buf` binary
ran. On a machine with a stale buf in GOBIN/$HOME/go/bin, the recipe
would silently downgrade buf itself and reintroduce version-skew
failures — the exact class of bug this PR was trying to fix.

Fix: two-stage resolution.

  1. `BUF_BIN=$(command -v buf)` resolves buf using the CALLER's PATH
     (Homebrew, Go install, distro package — whichever the developer
     actually runs day-to-day).
  2. Invoke the resolved buf via absolute path ("$BUF_BIN"), with a
     PATH whose first entry is the Go install dir. That affects ONLY
     plugin lookup inside `buf generate` (protoc-gen-ts-*,
     protoc-gen-openapiv3) — not buf itself, which was already resolved.

Adds a loud failure when `buf` is not on PATH:
    buf not found on PATH — run: make install-buf
Previously a missing buf would cascade into a confusing error deeper
in the pipeline.

Codex P3: added tests/makefile-generate-plugin-path.test.mjs — scrapes
the generate recipe text and asserts:
  - `command -v buf` captures buf before the PATH override
  - Missing-buf case fails loudly
  - buf is invoked via "$BUF_BIN" (absolute path)
  - GOBIN + GOPATH/bin resolution is present
  - Install-dir prepend precedes $$PATH (order matters)
  - The subshell expression resolves on the current machine

Codex P2 (Windows GOPATH semicolon delimiter) is acknowledged but
not fixed here — this repo does not support Windows dev per CLAUDE.md,
the pre-push hook and CI are Unix-only, and a cross-platform
implementation would require a separate Make detection or a
platform-selected helper script. Documented inline as a known
Unix assumption.

Verified:
- `make generate` clean
- `command -v buf` → /opt/homebrew/bin/buf
- protoc-gen-openapiv3 via plugin-PATH → ~/go/bin/protoc-gen-openapiv3
- New test suite 6/6 pass
- npm run typecheck clean

* fix(build): silence recipe comments with @# (review P2)

Codex P2: recipe lines starting with `#` are passed to the shell
(which ignores them) but Make still echoes them to stdout before
execution. Running `make generate` printed all 34 comment lines
verbatim. Noise for developers, and dilutes the signal when the
actual error output matters.

Fix: prefix every in-recipe `#` comment with `@` so Make suppresses
the echo. No semantic change — all comments still read identically
in the source.

Verified: `make generate` now prints only "Clean complete!", the
buf invocation line (silenced with @... would hide the invocation
which helps debugging, so leaving that audible), and "Code
generation complete!".

* fix(build): fail closed when go missing; verify plugin is present (review High)

Codex High: previous recipe computed PLUGIN_DIR from `go env GOBIN` /
`go env GOPATH` without checking that `go` itself was on PATH. When
go is missing:
  - `go env GOBIN`  fails silently, gobin=""
  - `go env GOPATH` fails silently, cut returns ""
  - printf '%s/bin' "" yields "/bin"
  - PATH becomes "/bin:$PATH" — doesn't override anything
  - `buf generate` falls back to whatever stale sebuf plugin is on
    PATH, reintroducing the exact duplicate-output failure this PR
    was supposed to fix.

Fix (chained in a single shell line so any guard failure aborts):
  1. `command -v go`  — fail with clear "install Go" message.
  2. `command -v buf` — fail with clear "run: make install-buf".
  3. Resolve PLUGIN_DIR via GOBIN / GOPATH[0]/bin.
  4. `[ -n "$$PLUGIN_DIR" ]` — fail if resolution returned empty
     (shouldn't happen after the go-guard, but belt-and-suspenders
     against future shell weirdness).
  5. `[ -x "$$PLUGIN_DIR/protoc-gen-ts-client" ]` — fail if the
     plugin isn't installed, telling the user to run
     `make install-plugins`. Catches the case where `go` exists but
     the user has never installed sebuf locally.
  6. `PATH="$$PLUGIN_DIR:$$PATH" "$$BUF_BIN" generate`.

Verified failure modes:
  - go missing        → "go not found on PATH — run: ... install-plugins"
  - buf missing       → "buf not found on PATH — run: make install-buf"
  - happy path        → clean `Code generation complete!`

Extended tests/makefile-generate-plugin-path.test.mjs with:
  - `fails loudly when go is not on PATH`
  - `verifies the sebuf plugin binary is actually present before invoking buf`
  - Rewrote `PATH override order` to target the new PLUGIN_DIR form.

All 8 tests pass. Typecheck clean.

* fix(makefile): guard all sebuf plugin binaries, not just ts-client

proto/buf.gen.yaml invokes THREE sebuf binaries:

- protoc-gen-ts-client
- protoc-gen-ts-server
- protoc-gen-openapiv3 (× 3 plugin entries)

The previous guard only verified protoc-gen-ts-client was present in
the pinned Go install dir. If the other two were missing from that
dir (or only partially installed by a prior failed `make install-plugins`),
`PATH="$PLUGIN_DIR:$PATH" buf generate` would fall through to whatever
stale copy happened to be earlier on the caller's normal PATH —
exactly the mixed-sebuf-version failure this PR is meant to eliminate.

Fix: iterate every plugin name in a shell `for` loop. Any missing
binary aborts with the same `Run: make install-plugins` remediation
the previous guard showed.

Tests:
- Update the existing plugin-presence assertion to require all three
  binaries by name AND the `[ -x "$PLUGIN_DIR/$p" ]` loop pattern.
- Add a cross-reference test that parses proto/buf.gen.yaml, extracts
  every `local:` plugin, and fails if any declared plugin is missing
  from the Makefile guard list. This catches future drift without
  relying on a human to remember the dependency.

Closes the PR #3371 High finding that a `ts-server` or `openapiv3`
missing from $PLUGIN_DIR would silently re-enable the stale-plugin bug.

* fix(pre-push): don't shadow caller's buf with stale ~/go/bin/buf

The proto-freshness hook's unconditional
`export PATH="$HOME/go/bin:$PATH"` defeated the Makefile-side
caller-PATH-first invariant: on machines with both a preferred buf
(Homebrew, /usr/local, etc.) AND an older `go install buf@<old>`
leftover at `~/go/bin/buf`, the prepend placed the stale copy first.
`make generate` then resolved buf via `command -v buf` and picked up
the shadowed stale binary — recreating the mixed-version failure
this PR is meant to eliminate.

Fix:

1. Only prepend `$HOME/go/bin` when buf is NOT already on the caller's
   PATH. Now buf's Homebrew/system copy always wins; `~/go/bin/buf` is
   a pure fallback.

2. Widen the plugin-presence check to accept either a PATH-resolvable
   `protoc-gen-ts-client` OR the default go-install location
   `$HOME/go/bin/protoc-gen-ts-client`. `make generate` now resolves
   plugins via its own PLUGIN_DIR (GOBIN, then first-entry GOPATH/bin),
   so requiring them on PATH is too strict.

3. Drop the redundant plugin-only PATH prepend — the Makefile's own
   plugin-path resolution handles it authoritatively.

Tests: add a regression guard that reads the hook, verifies the
prepend is gated on `! command -v buf`, and explicitly asserts the
OLD buggy pattern is not present.

Closes the PR #3371 High finding about the hook's unconditional
prepend defeating the Makefile-side caller-PATH-first invariant.
2026-04-24 19:01:47 +04:00

258 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Ensure dependencies are installed (worktrees start with no node_modules)
if [ ! -d node_modules ]; then
echo "node_modules missing, running npm install..."
npm install --prefer-offline || exit 1
fi
echo "Checking PR status for current branch..."
BRANCH=$(git branch --show-current)
if [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
PR_STATE=$(gh pr view "$BRANCH" --json state --jq '.state' 2>&1)
if [ "$PR_STATE" = "MERGED" ] || [ "$PR_STATE" = "CLOSED" ]; then
echo ""
echo "============================================================"
echo "ERROR: PR for branch '$BRANCH' is $PR_STATE."
echo "Do NOT push to a merged/closed PR branch — commits will be orphaned."
echo "Run: git checkout main && git pull && git checkout -b fix/new-branch"
echo "============================================================"
exit 1
fi
echo " Branch PR state: ${PR_STATE:-unknown}"
# Guard against branch contamination from a dirty local main.
# A feature/fix branch should never have more than 20 commits — if it does,
# the branch was likely created from a local main that had unmerged feature branches.
COMMIT_COUNT=$(git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
if [ "$COMMIT_COUNT" -gt 20 ]; then
echo ""
echo "============================================================"
echo "ERROR: Branch '$BRANCH' is $COMMIT_COUNT commits ahead of origin/main."
echo "This usually means it was branched from a dirty local main."
echo "Fix: create branches from a worktree or use: git checkout -b <name> origin/main"
echo "============================================================"
exit 1
fi
fi
echo "Checking scripts/package-lock.json sync..."
if git diff --name-only origin/main -- scripts/package.json | grep -q .; then
if ! git diff --name-only origin/main -- scripts/package-lock.json | grep -q .; then
echo ""
echo "============================================================"
echo "ERROR: scripts/package.json was modified but scripts/package-lock.json was not committed."
echo "Run: cd scripts && npm install && cd .."
echo "Then: git add scripts/package-lock.json && git commit --amend --no-edit"
echo "============================================================"
exit 1
fi
fi
echo "Running type check..."
npm run typecheck || exit 1
echo "Running API type check..."
npm run typecheck:api || exit 1
echo "Running CJS syntax check..."
for f in scripts/*.cjs; do
[ -f "$f" ] && node -c "$f" || exit 1
done
echo "Running Unicode safety check..."
node scripts/check-unicode-safety.mjs || exit 1
echo "Running architectural boundary check..."
npm run lint:boundaries || exit 1
echo "Running rate-limit policy coverage check..."
npm run lint:rate-limit-policies || exit 1
echo "Running premium-fetch parity check..."
npm run lint:premium-fetch || exit 1
echo "Running edge function bundle check..."
while IFS= read -r f; do
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null 2>/dev/null || {
echo "ERROR: esbuild failed to bundle $f — this will break Vercel deployment"
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null
exit 1
}
done < <(find api/ -name "*.js" -not -name "_*"; find api/ -maxdepth 1 -name "*.ts" -not -name "_*")
CHANGED_FILES=$(git diff --name-only origin/main...HEAD 2>/dev/null || echo "")
if [ -z "$CHANGED_FILES" ]; then
CHANGED_FILES=$(git diff --name-only HEAD~1 2>/dev/null || echo "")
fi
if [ -z "$CHANGED_FILES" ]; then
echo "WARNING: Could not determine changed files, running full test suite as safety fallback."
RUN_ALL=true
fi
# Determine which test categories to run based on changed files
RUN_RESILIENCE=false
RUN_SEED=false
RUN_SERVER=false
RUN_FRONTEND=false
${RUN_ALL:=false}
if echo "$CHANGED_FILES" | grep -q "^scripts/"; then RUN_SEED=true; fi
if echo "$CHANGED_FILES" | grep -q "^server/"; then RUN_SERVER=true; fi
if echo "$CHANGED_FILES" | grep -q "resilience"; then RUN_RESILIENCE=true; fi
if echo "$CHANGED_FILES" | grep -q "^src/\|^api/"; then RUN_FRONTEND=true; fi
if echo "$CHANGED_FILES" | grep -q "^tests/\|package.json\|tsconfig"; then RUN_ALL=true; fi
if [ "$RUN_ALL" = true ]; then
echo "Running full unit test suite (test files or config changed)..."
timeout 300 npx tsx --test tests/*.test.mjs tests/*.test.mts || exit 1
else
if [ "$RUN_RESILIENCE" = true ]; then
echo "Running resilience tests (resilience files changed)..."
timeout 120 npx tsx --test tests/resilience-*.test.mjs tests/resilience-*.test.mts || exit 1
fi
if [ "$RUN_SEED" = true ]; then
echo "Running seed tests (scripts/ changed)..."
SEED_TESTS=$(echo "$CHANGED_FILES" | grep "^scripts/" | sed 's|scripts/seed-\(.*\)\.mjs|tests/\1-seed.test.mjs|' | grep "^tests/" | while read -r t; do [ -f "$t" ] && echo "$t"; done)
if [ -n "$SEED_TESTS" ]; then
timeout 120 npx tsx --test $SEED_TESTS || exit 1
else
echo " No matching seed tests found, skipping."
fi
fi
if [ "$RUN_SERVER" = true ]; then
echo "Running server handler tests (server/ changed)..."
timeout 120 npx tsx --test tests/handlers.test.mts tests/server-handlers.test.mjs || exit 1
fi
if [ "$RUN_RESILIENCE" = false ] && [ "$RUN_SEED" = false ] && [ "$RUN_SERVER" = false ]; then
echo "Skipping unit tests (frontend-only changes)."
fi
fi
echo "Running edge function tests..."
if [ "$RUN_FRONTEND" = true ] || [ "$RUN_ALL" = true ]; then
node --test tests/edge-functions.test.mjs || exit 1
else
echo " Skipped (no api/ or src/ changes)."
fi
echo "Running markdown lint..."
if echo "$CHANGED_FILES" | grep -qE "\.md$|\.mdx$" || [ "$RUN_ALL" = true ]; then
npm run lint:md || exit 1
else
echo " Skipped (no markdown changes)."
fi
echo "Running MDX lint (Mintlify compatibility)..."
if echo "$CHANGED_FILES" | grep -q "\.mdx$" || [ "$RUN_ALL" = true ]; then
node --test tests/mdx-lint.test.mjs || exit 1
else
echo " Skipped (no MDX changes)."
fi
echo "Running proto freshness check..."
if git diff --name-only origin/main -- proto/ src/generated/ docs/api/ Makefile | grep -q .; then
# Only prepend $HOME/go/bin when buf is NOT already resolvable on the
# caller's PATH. An unconditional prepend would shadow a developer's
# preferred `buf` (e.g. Homebrew) with a potentially stale
# ~/go/bin/buf left over from an older `go install buf@<old>`, which
# is the exact mixed-version failure this PR is trying to eliminate.
# Plugin resolution is handled inside `make generate` via a pinned
# PLUGIN_DIR, so the hook no longer needs to prepend for plugin
# discovery — only for the `buf` binary itself, and only when there's
# no other candidate.
if ! command -v buf >/dev/null 2>&1 && [ -x "$HOME/go/bin/buf" ]; then
export PATH="$HOME/go/bin:$PATH"
fi
# Plugin-presence check: consider a plugin "available" if it's on
# PATH OR at the default `go install` location ($HOME/go/bin).
# `make generate` resolves plugins via its own PLUGIN_DIR (GOBIN,
# then first-entry GOPATH/bin), which matches the $HOME/go/bin
# default on almost every dev setup. Anything exotic (custom GOBIN
# not on PATH) still runs `make generate` cleanly because the
# Makefile's own plugin-executable guard fires there.
if command -v buf &>/dev/null && { command -v protoc-gen-ts-client &>/dev/null || [ -x "$HOME/go/bin/protoc-gen-ts-client" ]; }; then
make generate
if ! git diff --exit-code src/generated/ docs/api/; then
echo ""
echo "============================================================"
echo "ERROR: Proto-generated code is out of date."
echo "Run 'make generate' locally and commit the updated files."
echo "============================================================"
exit 1
fi
UNTRACKED=$(git ls-files --others --exclude-standard src/generated/ docs/api/)
if [ -n "$UNTRACKED" ]; then
echo ""
echo "============================================================"
echo "ERROR: Untracked generated files found:"
echo "$UNTRACKED"
echo "Run 'make generate' locally and commit the new files."
echo "============================================================"
exit 1
fi
echo "Proto-generated code is up to date."
else
echo "WARNING: buf or protoc plugins not installed, skipping proto freshness check."
echo " Install with: make install-buf install-plugins"
fi
else
echo "No proto-related changes, skipping."
fi
echo "Running pro-test bundle freshness check..."
# public/pro/ is NOT built by Vercel — the root build script only runs the
# main app's vite build. The /pro marketing app under pro-test/ must be
# rebuilt manually and its output committed to public/pro/. If source
# changes ship without the rebuild, production /pro serves the old bundle
# (see PR #3227 → #3228: source fix merged but deploy still broken because
# public/pro/ was stale).
#
# Scope to the BRANCH delta ($CHANGED_FILES, computed earlier from
# `git diff origin/main...HEAD`), not the worktree, so unstaged local
# pro-test/ scratch edits don't trigger a slow rebuild on unrelated
# branches. RUN_ALL forces the check when the branch delta couldn't be
# computed (matches the safety fallback used by the test runners above).
#
# Trigger on EITHER pro-test/ OR public/pro/ to match the CI workflow's
# path filter — a bundle-only PR that edits public/pro/ without touching
# pro-test/ (e.g. a fix-forward rebuild, or a hand-edit) is exactly the
# class of change this guard is meant to validate.
if [ "$RUN_ALL" = true ] || echo "$CHANGED_FILES" | grep -qE "^(pro-test|public/pro)/"; then
if [ ! -d pro-test/node_modules ]; then
echo " Installing pro-test deps..."
(cd pro-test && npm install --prefer-offline) || exit 1
fi
(cd pro-test && npm run build >/dev/null) || {
echo "ERROR: pro-test build failed"
exit 1
}
if ! git diff --exit-code public/pro/; then
echo ""
echo "============================================================"
echo "ERROR: pro-test sources changed but public/pro/ is stale."
echo "Vercel ships whatever is committed under public/pro/ —"
echo "it does NOT rebuild pro-test on deploy."
echo "Fix:"
echo " cd pro-test && npm run build && cd .."
echo " git add public/pro/ && git commit --amend --no-edit"
echo "============================================================"
exit 1
fi
UNTRACKED=$(git ls-files --others --exclude-standard public/pro/)
if [ -n "$UNTRACKED" ]; then
echo ""
echo "============================================================"
echo "ERROR: Untracked files in public/pro/ after rebuild:"
echo "$UNTRACKED"
echo "Run: git add public/pro/ && git commit --amend --no-edit"
echo "============================================================"
exit 1
fi
echo " pro-test bundle is up to date."
else
echo " Skipped (no pro-test/ or public/pro/ changes in branch)."
fi
echo "Running version sync check..."
npm run version:check || exit 1