diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aef397e..5cf90be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,37 +48,25 @@ repos: types_or: [ts, svelte] pass_filenames: false - - id: check-biome-pins - name: check biome version pins agree - entry: python3 scripts/check_biome_pins.py + # Runs the Biome that npm installed, via the same `npm run lint` a + # developer runs by hand, so there is exactly one Biome version in the + # repo: frontend/package.json. It used to be declared here as well (a + # `rev` plus an `additional_dependencies` pin) because pre-commit + # fetches its own hermetic copy and never looks at frontend/ + # node_modules -- which is how this repo once enforced 2.2.4 against a + # declared 2.5.11, nine releases apart and silently (issue #81). + # + # What Biome looks at is now biome.json's files.includes, not a regex + # here. Keeping that scoping in this file meant `npm run lint` had no + # scoping at all: it swept frontend/.svelte-kit/ and reported ~5600 + # diagnostics, so nobody could use it. + - id: biome-check + name: biome check + entry: bash -c "cd frontend && npm run lint" language: system - files: '^(frontend/package(-lock)?\.json|\.pre-commit-config\.yaml|biome\.json)$' + types_or: [ts, tsx, javascript, jsx, json, svelte] pass_filenames: false - # Both pins below must match "@biomejs/biome" in frontend/package.json. - # The rev is what actually gates CI; the npm pin only governs `npm run - # lint`, which CI never runs. When they drift, the gate silently enforces - # the older ruleset. The check-biome-pins hook above fails the run if so. - - repo: https://github.com/biomejs/pre-commit - rev: 'v2.5.13' - hooks: - - id: biome-check - # Scoped to source files rather than a bare ^frontend/. Biome gained - # HTML/SVG support after 2.2.4, so a bare prefix now sweeps in files - # the old pin silently skipped: - # - app.html / favicon.svg: attributes get reordered, a trailing - # space is emitted before '>', the pre-paint theme script's - # function expression is rewritten, and noSvgWithoutTitle fails - # on the favicon. Linting markup is a separate decision from - # this version bump. - # - app.css: the only CSS file, and biome.json already excludes it - # twice. Passing it explicitly bypasses experimentalScannerIgnores - # and the parser rejects Tailwind's @plugin before the override's - # disabled linter/formatter applies. - files: '^frontend/.*\.(jsx?|tsx?|c(js|ts)|m(js|ts)|jsonc?|svelte)$' - additional_dependencies: - - '@biomejs/biome@2.5.13' - ci: autofix_commit_msg: | [pre-commit.ci] auto fixes from pre-commit hooks diff --git a/AGENTS.md b/AGENTS.md index ffa8c8e..5b694da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,33 +37,34 @@ pre-commit run --all-files argument, not a pytest suite — it errors under plain collection. Run `pytest tests/ --ignore=tests/test_workers.py`. -### Biome's version is pinned in five places - -They must all agree, and `scripts/check_biome_pins.py` (wired into pre-commit) -fails the run when they don't: - -| Location | -| --- | -| `frontend/package.json` → `@biomejs/biome` | -| `frontend/package-lock.json` → resolved entry | -| `.pre-commit-config.yaml` → `rev` | -| `.pre-commit-config.yaml` → `additional_dependencies` | -| `biome.json` → `$schema` URL | - -The pre-commit `rev`/`additional_dependencies` pair is what actually gates CI: -the hook runs its own pinned copy of Biome in an isolated environment and never -touches `frontend/node_modules`. `npm run lint` uses the npm pin but CI never -invokes it. When these drift, **nothing fails** — the gate just silently -enforces an older ruleset. That is how the repo once ended up enforcing 2.2.4 -against a declared 2.5.11, nine minor releases apart. - -Dependabot bumps the first two and **cannot** touch the other three, so expect -`check-biome-pins` to fail on the first run after a Biome bump. Update all five -together. - -`frontend/node_modules` can also be stale relative to the lockfile — run -`npm ci` before trusting any local lint result. A stale tree is what masked the -nine-version discrepancy above. +### Biome's version lives in exactly one place + +`frontend/package.json` → `@biomejs/biome` (with the lockfile npm maintains +alongside it). Nothing else declares a Biome version, so Dependabot can bump it +unaided and there is nothing to keep in sync. + +It was not always so. Biome used to run through the remote +`biomejs/pre-commit` hook, which fetches its own hermetic copy and never looks +at `frontend/node_modules`. That copy needed its own version — a `rev` plus an +`additional_dependencies` pin — which, with the npm pin, the lockfile and +`biome.json`'s `$schema` URL, made five declarations of one fact. When they +drifted **nothing failed**: the gate just quietly enforced the older ruleset. +That is how this repo once enforced 2.2.4 against a declared 2.5.11, nine +releases apart (issue #81). A guard script was added to catch the drift, then +removed along with the drift itself. + +Biome now runs as a `repo: local` hook invoking `npm run lint`, the same +command a developer runs by hand — the pattern `svelte-check` already used. +Two consequences worth knowing: + +- **The hook needs `frontend/node_modules` to exist**, so run `npm ci` before + trusting any local lint result. A stale tree is what masked the nine-version + discrepancy above. CI installs it before running pre-commit. +- **What Biome looks at is `biome.json`'s `files.includes`**, not a regex in + `.pre-commit-config.yaml`. That scoping used to live in the hook, which meant + `npm run lint` had none: it swept `frontend/.svelte-kit/` and reported ~5600 + diagnostics, so the command was effectively unusable. Change the scope in + `biome.json` and both the hook and the npm script follow. --- @@ -155,9 +156,12 @@ for a webhook to come back around. several releases, review the diagnostics — a bump once began reformatting `app.html`/`favicon.svg` (reordered attributes, a trailing space before `>`, a rewritten pre-paint script) and erroring on the favicon, none of it wanted. -- **Prefer scoped lint globs over broad prefixes.** The Biome hook is scoped to - source extensions rather than a bare `^frontend/`, because tools grow support - for new file types and a broad prefix silently widens what they touch. +- **Keep lint scoping in the tool's own config, not the hook's.** `biome.json` + names the source extensions it covers rather than a bare `frontend/`, because + tools grow support for new file types and a broad prefix silently widens what + they touch — Biome gained HTML/SVG support after 2.2.4, which would sweep in + `app.html` and `favicon.svg`. Scoping there rather than in the hook also + keeps `npm run lint` and CI enforcing the same set. - **Ship user-visible changes with their docs.** See below — a change a user can see is not finished until the page describing it says so. @@ -222,7 +226,6 @@ frontend/ src/routes/config/+page.svelte # Settings UI src/lib/types.ts # Types matching backend API responses scripts/ - check_biome_pins.py # Lint-gate version-drift guard fix_container_mismatches.py # One-off library sweep, dry-run by default docs/ # User-facing reference ``` diff --git a/biome.json b/biome.json index c501541..d622511 100644 --- a/biome.json +++ b/biome.json @@ -1,9 +1,27 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json", + "$schema": "./frontend/node_modules/@biomejs/biome/configuration_schema.json", "files": { "ignoreUnknown": true, "maxSize": 4194304, - "experimentalScannerIgnores": ["frontend/src/app.css"] + "includes": [ + "frontend/**/*.js", + "frontend/**/*.jsx", + "frontend/**/*.cjs", + "frontend/**/*.mjs", + "frontend/**/*.ts", + "frontend/**/*.tsx", + "frontend/**/*.cts", + "frontend/**/*.mts", + "frontend/**/*.json", + "frontend/**/*.jsonc", + "frontend/**/*.svelte", + "!frontend/node_modules/**", + "!frontend/.svelte-kit/**", + "!frontend/build/**" + ], + "experimentalScannerIgnores": [ + "frontend/src/app.css" + ] }, "formatter": { "enabled": true, @@ -32,7 +50,9 @@ }, "overrides": [ { - "includes": ["frontend/src/app.css"], + "includes": [ + "frontend/src/app.css" + ], "linter": { "enabled": false }, @@ -41,7 +61,9 @@ } }, { - "includes": ["**/*.svelte"], + "includes": [ + "**/*.svelte" + ], "linter": { "rules": { "correctness": { diff --git a/frontend/package.json b/frontend/package.json index d2c99de..cc77231 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,8 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "lint": "biome check . --config-path=../biome.json", - "lint:fix": "biome check . --write --config-path=../biome.json" + "lint": "cd .. && biome check frontend", + "lint:fix": "cd .. && biome check frontend --write" }, "dependencies": { "daisyui": "^5.7.37" diff --git a/scripts/check_biome_pins.py b/scripts/check_biome_pins.py deleted file mode 100644 index 76779f5..0000000 --- a/scripts/check_biome_pins.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Fail if the Biome version is pinned inconsistently across the repo. - -Biome's version is declared in four places that must agree: - - 1. frontend/package.json "@biomejs/biome" (devDependencies) - 2. frontend/package-lock.json the resolved lock entry - 3. .pre-commit-config.yaml the biomejs/pre-commit `rev` (vX.Y.Z) - 4. .pre-commit-config.yaml `additional_dependencies` npm pin - 5. biome.json the "$schema" URL - -Only 3 and 4 gate CI: the lint hook runs its own pinned copy of Biome in an -isolated environment and never touches frontend/node_modules. `npm run lint` -uses 1/2 but CI never invokes it. So when these drift apart nothing fails -- -the gate just silently enforces an older ruleset than the repo advertises. -That is exactly how this repo ended up enforcing 2.2.4 against a declared -2.5.11, nine minor releases apart (issue #81). - -Dependabot bumps 1 and 2 on its own and cannot touch 3, 4 or 5, so this -check is what turns that silent divergence into a loud, actionable failure -on the very first run after a bump. -""" - -from __future__ import annotations - -import json -from pathlib import Path -import re -import sys - -REPO_ROOT = Path(__file__).resolve().parent.parent - -PACKAGE_JSON = REPO_ROOT / "frontend" / "package.json" -PACKAGE_LOCK = REPO_ROOT / "frontend" / "package-lock.json" -PRE_COMMIT_CONFIG = REPO_ROOT / ".pre-commit-config.yaml" -BIOME_CONFIG = REPO_ROOT / "biome.json" - -PACKAGE = "@biomejs/biome" - - -def _declared_version() -> str | None: - """The version in frontend/package.json — the source of truth.""" - data = json.loads(PACKAGE_JSON.read_text()) - for section in ("devDependencies", "dependencies"): - version = data.get(section, {}).get(PACKAGE) - if version: - # Tolerate (but do not require) a range prefix such as ^ or ~. - return version.lstrip("^~") - return None - - -def _locked_version() -> str | None: - """The resolved version in frontend/package-lock.json.""" - if not PACKAGE_LOCK.is_file(): - return None - data = json.loads(PACKAGE_LOCK.read_text()) - entry = data.get("packages", {}).get(f"node_modules/{PACKAGE}", {}) - return entry.get("version") - - -def _hook_versions() -> tuple[str | None, str | None]: - """The biomejs/pre-commit `rev` and its additional_dependencies pin.""" - text = PRE_COMMIT_CONFIG.read_text() - block = re.search( - r"- repo:\s*https://github\.com/biomejs/pre-commit\s*\n(.*?)(?=\n\s*- repo:|\nci:|\Z)", - text, - re.DOTALL, - ) - if block is None: - return None, None - body = block.group(1) - rev = re.search(r"^\s*rev:\s*['\"]?v?([0-9][^'\"\s]*)['\"]?", body, re.MULTILINE) - dep = re.search(rf"{re.escape(PACKAGE)}@([0-9][^'\"\s]*)", body) - return (rev.group(1) if rev else None, dep.group(1) if dep else None) - - -def _schema_version() -> str | None: - """The version embedded in biome.json's "$schema" URL.""" - if not BIOME_CONFIG.is_file(): - return None - data = json.loads(BIOME_CONFIG.read_text()) - match = re.search(r"/schemas/([0-9][^/]*)/schema\.json", data.get("$schema", "")) - return match.group(1) if match else None - - -def main() -> int: - """Compare every pin against package.json and report any mismatch.""" - declared = _declared_version() - if declared is None: - print(f"ERROR: could not find {PACKAGE} in {PACKAGE_JSON}", file=sys.stderr) - return 1 - - hook_rev, hook_dep = _hook_versions() - found = { - "frontend/package-lock.json (resolved)": _locked_version(), - ".pre-commit-config.yaml (rev)": hook_rev, - ".pre-commit-config.yaml (additional_dependencies)": hook_dep, - "biome.json ($schema)": _schema_version(), - } - - mismatched = {where: v for where, v in found.items() if v is not None and v != declared} - missing = [where for where, v in found.items() if v is None] - - if not mismatched and not missing: - return 0 - - print( - f"Biome version pins disagree with frontend/package.json ({declared}):\n", - file=sys.stderr, - ) - for where, version in mismatched.items(): - print(f" {where}: {version}", file=sys.stderr) - for where in missing: - print(f" {where}: could not be read", file=sys.stderr) - print( - "\nThe pre-commit rev/additional_dependencies pair is what actually gates CI," - "\nso leaving these out of step means the lint gate silently enforces a" - "\ndifferent Biome ruleset than this repo declares. Update every pin above" - f"\nto {declared} (rev takes a leading 'v'), then re-run.", - file=sys.stderr, - ) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main())