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
114 changes: 12 additions & 102 deletions .claude/hooks/pre-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#
# PreToolUse hook: fires before `Bash(gh pr create*)` invocations.
#
# Per CLAUDE.md rule 48, every PR runs through the local Sonar/Semgrep/OSV
# gate before push -- including docs-only PRs. Per rule 19/36, substantial
# Per CLAUDE.md rule 48, every PR runs through the local gate
# (npm run check:local: lint, tests, Semgrep, OSV) before push -- including
# docs-only PRs. CodeQL runs in CI. Per rule 19/36, substantial
# code changes update the matching domain runbook in the same PR.
#
# Hook surfaces the pre-pr checklist AND blocks if:
Expand All @@ -13,20 +14,20 @@
# code change), scoped to diffs that actually touch instrumented code
# - substantial code changed under app/src/{app,pages,lib,components}
# but no docs/runbook/CLAUDE.md/ROADMAP.md was touched
# - the local Sonar gate is RED with no documented bypass
#
# Degrades gracefully: every external dependency (python3, docker/curl
# reaching the local Sonar container, git having an origin/main to diff
# against) is optional. Missing tooling produces a WARNING and lets the
# command through -- a fresh clone with no Sonar container running must
# never be hard-blocked from opening a PR.
# (A third block, the local SonarQube quality gate, was removed 2026-10-16
# with SonarQube itself: CodeQL in CI covers the analysis.)
#
# Degrades gracefully: every external dependency (python3, git having an
# origin/main to diff against) is optional. Missing tooling produces a
# WARNING and lets the command through -- a fresh clone must never be
# hard-blocked from opening a PR.

set -uo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SKILL_PATH="$REPO_ROOT/.claude/skills/pre-pr/SKILL.md"
COVERAGE_PATH="$REPO_ROOT/app/coverage/lcov.info"
SONAR_PROJECT_KEY="holt"

HAVE_PY=0
command -v python3 >/dev/null 2>&1 && HAVE_PY=1
Expand Down Expand Up @@ -194,96 +195,5 @@ EOF
fi
fi

# ---------------------------------------------------------------------
# HARD BLOCK 3: Sonar Quality Gate must be GREEN (or RED with explicit
# bypass marker). Degrades to a WARNING if the local Sonar server can't
# be reached -- we cannot make every PR contingent on a container that
# may not be running on this machine (or a fresh clone that never set
# one up).
# ---------------------------------------------------------------------
SONAR_TOKEN=""
SONAR_HOST_URL="http://localhost:9000"
if [ -f "$REPO_ROOT/app/.env.local" ]; then
SONAR_TOKEN=$(grep '^SONAR_TOKEN=' "$REPO_ROOT/app/.env.local" 2>/dev/null | cut -d= -f2- | tr -d '\n\r' || echo "")
CFG_HOST=$(grep '^SONAR_HOST_URL=' "$REPO_ROOT/app/.env.local" 2>/dev/null | cut -d= -f2- | sed 's:/$::' | tr -d '\n\r' || echo "")
[ -n "$CFG_HOST" ] && SONAR_HOST_URL="$CFG_HOST"
fi

GATE_JSON=""
if [ -n "$SONAR_TOKEN" ] && command -v curl >/dev/null 2>&1; then
GATE_JSON=$(curl -s --max-time 2 -u "${SONAR_TOKEN}:" \
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" 2>/dev/null || echo "")
fi

if [ -z "$GATE_JSON" ] || ! echo "$GATE_JSON" | grep -q '"projectStatus"'; then
cat <<EOF >&2
NOTE: Sonar gate could not be queried (host: $SONAR_HOST_URL, token
configured: $([ -n "$SONAR_TOKEN" ] && echo yes || echo no)). This is
expected on a fresh clone or a machine without the local Sonar
container running -- proceeding without a gate-state check. Verify
manually if you have a Sonar server available:

curl -s -u "\${SONAR_TOKEN}:" "${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}"
EOF
exit 0
fi

if [ "$HAVE_PY" -eq 0 ]; then
echo " NOTE: python3 unavailable -- cannot parse Sonar gate JSON. Proceeding." >&2
exit 0
fi

GATE_STATUS=$(echo "$GATE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['projectStatus']['status'])" 2>/dev/null || echo "UNKNOWN")

if [ "$GATE_STATUS" = "OK" ]; then
echo "Pre-PR checks passed: coverage fresh, Sonar gate GREEN." >&2
exit 0
fi

# Gate is RED (or unknown). Look for explicit bypass marker.
BYPASS_FOUND=0
if printf '%s' "$BRANCH_COMMITS" | grep -q 'sonar-gate-justified:'; then
BYPASS_FOUND=1
fi

if [ "$BYPASS_FOUND" -eq 1 ]; then
cat <<EOF >&2
NOTE: Sonar gate is RED but a commit on this branch carries the
\`sonar-gate-justified:\` marker. Bypass logged -- verify the PR
body documents the failing condition + rationale before merging.
EOF
echo "$GATE_JSON" | python3 -m json.tool >&2 2>/dev/null
exit 0
fi

cat <<EOF >&2
======================================================================
BLOCKED: Sonar Quality Gate is RED (or unknown: $GATE_STATUS).

CLAUDE.md rule 48 expects a GREEN gate before push, or an explicit
documented bypass. Failing conditions:

EOF
echo "$GATE_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for c in d.get('projectStatus', {}).get('conditions', []):
if c.get('status') == 'ERROR':
op = '<' if c.get('comparator') == 'LT' else '>'
print(f\" - {c.get('metricKey')}: actual {c.get('actualValue')} {op} threshold {c.get('errorThreshold')}\")
" >&2 2>/dev/null

cat <<EOF >&2

To proceed, either:

(a) Fix the violations: cd app && npm run test:coverage \\
&& cd $REPO_ROOT && npm run sonar:scan
...then re-run this command.

(b) Document the trade-off: add a commit whose message body contains
\`sonar-gate-justified: <rationale>\`. The PR body must also
document the failing condition.
======================================================================
EOF
exit 2
echo "Pre-PR checks passed." >&2
exit 0
37 changes: 1 addition & 36 deletions .claude/hooks/session-start-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@
#
# Always exit 0 -- informational only. Blocking session start is
# user-hostile, and every check below is best-effort: missing tooling
# (no git repo yet, no docker, no network to the local Sonar container,
# unparseable dates) degrades to skipping that one section, never to a
# (no git repo yet, unparseable dates) degrades to skipping that one section, never to a
# non-zero exit.

set -uo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SKILL_PATH="$REPO_ROOT/.claude/skills/start-session/SKILL.md"
SONAR_PROJECT_KEY="holt"

{
echo ""
Expand All @@ -40,39 +38,6 @@ SONAR_PROJECT_KEY="holt"
fi
fi

# Surface a RED Sonar gate inherited from the prior session, if the
# local Sonar container is reachable. Silent skip otherwise -- most
# sessions (fresh clones, CI-less dev boxes) won't have it running.
if [ -f "$REPO_ROOT/app/.env.local" ] && command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
SONAR_TOKEN=$(grep '^SONAR_TOKEN=' "$REPO_ROOT/app/.env.local" 2>/dev/null | cut -d= -f2- | tr -d '\n\r')
SONAR_HOST_URL=$(grep '^SONAR_HOST_URL=' "$REPO_ROOT/app/.env.local" 2>/dev/null | cut -d= -f2- | sed 's:/$::' | tr -d '\n\r')
SONAR_HOST_URL="${SONAR_HOST_URL:-http://localhost:9000}"

if [ -n "$SONAR_TOKEN" ]; then
GATE_JSON=$(curl -s --max-time 2 -u "${SONAR_TOKEN}:" \
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" 2>/dev/null || echo "")
if [ -n "$GATE_JSON" ] && echo "$GATE_JSON" | grep -q '"projectStatus"'; then
STATUS=$(echo "$GATE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['projectStatus']['status'])" 2>/dev/null || echo "UNKNOWN")
if [ "$STATUS" = "ERROR" ]; then
echo ""
echo "WARNING: Sonar gate is RED on the new code period."
echo "$GATE_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for c in d.get('projectStatus', {}).get('conditions', []):
if c.get('status') == 'ERROR':
op = '<' if c.get('comparator') == 'LT' else '>'
print(f\" - {c.get('metricKey')}: actual {c.get('actualValue')} {op} threshold {c.get('errorThreshold')}\")
" 2>/dev/null
echo " Fix before new feature work (rule 48), or confirm a working-branch"
echo " commit carries \`sonar-gate-justified:\`."
elif [ "$STATUS" = "OK" ]; then
echo "Sonar gate: GREEN"
fi
fi
fi
fi

# CVE suppression expiry (rule 54). Pure string comparison on
# YYYY-MM-DD -- ISO dates sort lexicographically, so no date-arithmetic
# portability problems between BSD date (macOS) and GNU date (Linux
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/pre-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ cd app && npm run security:deps
New advisories publish continuously — sweep BEFORE opening the PR, not
after CI turns red for a reason unrelated to this diff.

## 3. Local Sonar / Semgrep / OSV gate evaluated AND documented (rule 48)
## 3. Local gate (lint, tests, Semgrep, OSV) evaluated AND documented (rule 48)

```bash
cd app && npm run check:local
```

The result belongs in the PR body, not just "I ran it." Every finding is
one of: fixed in this PR, tripwire-tested, or explicitly won't-fix with
rationale (never silent-ignore).
rationale (never silent-ignore). CodeQL runs in CI; its findings on the PR
follow the same rule. (SonarQube was removed 2026-10-16.)

## 4. Trace-before-refactor evidence if touching shared infra (rule 45)

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/start-session/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ the planned tasks.
| Stack | Next.js 16 (App Router), Prisma, PostgreSQL 17, tRPC |
| Validate command | `cd app && npm run validate` |
| Test command | `cd app && npm test` (unit) / `npm run test:coverage` (combined gate) |
| Local Sonar/Semgrep/OSV | `cd app && npm run check:local` |
| Local gate (lint, tests, Semgrep, OSV) | `cd app && npm run check:local` |
| Test DB | `fbc_test_db` only (rule 59) — never a restored or curated database |
| Structured logging | `logger.info/warn/error` — never `console.*` in `src/` |
| Cancelled line rule | Every aggregation: `lineItemStatus: { not: "CANCELLED" }` (rule 33) |
Expand Down
8 changes: 4 additions & 4 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ N/A
## Config-effect verification (Rule 44)

<!--
Required if this PR adds or changes a Sonar property, env var, threshold,
Required if this PR adds or changes a lint or scanner rule, env var, threshold,
feature flag, or rule-tuning knob. Show before/after of the observable.
Otherwise N/A.

Example:
Before: 234 S3776 findings
After: 88 S3776 findings (re-scan attached)
Before: 234 sonarjs/cognitive-complexity warnings
After: 88 (npm run lint output attached)
-->

N/A
Expand Down Expand Up @@ -68,7 +68,7 @@ N/A
- [ ] If touched: `spawn(absolute path)` not bare binary name (Rule 44 / S4036)
- [ ] If touched: `crypto.randomBytes` not `Math.random` for any value used as a path/token/secret (Rule 44 / S2245)

## Sonar / scans (Rule 48)
## Scans: lint, Semgrep, OSV, CodeQL (Rule 48)

- [ ] No new findings introduced (or new findings explicitly fixed/tripwired/won't-fixed in this PR)
- [ ] If a regression caused this PR: failure log entry added to `.claude/skills/post-failure/SKILL.md`
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The app is a Next.js 16 application on the **App Router + tRPC** (the modern-sta
- **App Router** (`src/app/`) — every feature page. Server components under route groups (`(dashboard)` supplies the shared nav chrome; `app/portal/*` is the public, no-auth customer surface; `app/print/*` are minimal-chrome render targets) gate via `requirePage()` and render `"use client"` views. Typed data flows through **tRPC** (`src/server/trpc/`), with query logic in framework-agnostic libs (`src/lib/reports/*` etc.).
- **REST API** (`src/pages/api/`) — retained by design for exports, mutations, cross-domain shared endpoints (`/api/staff`, `/api/departments`, `/api/dashboard/weekly`, `/api/mailchimp/*`), Stripe/webhook handlers, and file uploads. App Router views call these directly where tRPC isn't the right fit (downloads, multipart, third-party callbacks).

The database is accessed through Prisma ORM in every path. See `MIGRATION-PLAN.md` for the per-domain ledger + the canonical port recipe, and `docs/QUALITY.md` for the SonarQube quality gate.
The database is accessed through Prisma ORM in every path. See `MIGRATION-PLAN.md` for the per-domain ledger + the canonical port recipe. Quality gates: `docs/CI-OPERATIONS.md`.

```
Browser (iPad / Laptop)
Expand Down
43 changes: 43 additions & 0 deletions app/__tests__/boxes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// /app/__tests__/boxes.test.ts
//
// Boxes are evidence, never a quantity (lib/inventory/boxes.ts): box scans
// assemble into whole units, and what is left is incomplete sets.

import { assembleUnits, BoxError, boxNumbers, stillMissing } from "@/lib/inventory/boxes";

describe("assembleUnits", () => {
it("makes whole units from complete sets of boxes, in any order", () => {
expect(assembleUnits(3, [3, 1, 2, 2, 3, 1])).toEqual({ units: 2, incomplete: [] });
});

it("leaves partial units as incomplete sets, most complete first", () => {
expect(assembleUnits(3, [1, 1, 2, 3, 1, 2])).toEqual({
units: 1,
incomplete: [
{ present: [1, 2], missing: [3] },
{ present: [1], missing: [2, 3] },
],
});
});

it("treats a one-box product's every scan as a unit", () => {
expect(assembleUnits(1, [1, 1, 1])).toEqual({ units: 3, incomplete: [] });
expect(assembleUnits(0, [])).toEqual({ units: 0, incomplete: [] });
});

it("refuses a box number the product does not have", () => {
expect(() => assembleUnits(2, [1, 3])).toThrow(BoxError);
});
});

describe("stillMissing", () => {
it("takes arrived boxes off what a set misses", () => {
expect(stillMissing([2, 3], [3])).toEqual([2]);
expect(stillMissing([2, 3], [2, 3])).toEqual([]);
expect(() => stillMissing([3], [2])).toThrow("not one this set is missing");
});

it("numbers boxes from 1", () => {
expect(boxNumbers(3)).toEqual([1, 2, 3]);
});
});
77 changes: 77 additions & 0 deletions app/__tests__/countDifference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// /app/__tests__/countDifference.test.ts
//
// What posting a count moves (lib/inventory/count.ts countDifference).

import { countDifference } from "@/lib/inventory/count";

const book = [
{ productId: 1, free: 3, committed: 0 },
{ productId: 2, free: 1, committed: 2 },
];

describe("countDifference", () => {
it("a place count says what is not there: an uncounted product on the books was counted absent", () => {
const d = countDifference([{ productId: 1, counted: 3 }], book, "PLACE");
expect(d).toEqual([
{
productId: 1,
counted: 3,
expected: 3,
difference: 0,
addFree: 0,
removeFree: 0,
removeCommitted: 0,
},
{
productId: 2,
counted: 0,
expected: 3,
difference: -3,
addFree: 0,
removeFree: 1,
removeCommitted: 2,
},
]);
});

it("a quick count speaks only for what it lists", () => {
expect(countDifference([{ productId: 1, counted: 5 }], book, "PRODUCTS")).toEqual([
{
productId: 1,
counted: 5,
expected: 3,
difference: 2,
addFree: 2,
removeFree: 0,
removeCommitted: 0,
},
]);
});

it("takes free stock before committed, and adds a product the books did not have", () => {
const d = countDifference(
[
{ productId: 2, counted: 2 },
{ productId: 9, counted: 1.5 },
],
book,
"PLACE",
);
expect(d.find((x) => x.productId === 2)).toMatchObject({ removeFree: 1, removeCommitted: 0 });
expect(d.find((x) => x.productId === 9)).toMatchObject({ expected: 0, addFree: 1.5 });
expect(d.find((x) => x.productId === 1)).toMatchObject({ removeFree: 3 });
});

it("adds up lines for the same product", () => {
expect(
countDifference(
[
{ productId: 1, counted: 1 },
{ productId: 1, counted: 2 },
],
book,
"PRODUCTS",
)[0].counted,
).toBe(3);
});
});
Loading
Loading