diff --git a/.claude/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index a06fda4bb..716396731 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -9,31 +9,25 @@ // - %APPDATA%\caveman\config.json (Windows fallback) // 3. 'full' -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); -const VALID_MODES = [ - 'off', 'lite', 'full', 'ultra', - 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', - 'commit', 'review', 'compress' -]; +// biome-ignore format: the whole mode list reads best on one line +const VALID_MODES = ["off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress"]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { - return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + return path.join(process.env.XDG_CONFIG_HOME, "caveman"); } - if (process.platform === 'win32') { - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'caveman' - ); + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "caveman"); } - return path.join(os.homedir(), '.config', 'caveman'); + return path.join(os.homedir(), ".config", "caveman"); } function getConfigPath() { - return path.join(getConfigDir(), 'config.json'); + return path.join(getConfigDir(), "config.json"); } function getDefaultMode() { @@ -46,7 +40,7 @@ function getDefaultMode() { // 2. Config file try { const configPath = getConfigPath(); - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) { return config.defaultMode.toLowerCase(); } @@ -55,7 +49,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +80,21 @@ function safeWriteFlag(flagPath, content) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return; } catch (e) { - if (e.code !== 'ENOENT') return; + if (e.code !== "ENOENT") return; } const tempPath = path.join(flagDir, `.caveman-active.${process.pid}.${Date.now()}`); - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; let fd; try { fd = fs.openSync(tempPath, flags, 0o600); fs.writeSync(fd, String(content)); - try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + try { + fs.fchmodSync(fd, 0o600); + } catch (e) { + /* best-effort on Windows */ + } } finally { if (fd !== undefined) fs.closeSync(fd); } @@ -130,7 +128,7 @@ function readFlag(flagPath) { if (st.isSymbolicLink() || !st.isFile()) return null; if (st.size > MAX_FLAG_BYTES) return null; - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_RDONLY | O_NOFOLLOW; let fd; let out; @@ -138,7 +136,7 @@ function readFlag(flagPath) { fd = fs.openSync(flagPath, flags); const buf = Buffer.alloc(MAX_FLAG_BYTES); const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); - out = buf.slice(0, n).toString('utf8'); + out = buf.slice(0, n).toString("utf8"); } finally { if (fd !== undefined) fs.closeSync(fd); } diff --git a/.claude/hooks/caveman-mode-tracker.js b/.claude/hooks/caveman-mode-tracker.js index 1cc2e35b9..660d49f0f 100644 --- a/.claude/hooks/caveman-mode-tracker.js +++ b/.claude/hooks/caveman-mode-tracker.js @@ -2,69 +2,79 @@ // caveman — UserPromptSubmit hook to track which caveman mode is active // Inspects user input for /caveman commands and writes mode to flag file -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { getDefaultMode, safeWriteFlag, readFlag } = require("./caveman-config"); -const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), '.claude'); -const flagPath = path.join(claudeDir, '.caveman-active'); +const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), ".claude"); +const flagPath = path.join(claudeDir, ".caveman-active"); -let input = ''; -process.stdin.on('data', chunk => { input += chunk; }); -process.stdin.on('end', () => { +let input = ""; +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { try { const data = JSON.parse(input); - const prompt = (data.prompt || '').trim().toLowerCase(); + const prompt = (data.prompt || "").trim().toLowerCase(); // Natural language activation (e.g. "activate caveman", "turn on caveman mode", // "talk like caveman"). README tells users they can say these, but the hook // only matched /caveman commands — flag file and statusline stayed out of sync. - if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) { + if ( + /\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) + ) { if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { const mode = getDefaultMode(); - if (mode !== 'off') { + if (mode !== "off") { safeWriteFlag(flagPath, mode); } } } // Match /caveman commands - if (prompt.startsWith('/caveman')) { + if (prompt.startsWith("/caveman")) { const parts = prompt.split(/\s+/); const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. - const arg = parts[1] || ''; + const arg = parts[1] || ""; let mode = null; - if (cmd === '/caveman-commit') { - mode = 'commit'; - } else if (cmd === '/caveman-review') { - mode = 'review'; - } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { - mode = 'compress'; - } else if (cmd === '/caveman' || cmd === '/caveman:caveman') { - if (arg === 'lite') mode = 'lite'; - else if (arg === 'ultra') mode = 'ultra'; - else if (arg === 'wenyan-lite') mode = 'wenyan-lite'; - else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan'; - else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra'; + if (cmd === "/caveman-commit") { + mode = "commit"; + } else if (cmd === "/caveman-review") { + mode = "review"; + } else if (cmd === "/caveman-compress" || cmd === "/caveman:caveman-compress") { + mode = "compress"; + } else if (cmd === "/caveman" || cmd === "/caveman:caveman") { + if (arg === "lite") mode = "lite"; + else if (arg === "ultra") mode = "ultra"; + else if (arg === "wenyan-lite") mode = "wenyan-lite"; + else if (arg === "wenyan" || arg === "wenyan-full") mode = "wenyan"; + else if (arg === "wenyan-ultra") mode = "wenyan-ultra"; else mode = getDefaultMode(); } - if (mode && mode !== 'off') { + if (mode && mode !== "off") { safeWriteFlag(flagPath, mode); - } else if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} + } else if (mode === "off") { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } } // Detect deactivation — natural language and slash commands - if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || - /\bnormal mode\b/i.test(prompt)) { - try { fs.unlinkSync(flagPath); } catch (e) {} + if ( + /\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt) + ) { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } // Per-turn reinforcement: emit a structured reminder when caveman is active. @@ -78,17 +88,22 @@ process.stdin.on('end', () => { // If the flag is missing, corrupted, oversized, or a symlink pointing at // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing // — never inject untrusted bytes into model context. - const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + const INDEPENDENT_MODES = new Set(["commit", "review", "compress"]); const activeMode = readFlag(flagPath); if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " + - "Drop articles/filler/pleasantries/hedging. Fragments OK. " + - "Code/commits/security: write normal." - } - })); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: + "CAVEMAN MODE ACTIVE (" + + activeMode + + "). " + + "Drop articles/filler/pleasantries/hedging. Fragments OK. " + + "Code/commits/security: write normal.", + }, + }), + ); } } catch (e) { // Silent fail diff --git a/.claude/settings/hooks.jsonc b/.claude/settings/hooks.jsonc index 9b94acdb7..98b2c67a2 100644 --- a/.claude/settings/hooks.jsonc +++ b/.claude/settings/hooks.jsonc @@ -8,16 +8,16 @@ "type": "command", "command": "sh -c 'SESSION=$(jq -r \".session_id\" 2>/dev/null); DIR=\"/tmp/claude-$(id -u)\"; mkdir -p \"$DIR\"; printf \"%s\" \"$(git rev-parse --show-toplevel)\" > \"$DIR/.claude_project_root_${SESSION}\"'", "timeout": 5, - "statusMessage": "Caching project root...", + "statusMessage": "Caching project root..." }, { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/caveman-activate.js\"", "timeout": 5, - "statusMessage": "Loading caveman mode...", - }, - ], - }, + "statusMessage": "Loading caveman mode..." + } + ] + } ], "UserPromptSubmit": [ { @@ -26,10 +26,10 @@ "type": "command", "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | PROJECT_ROOT_DIR=\"$ROOT\" node \"$ROOT/.claude/hooks/caveman-mode-tracker.js\"'", "timeout": 5, - "statusMessage": "Tracking caveman mode...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/.claude/settings/models.jsonc b/.claude/settings/models.jsonc index 09ff5dbf6..8a5408811 100644 --- a/.claude/settings/models.jsonc +++ b/.claude/settings/models.jsonc @@ -1,12 +1,12 @@ { - "availableModels": [ - "fable", - "sonnet", - "opus", - "haiku", - "claude-opus-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-sonnet-4-6" - ] + "availableModels": [ + "fable", + "sonnet", + "opus", + "haiku", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6" + ] } diff --git a/.claude/settings/permissions/additional-dirs.jsonc b/.claude/settings/permissions/additional-dirs.jsonc index ee3026bfa..c4cd7b022 100644 --- a/.claude/settings/permissions/additional-dirs.jsonc +++ b/.claude/settings/permissions/additional-dirs.jsonc @@ -2,6 +2,6 @@ // Additional directories that Claude Code can access "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "additionalDirectories": ["/tmp", "/workspaces"], - }, + "additionalDirectories": ["/tmp", "/workspaces"] + } } diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index 573bfa6a2..efcbbe1c0 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -85,7 +85,7 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "ask": [ @@ -99,7 +99,7 @@ "Bash(gh pr close *)", "Bash(gh pr comment *)", "Bash(gh pr create *)", - "Bash(gh pr edit *)", + "Bash(gh pr edit *)" ], "deny": [ // Exceptions to generally allowed AI tooling @@ -178,7 +178,7 @@ "Bash(usermod *)", // System Control "Bash(reboot *)", - "Bash(shutdown *)", - ], - }, + "Bash(shutdown *)" + ] + } } diff --git a/.claude/settings/permissions/edit.jsonc b/.claude/settings/permissions/edit.jsonc index a2906ad5c..e2051fadd 100644 --- a/.claude/settings/permissions/edit.jsonc +++ b/.claude/settings/permissions/edit.jsonc @@ -6,6 +6,6 @@ // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], - }, + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"] + } } diff --git a/.claude/settings/permissions/read.jsonc b/.claude/settings/permissions/read.jsonc index 7916e1e87..c9d6b16dc 100644 --- a/.claude/settings/permissions/read.jsonc +++ b/.claude/settings/permissions/read.jsonc @@ -4,6 +4,6 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": ["Read(~/.zshrc)"], - "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"], - }, + "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"] + } } diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 82f1fdfdc..85dc0bb40 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,6 +25,8 @@ reviews: enabled: false pylint: # when the code contains python, pylint will be run in the CI pipeline, and coderabbit often generates false positives enabled: false + biome: # biome runs in the CI pipeline through pre-commit, and coderabbit would not find the config in .config/ and fall back to its own defaults + enabled: false flake8: # we use ruff instead (when we use Python) enabled: false poem: false diff --git a/.config/biome.jsonc b/.config/biome.jsonc new file mode 100644 index 000000000..350980ae3 --- /dev/null +++ b/.config/biome.jsonc @@ -0,0 +1,38 @@ +{ + "formatter": { + "indentStyle": "space", + "lineWidth": 120 + }, + "linter": { + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "files": { + "includes": [ + "**", + // force-ignored (!!) because Biome treats any biome.jsonc it is handed as a config, so this file and the generated repos' copies of it would otherwise be rejected as a second root config + "!!**/.config/biome.jsonc", + "!**/__snapshots__", + "!**/vendor_files", + "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs + "!**/schema.graphql", // exported by Strawberry from the Python schema + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates + ] + }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + } + ] +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dca586ef4..e93fd88f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,7 +43,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -60,9 +60,9 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/copier-base-template/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/copier-base-template/.config/pyrefly.toml", - "prettier.configPath": "/workspaces/copier-base-template/.config/.prettierrc", - "[jsonc][json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "biome.configurationPath": "/workspaces/copier-base-template/.config/biome.jsonc", + "[jsonc][json][javascript][typescript]": { + "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true } } @@ -71,5 +71,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): d487598c # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 85e48e15 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 23c5bc443..6c592d1d1 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -17,6 +17,7 @@ IDENTIFY_VERSION = "==2.6.20" PREK_VERSION = "==0.5.5" TASK_VERSION = "==3.53.1" +BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. @@ -54,7 +55,7 @@ "--install-pre-commit-tooling", default=False, action="store_true", - help="Install pre-commit, identify and prek in CI. Outside CI, this script always installs them.", + help="Install pre-commit, identify, prek and Biome in CI. Outside CI, this script always installs them.", ) _ = parser.add_argument( "--install-copier", @@ -180,6 +181,22 @@ def install_task(*, uv_path: str, uv_env: dict[str, str]) -> None: _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome() -> None: + """Install the pinned Biome globally through npm. + + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. + """ + run_with_retries([f"npm install -g @biomejs/biome@{BIOME_VERSION}"], description="Installing Biome", shell=True) # noqa: S604 # we need shell=True for npm commands, and this is all our own input + npm_prefix = subprocess.run(["npm prefix -g"], shell=True, check=True, capture_output=True, text=True).stdout # noqa: S602,S607 # we need shell=True for npm commands, and this is all our own input + global_bin_dir = Path(npm_prefix.strip()) / "bin" + _ = subprocess.run([str(global_bin_dir / "biome"), "--version"], check=True) # noqa: S603 # this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") + + def main(): args = parser.parse_args(sys.argv[1:]) uv_env = dict(os.environ) @@ -196,6 +213,7 @@ def main(): run_with_retries([f"npm install -g pnpm@{PNPM_VERSION}"], description="Installing pnpm", shell=True) # noqa: S604 # we need shell=True for npm commands, and this is all our own input _ = subprocess.run(["pnpm -v"], shell=True, check=True) # noqa: S602,S607 # we need shell=True for pnpm commands, and this is all our own input install_uv(uv_path=uv_path, uv_env=uv_env) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: if should_install(requested=args.install_copier): run_with_retries( @@ -210,7 +228,7 @@ def main(): description="Installing copier", env=uv_env, ) - if should_install(requested=args.install_pre_commit_tooling): + if install_pre_commit_tooling: run_with_retries( [ uv_path, @@ -233,6 +251,8 @@ def main(): description="Installing prek", env=uv_env, ) + if install_pre_commit_tooling: + install_biome() install_task(uv_path=uv_path, uv_env=uv_env) _ = subprocess.run( # noqa: S603 # this is all our own input [ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e66ab286..120c5df5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,45 +78,39 @@ repos: .*/vendor_files/.*| )$ - - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + # `language: system` so the hooks run the one Biome that .devcontainer/install-ci-tooling.py installs, which the VS Code + # extension uses too, instead of a second copy whose version has to be kept in sync (https://biomejs.dev/recipes/git-hooks/#pre-commit) + - repo: local hooks: - id: biome-check - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - )$ + name: biome check + entry: biome check --write --files-ignore-unknown=true --no-errors-on-unmatched --config-path .config/biome.jsonc + language: system + types: [text] + files: '\.(jsx?|tsx?|c(js|ts)|m(js|ts)|d\.(ts|cts|mts)|jsonc?|css|svelte|vue|astro|graphql|gql)$' + require_serial: true + - id: biome-format-config + name: format the biome config itself + # run from .config/ so Biome treats the file as its own root config instead of rejecting it as a second one + entry: bash -c 'cd .config && biome format --write biome.jsonc' + language: system + files: ^\.config/biome\.jsonc$ + pass_filenames: false - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 hooks: - id: prettier args: [--config, .config/.prettierrc] + # Biome formats everything else. Its Vue and HTML formatting is still experimental # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( - .devcontainer/devcontainer.json| - .devcontainer/devcontainer-lock.json| - .*devcontainer.json| - .*tsconfig.json| .*/__snapshots__/.*| - .*.min.js| - .*.min.css| - .*.umd.js| - .*.yaml| - .*.yml| - .*.md| - .*.jsonc| .*/vendor_files/.*| - .*/schema.graphql| - .*/generated/open[-_]api/.*| - .*generated/graphql.ts| template/.*| )$ - files: (.*.json)|(.*.ts)|(.*.jsx)|(.*.tsx)|(.*.yaml)|(.*.yml)|(.*.md)|(.*.html)|(.*.css)|(.*.scss)|(.*.less)|(.*.vue)|(.*.graphql)|(.*.gql) + files: \.(vue|html)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/biome.jsonc b/biome.jsonc deleted file mode 100644 index e0ea5856c..000000000 --- a/biome.jsonc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "formatter": { - "enabled": false, - }, - "linter": { - "enabled": false, - }, - "files": { - "includes": [ - "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], - }, -} diff --git a/extensions/context.py b/extensions/context.py index 75edad1dd..3cb7f1cd3 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -19,6 +19,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["prek_version"] = "==0.5.5" context["pnpm_version"] = "12.9.1" context["task_version"] = "==3.53.1" + context["biome_version"] = "2.5.15" # These also in pyproject.toml and the install-ci-tooling.py script in this repository context["copier_version"] = "==9.18.2" context["copier_template_extensions_version"] = "==0.3.3" diff --git a/template/.config/biome.jsonc b/template/.config/biome.jsonc new file mode 120000 index 000000000..d72573687 --- /dev/null +++ b/template/.config/biome.jsonc @@ -0,0 +1 @@ +../../.config/biome.jsonc \ No newline at end of file diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index c8667ab5e..b92ef7b1b 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -49,6 +49,7 @@ // VueJS "vue.volar@3.2.5", "vitest.explorer@1.36.0", + "esbenp.prettier-vscode@12.4.0", {% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} // All javascript "dbaeumer.vscode-eslint@3.0.24", @@ -58,7 +59,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -75,11 +76,16 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} + "biome.configurationPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/biome.jsonc", + "[jsonc][json][javascript][typescript][graphql][css]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %}, "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { + "[vue][html]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true - } + }{% endraw %}{% endif %}{% raw %} } }{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %}, "codespaces": { diff --git a/template/.devcontainer/install-ci-tooling.py.jinja-base b/template/.devcontainer/install-ci-tooling.py.jinja-base index 26ec9d58b..02fdcb608 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja-base +++ b/template/.devcontainer/install-ci-tooling.py.jinja-base @@ -18,6 +18,7 @@ PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}" IDENTIFY_VERSION = "{% endraw %}{{ identify_version }}{% raw %}" PREK_VERSION = "{% endraw %}{{ prek_version }}{% raw %}" TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}" +BIOME_VERSION = "{% endraw %}{{ biome_version }}{% raw %}" DOWNLOAD_TIMEOUT_SECONDS = 90 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. @@ -76,7 +77,7 @@ _ = parser.add_argument( "--install-pre-commit-tooling", action="store_true", default=False, - help="Install pre-commit, identify and prek in CI. Outside CI, this script always installs them.", + help="Install pre-commit, identify, prek and Biome in CI. Outside CI, this script always installs them.", ) _ = parser.add_argument( "--install-copier", @@ -227,6 +228,34 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome(*, is_windows: bool) -> None: + """Install the pinned Biome globally through npm. + + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. + """ + run_with_retries( # noqa: S604 # we need shell=True for npm commands, and this is all our own input + node_cmd(f"npm install -g @biomejs/biome@{BIOME_VERSION}", is_windows=is_windows), + description="Installing Biome", + shell=True, + ) + npm_prefix = subprocess.run( # noqa: S602 # we need shell=True for npm commands, and this is all our own input + node_cmd("npm prefix -g", is_windows=is_windows), shell=True, check=True, capture_output=True, text=True + ).stdout.strip() + # npm links global executables straight into its prefix on Windows, and into the prefix's bin directory elsewhere + if is_windows: + global_bin_dir = Path(npm_prefix) + else: + global_bin_dir = Path(npm_prefix) / "bin" + biome_env = dict(os.environ) + biome_env["PATH"] = f"{global_bin_dir}{os.pathsep}{biome_env['PATH']}" + _ = subprocess.run(node_cmd("biome --version", is_windows=is_windows), shell=True, check=True, env=biome_env) # noqa: S602 # we need shell=True for npm-installed commands, and this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") + + def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: if is_windows: return pwsh_cmd(cmd) @@ -372,6 +401,7 @@ def main(): else: uv_path = "uv" install_uv(uv_path=uv_path, uv_env=uv_env, is_windows=is_windows) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: if should_install(requested=args.install_copier): run_with_retries( @@ -386,7 +416,7 @@ def main(): description="Installing copier", env=uv_env, ) - if should_install(requested=args.install_pre_commit_tooling): + if install_pre_commit_tooling: run_with_retries( [ uv_path, @@ -409,6 +439,8 @@ def main(): description="Installing prek", env=uv_env, ) + if install_pre_commit_tooling: + install_biome(is_windows=is_windows) if not args.no_node: install_pnpm(is_windows=is_windows) # Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner diff --git a/template/.github/actions/install_deps/action.yml.jinja-base b/template/.github/actions/install_deps/action.yml.jinja-base index bbf895603..d3f915c2b 100644 --- a/template/.github/actions/install_deps/action.yml.jinja-base +++ b/template/.github/actions/install_deps/action.yml.jinja-base @@ -24,7 +24,7 @@ inputs: install-pre-commit-tooling: required: false default: false - description: Whether to install the tooling the pre-commit hooks need (pre-commit, identify and prek), for jobs that run pre-commit + description: Whether to install the tooling the pre-commit hooks need (pre-commit, identify, prek and Biome), for jobs that run pre-commit install-copier: required: false default: false diff --git a/template/biome.jsonc b/template/biome.jsonc deleted file mode 120000 index f6311a587..000000000 --- a/template/biome.jsonc +++ /dev/null @@ -1 +0,0 @@ -../biome.jsonc \ No newline at end of file diff --git a/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs b/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs index e9ca58ff1..d103dfd93 100644 --- a/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs +++ b/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs @@ -46,8 +46,7 @@ export default { meta: { type: "problem", docs: { - description: - "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", + description: "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", }, schema: [], messages: { diff --git a/template/extensions/context.py.jinja-base b/template/extensions/context.py.jinja-base index 3eec6073b..e6b188fd0 100644 --- a/template/extensions/context.py.jinja-base +++ b/template/extensions/context.py.jinja-base @@ -20,6 +20,7 @@ class ContextUpdater(ContextHook): context["identify_version"] = "{{ identify_version }}" context["prek_version"] = "{{ prek_version }}" context["task_version"] = "{{ task_version }}" + context["biome_version"] = "{{ biome_version }}" context["pytest_version"] = "{{ pytest_version }}" context["pytest_randomly_version"] = "{{ pytest_randomly_version }}" context["pytest_cov_version"] = "{{ pytest_cov_version }}" diff --git a/template/template/.config/biome.jsonc b/template/template/.config/biome.jsonc new file mode 120000 index 000000000..d72573687 --- /dev/null +++ b/template/template/.config/biome.jsonc @@ -0,0 +1 @@ +../../.config/biome.jsonc \ No newline at end of file diff --git a/template/template/biome.jsonc b/template/template/biome.jsonc deleted file mode 120000 index 90d995a87..000000000 --- a/template/template/biome.jsonc +++ /dev/null @@ -1 +0,0 @@ -../../biome.jsonc \ No newline at end of file