diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1f1dc631..636ddd21 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,8 +7,8 @@ # # Languages handled: # *.cs → dotnet format (whitespace + style) -# *.{ts,tsx,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm -# *.{json,md} → prettier --write +# frontend/**.{ts,tsx,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm +# frontend/**.{json,md} → prettier --write # # Intentionally NOT handled: *.yml / *.yaml. Compose / Dapr / APISIX YAMLs # are comment-heavy and prettier reflows them in a way that hurts review @@ -80,10 +80,24 @@ all_staged=() # diff --cached` invocation on every commit). while IFS= read -r -d '' f; do all_staged+=("$f") + # + # Prettier is scoped to `frontend/` on purpose. It is a frontend tool here: + # `make format` runs it from that directory, and no CI job verifies its + # output anywhere. Pointed at the documentation corpus it is not a no-op — + # its Markdown printer joins blockquote lines, drops the `>` from + # continuations, and reads the `*` inside a code span as an emphasis + # marker — which silently rewrote `/api/v1/*` to `/api/v1/_` in a roadmap + # record. The root `.prettierignore` draws the same boundary for the + # editor; this filter draws it here, because prettier runs from + # `frontend/` where that file does not apply. case "$f" in - *.cs) cs_files+=("$f") ;; - *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;; - *.json|*.md) prettier_only_files+=("$f") ;; + *.cs) cs_files+=("$f") ;; + frontend/*) + case "$f" in + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;; + *.json|*.md) prettier_only_files+=("$f") ;; + esac + ;; esac done < <(staged_files) @@ -96,7 +110,39 @@ restage() { git add -- "$@"; } # Iterating costs one CLI invocation per file but each one is fast # (Aho-Corasick pre-filter); for small commits this is sub-second. -if command -v leakwatch >/dev/null 2>&1; then +# Older builds only accept a DIRECTORY target. CI pins v1.5.0, which rejects a +# file with "source validation failed: source is not a directory" — so a +# developer who mirrors the CI pin gets a hook that aborts every commit. Probe +# once and degrade to a message rather than blocking; CI is the real gate. +leakwatch_takes_files() { + local probe + for probe in "${all_staged[@]}"; do + [[ -f "$probe" ]] || continue + if leakwatch scan fs "$probe" --config .leakwatch.yaml \ + --min-severity medium --no-verify >/dev/null 2>&1; then + return 0 + fi + # A finding also exits non-zero; only the validation error means the + # binary cannot take a file at all. Capture and match on the string + # rather than piping into grep: this script runs under `pipefail`, so + # a pipeline carries leakwatch's exit code even when grep matches, and + # `… | grep -q … && return 1` would never fire. + local out + out=$(leakwatch scan fs "$probe" --config .leakwatch.yaml \ + --min-severity medium --no-verify 2>&1) || true + case "$out" in + *"is not a directory"*) return 1 ;; + esac + return 0 + done + return 0 +} + +if command -v leakwatch >/dev/null 2>&1 && ! leakwatch_takes_files; then + printf "pre-commit: this leakwatch build only scans directories — skipping the\n" >&2 + printf " local secret scan (CI re-runs it on the whole tree).\n" >&2 + printf " upgrade: brew upgrade leakwatch | go install github.com/cemililik/leakwatch@latest\n" >&2 +elif command -v leakwatch >/dev/null 2>&1; then if [[ ${#all_staged[@]} -gt 0 ]]; then printf "pre-commit: leakwatch scan (%d file(s)) …\n" "${#all_staged[@]}" for f in "${all_staged[@]}"; do @@ -158,11 +204,32 @@ if [[ ${#real_prettier_batch[@]} -gt 0 || ${#js_like_files[@]} -gt 0 ]]; then restage "${real_prettier_batch[@]}" fi - if [[ ${#js_like_files[@]} -gt 0 ]]; then - # ESLint runs only on JS-like files (not on JSON/MD). + # ESLint is wired in exactly one workspace: `@learnstack/web` is the only + # package with a `lint` script, so `pnpm -r lint` in CI covers + # `frontend/apps/web` and nothing else. Two reasons the previous + # invocation could never work: `eslint` is a devDependency of that package + # only, so `pnpm exec eslint` from `frontend/` fails with + # ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL; and ESLint 9 does not read the + # `.eslintrc.cjs` this app still uses, so the bare binary bails with + # "couldn't find an eslint.config.js" even where it does resolve. + # `next lint` is what the package's own script runs and what CI therefore + # runs, and it understands the eslintrc format. + # `next lint` takes one `--file` FLAG per path, so the array is built as + # alternating flag/value pairs — a `${a[@]/#/--file }` rewrite would make + # each element a single argument containing a space and never split. + web_lint_args=() + web_count=0 + for f in "${js_like_files[@]:-}"; do + if [[ -n "$f" && "$f" == frontend/apps/web/* ]]; then + web_lint_args+=(--file "${f#frontend/apps/web/}") + web_count=$(( web_count + 1 )) + fi + done + + if [[ $web_count -gt 0 ]]; then # `--max-warnings 0` turns ANY warning into a hook failure. - printf "pre-commit: eslint --fix (%d file(s)) …\n" "${#js_like_files[@]}" - (cd frontend && pnpm exec eslint --fix --max-warnings 0 "${js_like_files[@]/#/../}") + printf "pre-commit: next lint --fix (%d file(s)) …\n" "$web_count" + (cd frontend/apps/web && pnpm exec next lint --fix --max-warnings 0 "${web_lint_args[@]}") restage "${js_like_files[@]}" fi fi diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d0002f70..64f0bf74 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -98,11 +98,29 @@ make typecheck # tsc --noEmit make test # unit + arch + contract + vitest ``` -The pre-commit hook (activated by `make install`) runs `dotnet format` + -prettier + ESLint + (if installed) `leakwatch scan fs ` on -staged files — so the lint / typecheck / test / secret-scan pass above is -mostly a sanity check. CI re-runs every check as a hard gate, so a -bypassed local commit will fail the PR build. +The pre-commit hook (activated by `make install`) runs, on staged files only: +`dotnet format` on `*.cs`; prettier on JS / TS / JSON / Markdown **under +`frontend/`**; `next lint --fix` on JS / TS under `frontend/apps/web` — the one +workspace with a `lint` script, so this is exactly what `pnpm -r lint` covers in +CI; and, when the binary is on PATH, `leakwatch scan fs `. So the +lint / typecheck / test / secret-scan pass above is mostly a sanity check. CI +re-runs every check as a hard gate, so a bypassed local commit will fail the PR +build. + +Prettier stops at the `frontend/` boundary, and the root `.prettierignore` +is what enforces it in your editor — `.vscode/settings.json` maps `[markdown]` +to the Prettier extension, which honours that file, so neither format-on-save +nor an explicit Format Document reaches `docs/`. Keep it that way: prettier's +Markdown printer rewrites hard-wrapped prose — it joins blockquote lines and +reads `*` inside a code span as emphasis — and it corrupted a roadmap record +doing exactly that. Nothing verifies its output outside the frontend workspace +anyway, since no CI job runs prettier and `make format` invokes it from +`frontend/`. + +Older Leakwatch builds — including the `v1.5.0` CI pins — only accept a +directory target. The hook detects that and skips the local scan with an +upgrade hint rather than failing your commit; CI scans the whole tree either +way. The secret scanner is [Leakwatch](https://github.com/cemililik/Leakwatch) — MIT licensed, verifier-equipped, hybrid Aho-Corasick + regex + entropy diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3fefc20b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,20 @@ +# Prettier is scoped to the frontend workspace. +# +# `make format` already runs it from `frontend/`, and no CI job verifies its +# output anywhere in this repository. Outside that workspace every file is +# hand-authored — the documentation corpus is hard-wrapped prose with +# blockquote-heavy delivery records, and prettier's Markdown printer rewrites +# it: it joins blockquote lines onto the preceding one, drops the `>` from +# continuation lines, and reads the `*` inside a code span as an emphasis +# marker. It silently turned `/api/v1/*` into `/api/v1/_` in +# docs/roadmap/phase-01-repository-tooling.md. +# +# This file is what stops the editor. `.vscode/settings.json` maps `[markdown]` +# to the Prettier extension, which honours `.prettierignore` from the workspace +# root — so ignoring here covers both format-on-save and an explicit +# Format Document. The pre-commit hook runs prettier with `frontend/` as its +# working directory, where this file does not apply, so it carries the same +# boundary as a path filter of its own. + +/* +!/frontend/ diff --git a/Makefile b/Makefile index a5f67448..59185594 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ install: .env hooks ## Restore backend NuGet + frontend pnpm deps + activate git .PHONY: hooks hooks: ## Activate the repo's pre-commit hook (.githooks/pre-commit). @git config core.hooksPath .githooks - @printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format + prettier + eslint + leakwatch if available)$(RESET)\n" + @printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format on *.cs, prettier + next lint under frontend/, leakwatch if available)$(RESET)\n" # ─── Env scaffolding ────────────────────────────────────────────────────── # `.env` is gitignored; this rule copies `.env.example` on first run so the diff --git a/docs/roadmap/phase-01-repository-tooling.md b/docs/roadmap/phase-01-repository-tooling.md index ea5a54a0..a0649986 100644 --- a/docs/roadmap/phase-01-repository-tooling.md +++ b/docs/roadmap/phase-01-repository-tooling.md @@ -112,14 +112,13 @@ > ### Known defects this phase shipped with, and where they are fixed > > All but one were remediated in **[Phase 02a Packet 3b](phase-02a-kernel-tenancy.md)** -> — see its [delivery record](phase-02a-kernel-tenancy.md#delivery-record-packet-3b). -> The exception is **branch protection**: the missing approval and -> bypass settings are *deferred by maintainer decision*, not fixed, and stay -> that way until a second contributor gains write access — see -> [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). Packet 3b +> — see its [delivery record](phase-02a-kernel-tenancy.md#delivery-record-packet-3b) — > which exists precisely so that Phase 01's record does not have to be rewritten. The -> list below is kept as written, because what a phase shipped with is part of its -> record: +> exception is **branch protection**: the missing approval and bypass settings are +> *deferred by maintainer decision*, not fixed, and stay that way until a second +> contributor gains write access — see +> [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). The list below is +> kept as written, because what a phase shipped with is part of its record: > > - `make seed`'s health gate requires every compose service to report healthy, but > `coturn`, `dapr-placement` and `dapr-sidecar-api` declare no healthcheck — so the @@ -140,6 +139,32 @@ > standard must agree; a security rule that differs from the live platform setting > makes a green build look stronger than it is. > +> Three more were found on **2026-08-18**, after Packet 3b shipped, and fixed the same +> day. They are recorded here rather than in Packet 3b's list because Phase 01 is what +> shipped them, and all three survived because each is invisible on the machine that +> wrote it: +> +> - `.githooks/pre-commit` calls `leakwatch scan fs ` per file. The +> Homebrew build accepts a file; the `v1.5.0` that CI pins rejects it with +> `source is not a directory`, so the hook aborted **every** commit for anyone who +> installed the pinned version. +> - The same hook's `pnpm exec eslint` runs from `frontend/`, where `eslint` is not a +> dependency — and ESLint 9 does not read the `.eslintrc.cjs` the app still uses, so +> the call could not have worked even where the binary resolved. The hook now runs +> `next lint`, which is what `pnpm -r lint` runs in CI, scoped to `frontend/apps/web`, +> the only workspace with a `lint` script. +> - Prettier formatted Markdown outside `frontend/`, and that is not a formatting +> no-op. Its Markdown printer joins blockquote lines onto the preceding one, drops +> the `>` from continuation lines, and reads the `*` inside a code span as an +> emphasis marker — it rewrote this file's own `/api/v1/*` into `/api/v1/_` and +> demoted a sentence clause to a list item. Two callers could reach the corpus: +> `.vscode/settings.json` maps `[markdown]` to the Prettier extension repository-wide +> — that is the one that fired — and `.githooks/pre-commit` fed prettier every staged +> `*.md`, which would have done the same for anyone who ran `make hooks`. Nothing +> checked the result: no CI job runs prettier, and `make format` scopes it to +> `frontend/`. A root `.prettierignore` now stops the editor, and the hook carries +> the same boundary as a path filter. +> > ### Conventions introduced after this phase > > - Every phase document now carries a **`## Phase Exit Decision`** section. This