From fcc7ef083a2146a09607a0c2ef960e0ff5d0f8bc Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 5 Aug 2026 16:43:36 +0200 Subject: [PATCH 01/29] chore(repo): enforce conventional commits and record release ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the repository against git-workflow-standards. Adds the governance the standard requires and that this repo lacked, minus anything CI-bound (no CI gate exists yet - a local lab is planned). - commitlint.config.mjs + a VERSIONED .githooks/commit-msg gate, so the rule travels with the repo instead of living in one laptop. The hook self-tests against a known-good message first and degrades to a warning if the toolchain is broken: a hook that fails closed on its own bugs gets bypassed with --no-verify, and then protects nothing. - .gitattributes: this plugin ships and is tested on Windows, and publishes signed zips. Without a declared policy a clone with autocrlf=true rewrites every file and invalidates artifact signatures. Vendored frontend bundles marked -diff so a bump is reviewable. - docs/adr/0001: records the two justified deviations (GitFlow for installable versioned software; GPG-on-YubiKey for real revocation and because the same key signs the distributed artifacts) and corrects a real violation - v4.3.2 and v4.4.1 were each re-cut and force-pushed three times. Published tags are now immutable; a post-tag mistake is fixed by the next patch version. CHANGELOG generation is deliberately still open: the tooling silently skips non-Conventional commits, so the message gate has to land first. Refs: git-workflow-standards §3.2, §4.1, §5.2, §5.3 --- .gitattributes | 51 ++ .githooks/commit-msg | 59 ++ commitlint.config.mjs | 35 + docs/adr/0001-release-process.md | 74 ++ package-lock.json | 1449 ++++++++++++++++++++++++++++-- package.json | 2 + 6 files changed, 1592 insertions(+), 78 deletions(-) create mode 100644 .gitattributes create mode 100755 .githooks/commit-msg create mode 100644 commitlint.config.mjs create mode 100644 docs/adr/0001-release-process.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b80696d0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,51 @@ +# Line endings and binary handling (git-workflow-standards §5.2, §5.3). +# +# This repository is developed on Linux but SHIPS AND IS TESTED ON WINDOWS (ClaudeBinaryLocator resolves npm, +# scoop, volta and chocolatey install paths; TerminalLauncher emits a PowerShell call operator). Without an +# explicit policy, a contributor cloning on Windows with core.autocrlf=true rewrites every checked-in file's +# line endings, which turns a one-line change into a whole-file diff and silently breaks anything that is +# byte-sensitive. Normalisation is declared here so it does not depend on each developer's local git config. + +# Default: let Git decide what is text, and store text as LF in the repository. +* text=auto eol=lf + +# --- Scripts whose line endings are load-bearing ----------------------------------------------------------- +# A shell script or the Gradle wrapper with CRLF fails at exec time with a confusing "bad interpreter" error. +*.sh text eol=lf +gradlew text eol=lf +bin/fake-claude text eol=lf + +# Windows-native scripts must keep CRLF or cmd.exe/PowerShell can misparse them. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# --- Binary: never diffed, never line-ending-converted ------------------------------------------------------ +# Plugin distribution artifacts and their detached signatures: a single byte of mangling invalidates the +# GPG signature and the SHA-256 the release publishes. +*.zip binary +*.jar binary +*.asc binary +*.gpg binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text eol=lf +*.woff binary +*.woff2 binary + +# --- Diff readability -------------------------------------------------------------------------------------- +# Vendored frontend libraries (marked, DOMPurify, highlight.js) are minified single-line bundles. Marking them +# linguist-vendored keeps them out of the language stats, and -diff stops a bundle bump from rendering as an +# unreadable multi-thousand-column diff nobody can review. +src/main/resources/jcef/marked.min.js linguist-vendored -diff +src/main/resources/jcef/purify.min.js linguist-vendored -diff +src/main/resources/jcef/highlight.min.js linguist-vendored -diff + +# Lockfiles are generated: reviewers should read the manifest change, not the lockfile churn. +package-lock.json -diff linguist-generated + +# The SDK reference is protocol documentation we vendor but do not author or ship. +node_modules/** linguist-vendored diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..d6bfc8b1 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Conventional Commits gate (git-workflow-standards §3.2). Local, because this repo has no CI gate yet. +# +# Enable once per clone: git config core.hooksPath .githooks +# The hook is VERSIONED so the rule travels with the repository instead of living in one laptop's .git/hooks, +# where it is invisible to everyone else and lost on the next clone. +# +# Design rule: this hook may block a BAD MESSAGE, but it must never block because the tool itself is broken. +# Those two failures are indistinguishable from an exit code, so the hook SELF-TESTS first against a message +# known to be valid. If that self-test fails, commitlint (or its runtime) is at fault, not the author — warn +# and let the commit through. A hook that fails closed on its own bugs gets bypassed with --no-verify within a +# day, and after that it protects nothing. +set -uo pipefail + +msg_file="$1" +cli="node_modules/.bin/commitlint" + +[ -x "$cli" ] || { + echo "commit-msg: commitlint not installed (npm install) — Conventional Commits check skipped." >&2 + exit 0 +} + +# Node 24 aborts on some hosts' system OpenSSL config (a documented local quirk, not present on clean images). +# Try clean first, then retry with OPENSSL_CONF neutralised — only if that was what broke it. +run_lint() { + "$cli" --edit "$1" 2>&1 || OPENSSL_CONF=/dev/null "$cli" --edit "$1" 2>&1 +} + +# --- self-test: can the tool validate a message we know is well-formed? ------------------------------------- +probe="$(mktemp)"; trap 'rm -f "$probe"' EXIT +printf 'chore: commitlint self-test\n' > "$probe" +if ! run_lint "$probe" >/dev/null 2>&1; then + echo "commit-msg: commitlint could not run (toolchain issue, not your message) — check skipped." >&2 + exit 0 +fi + +# --- the real check ---------------------------------------------------------------------------------------- +if output="$(run_lint "$msg_file")"; then + exit 0 +fi + +echo "$output" >&2 +cat >&2 <<'EOF' + +The commit message is not a Conventional Commit. + + [optional scope]: + + feat: a user-visible capability -> minor + fix: a user-visible bug fix -> patch + docs, refactor, perf, test, build, ci, chore -> no version bump + Breaking: add ! after the type, or a "BREAKING CHANGE:" footer -> major + +This is not style policing: the CHANGELOG and the version bump are derived from these +messages, and the release tooling SILENTLY SKIPS what it cannot parse. An unparseable +message is a change that never appears in a release note. + +EOF +exit 1 diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 00000000..a5dca242 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,35 @@ +/** + * Conventional Commits enforcement (git-workflow-standards §3.2, §4.2). + * + * Why this file is `.mjs` and not `.js`: package.json declares `"type": "commonjs"`, and Node 24 changed module + * loading such that commitlint fails to read a CommonJS-named config in that setup ("Please add rules to your + * commitlint.config.js"). The explicit `.mjs` extension sidesteps it without flipping the whole package to ESM, + * which would break the vitest/jsdom frontend suite that currently relies on CommonJS `require` in its helpers. + * + * Why it is enforced in CI rather than trusted: the release tooling derives the CHANGELOG and the version bump + * from these messages, and it *silently skips* commits it cannot parse. An unlinted message is therefore not a + * style problem — it is a change that quietly never appears in a release note. The lint is part of the release + * system, not cosmetics. + * + * Deliberately kept at the stock `config-conventional` ruleset: this repo has no bespoke commit vocabulary, and + * a custom rule set is one more thing to maintain and to explain to a first-time contributor. + */ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + // The subject line is what lands in `main` on a squash merge and what a future `git log --oneline` reader + // sees; 72 keeps it readable in a terminal and in the GitHub/GitLab commit list without truncation. + 'header-max-length': [2, 'always', 72], + // Scope is optional here on purpose. This is a single-artifact plugin, not a monorepo: mandating a scope + // would produce noise like `chore(repo):` rather than information. + 'body-max-line-length': [2, 'always', 100], + }, + ignores: [ + // Merge commits are generated by the forge/GitFlow merges and are not authored messages. `--no-ff` release + // merges are the repo's integration mechanism (see docs/adr/0001), so linting them would fail every release. + (message) => message.startsWith('Merge '), + // `git revert` generates its own subject from the reverted commit; rewriting it by hand loses the SHA + // reference that makes the revert traceable. + (message) => message.startsWith('Revert "'), + ], +}; diff --git a/docs/adr/0001-release-process.md b/docs/adr/0001-release-process.md new file mode 100644 index 00000000..b7ffff29 --- /dev/null +++ b/docs/adr/0001-release-process.md @@ -0,0 +1,74 @@ +# ADR 0001 — Release process: branching, signing and tag immutability + +- **Status:** accepted +- **Date:** 2026-08-05 +- **Context skill:** `git-workflow-standards` + +## Context + +The plugin is installable software distributed through the JetBrains Marketplace, with real users on +previously published versions. Releases are cut manually from a workstation; there is no CI gate yet (a local +lab is planned). This ADR records the three places where the repository deliberately departs from the default +standards, and the one place where it was simply **wrong**. + +## Decisions + +### 1. GitFlow instead of trunk-based — accepted deviation + +The standard default is trunk-based with short-lived branches and squash merges. This repository uses +`develop` → `main` with `--no-ff` merges and signed tags on `main`. + +**Why the deviation is justified:** the standard itself carves out GitFlow for "releases versionadas y varias +versiones mayores soportadas en paralelo (software instalable)". This is exactly that case: a published +artifact where a user can be running 4.2.0 while 4.4.1 is current, and where a hotfix must be reproducible +from the exact tag that shipped. `--no-ff` keeps each release an identifiable merge commit, so +`git log --first-parent main` reads as the release history. + +**Cost accepted:** merge commits are not Conventional Commits. They are excluded from the commitlint gate +(`commitlint.config.mjs` `ignores`) rather than being reworded, because rewriting a merge subject loses the +branch reference that makes it traceable. + +### 2. GPG on a YubiKey instead of SSH `ed25519-sk` — accepted deviation + +The standard default is SSH signing with a hardware-backed `ed25519-sk` key. This repository signs commits and +tags with GPG (ECDSA, key `6CD3…435A`) on a YubiKey, touch-required. + +**Why:** the standard lists GPG-with-OpenPGP-applet as explicitly justifiable "si necesitas revocación real". +Published, signed release artifacts need a revocation story that outlives any one forge: an offline revocation +certificate works everywhere, whereas GitHub does not revoke SSH signing keys at all. The same key signs the +distributed `.zip` and its `.sha256`, which SSH signing does not cover. + +**Obligation this creates:** a second enrolled backup key, held separately. Losing the only signing key means +losing the ability to sign anything users can verify against previous releases. + +### 3. Published tags are immutable — CORRECTING A REAL VIOLATION + +The standard is unambiguous: *"Prohibido mover un tag publicado: si la release está mal, se publica X.Y.Z+1"*. + +**This repository violated that, repeatedly.** `v4.3.2` was re-cut and force-pushed three times, and `v4.4.1` +three times, each time moving a tag that had already been pushed to `origin` and attached to a published +GitHub release with signed artifacts. + +**Why it is a real problem, not a formality.** A tag is the identity of a shipped artifact. Anyone who fetched +`v4.4.1` before a re-cut holds a different tree, a different `.zip` and a different SHA-256 than someone who +fetched it after — while both believe they have "v4.4.1". That breaks the one thing a signature is for: +proving *which* bytes were blessed. It also silently invalidates any checksum a user recorded, and makes +`git bisect` across the release boundary meaningless. + +**Decision, effective immediately:** a tag that has been pushed is final. A mistake found after tagging is +fixed by cutting the next patch version, never by moving the tag. If a released version must be withdrawn, the +GitHub release is marked accordingly and a superseding version is published — the tag itself stays put. + +**Not retroactively rewritten:** the already-moved tags are left alone. Re-cutting them again to "fix" the +history would repeat the exact mistake this decision exists to stop. + +## Consequences + +- `commitlint` runs as a versioned local hook (`.githooks/commit-msg`), enabled with + `git config core.hooksPath .githooks`. It is advisory-on-toolchain-failure by design (see the hook's header) + so it cannot become a reason to reach for `--no-verify`. +- The `CHANGELOG.md` is still written by hand. The standard requires it to be generated from the commit + history, and that remains **open**: generation is only meaningful once enough history is Conventional for the + tooling not to silently drop most of it. The commit gate landing now is the prerequisite, not the fix. +- No release automation (`release-please`) is wired, because it is a GitHub Action and this project's GitHub + Actions are disabled for billing. Revisit when the local lab exists. diff --git a/package-lock.json b/package-lock.json index 172ef41d..9c0174c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.161" }, "devDependencies": { + "@commitlint/cli": "^21.2.1", + "@commitlint/config-conventional": "^21.2.0", "jsdom": "^29.1.1", "vitest": "^4.1.10" } @@ -217,6 +219,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -240,6 +267,294 @@ "specificity": "bin/cli.js" } }, + "node_modules/@commitlint/cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-conventional": "^21.2.0", + "@commitlint/format": "^21.2.0", + "@commitlint/lint": "^21.2.0", + "@commitlint/load": "^21.2.0", + "@commitlint/read": "^21.2.1", + "@commitlint/types": "^21.2.0", + "tinyexec": "^1.0.0", + "yargs": "^18.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", + "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-conventionalcommits": "^10.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", + "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/ensure": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", + "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/format": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", + "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", + "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/lint": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", + "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^21.2.0", + "@commitlint/parse": "^21.2.0", + "@commitlint/rules": "^21.2.0", + "@commitlint/types": "^21.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/load": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", + "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/types": "^21.2.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", + "is-plain-obj": "^4.1.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/message": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/parse": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", + "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^21.2.0", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/read": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", + "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.0", + "@conventional-changelog/git-client": "^3.0.0", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", + "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^21.2.0", + "@commitlint/types": "^21.2.0", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/rules": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", + "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^21.2.0", + "@commitlint/message": "^21.2.0", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/top-level": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/types": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", + "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^7.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@conventional-changelog/git-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz", + "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.0.1" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", + "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -786,6 +1101,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@simple-libs/child-process-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -836,78 +1180,449 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "undici-types": "~8.3.0" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/snapshot": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", @@ -968,7 +1683,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -998,6 +1712,52 @@ } } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argue-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", + "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1098,6 +1858,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1108,6 +1878,39 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1132,6 +1935,49 @@ "node": ">= 0.6" } }, + "node_modules/conventional-changelog-angular": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", + "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/conventional-commits-parser": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz", + "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1159,22 +2005,67 @@ "node": ">=6.6.0" } }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "object-assign": "^4", - "vary": "^1" + "jiti": "2.6.1" }, "engines": { - "node": ">= 0.10" + "node": ">=v18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" } }, "node_modules/cross-spawn": { @@ -1287,6 +2178,13 @@ "license": "MIT", "peer": true }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1310,6 +2208,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1350,6 +2268,28 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1477,8 +2417,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-sha256": { "version": "1.3.0", @@ -1501,8 +2440,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -1589,6 +2527,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1628,6 +2589,22 @@ "node": ">= 0.4" } }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1728,6 +2705,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1735,6 +2739,16 @@ "license": "ISC", "peer": true }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -1755,6 +2769,26 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1776,6 +2810,16 @@ "license": "ISC", "peer": true }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -1786,6 +2830,36 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -1827,6 +2901,13 @@ } } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -1845,8 +2926,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", @@ -2116,6 +3196,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -2299,6 +3386,38 @@ "wrappy": "1" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2484,6 +3603,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2555,6 +3684,19 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2760,6 +3902,39 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2915,6 +4090,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -2925,6 +4136,14 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3194,6 +4413,42 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3218,6 +4473,44 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index c5724c47..707db103 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.161" }, "devDependencies": { + "@commitlint/cli": "^21.2.1", + "@commitlint/config-conventional": "^21.2.0", "jsdom": "^29.1.1", "vitest": "^4.1.10" } From 9dd6c3e53521c1f560703593b9d2998003653e8e Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 5 Aug 2026 16:52:48 +0200 Subject: [PATCH 02/29] feat(licensing): ship third-party attribution inside the artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published zip redistributes third-party code - marked 12.0.0 (MIT), DOMPurify 3.0.11, highlight.js 11.9.0 (BSD-3-Clause) vendored into the plugin jar, and kotlinx.serialization 1.7.3 (Apache-2.0) as its own jars. MIT, BSD-3-Clause and Apache-2.0 all require the copyright notice and licence text to be preserved ON REDISTRIBUTION, and a file sitting in the Git repository does not accompany the binary a user installs from the Marketplace. This was unmet. processResources now packages THIRD-PARTY-NOTICES.md, LICENSE and the three licence texts into META-INF/ of the plugin jar - verified present in the built artifact, not just in the source tree. Generated at build time from one root-level source of truth rather than a checked-in copy, so the notices cannot drift from the files they describe. Every licence was verified by reading the LICENSE of the exact shipped version, never the manifest or a badge. That found a real one: DOMPurify 3.0.11 is dual "Apache-2.0 OR MPL-2.0". An OR expression is a choice the redistributor must make and record; leaving it unstated is an unmade decision. Apache-2.0 is selected, with the reasoning recorded in the notices file. kotlinx.serialization ships no META-INF/LICENSE in its jars, so its licence was read from the project source instead of inferred. Also corrects the documented JDK path across CLAUDE.md, README, CONTRIBUTING and docs/: ~/.local/jdks/jdk-21.0.11+10 no longer exists on this machine (it is now ~/.jdks/jbr-21.0.11), and gradlew failed with "JAVA_HOME is set to an invalid directory" rather than anything a build log filter would obviously catch. Refs: opensource-licensing-standards §3.5, §3.6, §5.4, §8 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- LICENSES/Apache-2.0.txt | 201 ++++++++++++++++++++++++++++++++++++++ LICENSES/BSD-3-Clause.txt | 29 ++++++ LICENSES/MIT.txt | 23 +++++ README.md | 2 +- THIRD-PARTY-NOTICES.md | 73 ++++++++++++++ build.gradle.kts | 16 +++ docs/RELEASE_PROCEDURE.md | 2 +- docs/UI_TESTING.md | 4 +- 10 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/BSD-3-Clause.txt create mode 100644 LICENSES/MIT.txt create mode 100644 THIRD-PARTY-NOTICES.md diff --git a/CLAUDE.md b/CLAUDE.md index 34e93ac0..e2218167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Frontend (`resources/jcef/`, all inlined): `shell.html` (the CSP'd document) + E ## Stack & build IntelliJ Platform Gradle Plugin **2.16.0** (requires Gradle ≥9 → wrapper at **9.5.1**). Kotlin **2.1.20** + serialization, toolchain **JDK 21** (ceiling: the IDE runs on JBR 21). Target `IC 2025.1`, since=243 until=262.*. Runtime: `kotlinx-serialization-json:1.7.3` (stdlib/annotations excluded from the bundle, provided by the platform). -Build: `JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 ./gradlew buildPlugin` → zip in `build/distributions/`. Also `verifyPlugin`, `runIde`. Install: Settings → Plugins → ⚙ → Install Plugin from Disk. +Build: `JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin` → zip in `build/distributions/`. Also `verifyPlugin`, `runIde`. Install: Settings → Plugins → ⚙ → Install Plugin from Disk. `verifyPlugin` validates against the EAP **and RC** channels (`pluginVerification.ides.select`, build 262) before promising compatibility via `untilBuild`. **Guideline — always latest, zero deprecations:** keep platform/Gradle/Kotlin/deps on the newest stable, widen `untilBuild` to the current EAP/RC, and **never ship a deprecated or scheduled-for-removal API**. If `verifyPlugin` flags one, migrate it before release — treat it as a blocker, not a warning. Everything up to date, always. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be7cc3e6..74341de6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ The project uses the IntelliJ Platform Gradle Plugin 2.x with a JDK 21 toolchain (the IDE itself runs on JBR 21). ```bash -JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 ./gradlew test verifyPlugin buildPlugin +JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew test verifyPlugin buildPlugin ``` This runs the JUnit 5 suite, validates the plugin against the configured diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000..9c8f3ea0 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt new file mode 100644 index 00000000..2250cc7e --- /dev/null +++ b/LICENSES/BSD-3-Clause.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 00000000..4d7ea9a7 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,23 @@ +## Marked + +Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) +Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/README.md b/README.md index 44a117ab..60406af6 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ You can also register **custom MCP servers** as a JSON object of `name → serve Requires **JDK 21** (the IDE runs on JBR 21). The Gradle wrapper is included. ```bash -JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 ./gradlew buildPlugin +JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin # → build/distributions/claude-code-native-4.3.3.zip ``` diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..a39810b7 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,73 @@ +# Third-party notices + +Claude Code Native is licensed under the **GNU General Public License v3.0** (see `LICENSE`). + +This file lists the third-party components **redistributed inside the published plugin artifact** +(`claude-code-native-.zip`) and the notices their licenses require to be preserved on +redistribution. It covers what is actually shipped — not the project's development dependencies, +which are never distributed. + +Every entry below was verified by reading the license text of the **exact version that ships**, not +the package manifest or a badge (see `docs/adr/0002-third-party-attribution.md` for why that +distinction matters). + +Last verified: 2026-08-05. + +--- + +## Bundled inside `lib/claude-code-native-.jar` + +These are vendored into the plugin's embedded web UI under `jcef/` and are served to the JCEF +browser at runtime. They are redistributed verbatim, unmodified. + +### marked — 12.0.0 +- **License:** MIT (`SPDX-License-Identifier: MIT`) +- **Copyright:** Copyright (c) 2011-2024, Christopher Jeffrey (https://github.com/chjj/) +- **Project:** https://github.com/markedjs/marked +- **Full text:** `LICENSES/MIT.txt` + +### DOMPurify — 3.0.11 +- **License:** `Apache-2.0 OR MPL-2.0` — dual-licensed. +- **License chosen by this project: Apache-2.0.** + A dual `OR` license is a choice the redistributor must make and record; leaving it unstated is an + unmade decision. Apache-2.0 is selected because it is already the license of another component in + this artifact (kotlinx.serialization), so the artifact carries one fewer distinct license text, and + because Apache-2.0 grants patent rights explicitly whereas MPL-2.0's grant is narrower in scope. + MPL-2.0's per-file copyleft would also attach obligations if the file were ever modified — it is + not, but choosing Apache-2.0 removes the question entirely. +- **Copyright:** Copyright (c) Cure53 and other contributors +- **Project:** https://github.com/cure53/DOMPurify +- **Full text:** `LICENSES/Apache-2.0.txt` + +### highlight.js — 11.9.0 +- **License:** BSD-3-Clause (`SPDX-License-Identifier: BSD-3-Clause`) +- **Copyright:** Copyright (c) 2006, Ivan Sagalaev. All rights reserved. +- **Project:** https://github.com/highlightjs/highlight.js +- **Full text:** `LICENSES/BSD-3-Clause.txt` +- **Note:** a curated subset build (~35 languages), redistributed unmodified. + +--- + +## Shipped as separate jars in `lib/` + +### kotlinx.serialization (`kotlinx-serialization-core-jvm`, `kotlinx-serialization-json-jvm`) — 1.7.3 +- **License:** Apache-2.0 (`SPDX-License-Identifier: Apache-2.0`) +- **Copyright:** Copyright 2017-2024 JetBrains s.r.o. and Kotlin Programming Language contributors +- **Project:** https://github.com/Kotlin/kotlinx.serialization +- **Full text:** `LICENSES/Apache-2.0.txt` +- **Verified:** the published jars carry no `META-INF/LICENSE`, so the license was read from the + project's `LICENSE.txt` at source rather than inferred from the artifact. + +--- + +## Not redistributed + +The following are used during development or referenced as documentation and are **not** part of the +published artifact, so they create no redistribution obligation here: + +- **`@anthropic-ai/claude-agent-sdk`** — kept as a protocol reference only (`node_modules/`), never + bundled. The plugin speaks the `claude` binary's wire protocol directly. +- **vitest, jsdom, commitlint** — development and test tooling. +- **The `claude` CLI itself** — a separate program the user installs and licenses independently. The + plugin executes it; it does not redistribute it. +- **The IntelliJ Platform** — provided by the host IDE at runtime, not bundled. diff --git a/build.gradle.kts b/build.gradle.kts index b9eecbb6..07e5fb85 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -94,6 +94,22 @@ configurations.named("runtimeClasspath") { } tasks { + // Attribution travels INSIDE the artifact (opensource-licensing-standards §5.4). + // + // The published zip redistributes third-party code: marked, DOMPurify and highlight.js are vendored into + // the plugin jar under `jcef/`, and kotlinx.serialization ships as its own jars. MIT, BSD-3-Clause and + // Apache-2.0 all require the copyright notice and licence text to be preserved *on redistribution* — and a + // file sitting in the Git repository does not accompany the binary a user installs from the Marketplace. + // Copying them into the jar's resources is what actually discharges the obligation. + // + // Kept as a build step rather than a checked-in copy under `src/main/resources/`, so the notices cannot + // drift out of sync with the files they describe: one source of truth at the repository root, packaged at + // build time. `THIRD-PARTY-NOTICES.md` is surfaced to the user by the About dialog (see InfoDialogs). + processResources { + from(rootProject.file("THIRD-PARTY-NOTICES.md")) { into("META-INF") } + from(rootProject.file("LICENSE")) { into("META-INF") } + from(rootProject.file("LICENSES")) { into("META-INF/licenses") } + } runIde { jvmArgs("-Djb.privacy.policy.text=", "-Djb.consents.confirmation.enabled=false") } diff --git a/docs/RELEASE_PROCEDURE.md b/docs/RELEASE_PROCEDURE.md index 3651c967..95e77994 100644 --- a/docs/RELEASE_PROCEDURE.md +++ b/docs/RELEASE_PROCEDURE.md @@ -62,7 +62,7 @@ git pull --ff-only ### 2. Run the full local verification ```bash -JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 \ +JAVA_HOME=~/.jdks/jbr-21.0.11 \ ./gradlew test verifyPlugin buildPlugin ``` diff --git a/docs/UI_TESTING.md b/docs/UI_TESTING.md index 0fd22500..f5ac4a99 100644 --- a/docs/UI_TESTING.md +++ b/docs/UI_TESTING.md @@ -50,7 +50,7 @@ and for these UI tests. Two steps, in order — the IDE must be **up** before the client suite connects: ```bash -export JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 +export JAVA_HOME=~/.jdks/jbr-21.0.11 # Terminal 1: boot the IDE-under-test (keep it running). robot-server listens on :8082. ./gradlew runIdeForUiTests @@ -64,7 +64,7 @@ export JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 Wrap the IDE launch in `xvfb-run` (or start an `Xvfb` on a `$DISPLAY` and export it). Example: ```bash -export JAVA_HOME=~/.local/jdks/jdk-21.0.11+10 +export JAVA_HOME=~/.jdks/jbr-21.0.11 # Boot the IDE under a virtual framebuffer, in the background. xvfb-run -a -s "-screen 0 1920x1080x24" ./gradlew runIdeForUiTests & From 0e40116903a8cb14769c43558315403c8927d23f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 5 Aug 2026 16:59:56 +0200 Subject: [PATCH 03/29] feat(a11y): announce turn state and give keyboard focus a visible ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the JCEF web UI against WCAG 2.2 AA. This is a conformance requirement, not a nice-to-have: the plugin is distributed to consumers in the EU, where Directive (EU) 2019/882 has applied since 28 June 2025 (in Spain, Ley 11/2023). 4.1.3 Status Messages (AA) - the transcript streams without ever moving focus, so a screen-reader user got NO signal that Claude started, finished, or was blocked on them: the turn simply stopped, silently. Adds a polite live region declared in the static shell (it has to exist in the DOM before text is written into it, or the first change is never announced) plus CC.announce. Only turn EDGES are announced, never per token - a region updated on every delta talks over itself and gets switched off, which is worse than silence. The most important case is a pending permission card: it appears without taking focus, so it was previously undetectable. 2.4.7 Focus Visible / 1.4.11 Non-text Contrast (AA) - the stylesheet suppressed the default outline in five places. Four had some replacement; .find-input had NONE, so focus there was invisible. Adds a :focus-visible baseline covering the controls built as (the code-block Copy control, menu items, tool cards), which get nothing for free because they are not native buttons, and honours forced-colors instead of overriding the system's focus colour. prefers-reduced-motion was already correct (a universal reset covering all eight keyframe animations) and is now pinned by a test so it cannot decay into a hand-picked subset as animations are added. Refactor: the frontend test harness hand-copied the shell DOM and had already drifted - shell.html gained the live region, the harness did not, so tests exercised a DOM the product does not have. It now extracts the body from the real shell.html. A mount point added to the shell reaches tests automatically; one removed breaks the tests that relied on it. Scope stated honestly in the test file: automated checks catch ~40-57% of real barriers and none of the semantic judgements. These pin the structural guarantees that regress silently. They do not certify conformance - that still needs keyboard-only and screen-reader passes by a person. Refs: accessibility-standards §3.2, §3.4, §3.8, §4.1 --- src/main/resources/jcef/app-composer.js | 25 +++++ src/main/resources/jcef/app-core.js | 27 +++++- src/main/resources/jcef/app-permissions.js | 32 +++++++ src/main/resources/jcef/app.css | 49 ++++++++++ src/main/resources/jcef/shell.html | 12 ++- src/test/frontend/accessibility.test.js | 102 +++++++++++++++++++++ src/test/frontend/helpers/load.js | 28 ++++-- 7 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/test/frontend/accessibility.test.js diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index d6ab419e..cfe1aa0b 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -827,8 +827,33 @@ } } + /** + * Announce turn transitions to assistive technology (WCAG 2.2 AA — 4.1.3 Status Messages). + * + * Only the EDGES are announced, never the streaming itself: a screen reader that re-reads on every token is + * unusable, and a user would silence it — which is worse than saying nothing. + * + * Scope note: permission cards are NOT announced from here. The composer's state payload carries no + * permission field (they arrive separately through cc.permissions), so a check for one here would be a + * branch that silently never fires. That announcement lives in app-permissions.js, where the cards are + * actually rendered. + */ + var lastTurnPhase = null; + function announceTurnState(s) { + if (!window.CC || typeof CC.announce !== 'function') return; + var phase = s.interrupting ? 'interrupting' : (s.turnActive ? 'working' : 'idle'); + if (phase === lastTurnPhase) return; + var wasWorking = lastTurnPhase === 'working' || lastTurnPhase === 'interrupting'; + lastTurnPhase = phase; + if (phase === 'working') CC.announce('Claude is working…'); + else if (phase === 'interrupting') CC.announce('Stopping…'); + // Only report completion if a turn was actually running — otherwise every idle state push would announce. + else if (wasWorking) CC.announce('Claude finished responding.'); + } + function renderState(s) { if (!s) return; + announceTurnState(s); renderSendMode(s); renderPills(s); renderQueue(s.queue); diff --git a/src/main/resources/jcef/app-core.js b/src/main/resources/jcef/app-core.js index e8802f24..45109910 100644 --- a/src/main/resources/jcef/app-core.js +++ b/src/main/resources/jcef/app-core.js @@ -289,7 +289,32 @@ dock: byId("dock"), permissions: byId("permissions"), composer: byId("composer"), - palette: byId("palette") + palette: byId("palette"), + a11yStatus: byId("a11y-status") + }; + + /** + * Announce a short status phrase to assistive technology (WCAG 2.2 AA — 4.1.3 Status Messages). + * + * The transcript streams without ever moving focus, so without this a screen-reader user has no way to know + * that Claude began answering, finished, or is now blocked on a permission card. Focus is deliberately NOT + * moved: 4.1.3 exists precisely for changes that must be perceivable *without* stealing focus. + * + * Deliberately terse and low-frequency: this is called on turn transitions, never per streamed token. A live + * region updated on every delta is unusable — the screen reader would talk over itself continuously and the + * user would turn it off, which is worse than silence. + * + * Re-announcing identical text is a no-op in most screen readers (the node did not change), so repeated + * states are skipped explicitly rather than relying on that behaviour being uniform. + */ + var lastAnnouncement = ""; + CC.announce = function (message) { + var el = CC.els && CC.els.a11yStatus; + if (!el) return; + var text = message == null ? "" : String(message); + if (text === lastAnnouncement) return; + lastAnnouncement = text; + el.textContent = text; }; // --------------------------------------------------------------------------- diff --git a/src/main/resources/jcef/app-permissions.js b/src/main/resources/jcef/app-permissions.js index 9854ebbd..49abca04 100644 --- a/src/main/resources/jcef/app-permissions.js +++ b/src/main/resources/jcef/app-permissions.js @@ -402,10 +402,42 @@ // ---- public API ------------------------------------------------------------ + /** + * Announce that Claude is blocked on the user (WCAG 2.2 AA — 4.1.3 Status Messages). + * + * This is the single most important announcement in the whole UI: a permission card appears in the dock + * WITHOUT taking focus, so to a screen-reader user the turn simply stops with no explanation and no + * indication that anything is waiting for them. Sighted users see a card slide in; everyone else got silence. + * + * Announces only the 0 -> n transition, and names the tool when there is exactly one card, since "Claude + * needs your permission to run Bash" is actionable in a way that "Claude needs your response" is not. + * Resolution is left silent on purpose — the user just acted, so they know. + */ + var lastPendingCount = 0; + function announcePending(list) { + var C = core(); + if (!C || typeof C.announce !== 'function') return; + var count = list.length; + var previous = lastPendingCount; + lastPendingCount = count; + if (count === 0 || count <= previous) return; + if (count === 1) { + var only = list[0] || {}; + var tool = only.tool ? String(only.tool) : ''; + if (only.questions) C.announce('Claude is asking you a question.'); + else if (only.isPlan) C.announce('Claude is proposing a plan for your approval.'); + else if (only.elicitation) C.announce('An MCP server is requesting input.'); + else C.announce(tool ? ('Claude needs your permission to use ' + tool + '.') : 'Claude needs your response.'); + return; + } + C.announce(count + ' requests are waiting for your response.'); + } + function permissions(list) { var region = mount(); if (!region) return; if (!list || !Array.isArray(list)) list = []; + announcePending(list); // Reconcile by card id rather than wiping + rebuilding the whole region. A blunt innerHTML='' on every push // (the host re-pushes on ANY permission change — a second card arriving, one resolving) destroyed the diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index 07bd4293..d217d2c6 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -985,3 +985,52 @@ body.vibe .msg.assistant .avatar .avatar-nyan { display: block; width: 100%; hei /* a gentle rainbow ring on the cards/composer so the whole UI joins in */ body.vibe .composer-card:focus-within { box-shadow: var(--shadow), 0 0 0 3px var(--accent-soft); } + +/* ── Accessibility: visible keyboard focus (WCAG 2.2 AA — 2.4.7, 1.4.11) ───────────────────────────────────── + Baseline for EVERY interactive element, including the ones built as (the code-block + Copy control, menu items, tool cards). Those are not native buttons, so nothing gives them a focus ring for + free — without this, a keyboard user cannot see where they are. + + `:focus-visible` rather than `:focus` on purpose: it shows the ring for keyboard interaction and keeps it + away from mouse clicks, which is exactly what makes designers reach for `outline: none` in the first place. + + Two layers so it is visible on ANY background: a solid outline plus a contrasting offset ring. 1.4.11 asks + for 3:1 against adjacent colours for the focus indicator itself, and a single hairline in the accent colour + does not reliably clear that over the card backgrounds used here. `outline-offset` keeps it from being + clipped by the element's own border-radius. */ +:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible, +.copy:focus-visible, +.menu-item:focus-visible, +.tool-head:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* The find bar's input suppressed its outline with no replacement — focus was simply invisible there. + It sits inside a floating bar, so it gets the ring treatment its siblings already had via :focus-within. */ +.find-input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* Windows/forced-colors mode: honour the system's own focus colour instead of ours (3.8 — the interface must + stay usable under prefers-contrast / forced colours, and overriding system colours defeats the point). */ +@media (forced-colors: active) { + :where(a, button, input, textarea, select, summary, [tabindex]):focus-visible, + .copy:focus-visible, .menu-item:focus-visible, .tool-head:focus-visible, .find-input:focus-visible { + outline: 2px solid Highlight; + } +} + +/* Visually hidden but present for assistive technology. NOT `display:none` or `visibility:hidden` — both + remove the node from the accessibility tree, which would silence the live region entirely. The clip-path + technique keeps it rendered (and therefore announced) while occupying no visible space. */ +.visually-hidden { + position: absolute !important; + width: 1px; height: 1px; + margin: -1px; padding: 0; border: 0; + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} diff --git a/src/main/resources/jcef/shell.html b/src/main/resources/jcef/shell.html index 5f27a6e8..64f6cbd8 100644 --- a/src/main/resources/jcef/shell.html +++ b/src/main/resources/jcef/shell.html @@ -12,7 +12,17 @@
-
+ +
+
✶
Claude Code
diff --git a/src/test/frontend/accessibility.test.js b/src/test/frontend/accessibility.test.js new file mode 100644 index 00000000..30dcd8fe --- /dev/null +++ b/src/test/frontend/accessibility.test.js @@ -0,0 +1,102 @@ +// Accessibility conformance (WCAG 2.2 AA). These are not cosmetic assertions: the plugin is distributed to +// consumers in the EU, where Directive (EU) 2019/882 has applied since 28 June 2025 (in Spain, Ley 11/2023). +// +// Scope honesty, stated up front: automated checks catch roughly 40-57% of real barriers, and NONE of the +// semantic judgements (does the label describe the control, does the focus order make sense, is the announcement +// useful). These tests pin the structural guarantees that regress silently; they do not certify conformance. +// Conformance still requires keyboard-only and screen-reader passes by a person. +const fs = require('node:fs'); +const path = require('node:path'); +const { loadFrontend, JCEF } = require('./helpers/load'); + +const css = () => fs.readFileSync(path.join(JCEF, 'app.css'), 'utf8'); +const shell = () => fs.readFileSync(path.join(JCEF, 'shell.html'), 'utf8'); + +describe('a11y — status messages (WCAG 4.1.3, Level AA)', () => { + // The transcript streams without ever moving focus. Without a live region a screen-reader user gets no + // signal that Claude started, finished, or is blocked on them — the turn just stalls silently. + it('the live region is declared in the static shell, not created on first use', () => { + // It must exist in the DOM BEFORE text is written into it, or the first change is never announced. + // Creating it lazily when the first message arrives is the classic way to ship a silent live region. + const html = shell(); + expect(html).toMatch(/id="a11y-status"/); + expect(html).toMatch(/aria-live="polite"/); + expect(html).toMatch(/role="status"/); + }); + + it('CC.announce writes into the live region', () => { + const win = loadFrontend([]); + win.CC.announce('Claude is working…'); + expect(win.document.getElementById('a11y-status').textContent).toBe('Claude is working…'); + }); + + it('does not re-announce identical text', () => { + // A live region re-set to the same string is a no-op in most screen readers, but not uniformly. Skipping + // it explicitly keeps behaviour predictable instead of relying on that. + const win = loadFrontend([]); + const el = win.document.getElementById('a11y-status'); + win.CC.announce('Claude finished responding.'); + el.textContent = 'MUTATED'; + win.CC.announce('Claude finished responding.'); + expect(el.textContent).toBe('MUTATED'); // skipped, so our mutation survives + }); + + it('a pending permission is announced, naming the tool when there is exactly one', () => { + // The single most important announcement: a permission card appears WITHOUT taking focus, so otherwise + // the turn stops with no explanation for anyone not watching the dock. + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([{ id: 'p1', tool: 'Bash', title: 'Run', summary: '', reviewable: false }]); + expect(win.document.getElementById('a11y-status').textContent).toMatch(/permission to use Bash/); + }); + + it('resolving the last card does not announce (the user just acted)', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([{ id: 'p1', tool: 'Bash', title: 'Run', summary: '', reviewable: false }]); + const el = win.document.getElementById('a11y-status'); + el.textContent = 'SENTINEL'; + win.cc.permissions([]); + expect(el.textContent).toBe('SENTINEL'); + }); +}); + +describe('a11y — visible focus (WCAG 2.4.7 / 1.4.11, Level AA)', () => { + it('every element that suppresses its outline has a :focus-visible replacement', () => { + // "outline: none with no substitute" is the single most repeated one-line accessibility defect there is. + // The stylesheet legitimately suppresses the default outline in several places; each must be replaced. + const sheet = css(); + expect(sheet).toMatch(/:focus-visible/); + // The find bar's input was the one with no replacement at all — pin it specifically. + expect(sheet).toMatch(/\.find-input:focus-visible/); + }); + + it('the focus ring is honoured under forced-colors instead of being overridden', () => { + // Overriding system colours in Windows high-contrast mode defeats the accommodation entirely. + expect(css()).toMatch(/@media \(forced-colors: active\)/); + }); + + it('the visually-hidden helper keeps the node in the accessibility tree', () => { + // display:none / visibility:hidden would remove the live region from the a11y tree and silence it. + const sheet = css(); + const rule = sheet.slice(sheet.indexOf('.visually-hidden')); + expect(rule).not.toMatch(/display:\s*none/); + expect(rule).not.toMatch(/visibility:\s*hidden/); + expect(rule).toMatch(/clip-path/); + }); +}); + +describe('a11y — motion and language', () => { + it('respects prefers-reduced-motion for every animation', () => { + // The stylesheet defines several keyframe animations; the reduce query must neutralise them all rather + // than a hand-picked subset that goes stale as animations are added. + const sheet = css(); + expect(sheet).toMatch(/@media \(prefers-reduced-motion: reduce\)/); + const block = sheet.slice(sheet.indexOf('prefers-reduced-motion')); + expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/); // universal, not per-animation + }); + + it('declares a document language (WCAG 3.1.1, Level A)', () => { + // Missing document language is in the top six most common failures on the web; without it a screen + // reader pronounces the interface with the wrong phonetics. + expect(shell()).toMatch(/]+lang=/); + }); +}); diff --git a/src/test/frontend/helpers/load.js b/src/test/frontend/helpers/load.js index e77d5dfd..90aecb1f 100644 --- a/src/test/frontend/helpers/load.js +++ b/src/test/frontend/helpers/load.js @@ -13,13 +13,25 @@ function readApp(name) { return fs.readFileSync(path.join(JCEF, name), 'utf8'); } -/** The shell.html mount points, minimal but faithful. */ -const SHELL = ` -
-
-
- -
`; +/** + * The shell DOM, extracted from the REAL `shell.html` rather than hand-copied. + * + * This used to be a hardcoded approximation, and it drifted: `shell.html` gained the `#a11y-status` live + * region and the harness did not, so tests exercised a DOM the product does not have. That is the worst + * failure mode a test harness has — it does not fail loudly, it quietly tests something else. + * + * Reading the real file means a mount point added to the shell is available to tests automatically, and a + * mount point *removed* from the shell breaks the tests that depended on it, which is exactly right. + * + * `` with a space, so a regex "sanitiser" that looks right silently is not. The risk + * here was low (this reads OUR shell.html off disk, and the goal is load control, not sanitisation), but the + * fix for parsing HTML with regexes is not a better regex — it is the parser that is already a dependency of + * this harness. */ function shellBody() { const html = fs.readFileSync(path.join(JCEF, 'shell.html'), 'utf8'); - const body = html.match(/]*>([\s\S]*?)<\/body>/i); + // `includeNodeLocations: false` and no `runScripts`: this instance only reads structure, so nothing in the + // parsed document can execute — the scripts are removed below and the modules are injected by the caller. + const parsed = new JSDOM(html); + const body = parsed.window.document.body; if (!body) throw new Error('helpers/load: could not find in shell.html'); - return body[1].replace(//gi, ''); + body.querySelectorAll('script').forEach((node) => node.remove()); + return body.innerHTML; } // The vendored libs shell.html loads BEFORE the app modules. Load them faithfully so CC.markdown is the real From ffd0b8769d788a6edb8bb51276ce01a537cef460 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 00:11:15 +0200 Subject: [PATCH 11/29] fix: repair the tab-killing NPE and the silences it hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression introduced by this branch made the plugin unusable, and fixing it surfaced a run of smaller defects that all shared one shape: something failed or was still loading, and the UI said nothing at all. THE REGRESSION JcefChatPanel.pendingUntilReady was declared below the `init` block that uses it. Kotlin runs property initializers and init blocks in declaration order, so the list was still null while init ran and the constructor threw. It took the whole tab with it: no chat could be opened or restored. lastUsage/lastUsageAt had the same defect and stayed silent, being nullable and primitive. InitOrderContractTest now fails the build on any property declared after its class's init. WAITING IS NOW VISIBLE The binary is launched BEFORE the tab is built (start() only dispatches, so `claude` boots while JCEF creates its browser), and a boot screen holds the tab until the process is up. Three states, not two: running, starting, and neither — that last one is a failed launch and must clear the screen, or a missing binary leaves the tab covered forever. Context and cost no longer wait on a clock. The poll timer's initial delay equals its interval, so the first reading was a minute late; worse, that tick landed while the binary was still starting and returned early, costing a second minute. Ready, tab-open and both turn edges now poll directly, and the timer retires at the end of a turn instead of round-tripping forever on an idle session. Plan limits were pushed to the dashboard but not to the composer, so the same number appeared instantly in one place and "later" in the other. Reasoning tokens, context and output now settle at 0 rather than being omitted until non-zero: an absent figure is indistinguishable from one that failed to load. FAILURES THAT WERE HARD TO READ - The CLI wraps failed tool results in ... (verified in claude 2.1.222, which carries the same text unwrapped in a sibling field). Rendered verbatim it put raw markup in a native GUI. Stripped only when it encloses the whole payload; is_error already conveys the failure. - A failed tool card stayed collapsed, so the entire message was "the header is red", and its output scrolled sideways rather than wrapping, hiding the half that says what to do instead. It now opens itself once, and error text wraps. - ToolSearch was missing from SensitiveGuard.AGENT_TOOLS, so on a session that defers tools, the call that loads every other tool's schema landed in the untrusted branch. Added, with AskUserQuestion, Mcp and FileRead/Edit/Write. Entries are only ever added here: this is a trust allowlist, not an inventory, and a missing first-party name is the 4.4.0 hard-DENY incident. - A markdown link whose href is a path did nothing when clicked: the host handled https:// and jb://open and dropped the rest without a sound. Both paths now go through one gate (LinkResolver.isOpenable). The scheme test requires two or more characters so a Windows drive letter stays a path. - Copy on a message copied and gave no feedback, which reads as a broken button; the shared flash helper is now exported rather than reimplemented, and the .copied class finally has a CSS rule — it had never had one. Suite: 692 -> 694 JVM, 67 -> 84 frontend. verifyPlugin Compatible on IC-251, IC-252, IU-253, IU-261 and IU-262. --- CHANGELOG.md | 22 +++- CLAUDE.md | 4 +- .../claudejb/permission/SensitiveGuard.kt | 13 ++ .../dev/lain/claudejb/protocol/ClaudeEvent.kt | 25 +++- .../lain/claudejb/session/ClaudeSession.kt | 56 +++++++- .../claudejb/ui/ClaudeToolWindowFactory.kt | 15 ++- .../dev/lain/claudejb/ui/JcefChatPanel.kt | 73 ++++++++--- .../dev/lain/claudejb/ui/LinkResolver.kt | 18 +++ .../dev/lain/claudejb/ui/jcef/JcefState.kt | 12 ++ src/main/resources/jcef/app-composer.js | 59 ++++++++- src/main/resources/jcef/app-core.js | 5 + src/main/resources/jcef/app-transcript.js | 11 ++ src/main/resources/jcef/app.css | 122 ++++++++++++++++++ src/main/resources/jcef/shell.html | 15 +++ src/test/frontend/boot.test.js | 67 ++++++++++ src/test/frontend/readout.test.js | 51 ++++++++ src/test/frontend/tool-error.test.js | 80 ++++++++++++ src/test/frontend/transcript.test.js | 27 ++++ .../claudejb/protocol/ProtocolParserTest.kt | 28 ++++ .../lain/claudejb/ui/InitOrderContractTest.kt | 68 ++++++++++ .../dev/lain/claudejb/ui/LinkGateTest.kt | 30 +++++ 21 files changed, 766 insertions(+), 35 deletions(-) create mode 100644 src/test/frontend/boot.test.js create mode 100644 src/test/frontend/readout.test.js create mode 100644 src/test/frontend/tool-error.test.js create mode 100644 src/test/kotlin/dev/lain/claudejb/ui/InitOrderContractTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e3080c..c9bb58e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The standards-compliance major. Not a feature release: the repository was taken through the standards catalogue domain by domain, and the major number reflects that the **code** changed to comply, not only the -documentation. No user-facing behaviour is removed or altered — the break this major records is one of -*process and packaging*, and it is stated plainly rather than hidden in a patch. +documentation. The break this major records is one of *process and packaging*, and it is stated plainly rather +than hidden in a patch. + +It did not stay purely that, and saying so is cheaper than letting a reader discover it: the release also +carries the **plan-limits panel** and a run of user-facing fixes (below). Nothing is removed or behaves +differently on purpose — but a release note claiming "no user-facing change" while shipping a new dashboard +card would be the kind of small untruth that makes the rest of the document unusable as evidence. ### Security - **The protocol SDK was declared as a runtime dependency while never being one.** `@anthropic-ai/claude-agent-sdk` sat in `dependencies` although it is protocol reference material — kept so the Kotlin layer can be diffed against the binary's real surface — and is not executed or packaged. The published artifact contains jars and inlined web assets and **zero** `node_modules` entries, which anyone can confirm with `unzip -l build/distributions/*.zip | grep -c node_modules`. The consequence of the wrong declaration was seven permanent `npm audit` findings (three high) against code no user ever receives: an alarm backlog that cannot be acted on, which is worse than no alarm because it trains you to ignore the one that matters. Moved to `devDependencies`, so `npm audit --omit=dev` — the distributed scope — now reports **zero**. `SECURITY.md` states the triage boundary explicitly, with the command to verify it rather than a request to trust it. @@ -56,6 +61,19 @@ documentation. No user-facing behaviour is removed or altered — the break this - **Four defects in the shipped frontend**, all found by its first lint run: `obj.hasOwnProperty(k)` in both DOM-building helpers (breaks if the object carries its own `hasOwnProperty` — and those helpers build DOM from host-supplied data), an empty `catch` in the Vibe Mode theme restore that silently left the theme half-reverted, and two dead functions (`isAgentTool`, `esc`) nobody called. - **`sniffMediaType` no longer confuses any RIFF container for WEBP.** Rewritten around named signatures (complexity 23 → 4), it now checks the four-byte *form type* that actually identifies the format, not just the `RIFF` header that WAV and AVI share. +### Fixed — a tab-killing regression, and the silences it hid + +- **No chat could be opened or restored (regression, introduced on this branch).** `JcefChatPanel.pendingUntilReady` was declared *below* the `init` block that uses it. Kotlin runs property initializers and `init` blocks in declaration order, so the list was still `null` while `init` ran and the constructor threw `NullPointerException` — taking the whole tab with it, on new chats and on startup restore alike. `lastUsage`/`lastUsageAt` had the identical defect and stayed **silent**, because a nullable reference and a primitive read as `null`/`0` instead of throwing: the loud version of this bug was the lucky one. The compiler does not catch it — it flags a direct reference in an initializer, but here the read happens inside a function called *from* `init`, which it cannot see through — so `InitOrderContractTest` scans the sources and fails the build on any class-body property declared after its own `init`. +- **Nothing said the agent was still starting.** The binary is now launched *before* the tab is built (`start()` only dispatches, so `claude` boots while JCEF creates its browser), and a **boot screen** holds the tab until the process is up. Three states, not two: `running`, `starting`, and **neither** — that last one is a launch that failed (missing binary, declined trust prompt, refused remote-mount project) and it must bring the screen down, or the tab stays covered forever with no way to reach the notification explaining why. The screen is declared visible in the static shell, since at page load the process genuinely is not up yet. +- **Context and cost were a minute late, twice over.** A `javax.swing.Timer`'s initial delay equals its interval, so the first poll came a full `QUOTA_POLL_MS` after the panel attached — and that tick landed while the binary was still launching and returned early on the not-running guard, costing a second interval. Process-ready, tab-open and both turn edges now poll directly, and the timer **retires at the end of a turn**: context and cost cannot move while a session sits idle, so polling forever was a round-trip through the binary, per tab, for two numbers that provably had not changed. +- **The plan-limit figures disagreed with themselves.** A `get_usage` reply refreshed the dashboard bars but not the composer's dots, so the same number appeared immediately in one surface and "a while later" in the other, whenever some unrelated state change happened to re-push. Both are pushed together now. Opening the dashboard also refreshes them, which `requestUsage`'s own contract had claimed and the code had never done. +- **Reasoning tokens, context and output are rendered at `0` instead of omitted.** An item that only appears once it is non-zero is indistinguishable from one that failed to load — which is exactly how a fresh tab read: a lone "Idle" and no figures. Cost stays gated, because a currency amount of zero is noise rather than an ambiguity to resolve. +- **The CLI's `` wrapper reached the transcript verbatim.** `claude` 2.1.222 wraps a failed tool result's `content` in that tag pair and carries the same message *unwrapped* in a sibling field — framing for the model, not text for a human. Rendered as-is it put raw markup in a native GUI, the "never mirror raw CLI output" antipattern this plugin exists to avoid. Stripped only when it encloses the whole payload, so output that legitimately mentions the tag survives; `is_error` already conveys the failure structurally, and is what reddens the card. +- **A failed tool card hid its own error.** Tool output lives behind the card's collapse, so for a failure the entire message was "the header is red", and the text scrolled sideways rather than wrapping — hiding the actionable half at the end of the line. A failed card now opens itself **once** (tracked on the node, so it never fights a user who deliberately collapsed it) and its error text wraps. Healthy output still scrolls: wrapping code or a log corrupts its alignment. +- **`ToolSearch` was missing from the `SensitiveGuard` trust allowlist**, along with `AskUserQuestion`, `Mcp` and `FileRead`/`FileEdit`/`FileWrite`. `ToolSearch` is the one that mattered: it loads the schema of every *deferred* tool, so on a session that defers them, the call that unlocks all the others was the one landing in the third-party branch. Entries are only ever **added** to that list — it is a trust allowlist, not an inventory, and a missing first-party name is precisely the 4.4.0 hard-DENY incident. Found by diffing it against a live session's real tool inventory rather than against the SDK's type names, which are *not* the runtime registry (the SDK calls them `FileRead`/`FileEdit`/`FileWrite`; the tools are `Read`/`Edit`/`Write`). +- **A Markdown link whose href is a path did nothing when clicked.** The host handled `https://` and `jb://open` and dropped everything else without a sound — so `[BACKLOG](docs/BACKLOG.md)` was inert while bare paths written in prose worked, making the more deliberate link the one that failed. Both routes now go through a single authorising gate (`LinkResolver.isOpenable`). The scheme test requires **two or more** characters before the colon, so a Windows drive (`C:\src\main.kt`) stays a path rather than being mistaken for a URI scheme. +- **Copy on a message copied and said nothing.** Message-level buttons carry their own click handler and never reached the delegated code-block path that flashes "Copied", which reads as a broken button — and was reported as one. The flash helper is now exported and shared rather than reimplemented, so wording and duration cannot drift. The `.copied` class had been applied by the JS since 4.0.4 and **had no CSS rule at all**; it now has one. + ## [4.4.1] — 2026-07-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0d8a14e9..ce357d6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Build: `JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin` → zip in `build/d **Guideline — always latest, zero deprecations:** keep platform/Gradle/Kotlin/deps on the newest stable, widen `untilBuild` to the current EAP/RC, and **never ship a deprecated or scheduled-for-removal API**. If `verifyPlugin` flags one, migrate it before release — treat it as a blocker, not a warning. Everything up to date, always. ## Status -Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.0**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=[,…]`, which now takes a comma-separated list). **5.0.0 = the standards-compliance major.** Not a feature release: the repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean. +Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.0**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=[,…]`, which now takes a comma-separated list). **5.0.0 = the standards-compliance major.** The repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. It did NOT stay purely that: it also ships the **plan-limits panel** (`get_usage`, a control request known since 4.0.1 and never sent — all rate-limit windows plus the extra-credit balance, as dashboard bars and composer dots, blue <65% / amber <85% / red above, announced once per threshold per window) and a run of user-facing fixes, the largest being a **tab-killing NPE this branch itself introduced**: `JcefChatPanel.pendingUntilReady` was declared BELOW the `init` block that uses it, and Kotlin runs property initializers and `init` blocks in declaration order — so it was null inside the constructor and NO chat could be opened or restored. `lastUsage`/`lastUsageAt` had the same defect and stayed silent (nullable/primitive read as null/0), which is why `InitOrderContractTest` now scans the sources: the compiler only flags a *direct* reference in an initializer, not one made through a function called from `init`. Also from that pass: a **boot screen** (the binary is launched BEFORE the tab is built, since `start()` only dispatches; three states — running / starting / **neither**, that last being a failed launch which MUST clear the screen); context and cost polled on ready, tab-open and both turn edges instead of waiting out a `javax.swing.Timer` whose initial delay equals its 60s interval (and the timer now retires at turn end — those numbers cannot move while idle); the CLI's `` wrapper stripped in `ProtocolParser.unwrapToolError` (verified in 2.1.222, which carries the same text unwrapped in a sibling field — rendering it verbatim put raw markup in a native GUI); failed tool cards auto-open once and wrap their error text (collapsed, the whole message was "the header is red"); `ToolSearch` + `AskUserQuestion`/`Mcp`/`FileRead`/`FileEdit`/`FileWrite` added to `SensitiveGuard.AGENT_TOOLS` — **that list is only ever appended to**, it is a trust allowlist and not an inventory, and `ToolSearch` was the load-bearing gap (it loads every deferred tool's schema, so on a session that defers them the call unlocking all the others was landing in the untrusted branch); and markdown links whose href is a path now open (`LinkResolver.isFilePathHref`, with a two-or-more-character scheme test so a Windows drive stays a path) through the same `isOpenable` gate as `jb://`. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean. **4.0.0 post-rewrite UI/UX hardening (frontend-only — the Kotlin backend was untouched, validating the binary-direct architecture):** subagent activity nests inside its Agent/Task card with per-card collapse (was a CSS descendant-selector bug); **native rewind as the default rollback** — "Restore" asks Claude Code to `rewind_files` to that turn (client-tagged user-message `uuid` + `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING`, setting default-on), with a confirmed IDE-side per-file revert fallback (`ClaudeSession.requestRewindFiles`/`userMessageIdFor`); **clipboard paste on Wayland** read host-side (image via `wl-paste`/`xclip` resolved across common bin dirs, plus `text/uri-list` for copied image files; **text via AWT with a `wl-paste`/`xclip` fallback added in 4.0.2** for the native Wayland toolkit); tool-card states (loading/running fade sky-blue↔amber, done green, **error red** via `ToolState.ERROR`), colourised inline edit diffs, flat single-row composer control bar with the ported icon set, Ctrl+O reasoning toggle (collapsed by default), auto-follow toggle, 🌈 Vibe Mode (Nyan Cat + rainbow), diffs open without stealing keyboard focus, request cards capped at 50% height (scrollable body, actions always visible) with a Cancel on question cards, `/login` runs in the IDE terminal (browser auto-capture) and appears in the palette, "Explain with Claude" carries the Claude icon, and the ⚙ menu reuses the formatted JCEF dashboard. Fixes: a non-compiling tree (`object a ChatTheme` + a nested-comment KDoc), and session-cost + JetBrains-MCP reading the binary's `mcpServers` (camelCase) reply. @@ -72,7 +72,7 @@ Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, nam **4.0.0 expert-consensus review hardening (frontend + thin UI wiring; protocol backend untouched):** a multi-reviewer pass confirmed and fixed: **partial-accept stale-snapshot** (`JcefChatPanel` `ResolvePermission` re-reads disk before reconstructing; falls back to a full accept if the file diverged from the cached `HunkCtx` snapshot, never writing stale/clobbering content); **`hunkCache` leak** (pruned to the still-pending requestIds on every `pushPermissions`, cleared in `dispose()` — so permissions cleared on stop/interrupt can't accumulate); **EDT freeze on large files** (`computeHunks` skips files > `MAX_HUNK_FILE_BYTES` = 1 MB; full accept still works); **dropped `sms:` URI scheme** restored in the `app-core.js` DOMPurify `ALLOWED_URI_REGEXP` (`data:image/` + `jb:` still allowed, `data:text/html` still blocked). Also a **zero-deprecation** fix: the rewind-fallback confirmation moved off the deprecated `Messages.showYesNoDialog(…DoNotAskOption)` overload to `MessageDialogBuilder.yesNo`. `test` green, `verifyPlugin` Compatible IC-252 → IU-262. -**Test pyramid (677 tests in the default `test` task + 54 frontend, 0 failures, 2 Windows-only skips; the on-demand `checkDrift` task adds the `driftLive` check):** (A) **unit** (pure JVM) — protocol parse/build, diff reconstruction, edit-snapshot capture, permission tool_use_id plumbing + the exhaustive `PermissionBroker` matrix, hunk reconstruction/encode, markdown rendering + edge cases, `DiffPresenter.isWithinRoot` (incl. symlink escapes), `ClaudeBinaryLocator`, `McpConfigBuilder`, `parseAskQuestions`, session open-tab id (de)serialization, `SessionStore` path-traversal guard + cwd encoding, `SessionTitleReader`/`SessionTranscriptReader` JSONL parsing, settings enums, transcript hierarchy, rate-limit math and env parsing; (B) **headless component** (`src/test/.../headless/`, `BasePlatformTestCase` in-process) — `OpenedDiffsService`, `ChatSessionManager`, `SessionHistory`/`ClaudeSettings` services, `ClaudeSettingsConfigurable`, and real token accounting via the `@TestOnly` `ClaudeSession.handleEventForTest` seam; (C) **integration** (`src/test/.../integration/`) — a real `ClaudeSession` driven against the deterministic `bin/fake-claude` Python stand-in with JSONL fixtures (init, streaming, thinking, token fold, rate-limit, tool permission, resume, interrupt, Write-unsafe regression); (D) **UI end-to-end** (`src/uiTest/`, RemoteRobot, gated by `-PuiTest.enabled=true`, nightly); (E) **frontend** (`src/test/frontend/`, **vitest + jsdom**, run with `npm test` — devDependencies only, nothing ships in the plugin) — loads the real inlined `resources/jcef/*.js` (vendored `marked`/`DOMPurify`/`highlight` first, then app-core, then the module) into a jsdom shell (`helpers/load.js`) and drives the public `window.cc.*`/`CC` surface: a **JS↔CSS class contract** (the check that would have caught the missing `.mcp-actions` rule), user-prompt verbatim render, code-block Copy decoration + the delegated handler, inline diff colouring, the MCP card / switch / `wide` cards, permission read-only diff + reconcile-by-id, the composer send/stop/interrupting button, and (5.0.0) the **accessibility contract** in `accessibility.test.js` — the live region declared in the *static* shell rather than created on first use, `CC.announce` dedup, the permission announcement, the `:focus-visible` replacement for every suppressed outline, `forced-colors`, and `lang` on the document. NB `helpers/load.js` now extracts the shell DOM from the real `shell.html` instead of hand-copying it: the hand-copy had already drifted (it lacked `#a11y-status`), which is the worst failure mode a harness has — it doesn't fail, it quietly tests something else. Wired into CI as the `Frontend tests` job (Node image), a required check on both protected branches. NB: on this machine node-24 needs `OPENSSL_CONF=/dev/null` (a local env quirk, not needed on the clean CI image). Coverage via `kotlinx-kover` (`./gradlew koverHtmlReport`). NB: headless+integration run inside the plugin's own `test` task (the IntelliJ Platform Gradle plugin only instruments that task with the platform runtime); a hand-rolled Test task would miss `Project` on its classpath. +**Test pyramid (694 tests in the default `test` task + 84 frontend, 0 failures, 2 Windows-only skips; the on-demand `checkDrift` task adds the `driftLive` check):** (A) **unit** (pure JVM) — protocol parse/build, diff reconstruction, edit-snapshot capture, permission tool_use_id plumbing + the exhaustive `PermissionBroker` matrix, hunk reconstruction/encode, markdown rendering + edge cases, `DiffPresenter.isWithinRoot` (incl. symlink escapes), `ClaudeBinaryLocator`, `McpConfigBuilder`, `parseAskQuestions`, session open-tab id (de)serialization, `SessionStore` path-traversal guard + cwd encoding, `SessionTitleReader`/`SessionTranscriptReader` JSONL parsing, settings enums, transcript hierarchy, rate-limit math and env parsing; (B) **headless component** (`src/test/.../headless/`, `BasePlatformTestCase` in-process) — `OpenedDiffsService`, `ChatSessionManager`, `SessionHistory`/`ClaudeSettings` services, `ClaudeSettingsConfigurable`, and real token accounting via the `@TestOnly` `ClaudeSession.handleEventForTest` seam; (C) **integration** (`src/test/.../integration/`) — a real `ClaudeSession` driven against the deterministic `bin/fake-claude` Python stand-in with JSONL fixtures (init, streaming, thinking, token fold, rate-limit, tool permission, resume, interrupt, Write-unsafe regression); (D) **UI end-to-end** (`src/uiTest/`, RemoteRobot, gated by `-PuiTest.enabled=true`, nightly); (E) **frontend** (`src/test/frontend/`, **vitest + jsdom**, run with `npm test` — devDependencies only, nothing ships in the plugin) — loads the real inlined `resources/jcef/*.js` (vendored `marked`/`DOMPurify`/`highlight` first, then app-core, then the module) into a jsdom shell (`helpers/load.js`) and drives the public `window.cc.*`/`CC` surface: a **JS↔CSS class contract** (the check that would have caught the missing `.mcp-actions` rule), user-prompt verbatim render, code-block Copy decoration + the delegated handler, inline diff colouring, the MCP card / switch / `wide` cards, permission read-only diff + reconcile-by-id, the composer send/stop/interrupting button, and (5.0.0) the **accessibility contract** in `accessibility.test.js` — the live region declared in the *static* shell rather than created on first use, `CC.announce` dedup, the permission announcement, the `:focus-visible` replacement for every suppressed outline, `forced-colors`, and `lang` on the document. NB `helpers/load.js` now extracts the shell DOM from the real `shell.html` instead of hand-copying it: the hand-copy had already drifted (it lacked `#a11y-status`), which is the worst failure mode a harness has — it doesn't fail, it quietly tests something else. Wired into CI as the `Frontend tests` job (Node image), a required check on both protected branches. NB: on this machine node-24 needs `OPENSSL_CONF=/dev/null` (a local env quirk, not needed on the clean CI image). Coverage via `kotlinx-kover` (`./gradlew koverHtmlReport`). NB: headless+integration run inside the plugin's own `test` task (the IntelliJ Platform Gradle plugin only instruments that task with the platform runtime); a hand-rolled Test task would miss `Project` on its classpath. **Maintenance workflow (the plugin has real Marketplace users):** the CI/CD is **GitHub Actions** (5.0.0). The long-standing "GitHub Actions is capped by billing" claim in this file and in `.gitlab-ci.yml` was simply **FALSE** — the repo is public, and Actions on standard hosted runners is free and unmetered for public repos (verified: `gh api repos/…/actions/permissions` → `enabled: true, allowed_actions: all`). The workflows had just been deleted at some point and the billing story lived on in a comment. `.gitlab-ci.yml` is now REMOVED (not kept alongside: two pipelines that can each publish is one publisher too many). Four workflows, every action pinned by full commit SHA with Dependabot proposing bumps: **`ci.yml`** (push to develop/main/`feature|bugfix|hotfix/**` + PRs → JVM tests, frontend tests, `npm audit --omit=dev` as the blocking scope, `verifyPlugin`, `buildPlugin` + two artifact assertions: zero `node_modules` entries and `META-INF/{LICENSE,THIRD-PARTY-NOTICES.md}` present, i.e. the claims SECURITY.md makes are enforced rather than trusted); **`codeql.yml`** (`java-kotlin` manual-build + `javascript-typescript`, `security-extended`, weekly); **`release.yml`** (tag `vX.Y.Z` only → `guard` asserts the tagged commit is REACHABLE FROM `main` and that the tag matches `build.gradle.kts`'s version, BEFORE any secret is in scope → full gate on the tagged tree → build once + SLSA attestation → `publish` gated on the **`marketplace` GitHub Environment** with a required reviewer, credentials scoped there and nowhere else → GitHub Release). The lineage guard is the load-bearing one: without it anyone who can push a tag can publish from any code, and the PR review the approval assumes becomes optional; **`drift.yml`** (weekly `checkDrift` against a freshly installed CLI + latest SDK, **files an issue**, never commits — reconciling drift is a judgement call). Branch protection is VERSIONED in `.github/rulesets/{main,develop}.json` and applied by `scripts/apply-rulesets.sh` (idempotent, updates by name); no bypass actors, not even admins — the old documented admin bypass existed for a structural blocker (capped Actions) that never existed. NB a ruleset references a check by the job's DISPLAY NAME: renaming a job doesn't fail the gate, it silently stops applying. Policy docs in `docs/` (`RELEASE_PROCEDURE`, `RELEASE_CHECKLIST`, `BINARY_COMPAT`, `BRANCHING`, `FAQ`, `TROUBLESHOOTING`, `TELEMETRY`) plus `SECURITY.md`, `CONTRIBUTING.md`, `CODEOWNERS`, issue/PR templates, `dependabot.yml`, and `scripts/probe-binary.sh`. **Implemented features:** protocol+transport, multi-chat with queue, permissions+native diff, AskUserQuestion, markdown tables, auto-diff on acceptEdits/bypass, multi-line commands, Ctrl+O reasoning, quota bar + spinner/tokens, menus that close on selection, `/btw`, UI rethemed to IDE theme, **Windows support**, **persistent settings** (model/mode/effort/thinking/tools/env via `ClaudeSettings` + settings UI), **plugin is the source of truth for `permissionMode`**. `claude` 2.1.220 at `~/.local/bin/claude`; SDK reference (protocol-only) `node_modules/@anthropic-ai/claude-agent-sdk@0.3.220`. diff --git a/src/main/kotlin/dev/lain/claudejb/permission/SensitiveGuard.kt b/src/main/kotlin/dev/lain/claudejb/permission/SensitiveGuard.kt index cc74d1b9..b1ec0571 100644 --- a/src/main/kotlin/dev/lain/claudejb/permission/SensitiveGuard.kt +++ b/src/main/kotlin/dev/lain/claudejb/permission/SensitiveGuard.kt @@ -114,6 +114,19 @@ object SensitiveGuard { "Artifact", "ClaudeDesign", "DesignSync", "Monitor", "Projects", "ProposeSkills", "PushNotification", "RemoteTrigger", "REPL", "ReportFindings", "SendFeedback", "ShowOnboardingRolePicker", "Workflow", + // Re-audited against `claude` 2.1.222 / SDK 0.3.222. Entries are only ever ADDED here, never removed: + // this is a TRUST allowlist, not an inventory. A name that no longer exists costs nothing, while a + // first-party name that is missing falls into the third-party branch and is hard-DENIED — the 4.4.0 + // incident described above. NB the CLI can also retire a tool per-session (it ships distinct + // "is disabled for this session" / "is not available in this context" messages, and Glob/Grep do get + // withdrawn in some sessions), which is another reason absence here must never be inferred from one run. + "AskUserQuestion", "Mcp", "FileRead", "FileEdit", "FileWrite", + // ToolSearch was absent, and it is the one that mattered most: it is how the agent loads the schema of + // every DEFERRED tool (web, tasks, cron, worktrees), so on a session that defers them, the call that + // unlocks all the others was the one landing in the untrusted branch. Found by diffing this list + // against a live session's actual tool inventory rather than against the SDK's type names — those are + // not the runtime registry (the SDK calls them FileRead/FileEdit/FileWrite; the tools are Read/Edit/Write). + "ToolSearch", ) /** Everything the guard needs to judge a call. Assembled by the IDE side; pure input here. */ diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt index 5efb0ee7..87157093 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt @@ -445,6 +445,29 @@ object ProtocolParser { outputTokens = u.intField("output_tokens") ?: 0, ) + /** + * Strips the CLI's `…` wrapper from a tool result's text. + * + * The binary wraps every failed tool call's `content` in that tag pair — verified against `claude` 2.1.222, + * where it appears ten times and is emitted as + * `content: "Error: …", is_error: true`. The tag is framing for the MODEL, + * not text for a human: the same message is carried unwrapped in the sibling `toolUseResult` field. Rendered + * verbatim it put raw markup in the transcript of a native GUI, which is exactly the "never mirror raw CLI + * output" antipattern this plugin exists to avoid — the failure is already conveyed structurally by + * `is_error`, which is what paints the card red. + * + * Only strips a wrapper that encloses the WHOLE payload, so an error whose body legitimately mentions the + * tag is left alone. Absent the wrapper this is the identity function, so it costs nothing on the happy path. + */ + internal fun unwrapToolError(text: String): String { + val trimmed = text.trim() + if (!trimmed.startsWith(TOOL_ERROR_OPEN) || !trimmed.endsWith(TOOL_ERROR_CLOSE)) return text + return trimmed.removeSurrounding(TOOL_ERROR_OPEN, TOOL_ERROR_CLOSE).trim() + } + + private const val TOOL_ERROR_OPEN = "" + private const val TOOL_ERROR_CLOSE = "" + private fun parseUser(root: JsonObject): List { val message = root["message"] as? JsonObject ?: return emptyList() val content = message["content"] as? JsonArray ?: return emptyList() @@ -458,7 +481,7 @@ object ProtocolParser { is JsonArray -> c.filterIsInstance().mapNotNull { it.str("text") }.joinToString("\n") else -> "" } - ClaudeEvent.ToolResult(toolUseId, text, isError, parentToolUseId) + ClaudeEvent.ToolResult(toolUseId, unwrapToolError(text), isError, parentToolUseId) } } diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 2b2a20a3..65186ae2 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -389,6 +389,17 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : fireState() } } + // The timer exists to track a turn AS IT RUNS, nothing else. Context and cost cannot move while the + // session sits idle, so a poll-per-minute forever was a round-trip through the binary — per tab — for + // two numbers that provably had not changed. It now switches itself off at the end of a turn; the + // turn-start, turn-end and process-ready paths each poll directly, so nothing waits on a clock. + if (!turnActive) edt { quotaPollTimer.stop() } + } + + /** Begin tracking a running turn: poll now, then keep the meters live until it ends. */ + private fun startQuotaPolling() = edt { + pollQuota() + if (!quotaPollTimer.isRunning) quotaPollTimer.start() } private val broker by lazy { @@ -423,10 +434,11 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : fun addListener(listener: SessionListener) { listeners.add(listener) - // Start the shared quota poll on the first observer (a ChatPanel). javax.swing.Timer must be started on - // the EDT; addListener is called from the GUI (ChatPanel.init, on the EDT) but guard with edt{} so a - // non-EDT caller can't break the timer's thread affinity. Idempotent: Timer.start() is a no-op if running. - edt { if (!quotaPollTimer.isRunning) quotaPollTimer.start() } + // Fill this observer's meters NOW rather than starting a timer it would then have to wait out. A panel + // attaching to an ALREADY-RUNNING session (a second tab, a reopened tool window) used to show empty + // context and cost for up to a full poll interval for no reason: the data was one control request away + // the whole time. `pollQuota` no-ops when the process is not up, and the ready path polls again then. + edt { pollQuota() } } fun removeListener(listener: SessionListener) { @@ -436,6 +448,16 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } fun isRunning(): Boolean = process?.isRunning() == true + + /** + * True between [start] dispatching a launch and the process being up (or the launch failing). + * + * Exposed because "not running" alone is ambiguous to the UI: a session that is booting and one that never + * started look identical through [isRunning], and the composer rendered both as "Idle" — which is a claim, + * and a false one, during the seconds the launch takes (env resolution sources a login shell). + */ + fun isStarting(): Boolean = starting + fun queuedPrompts(): List = queue.map { it.displayText } fun pendingPermissions(): List = cards.all() @@ -468,6 +490,9 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : ready = false starting = true reconciler.onMessageBoundary() + // Tell the GUI we are booting BEFORE handing off to the pooled thread, so the loading screen is up for + // the whole launch rather than appearing after the slow part (env resolution) has already finished. + fireState() val launchGen = ++generation // this launch's generation; the process's onTerminated is gated on it // Off the EDT: env resolution sources a shell (seconds) and process spawn can block. Hand back to the EDT @@ -478,7 +503,12 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } finally { // Release the launch guard, but only if we still own the current generation — a newer start() // bumped it and is now the owner, so it must keep `starting` set. - if (launchGen == generation) starting = false + if (launchGen == generation) { + starting = false + // And tell the GUI, or a launch that FAILED leaves the loading screen up forever: `launch` + // only fires state on the paths that succeed. This runs on the pooled thread, hence edt {}. + edt { fireState() } + } } } return true @@ -565,6 +595,14 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : ready = true transcript.add(Speaker.SYSTEM, "Claude Code ready.") fireState() + // Fill the context and cost meters NOW rather than on the poll timer's first tick. + // + // The timer's initial delay equals its interval (a javax.swing.Timer default), so the first poll is + // a full QUOTA_POLL_MS — one minute — after the panel registered. Worse, that registration happens + // while the binary is still launching, so `pollQuota` returns early on the not-running guard and the + // meters stay empty for a SECOND interval. The data is available the moment the process is up; there + // is no reason to make the user look at an empty readout while we wait for a clock. + pollQuota() pump() } } @@ -765,6 +803,7 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : write(ControlProtocol.userMessage(trimmed)) if (!turnActive) { turnActive = true + startQuotaPolling() fireState() } // Flush anything still queued from startup; the binary accumulates messages mid-turn. @@ -800,6 +839,7 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : currentUserMessageId = msgUuid write(ControlProtocol.userMessageWithImages(next.text, next.images, uuid = msgUuid)) turnActive = true + startQuotaPolling() } promptSuggestion = null // a new prompt was sent; the previous turn's suggestion is now stale fireState() @@ -850,6 +890,9 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : interrupting = false turnActive = false liveThinkingTokens = 0 + // An interrupted turn still consumed context and cost, and it is also a turn END — so this both + // refreshes the meters and lets the poll retire, exactly as a normal result does. + pollQuota() fireState() } @@ -1579,6 +1622,9 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : tokens.foldIntoSession() reconciler.onMessageBoundary() turnActive = false + // The turn just moved both numbers; read them once now. Setting turnActive false first means the poll + // also retires the timer, so an idle session goes quiet instead of ticking forever. + pollQuota() interrupting = false // the turn ended (possibly via our interrupt) — clear the transient label liveThinkingTokens = 0 if (event.result.isError) { diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt index bde04c71..48bcee7a 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt @@ -90,8 +90,19 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { toolWindow.setAdditionalGearActions(buildGearGroup(project, cm)) } - /** Adds a tab for [session], wires it, and starts its process. */ + /** Starts [session]'s process, then adds a tab for it and wires it. */ private fun openChat(project: Project, cm: ContentManager, session: ClaudeSession) { + // Launch the binary FIRST, before building the tab. `start()` only dispatches — it hands the blocking + // work (env resolution sources a login shell, then the spawn) to a pooled thread and returns — so doing + // it here means `claude` boots WHILE JCEF creates its browser, instead of waiting for it to finish. + // Constructing the panel is not free, and it used to be entirely in front of the launch. + // + // Nothing is lost by having no listener attached yet: the panel's constructor pushes the full state and + // marks the transcript structural, so anything that landed in the gap is sent on its first frame, and + // `whenReady` runs its deferred requests immediately if the session is already up by then. + ClaudeSettings.getInstance(project).applyTo(session) + session.start() + val panel = JcefChatPanel(project, session) val content = ContentFactory.getInstance().createContent(panel, tabTitle(session.title), false) // Tell the platform WHERE the keyboard focus of this tab lives. Without it the ContentManager has nowhere @@ -121,8 +132,6 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { // finishes, stepping on our request. (The caret itself is settled later, when the page is up — see // JcefHost.markWebReady.) The trailing `true` IS requestFocus — a Java API, so it cannot be named here. cm.setSelectedContent(content, true) - ClaudeSettings.getInstance(project).applyTo(session) - session.start() } /** diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index 8ae22ec3..f3e630f7 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -67,6 +67,30 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : private val attachments = LinkedHashMap() private var nextAttachmentId = 0L + /** + * Actions deferred by [whenReady]. EDT-confined: both the add and the drain happen on the EDT. + * + * MUST be declared BEFORE the `init` block: Kotlin runs property initializers and `init` blocks in + * declaration order, so a list declared below `init` is still null while `init` runs — and `init` calls + * [whenReady] three times. Declaring it after threw NPE inside the constructor, which took the whole tab + * with it: no chat could be opened or restored at all. + */ + private val pendingUntilReady = mutableListOf<() -> Unit>() + + /** + * The last `get_usage` reply, and when it was asked for. Cached because [pushSession] runs on every state + * change (many per turn) while the usage figures move on the order of minutes — re-asking the binary each + * time would be a round-trip per keystroke-ish event for a number that has not changed. + * + * Declared above `init` for the same reason as [pendingUntilReady]: `init` reads `lastUsage` (via + * [pushMetaState]/[pushSession]) and can write `lastUsageAt` (via [requestUsage], when the session is + * already running). A property initializer below `init` runs AFTER it and would silently reset the throttle + * it had just set. Nullable and primitive types hide this — they read as null/0 rather than throwing — which + * is precisely why it is worth stating instead of relying on someone noticing. + */ + private var lastUsage: dev.lain.claudejb.protocol.UsageReport? = null + private var lastUsageAt = 0L + init { background = ChatTheme.BG add(host.component, BorderLayout.CENTER) @@ -180,8 +204,6 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : queued.forEach { it() } } - /** Actions deferred by [whenReady]. EDT-confined: both the add and the drain happen on the EDT. */ - private val pendingUntilReady = mutableListOf<() -> Unit>() override fun onMetadataChanged() { pushMetaState() pushSession() @@ -317,6 +339,10 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : pushSession() requestMcp() requestVersion() + // Opening the dashboard is one of the two documented refresh triggers for the plan limits (the other is + // a rate_limit_event). It was stated in requestUsage's contract and not actually wired, so the bars + // showed whatever the last unrelated refresh had left. Throttled, so re-opening is free. + requestUsage() host.exec("window.cc.openDashboard && window.cc.openDashboard()") } @@ -325,13 +351,6 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : host.exec("window.cc.session && window.cc.session(" + JcefSessionData.sessionJson(session, lastUsage) + ")") } - /** - * The last `get_usage` reply. Cached because [pushSession] runs on every state change (many per turn) while - * the usage figures move on the order of minutes — re-asking the binary each time would be a round-trip per - * keystroke-ish event for a number that has not changed. - */ - private var lastUsage: dev.lain.claudejb.protocol.UsageReport? = null - /** * Refreshes the plan-limit windows, then re-pushes the dashboard. * @@ -349,13 +368,16 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : session.requestUsage { report -> if (report != null) { lastUsage = report + // BOTH surfaces, or they disagree. `lastUsage` feeds the dashboard bars (pushSession) AND the + // composer's usage dots (pushMetaState → stateJson). Pushing only the dashboard left the dots + // blank until some unrelated state change happened to re-push — so the same number appeared in + // one place immediately and in the other "a while later", which reads as a broken readout. pushSession() + pushMetaState() } } } - private var lastUsageAt = 0L - /** Fetch MCP server status asynchronously and hand the raw payload to the dashboard's MCP health card. */ private fun requestMcp() { session.requestMcpStatus { json -> @@ -626,7 +648,17 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : val u = url.trim() when { u.lowercase().startsWith("https://") -> BrowserUtil.browse(u) + u.startsWith("jb://open") -> openJbLink(u) + + // A markdown link whose href is a PATH rather than a URL — `[BACKLOG](docs/BACKLOG.md)`. It carries + // no scheme, so it matched neither branch above and the click did NOTHING: no navigation, no error, + // nothing in any log. Bare paths written in prose already resolve (LinkResolver confirms them before + // linking), which made this the odd one out — the more deliberate the link, the less it worked. + // + // The scheme test is what keeps this from swallowing the other schemes DOMPurify allows (`mailto:`, + // `tel:`, `sms:`…): anything with a scheme is not a path, and is still ignored here as before. + LinkResolver.isFilePathHref(u) -> openPath(u.substringBefore('#').trim()) } } @@ -673,9 +705,19 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : if (k.isEmpty()) null else k to runCatching { java.net.URLDecoder.decode(v, Charsets.UTF_8) }.getOrDefault(v) }.toMap() val raw = params["file"] ?: return - // A link normally carries a PROJECT-RELATIVE path; one pointing into the user's home carries an absolute - // one. Either way this only builds the path — the gate below is what authorises it, and it is the single - // place that decides, so a hand-crafted `jb://` URL cannot reach a file we would not have linked. + openPath(raw, (params["line"]?.toIntOrNull() ?: 1)) + } + + /** + * Opens [raw] — project-relative or absolute — in the editor, or reveals it in the tree when it is a + * directory or an archive. The single authorising gate for every link the transcript can produce. + * + * A link normally carries a PROJECT-RELATIVE path; one pointing into the user's home carries an absolute + * one. Either way this only *builds* the path — [LinkResolver.isOpenable] is what authorises it, and it is + * the one place that decides, so neither a hand-crafted `jb://` URL nor a markdown href can reach a file we + * would not have linked ourselves. + */ + private fun openPath(raw: String, line: Int = 1) { val path = resolveAgainstRoot(raw) ?: return if (!LinkResolver.isOpenable(path, project.basePath)) return // project or the user's own home, nothing else // refreshAndFind, not find: a file Claude has just written may not be in the VFS yet, and a plain lookup @@ -689,8 +731,7 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : revealDirectory(vf) return } - val line = (params["line"]?.toIntOrNull() ?: 1).coerceAtLeast(1) - 1 - com.intellij.openapi.fileEditor.OpenFileDescriptor(project, vf, line, 0).navigate(true) + com.intellij.openapi.fileEditor.OpenFileDescriptor(project, vf, line.coerceAtLeast(1) - 1, 0).navigate(true) selectInProjectView(vf) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt b/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt index 698a7f46..0fb211d1 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt @@ -71,6 +71,24 @@ object LinkResolver { fun userHome(): String? = System.getProperty("user.home")?.takeIf { it.isNotBlank() } /** `~/notes/x.md` → an absolute path under the user's home. Anything else is returned unchanged. */ + // A URI scheme prefix (`https:`, `mailto:`, `jb:`…), used to tell a link's href apart from a file path. + // Two-or-more characters before the colon on purpose: a single letter is a WINDOWS DRIVE (`C:\src`), which + // is a path, not a scheme. This plugin ships on Windows, so getting that backwards would break every + // absolute-path link there. + private val HAS_SCHEME = Regex("^[A-Za-z][A-Za-z0-9+.\\-]+:") + + /** + * True when a link's href is a FILE PATH rather than a URL — that is, it carries no URI scheme. + * + * Markdown links written by the model use plain relative paths (`[BACKLOG](docs/BACKLOG.md)`). Those matched + * no scheme branch in the host's link handler and were silently dropped, so the most deliberate kind of link + * was the only one that did nothing. Bare paths in prose already worked, which is what made it confusing. + */ + fun isFilePathHref(href: String): Boolean { + val h = href.trim() + return h.isNotEmpty() && !HAS_SCHEME.containsMatchIn(h) + } + fun expandHome(raw: String): String { if (raw != "~" && !raw.startsWith("~/")) return raw val home = userHome() ?: return raw diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt index d1df494f..d6a0d7c4 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -61,6 +61,18 @@ object JcefState { put("turnActive", session.turnActive) put("interrupting", session.interrupting) put("running", session.isRunning()) + // "Booting" is a THIRD state, not the absence of `running`: the web app blocks input behind a loading + // screen while this is true, and a session that failed to launch must fall out of it (both flags + // false) rather than wait forever. + put("starting", session.isStarting()) + // Resuming reads an existing transcript back and is the slower of the two waits, so the boot screen + // labels it differently rather than calling both "Starting" and making the long one look hung. + put("resuming", session.isStarting() && session.sessionId != null) + + // The live reasoning estimate as a NUMBER, always present (0 when nothing is being reasoned about), + // so the readout can render a settled "0" instead of omitting the item. An item that only exists + // once it is non-zero is indistinguishable from one that failed to load. + put("reasoningTokens", session.liveThinkingTokens) // Live reasoning suffix while a thinking block is accumulating; null when there's nothing to show. val suffix = StatusLineFormatter.thinkingSuffix(session.liveThinkingTokens) diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index a3589701..d28fda3d 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -51,6 +51,7 @@ var built = false; var els = null; // { card, input, send, pills:{provider,model,mode,effort,thinking}, queue, ghost, readout, sendIcon } var lastState = null; // last cc.state payload + var announcedBoot = false; // guards the boot screen's one-per-boot screen-reader announcement var commands = []; // from cc.meta var hostClipboard = false; // from cc.meta: native-Wayland toolkit → route paste through the host (wl-paste) var ghostText = ''; // current ghost suggestion (empty field only) @@ -999,12 +1000,22 @@ ); ro.appendChild(status); - if (s.context && typeof s.context.pct === 'number') { - ro.appendChild(h('span', { class: 'ro-item', text: 'Context ' + Math.round(s.context.pct) + '%' })); - } - if (typeof s.tokensOut === 'number' && s.tokensOut > 0) { - ro.appendChild(h('span', { class: 'ro-item', text: formatTokens(s.tokensOut) + ' out' })); - } + // These three are ALWAYS rendered, settling at 0 rather than being omitted until they are non-zero. + // Hiding an item until it has a value makes "nothing has happened yet" look identical to "this failed to + // load", which is exactly how the readout read on a fresh tab: a lone "Idle" and no numbers. A zero is a + // measurement; an absence is not. + var ctxPct = s.context && typeof s.context.pct === 'number' ? Math.round(s.context.pct) : 0; + ro.appendChild(h('span', { class: 'ro-item', text: 'Context ' + ctxPct + '%' })); + + var out = typeof s.tokensOut === 'number' ? s.tokensOut : 0; + ro.appendChild(h('span', { class: 'ro-item', text: formatTokens(out) + ' out' })); + + var reasoning = typeof s.reasoningTokens === 'number' ? s.reasoningTokens : 0; + ro.appendChild(h('span', { class: 'ro-item', text: formatTokens(reasoning) + ' reasoning' })); + + // Cost stays gated: unlike the counters above it is a currency amount, and "$0.0000" on every idle tab is + // noise rather than information — there is no ambiguity to resolve, since a session that has spent nothing + // has nothing to report. if (typeof s.costUsd === 'number' && s.costUsd > 0) { ro.appendChild(h('span', { class: 'ro-item', text: '$' + s.costUsd.toFixed(s.costUsd < 1 ? 4 : 2) })); } @@ -1129,6 +1140,37 @@ else if (wasWorking) CC.announce('Claude finished responding.'); } + /** + * The boot screen: up until the binary is running, then gone for good. + * + * Three states, not two. `running` means go; `starting` means wait; NEITHER means the launch finished without + * a process — a missing binary, a declined trust prompt, a refused remote-mount project. That last case must + * clear the screen, or a failed launch leaves the tab covered forever with no way to see the notification + * explaining why. The host fires a state push on that path precisely so this can happen. + */ + function renderBoot(s) { + var boot = document.getElementById('boot'); + var app = document.getElementById('app'); + if (!boot) return; + var booting = !s.running && !!s.starting; + boot.hidden = !booting; + // Announce the FIRST booting render, not just a transition into it. The screen is already on-screen when + // the page loads, so the common case never transitions — and the element's own aria-live never fires + // either, because static markup present at load is not a mutation. Once per boot: `announcedBoot` resets + // when the screen comes down, so a later relaunch announces again. + if (booting && !announcedBoot) { + announcedBoot = true; + CC.announce && CC.announce('Loading Claude Code'); + } + if (!booting) announcedBoot = false; + if (app) app.classList.toggle('booting', booting); + if (!booting) return; + var sub = document.getElementById('boot-sub'); + // Distinguish the two waits: a fresh launch versus resuming an existing session, which reads a transcript + // back and is the slower of the two. Guessing "Starting" for both made the longer wait look like a hang. + if (sub) sub.textContent = s.resuming ? 'Resuming your session' : 'Starting the agent'; + } + function renderState(s) { if (!s) return; announceTurnState(s); @@ -1341,6 +1383,11 @@ // ---- Kotlin-facing API ---------------------------------------------------- cc.state = function (s) { lastState = s || null; + // The boot screen is updated FIRST and OUTSIDE the ensureBuilt gate below. It covers the whole tab and + // blocks input, so it must never be hostage to the composer having mounted: if `ensureBuilt()` returns + // false we bail out early, and an overlay left up with no path to clear it is a worse failure than the + // empty composer this screen exists to hide. + if (lastState) renderBoot(lastState); if (!ensureBuilt()) return; // will render on build via lastState renderState(lastState); }; diff --git a/src/main/resources/jcef/app-core.js b/src/main/resources/jcef/app-core.js index 3758f3bc..ed024657 100644 --- a/src/main/resources/jcef/app-core.js +++ b/src/main/resources/jcef/app-core.js @@ -655,6 +655,11 @@ copyEl.classList.remove('copied'); }, 1200); } + // Shared so every Copy affordance confirms the same way. The message-level buttons in app-transcript.js + // carry their OWN click handler (they copy a rendered message, not a `pre > code`), so the delegated + // code-head path below never reaches them — they copied silently, which reads as a dead button. Exported + // rather than reimplemented so the two can never drift in wording or duration. + CC.flashCopied = flashCopied; function handleCopyFromCodeHead(ev, copyEl) { var text = copyTargetText(copyEl); if (!text) return; diff --git a/src/main/resources/jcef/app-transcript.js b/src/main/resources/jcef/app-transcript.js index a0be53b2..d65a41b1 100644 --- a/src/main/resources/jcef/app-transcript.js +++ b/src/main/resources/jcef/app-transcript.js @@ -159,6 +159,9 @@ e.preventDefault(); e.stopPropagation(); safeSend({ type: 'copy', text: getText() }); + // Same "Copied" confirmation the code-block buttons give. Without it this button did its job and + // said nothing, which is indistinguishable from a broken one — and was reported as exactly that. + if (CC.flashCopied) CC.flashCopied(e.currentTarget || this); }, }, }); @@ -419,6 +422,14 @@ node.classList.remove('loading', 'running', 'done', 'failed'); if (state === 'ERROR' || meta === 'error') { node.classList.add('failed'); // red — wins over done/loading + // Reveal the failure. A tool card is collapsed by default, and its output — which for a failed call is + // the ERROR — lives behind that collapse, so a red header was the entire message: you had to know to + // expand a card to find out what went wrong. Opened ONCE, tracked on the node, so this never fights a + // user who deliberately collapsed it (applyToolState runs again on every state push). + if (!node.__autoOpenedOnError) { + node.__autoOpenedOnError = true; + node.classList.add('open'); + } } else if (state === 'LOADING') { node.classList.add('loading'); // fade sky-blue ↔ amber (active) } else if (state === 'RUNNING') { diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index 7415816c..d7d225f9 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -90,6 +90,9 @@ body { flex-direction: column; height: 100vh; overflow: hidden; + /* Containing block for #boot's `position: absolute; inset: 0` — without it the overlay anchors to the + viewport, which happens to look the same here and stops doing so the moment anything wraps #app. */ + position: relative; } #conversation { @@ -265,6 +268,18 @@ body { background: var(--surface); } +/* Confirmation state, shared by every Copy affordance (message heads and code-block heads alike). The class + was already being applied by the JS and had NO rule at all, so the only feedback was the word changing — + easy to miss on a control you are not looking straight at. Green because it reports an outcome, and it + holds through :hover so moving the pointer does not wipe the confirmation you just triggered. */ +.act.copied, +.copy.copied, +.act.copied:hover, +.copy.copied:hover { + color: var(--success); + background: color-mix(in srgb, var(--success) 14%, transparent); +} + /* USER — a soft full-width card, not a bubble */ .msg.user .body { background: var(--surface); @@ -707,6 +722,17 @@ details.fold .fold-body p:first-child { .tool-out pre code { font-size: 12px; } +/* A FAILED tool's output wraps instead of scrolling sideways. + Normal output keeps `overflow: auto` on purpose — it is code, a log or a file dump, and wrapping those + corrupts their alignment. An error is prose: "Error: No such tool available: Glob. Glob is not available in + this session — find files with `find` via the Bash tool instead" is one long line, so it ran off the right + edge and the actionable half (what to use instead) was invisible unless you thought to scroll a card you had + no reason to think was scrollable. An error nobody can read without discovering a horizontal scrollbar is an + error nobody reads. `anywhere` rather than `break-word` so a long unbroken path or URL also gives way. */ +.tool.failed .tool-out pre code { + white-space: pre-wrap; + overflow-wrap: anywhere; +} /* coloured unified diff inside a tool card */ .tool-out pre.diff code { display: block; @@ -2354,6 +2380,102 @@ mark.cc-hit.active { } } +/* ════════════════════════════════════════════════════════════════════════════ + BOOT SCREEN — shown while the `claude` binary launches + ════════════════════════════════════════════════════════════════════════════ + Covers the whole tab rather than sitting inside the transcript: while the agent is booting there is nothing + to interact with, and a composer that looks live but silently drops what you type is worse than one that is + plainly not ready yet. Input is blocked by `#app.booting` below, so this is not merely a visual cover. + + Opaque, not translucent: a half-visible transcript underneath reads as "something is broken", not "wait". */ +#boot { + position: absolute; + inset: 0; + z-index: 60; /* above the dock and permission cards, below nothing else this app draws */ + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} +#boot[hidden] { + display: none; +} +.boot-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; + padding: 0 24px; +} +/* The Claude starburst, same glyph as the empty state — a character, not an asset, because the CSP forbids + url() and every external resource. Breathes gently so the screen is alive without a spinner competing with + the dots for attention. */ +.boot-mark { + font-size: 30px; + line-height: 1; + color: var(--accent); + animation: bootBreathe 2.2s ease-in-out infinite; +} +@keyframes bootBreathe { + 0%, + 100% { + opacity: 0.55; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.06); + } +} +.boot-title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} +/* Dots cycle . → .. → ... and repeat. + + `steps(1, end)` with content swapped at each third holds each state for a full beat instead of easing + between them — the point is a discrete count, not a fade. Rendered in a ::after so the dots are not part of + the text node: the element is aria-hidden, so a screen reader announces "Loading Claude Code" once rather + than re-reading the phrase three times a second as the content mutates. */ +.boot-dots::after { + content: '.'; + animation: bootDots 1.2s steps(1, end) infinite; +} +@keyframes bootDots { + 0% { + content: '.'; + } + 33% { + content: '..'; + } + 66% { + content: '...'; + } +} +/* Reduced motion: the universal reset would freeze both at their first frame, leaving a single dot and a + half-faded mark that read as "stuck". Show the settled state instead — all three dots, mark at full + opacity — which is the honest still image of this screen. */ +body.reduced-motion .boot-mark { + animation: none !important; + opacity: 1; +} +body.reduced-motion .boot-dots::after { + animation: none !important; + content: '...'; +} +.boot-sub { + font-size: 12px; + color: var(--dim); +} +/* The wait is real: block interaction rather than letting keystrokes fall on the floor. */ +#app.booting #dock, +#app.booting #conversation { + pointer-events: none; + user-select: none; +} + /* Reduced motion — driven by the HOST (body.reduced-motion), deliberately NOT by `@media (prefers-reduced-motion: reduce)`. diff --git a/src/main/resources/jcef/shell.html b/src/main/resources/jcef/shell.html index 070ffc54..68505e9f 100644 --- a/src/main/resources/jcef/shell.html +++ b/src/main/resources/jcef/shell.html @@ -34,6 +34,21 @@
+ + +
+
+ +
Loading Claude Code
+
Starting the agent
+
+
diff --git a/src/test/frontend/boot.test.js b/src/test/frontend/boot.test.js new file mode 100644 index 00000000..525aa88e --- /dev/null +++ b/src/test/frontend/boot.test.js @@ -0,0 +1,67 @@ +// The boot screen: it must appear while the binary launches, and — more importantly — must always come down. +// +// It covers the whole tab and blocks input, so a stuck boot screen is a worse failure than the empty composer +// it exists to hide. These pin the three-state logic (running / starting / neither) and the fact that it is +// driven outside the composer's ensureBuilt() gate. +const { loadFrontend } = require('./helpers/load'); + +describe('boot screen', () => { + let win; + beforeEach(() => { + win = loadFrontend(['app-composer.js'], { vendor: false }); + }); + + const boot = () => win.document.getElementById('boot'); + const app = () => win.document.getElementById('app'); + + it('is declared in the static shell and starts visible', () => { + // Visible by DEFAULT, before any state arrives: the page loads before the process is up, so "waiting" is + // the honest initial state. Starting hidden would flash a live-looking composer on every new tab. + expect(boot()).toBeTruthy(); + expect(boot().hidden).toBe(false); + }); + + it('stays up while the session is starting, and blocks input', () => { + win.cc.state({ starting: true, running: false }); + expect(boot().hidden).toBe(false); + expect(app().classList.contains('booting')).toBe(true); + }); + + it('comes down once the process is running', () => { + win.cc.state({ starting: true, running: false }); + win.cc.state({ starting: false, running: true }); + expect(boot().hidden).toBe(true); + expect(app().classList.contains('booting')).toBe(false); + }); + + it('comes down when the launch FAILED — neither starting nor running', () => { + // The regression that matters. A missing binary, a declined trust prompt or a refused remote-mount project + // all end with both flags false. If that did not clear the screen, the tab would stay covered forever with + // no way to reach the notification explaining why. + win.cc.state({ starting: true, running: false }); + win.cc.state({ starting: false, running: false }); + expect(boot().hidden).toBe(true); + }); + + it('distinguishes resuming from a cold start', () => { + win.cc.state({ starting: true, running: false, resuming: false }); + expect(win.document.getElementById('boot-sub').textContent).toBe('Starting the agent'); + win.cc.state({ starting: true, running: false, resuming: true }); + expect(win.document.getElementById('boot-sub').textContent).toBe('Resuming your session'); + }); + + it('the dots are aria-hidden so the phrase is announced once, not re-read per frame', () => { + const dots = win.document.querySelector('.boot-dots'); + expect(dots).toBeTruthy(); + expect(dots.getAttribute('aria-hidden')).toBe('true'); + // The dots live in ::after, not in the text node, so the accessible name is the stable phrase. + expect(dots.textContent).toBe(''); + expect(win.document.querySelector('.boot-title').textContent.trim()).toBe('Loading Claude Code'); + }); + + it('announces the wait to assistive technology', () => { + const region = win.document.getElementById('a11y-status'); + win.cc.state({ starting: true, running: false }); + expect(region.textContent).toContain('Loading Claude Code'); + }); +}); diff --git a/src/test/frontend/readout.test.js b/src/test/frontend/readout.test.js new file mode 100644 index 00000000..09660962 --- /dev/null +++ b/src/test/frontend/readout.test.js @@ -0,0 +1,51 @@ +// The composer readout: a zero is a measurement, an absence is not. +// +// These items used to be hidden until they were non-zero, which made a fresh tab show a lone "Idle" with no +// numbers — indistinguishable from a readout that failed to load. That ambiguity cost real debugging time, so +// the settled-at-zero behaviour is pinned here rather than left as a style someone tidies away. +const { loadFrontend } = require('./helpers/load'); + +describe('composer readout', () => { + let win; + beforeEach(() => { + win = loadFrontend(['app-composer.js'], { vendor: false }); + }); + + const readoutText = () => win.document.querySelector('.readout').textContent; + + it('shows context, output and reasoning at 0 before any data arrives', () => { + win.cc.state({ running: true, starting: false }); + const text = readoutText(); + expect(text).toContain('Context 0%'); + expect(text).toContain('0 out'); + expect(text).toContain('0 reasoning'); + }); + + it('renders real values once they arrive', () => { + win.cc.state({ + running: true, + starting: false, + context: { pct: 42 }, + tokensOut: 1500, + reasoningTokens: 2400, + }); + const text = readoutText(); + expect(text).toContain('Context 42%'); + expect(text).not.toContain('Context 0%'); + expect(text).toContain('reasoning'); + }); + + it('keeps cost gated — a currency amount of zero is noise, not information', () => { + win.cc.state({ running: true, starting: false }); + expect(readoutText()).not.toContain('$'); + win.cc.state({ running: true, starting: false, costUsd: 0.25 }); + expect(readoutText()).toContain('$0.25'); + }); + + it('reports idle vs running honestly', () => { + win.cc.state({ running: true, starting: false, turnActive: false }); + expect(readoutText()).toContain('Idle'); + win.cc.state({ running: true, starting: false, turnActive: true }); + expect(readoutText()).toContain('Running'); + }); +}); diff --git a/src/test/frontend/tool-error.test.js b/src/test/frontend/tool-error.test.js new file mode 100644 index 00000000..50bd89db --- /dev/null +++ b/src/test/frontend/tool-error.test.js @@ -0,0 +1,80 @@ +// Where does a FAILED tool's error text actually land, and can it be read? +// +// Written because the answer was not obvious from the source: `.tool-out` is `display:none` until the card is +// `.open`, yet a reported screenshot showed the error visible on a collapsed card — so either failed cards +// auto-open, or the text renders somewhere else entirely. Guessing at an unobservable DOM has cost this project +// three wrong diagnoses before; this measures it instead. +const fs = require('node:fs'); +const path = require('node:path'); +const { loadFrontend, JCEF } = require('./helpers/load'); + +const css = () => fs.readFileSync(path.join(JCEF, 'app.css'), 'utf8'); + +function row(id, order, speaker, text, extra = {}) { + return { id, order, speaker, text, state: 'FINISHED', elapsed: 0, ...extra }; +} + +const LONG_ERROR = + 'Error: No such tool available: Glob. Glob is not available in this session — ' + + 'find files with `find` via the Bash tool instead.'; + +describe('failed tool cards', () => { + it('marks the card failed and routes the error into its output node', () => { + const win = loadFrontend(['app-transcript.js']); + win.cc.batch([ + row(40, 0, 'TOOL', 'Glob(src/**/*.test.js)', { + meta: 'Glob', + toolUseId: 'tu-glob', + state: 'ERROR', + }), + row(41, 1, 'TOOL_OUTPUT', LONG_ERROR, { meta: 'error', toolUseId: 'tu-glob' }), + ]); + + const card = win.document.querySelector('.tool'); + expect(card).not.toBeNull(); + expect(card.classList.contains('failed')).toBe(true); + + const block = win.document.querySelector('[data-out-id="to-41"]'); + expect(block).not.toBeNull(); + expect(block.textContent).toContain('No such tool available'); + // Measured, not assumed: the error lives INSIDE the card's collapsible output. + expect(block.closest('.tool-out')).not.toBeNull(); + // …which is why the card must open itself. Collapsed, the whole message was "the header is red". + expect(card.classList.contains('open')).toBe(true); + }); + + it('does not re-open a failed card the user collapsed', () => { + // applyToolState runs on every state push, so a naive `add('open')` would undo a deliberate collapse on the + // next frame — the kind of fight-the-user bug that is very annoying and very easy to ship. + const win = loadFrontend(['app-transcript.js']); + const tool = row(50, 0, 'TOOL', 'Glob(x)', { meta: 'Glob', toolUseId: 'tu-x', state: 'ERROR' }); + win.cc.batch([tool, row(51, 1, 'TOOL_OUTPUT', LONG_ERROR, { meta: 'error', toolUseId: 'tu-x' })]); + + const card = win.document.querySelector('.tool'); + expect(card.classList.contains('open')).toBe(true); + card.classList.remove('open'); // the user collapses it + win.cc.batch([tool]); // another state push arrives + expect(card.classList.contains('open')).toBe(false); + }); + + it('the error text wraps rather than scrolling out of view', () => { + // The actionable half of that message ("find files with `find` via the Bash tool instead") sits at the end + // of a very long single line. With the default `overflow:auto` it was clipped past the right edge of a card + // nobody had reason to think was scrollable. + const sheet = css(); + expect(sheet).toMatch(/\.tool\.failed \.tool-out pre code\s*\{[^}]*white-space:\s*pre-wrap/); + expect(sheet).toMatch(/\.tool\.failed \.tool-out pre code\s*\{[^}]*overflow-wrap:\s*anywhere/); + }); + + it('normal (non-failed) output still scrolls instead of wrapping — code must keep its alignment', () => { + const win = loadFrontend(['app-transcript.js']); + win.cc.batch([ + row(42, 0, 'TOOL', 'Bash(ls)', { meta: 'Bash', toolUseId: 'tu-ok' }), + row(43, 1, 'TOOL_OUTPUT', 'a b c', { meta: 'command', toolUseId: 'tu-ok' }), + ]); + const card = win.document.querySelector('.tool'); + expect(card.classList.contains('failed')).toBe(false); + // The wrap rule is scoped to .tool.failed, so a healthy card is untouched by it. + expect(css()).not.toMatch(/^\.tool-out pre code\s*\{[^}]*white-space:\s*pre-wrap/m); + }); +}); diff --git a/src/test/frontend/transcript.test.js b/src/test/frontend/transcript.test.js index 4462f6f2..04bfddbe 100644 --- a/src/test/frontend/transcript.test.js +++ b/src/test/frontend/transcript.test.js @@ -50,6 +50,33 @@ describe('transcript — assistant Markdown + code blocks', () => { copy.click(); // delegated document handler resolves the sibling text expect(sent.some((m) => m.type === 'copy' && /hello world/.test(m.text))).toBe(true); }); + + // A Copy button that copies and says nothing is reported as a broken Copy button — it happened. The + // message-level buttons carry their own click handler, so they never reach the delegated code-head path + // that flashes; both must confirm, and via the SAME helper so the wording cannot drift apart. + it('every Copy affordance confirms with "Copied", message-level ones included', () => { + const win = loadFrontend(['app-transcript.js']); + win.CC.send = () => {}; + win.cc.batch([row(4, 0, 'ASSISTANT', 'plain answer with no code')]); + + const msgCopy = win.document.querySelector('.msg-head .copy'); + expect(msgCopy).not.toBeNull(); + expect(msgCopy.textContent).toBe('Copy'); + msgCopy.click(); + expect(msgCopy.textContent).toBe('Copied'); + expect(msgCopy.classList.contains('copied')).toBe(true); + }); + + it('the user message Copy confirms too', () => { + const win = loadFrontend(['app-transcript.js']); + win.CC.send = () => {}; + win.cc.batch([row(5, 0, 'USER', 'a prompt I typed')]); + + const userCopy = win.document.querySelector('.msg.user .copy'); + expect(userCopy).not.toBeNull(); + userCopy.click(); + expect(userCopy.textContent).toBe('Copied'); + }); }); describe('transcript — inline diff colouring', () => { diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt index 7fdf8ca8..dbcd5eb8 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt @@ -135,6 +135,34 @@ class ProtocolParserTest { assertTrue(event.isError) } + /** + * The CLI wraps a failed tool call's `content` in `…`. Verified against + * `claude` 2.1.222, which emits exactly: + * `content: "Error: …", is_error: true` + * and carries the same message UNWRAPPED in the sibling `toolUseResult` field — i.e. the tag is framing for + * the model, not text for a human. Rendered verbatim it put raw markup in a native GUI's transcript, which + * is the "never mirror raw CLI output" antipattern. `is_error` already conveys the failure structurally. + */ + @Test + fun `tool_use_error wrapper is stripped from the rendered text`() { + val inner = "Error: No such tool available: Glob. Glob is not available in this session." + val line = """{"type":"user","message":{"role":"user","content":[{"type":"tool_result",""" + + """"tool_use_id":"t9","content":"$inner","is_error":true}]}}""" + val event = parseOne(line) + assertEquals(inner, event.content) + assertTrue(event.isError) // the failure survives structurally, which is what reddens the card + } + + @Test + fun `a tool result that merely mentions the tag is left intact`() { + // Only a wrapper enclosing the WHOLE payload is stripped — otherwise output that legitimately discusses + // the tag (this project's own source and tests do) would be silently mangled. + val body = "see in the CLI bundle" + val line = """{"type":"user","message":{"role":"user","content":[{"type":"tool_result",""" + + """"tool_use_id":"t10","content":"$body","is_error":false}]}}""" + assertEquals(body, parseOne(line).content) + } + // --- stream events (partial messages) --- @Test diff --git a/src/test/kotlin/dev/lain/claudejb/ui/InitOrderContractTest.kt b/src/test/kotlin/dev/lain/claudejb/ui/InitOrderContractTest.kt new file mode 100644 index 00000000..b0650189 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/ui/InitOrderContractTest.kt @@ -0,0 +1,68 @@ +package dev.lain.claudejb.ui + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * A class-body property must be declared BEFORE the `init` block that can reach it. + * + * REGRESSION THIS PINS (5.0.0): `JcefChatPanel.pendingUntilReady` was declared ~90 lines below the `init` block + * that calls [dev.lain.claudejb.ui.JcefChatPanel] `whenReady` three times. Kotlin runs property initializers and + * `init` blocks in declaration order, so the list was still null while `init` ran: + * + * ``` + * java.lang.NullPointerException: Cannot invoke "java.util.Collection.add(Object)" + * because "this.pendingUntilReady" is null + * at JcefChatPanel.whenReady(JcefChatPanel.kt:173) + * at JcefChatPanel.(JcefChatPanel.kt:91) + * ``` + * + * It threw inside the constructor, so it took the whole tab with it: no chat could be opened or restored — the + * plugin was unusable, not degraded. The compiler does NOT catch this: it reports a direct reference in an + * initializer, but the read here happens inside a *function* called from `init`, which it cannot see through. + * + * The nullable and primitive fields nearby (`lastUsage`, `lastUsageAt`) had the same defect and stayed silent — + * they read as null/0 instead of throwing — which is why this is a source contract rather than a note in a + * review: the loud version of the bug is the lucky one. + * + * Deliberately a source scan and not a runtime test. Constructing a [dev.lain.claudejb.ui.JcefChatPanel] needs a + * live IDE and a JCEF browser, which is exactly why `ui/` is excluded from coverage and why nothing caught this. + * Reading the file costs nothing and covers the whole class of defect. + * + * The 4-space indent is what scopes this to top-level class bodies: a nested `init` (an anonymous + * `object : JComponent()`, as in `ChatTheme.avatarLabel()`) sits deeper and is correctly ignored — its + * enclosing properties are not initialised by it. + */ +class InitOrderContractTest { + + private val classBodyInit = Regex("""^ {4}init \{""") + private val classBodyProperty = Regex("""^ {4}(?:private |internal |protected )?(?:val|var) """) + + @Test + fun `no class-body property is declared after the init block that could use it`() { + val offenders = mutableListOf() + + sourceRoot().walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file -> + val lines = file.readLines() + val initAt = lines.indexOfFirst { classBodyInit.containsMatchIn(it) } + if (initAt < 0) return@forEach + lines.drop(initAt + 1).forEachIndexed { offset, line -> + if (classBodyProperty.containsMatchIn(line)) { + offenders += "${file.name}:${initAt + offset + 2}: ${line.trim()}" + } + } + } + + assertTrue(offenders.isEmpty()) { + "These properties are declared AFTER their class's init block, so they are still null/0 while it " + + "runs. Move them above `init`.\n" + offenders.joinToString("\n") + } + } + + /** Resolves `src/main/kotlin` whether the test runs from the module dir or the repo root. */ + private fun sourceRoot(): File = + sequenceOf(File("src/main/kotlin"), File("../src/main/kotlin")) + .firstOrNull { it.isDirectory } + ?: error("could not locate src/main/kotlin from ${File("").absolutePath}") +} diff --git a/src/test/kotlin/dev/lain/claudejb/ui/LinkGateTest.kt b/src/test/kotlin/dev/lain/claudejb/ui/LinkGateTest.kt index 5f2eab9d..b2b90ccc 100644 --- a/src/test/kotlin/dev/lain/claudejb/ui/LinkGateTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/ui/LinkGateTest.kt @@ -147,4 +147,34 @@ class LinkGateTest { assertTrue(LinkResolver.scanForNames(null, listOf("x.kt" to null)).isEmpty()) assertTrue(LinkResolver.scanForNames(tmp.toFile().path, emptyList()).isEmpty()) } + + /** + * A markdown link whose href is a path — `[BACKLOG](docs/BACKLOG.md)` — used to do NOTHING when clicked: the + * host handled `https://` and `jb://open` and dropped everything else without a sound. Bare paths in prose + * already worked, so the more deliberate the link, the less it did. + */ + @Test + fun `a path href is recognised, a URL href is not`() { + assertTrue(LinkResolver.isFilePathHref("docs/BACKLOG.md")) + assertTrue(LinkResolver.isFilePathHref("/etc/hosts")) // recognised here; isOpenable is what refuses it + assertTrue(LinkResolver.isFilePathHref("~/notes.md")) + assertTrue(LinkResolver.isFilePathHref("src/main/kotlin/A.kt")) + + assertFalse(LinkResolver.isFilePathHref("https://example.com")) + assertFalse(LinkResolver.isFilePathHref("jb://open?file=x")) + // The other schemes DOMPurify allows must keep falling through untouched rather than being opened as files. + assertFalse(LinkResolver.isFilePathHref("mailto:someone@example.invalid")) + assertFalse(LinkResolver.isFilePathHref("tel:+34000000000")) + assertFalse(LinkResolver.isFilePathHref("sms:+34000000000")) + assertFalse(LinkResolver.isFilePathHref("data:image/png;base64,AAAA")) + } + + @Test + fun `a Windows drive letter is a path, not a scheme`() { + // The plugin ships on Windows. A single letter before the colon is a drive, so requiring two-or-more + // characters is what keeps `C:\src\main.kt` from being mistaken for a URI scheme and silently ignored. + assertTrue(LinkResolver.isFilePathHref("""C:\src\main.kt""")) + assertTrue(LinkResolver.isFilePathHref("D:/work/notes.md")) + assertFalse(LinkResolver.isFilePathHref("")) // nothing to open + } } From 1d633a6ef8819682c0c96e5e46ae0c79e1d558f2 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 00:27:14 +0200 Subject: [PATCH 12/29] build: keep checkDrift out of the coverage graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures, one of them ours. OURS — the `Static analysis` job died with an IOException from DriftLiveCheck. Kover instruments and aggregates EVERY `Test` task in the project, and `checkDrift` is registered as one, so it had silently become a dependency of `koverVerify`. That task is on-demand by design: it downloads the latest SDK and probes a `claude` binary installed on the machine. A runner has no such binary. The task's own documentation already said "NOT wired into `check`" — it just was not true of the coverage graph, and nothing checked that it was. It passed locally for the one reason that makes this class of bug expensive: the maintainer's machine has the binary, so "on-demand" and "wired in" looked identical until CI ran it. Verified with `koverVerify --dry-run`, which listed `:checkDrift` in the graph before and does not after. NOT OURS — the `JVM tests` job failed resolving com.jetbrains.intellij.platform:test-framework with a 502 Bad Gateway from cache-redirector.jetbrains.com. Nothing to fix; it needs a re-run. Also bumps the IntelliJ Platform Gradle Plugin 2.16.0 -> 2.18.1, which the build had been warning about on every run. Stated plainly: the plugin bump is NOT verified locally. A 2.16 -> 2.18 jump touches the whole build, and CI is the verification here rather than a claim made in advance. --- build.gradle.kts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index ee66d59f..9a71cca0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.intellij.platform.gradle.models.ProductRelease plugins { kotlin("jvm") version "2.1.20" kotlin("plugin.serialization") version "2.1.20" - id("org.jetbrains.intellij.platform") version "2.16.0" + id("org.jetbrains.intellij.platform") version "2.18.1" // Coverage, gated per package — see the `kover { }` block near the bottom for the thresholds and why they // differ by package. (Until 5.0.0 this comment claimed a "≥90% target documented in // docs/RELEASE_CHECKLIST.md". That document says nothing about coverage, and the real figure was 53%. A @@ -407,6 +407,21 @@ tasks.withType().configure // what is uncovered there cannot run in CI. That is a known gap, not an endorsement. // --------------------------------------------------------------------------- kover { + // `checkDrift` must NOT be dragged into the coverage graph. + // + // Kover instruments and aggregates EVERY `Test` task in the project, and `checkDrift` is registered as one. + // That silently made it a dependency of `koverVerify`, so the `Static analysis` CI job ran the on-demand + // drift check — which downloads the latest SDK and probes a LOCALLY INSTALLED `claude` binary. There is no + // such binary on a runner, so it died with an IOException and failed the job. + // + // It passed locally, which is the whole lesson: the maintainer's machine has the binary, so the difference + // between "this task is on-demand" and "this task is wired into check" was invisible until CI ran it. The + // task's own KDoc already said "NOT wired into `check`" — it just was not true of the coverage graph. + currentProject { + instrumentation { + disabledForTestTasks.add("checkDrift") + } + } reports { filters { excludes { From 4a86da6f9e8cd146d0796c9865658b8f3c21a462 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 00:31:11 +0200 Subject: [PATCH 13/29] build: revert the platform plugin bump, it hangs the headless suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the 2.16.0 -> 2.18.1 half of the previous commit. The `checkDrift` coverage-graph fix in that commit stands; only the plugin version goes back. On 2.18.1 the headless suite never runs. `ChatSessionManagerHeadlessTest` hangs before its first assertion, and a thread dump puts the EDT here: BasePlatformTestCase.setUp -> LightPlatformTestCase.doSetup -> IndexingTestUtil.waitUntilIndexesAreReady (267s and counting) That is the platform's own fixture waiting for indexing that never completes. Our code is not on the stack at all — the bump changes which platform test-framework is resolved, so this is a fixture-level regression rather than something fixable from this side. Measured both ways rather than inferred: the same test on 2.16.0 finishes in 19 seconds, and the full gate is green at 694 tests. The version is now pinned with the reason written AT the setting, because the build prints an "outdated" warning on every single run and the next person to see it will otherwise do exactly what I did. Re-attempt it as its own change with the headless suite as the acceptance test, not as a drive-by on a release branch. I shipped that bump unverified and said so in the message; this is what it cost. The one-line lesson is the boring one: a build-tooling bump is a change like any other and does not get to skip the suite because it looks like configuration. --- build.gradle.kts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9a71cca0..ac6c5f7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,14 @@ import org.jetbrains.intellij.platform.gradle.models.ProductRelease plugins { kotlin("jvm") version "2.1.20" kotlin("plugin.serialization") version "2.1.20" - id("org.jetbrains.intellij.platform") version "2.18.1" + // PINNED AT 2.16.0 DELIBERATELY. 2.18.1 exists and the build warns about it on every run, but bumping it + // hangs the headless suite: `ChatSessionManagerHeadlessTest` never starts, because + // BasePlatformTestCase.setUp → LightPlatformTestCase.doSetup → IndexingTestUtil.waitUntilIndexesAreReady + // waits forever (confirmed by thread dump — the EDT sits in that frame; our code is never reached). The + // bump changes which platform test-framework is resolved, so this is a fixture-level regression, not ours + // to fix from here. Re-attempt as its own change, with the headless suite as the acceptance test — NOT as + // a drive-by inside a release branch, which is exactly how it got in and straight back out. + id("org.jetbrains.intellij.platform") version "2.16.0" // Coverage, gated per package — see the `kover { }` block near the bottom for the thresholds and why they // differ by package. (Until 5.0.0 this comment claimed a "≥90% target documented in // docs/RELEASE_CHECKLIST.md". That document says nothing about coverage, and the real figure was 53%. A From b5f97ab2dd4bdb36f38e02f83e511f01e256b7f5 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 00:59:48 +0200 Subject: [PATCH 14/29] feat(ci): release on merge to main, version read from the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging `develop` into `main` now publishes. The tag is DERIVED from the version in build.gradle.kts rather than supplied alongside it, so the two can no longer disagree — the mismatch the old flow guarded against with a comparison simply cannot occur. A merge that does not bump the version publishes nothing: the workflow finds the tag present, logs a notice and stops. It does not fail. `main` legitimately receives merges that are not releases, and a red run on each of those is an alarm people learn to ignore. Published tags stay immutable, which is the correction ADR 0001 records after v4.3.2 and v4.4.1 were each force-re-cut three times. Pushing a tag by hand still works, as the escape hatch for re-cutting after a failed publish without an empty commit on main. A tag this workflow creates does not re-trigger it, so there is no loop. The tag is cut AFTER the approval and the publish, not in the guard. Created earlier it would name a version that was never published when a build fails or an approval is declined — and since tags here are immutable, that would block the next attempt. Cutting it last makes it mean "this was published", which is the only claim it can honestly make once the version, not the tag, is the input. WHAT THIS COSTS, STATED RATHER THAN GLOSSED The tag is signed by the CI key, not the maintainer's YubiKey — which cannot sign inside a runner, and whose non-exportability is exactly what makes it worth trusting. The chain still ends in hardware because the CI key is certified by it. But no signature on a release now asserts that a person authorised it. That claim moves entirely to the two gates around publication: `main` accepts only reviewed pull requests, and publishing requires an approval from a named reviewer on a protected environment. BRANCHING.md and SECURITY.md said the opposite and are corrected — a verification instruction that overstates what it proves is worse than none, because someone acts on it. ADR 0001 still describes the tag-triggered flow and needs superseding. --- .github/workflows/release.yml | 114 ++++++++++++++++++++++++++++------ SECURITY.md | 16 ++++- docs/BRANCHING.md | 31 +++++++-- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae6d61b..2706eb23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,12 @@ name: Release on: push: + # Primary path: a merge into `main` releases whatever version build.gradle.kts declares. The version in + # the code is the single source of truth — the tag is derived from it, so the two can no longer disagree. + branches: [main] + # Kept as the manual escape hatch: an explicit tag still releases. Useful to re-cut after a failed + # publish without pushing an empty commit to main. A tag created BY this workflow does not re-trigger it + # (GitHub deliberately does not fire workflows for GITHUB_TOKEN-pushed refs), so there is no loop. tags: ['v[0-9]+.[0-9]+.[0-9]+'] permissions: @@ -34,16 +40,23 @@ env: jobs: # Gate 2, on its own so it fails in seconds and before any secret is in scope. guard: - name: Tag must come from main + name: Decide the version and check lineage runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + release: ${{ steps.resolve.outputs.release }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # need the graph, not just the tagged commit + fetch-depth: 0 # need the graph and the tags, not just this commit persist-credentials: false - - name: Assert the tagged commit is on main + # Lineage. On a tag push this is the load-bearing gate: without it, anyone who can push a tag can + # publish from any code. On a main push it is trivially true, and checked anyway rather than assumed — + # the cost is one command and the failure mode it guards against is publishing unreviewed code. + - name: Assert this commit is on main run: | git fetch --no-tags origin main:refs/remotes/origin/main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then @@ -51,17 +64,36 @@ jobs: echo "Releases are cut from main only, and main only accepts reviewed PRs from develop." exit 1 fi - echo "$GITHUB_REF_NAME is on main — lineage OK." + echo "lineage OK — $GITHUB_SHA is reachable from main." - # The tag says which version this is; build.gradle.kts says which version gets built. If they - # disagree, the artifact would be published under a number nobody chose. Cheap check, real bug. - - name: Assert the tag matches the built version + # build.gradle.kts is the SINGLE SOURCE OF TRUTH for the version. On a main push the tag is derived + # from it; on a tag push the two must agree. Either way a release can never be published under a + # number nobody chose. + - name: Resolve the version and decide whether to release + id: resolve run: | declared=$(grep -m1 '^version = ' build.gradle.kts | sed 's/.*"\(.*\)".*/\1/') - expected="${GITHUB_REF_NAME#v}" - [ "$declared" = "$expected" ] || { - echo "::error::tag $GITHUB_REF_NAME does not match build.gradle.kts version $declared"; exit 1; } - echo "version $declared matches the tag." + [ -n "$declared" ] || { echo "::error::could not read version from build.gradle.kts"; exit 1; } + tag="v${declared}" + + if [ "$GITHUB_REF_TYPE" = "tag" ]; then + [ "$GITHUB_REF_NAME" = "$tag" ] || { + echo "::error::tag $GITHUB_REF_NAME does not match build.gradle.kts version $declared"; exit 1; } + fi + + # An existing tag means this version is already released. Do NOT publish again, and do NOT fail: + # main legitimately receives merges that are not releases (a docs fix, a reverted change), and a + # red run on every one of those is an alarm people learn to ignore. Published tags stay immutable + # — that is the correction ADR 0001 records, after v4.3.2 and v4.4.1 were each force-re-cut. + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "::notice::$tag already exists — nothing to release. Bump the version in build.gradle.kts to cut a new one." + else + echo "release=true" >> "$GITHUB_OUTPUT" + echo "::notice::will release $tag from $GITHUB_SHA" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$declared" >> "$GITHUB_OUTPUT" # Full gate again on the exact tagged tree. CI already ran on the branch, but a release must be # verified against what is actually being shipped, not against what was on develop last week. @@ -70,6 +102,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 needs: [guard] + if: needs.guard.outputs.release == 'true' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -119,7 +152,8 @@ jobs: name: Build, sign and publish runs-on: ubuntu-latest timeout-minutes: 45 - needs: [verify] + needs: [guard, verify] + if: needs.guard.outputs.release == 'true' environment: name: marketplace url: https://plugins.jetbrains.com/plugin/31965-claude-code-native @@ -170,7 +204,7 @@ jobs: exit 1 fi mkdir -p dist - name="claude-code-native-${GITHUB_REF_NAME#v}.zip" + name="claude-code-native-${{ needs.guard.outputs.version }}.zip" cp "$signed" "dist/$name" echo "name=$name" >> "$GITHUB_OUTPUT" echo "published $name sha256=$(sha256sum "dist/$name" | cut -d' ' -f1)" @@ -204,6 +238,43 @@ jobs: # Check our own output before it leaves the runner: a signature nobody verified is just a file. for f in "$NAME" "$NAME.sha256"; do gpg --verify "$f.asc" "$f"; done sha256sum -c "$NAME.sha256" + + # --- Cut the tag, signed, AFTER the release was approved and actually published ------------- + # + # Deliberately last, and deliberately not in `guard`. Creating it earlier would mean a tag exists for + # a version that was never published (a failed build, a declined approval), and published tags are + # immutable here — so the next attempt would be blocked by a tag naming a release that does not exist. + # Cutting it here makes the tag mean "this was published", which is the only claim it can honestly make + # when the version, not the tag, is the input. + # + # Signed with the CI key, NOT the maintainer's YubiKey — which cannot sign inside a runner, and whose + # non-exportability is exactly what makes it worth trusting. The chain still terminates in hardware + # because the CI key is certified by it. The claims therefore shift, and SECURITY.md says so: the tag + # now attests "this workflow published these bytes", and the human authorisation lives in the two gates + # that remain — the reviewed PR into main, and the required approval on the `marketplace` environment. + - name: Create and sign the release tag + if: github.ref_type != 'tag' + env: + GPG_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }} + TAG: ${{ needs.guard.outputs.tag }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # git cannot pass gpg the loopback flags it needs in a headless runner, so it gets a wrapper that + # supplies them. The passphrase travels in the environment, never in argv, where `ps` would see it. + printf '#!/bin/sh\nexec gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" "$@"\n' \ + > /tmp/gpg-loopback + chmod +x /tmp/gpg-loopback + + # A bot identity, not a person: this tag is not a human's assertion and must not look like one. + # The noreply address is required by git and is not anyone's mailbox. + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git config gpg.program /tmp/gpg-loopback + git config user.signingkey "$GPG_FPR" + + git tag -s "$TAG" -m "Release $TAG — published by the release workflow from $GITHUB_SHA" + git verify-tag "$TAG" # never push a signature we have not checked ourselves + git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" ls -la # --- GitHub Release ------------------------------------------------------------------------- @@ -221,18 +292,21 @@ jobs: --- - **Verifying this release.** The `.asc` files are signed by the project's **CI signing key** - (`docs/ci-signing-key.asc`), which is itself certified by the maintainer's hardware key — so the - chain terminates in a key that has never been on a computer. The tag is signed by that hardware - key directly. Check both; they claim different things: + **Verifying this release.** Both the `.asc` files and the tag are signed by the project's **CI + signing key** (`docs/ci-signing-key.asc`), which is itself certified by the maintainer's hardware + key — so the chain terminates in a key that has never been on a computer. + + What the signatures do NOT assert is that a human pressed a button: the release is cut + automatically from `main`. That claim rests on the two gates around it — `main` accepts only + reviewed pull requests, and publication requires an approval on a protected environment. ```sh gpg --import docs/ci-signing-key.asc gpg --verify claude-code-native-*.zip.asc # these bytes came from this workflow - git verify-tag # a person authorised this release + git verify-tag # this workflow cut this release from main ``` EOF - gh release create "$GITHUB_REF_NAME" dist/* \ - --title "$GITHUB_REF_NAME" \ + gh release create "${{ needs.guard.outputs.tag }}" dist/* \ + --title "${{ needs.guard.outputs.tag }}" \ --notes-file /tmp/notes.md \ --verify-tag diff --git a/SECURITY.md b/SECURITY.md index 48884b19..4ab4f97a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -268,17 +268,27 @@ gpg --check-sigs "$(gpg --show-keys --with-colons docs/ci-signing-key.asc | awk ``` **Verify both signatures.** They are complementary, not redundant — the artifact -signature alone cannot tell you a human intended the release, and the tag -signature alone says nothing about the bytes you downloaded: +signature covers the bytes you downloaded, and the tag ties those bytes to a +commit on `main`: ```sh gpg --import docs/ci-signing-key.asc gpg --verify claude-code-native-X.Y.Z.zip.asc # bytes came from the workflow -git verify-tag vX.Y.Z # a person authorised the release +git verify-tag vX.Y.Z # cut from main by that workflow gh attestation verify claude-code-native-X.Y.Z.zip \ --repo serialexperimentslainnnn/claude-code-for-jetbrains # build provenance ``` +**What no signature here claims.** Releases are cut automatically when `develop` +is merged into `main`, and both the tag and the artifact are signed by the CI +key — which is certified by the maintainer's hardware key, so the chain still +ends in hardware, but which signs without a human present. **Nothing in a +release attests that a person authorised it.** That rests on the two gates +around publication: `main` accepts only reviewed pull requests, and publishing +requires an approval from a named reviewer on a protected environment. Read +`git verify-tag` as *"this workflow cut this from main"*, and treat the human +judgement as living in the pull request, not in the signature. + The attestation is worth having and worth not overtrusting: it proves *where* a build ran, not that the result is benign. A compromised runner can produce a valid attestation for a malicious artifact. What actually reduces that risk is diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 874d8edd..5c64a7d8 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -32,13 +32,32 @@ Naming: `feature/`, e.g. `feature/hunk-selection`, `bugfix/ 1. Land everything for the version on `develop`; bump `version` in `build.gradle.kts` and add the section to `RELEASE_NOTES.md` / `CHANGELOG.md`. 2. Merge `develop` → `main` via PR. `main` is protected: the CI checks must be green and the PR approved. -3. Tag the merge commit `vX.Y.Z` and push the tag. `release.yml` then verifies the tag came from `main`, - re-runs the full gate on the tagged tree, builds and attests, and waits on the `marketplace` environment - approval before `signPlugin publishPlugin`. +3. **That is the whole procedure.** The merge triggers `release.yml`, which reads the version from + `build.gradle.kts`, re-runs the full gate on the merged tree, builds and attests, waits on the + `marketplace` environment approval, publishes, and only then cuts and signs the `vX.Y.Z` tag. -> A tag pushed from anywhere other than `main` is rejected by the workflow's first job, before any -> credential is in scope. That is the mechanism that makes "release only via PR into main" true rather -> than merely intended. +**`build.gradle.kts` is the single source of truth for the version.** The tag is derived from it rather than +supplied alongside it, so the two can no longer disagree — the failure mode the old flow guarded against with +a comparison simply cannot occur now. + +**A merge to `main` that does not bump the version publishes nothing.** The workflow finds the tag already +present, logs a notice and stops. It does not fail: `main` legitimately receives merges that are not releases, +and a red run on each of those is an alarm people learn to ignore. Published tags remain immutable. + +> Pushing a `vX.Y.Z` tag by hand still works and is kept as the escape hatch — re-cutting after a failed +> publish, without pushing an empty commit to `main`. On that path the tag must match the declared version, +> and its commit must be reachable from `main`, both checked before any credential is in scope. + +### What the signatures claim, now that the tag is automatic + +The tag is cut by the workflow and signed with the **CI key**, not the maintainer's YubiKey — which cannot +sign inside a runner, and whose non-exportability is precisely what makes it worth trusting. The chain still +terminates in hardware, because the CI key is certified by the YubiKey. + +The cost is stated rather than glossed: **no signature on a release asserts that a person authorised it.** That +claim now rests entirely on the two gates around the publish — `main` accepts only reviewed pull requests, and +publication requires an approval on the `marketplace` environment by a named reviewer. Anyone verifying a +release should read `git verify-tag` as *"this workflow cut this from main"*, not as *"a human signed off"*. ## Cleaning up obsolete branches From 9ad75f562c5a604f580c9f968732264c424e2b66 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:00:11 +0200 Subject: [PATCH 15/29] ci: enforce the zero-deprecation rule and stop duplicating work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRICTER — the policy is now a gate rather than a promise CLAUDE.md says "never ship a deprecated or scheduled-for-removal API — treat it as a blocker, not a warning". Nothing enforced it. The verifier's default failure level is COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES + OVERRIDE_ONLY_API_USAGES, so a deprecated usage was reported and the job went green anyway. A rule that lives only in prose is not a rule; DEPRECATED_API_USAGES is what makes the sentence true. Verified green today, so it lands with no debt to forgive. EXPERIMENTAL_API_USAGES is deliberately excluded, and that is a decision rather than an oversight: DiffTabCleanup uses ProjectCloseListener.projectClosingBeforeSave knowingly, because it is the only hook that runs before the workspace is written. An experimental API is acceptable with a reason. A deprecated one is not, because it has an announced removal and this plugin must keep working across 251 → 262. LEANER — four measured duplications, none of them a weakened gate - Every commit ran the pipeline TWICE. `push` on topic branches and `pull_request` both fire, and the concurrency group keyed on `github.ref` differs between them (refs/heads/x vs refs/pull/N/merge), so neither cancelled the other. Keyed on the commit now: same SHA, same group, duplicate cancelled. - Superseded runs were only cancelled for pull requests. Three pushes in a row left three full pipelines racing, each spending ten minutes downloading IDEs for a commit that had already been replaced. - The JVM suite ran twice. `koverVerify` depends on `:test`, so putting it in the `Static analysis` job re-ran the entire suite on a second runner with a cold cache. Coverage is a property OF a test run and now shares its job. - The plugin was built twice. `verifyPlugin` already produces the distributable; `Build plugin` built its own. That was not only wasteful but subtly wrong — the bytes being asserted were never the bytes that were verified. It now downloads the verified artifact. Job DISPLAY NAMES are unchanged, deliberately: a ruleset references a required check by its name, so renaming one does not fail the gate — it silently stops applying it. No rulesets need reapplying. Also gives drift.yml the concurrency group it was missing (queue, do not cancel: a half-written drift report is worse than a late one). Caught while writing this: the SHA I pinned actions/download-artifact to was invented. Verified against the API and corrected. Pinning by SHA protects nothing if the SHA is made up. --- .github/workflows/ci.yml | 66 +++++++++++++++++++++++-------------- .github/workflows/drift.yml | 7 ++++ build.gradle.kts | 21 ++++++++++++ 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc51c5c3..71b08c6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,14 @@ permissions: # One run per ref. Superseded PR runs are cancelled — but pushes to develop/main are NOT, so the history # of what passed on the trunk stays complete. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Keyed on the COMMIT, not the ref. A branch with an open PR fires both `push` and `pull_request` for the + # same commit, and `github.ref` differs between them (refs/heads/x vs refs/pull/N/merge) — so the old group + # let both run to completion, doubling every job on every push for no extra information. Same SHA now means + # same group, so the duplicate is cancelled and whichever run survives reports the checks. + group: ci-${{ github.event.pull_request.head.sha || github.sha }} + # Cancel superseded runs on EVERY event, not just PRs. Pushing three times in a row previously left three + # full pipelines racing, each spending ten minutes downloading IDEs for a commit already replaced. + cancel-in-progress: true env: # No daemon: a fresh JVM per job is the honest measurement on ephemeral runners, and a leaked daemon @@ -65,8 +71,12 @@ jobs: # the next build on develop reads back. cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} - - name: Run tests - run: ./gradlew --no-daemon --stacktrace test + # Coverage is verified HERE, in the same job and the same Gradle invocation as the tests. + # `koverVerify` depends on `:test`, so running it in the separate `Static analysis` job re-ran the whole + # JVM suite on a second runner with a cold cache — the single most expensive duplicate in this pipeline, + # and invisible because both jobs were green. Coverage is a property OF a test run; it belongs with it. + - name: Run tests and verify coverage gates + run: ./gradlew --no-daemon --stacktrace test koverVerify - name: Upload test reports if: always() @@ -118,10 +128,9 @@ jobs: - name: Formatting (Spotless / ktlint) run: ./gradlew --no-daemon --stacktrace spotlessCheck - # Per-package coverage gates. See docs/RELEASE_CHECKLIST.md §Coverage policy for the thresholds, - # what is excluded and why, and the Kover limitation behind the current floor+aggregate shape. - - name: Coverage gates - run: ./gradlew --no-daemon --stacktrace koverVerify + # NB the per-package coverage gates (`koverVerify`) are NOT run here. They live in the `JVM tests` job, + # because Kover derives coverage from an actual test run: invoking it here re-executed the entire JVM + # suite on this runner. See docs/RELEASE_CHECKLIST.md §Coverage policy for the thresholds themselves. # The shipped JCEF JavaScript. no-eval / no-implied-eval / no-new-func are errors here because the # page runs under a hash-pinned CSP with no 'unsafe-eval': without this gate, code the browser will @@ -234,6 +243,18 @@ jobs: - name: Verify plugin run: ./gradlew --no-daemon --stacktrace verifyPlugin + # `verifyPlugin` depends on `buildPlugin`, so the distributable already exists here. Hand it to the + # `Build plugin` job instead of letting it build a second time on a fresh runner: that job asserts + # properties OF the artifact, and asserting them on a DIFFERENT build than the one just verified was + # both wasteful and subtly wrong — the bytes checked were never the bytes verified. + - name: Hand the built distributable to the assertions job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verified-distribution + path: build/distributions/*.zip + retention-days: 1 + if-no-files-found: error + - name: Upload verifier report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -242,29 +263,24 @@ jobs: path: build/reports/pluginVerifier/ retention-days: 30 - # The distributable. Unsigned and unpublished here by design: signing and publishing happen only from - # a tag, in release.yml, behind a human approval. This job proves the artifact BUILDS on every change. + # Properties of the DISTRIBUTABLE, asserted on the exact artifact the verifier just checked. + # + # It no longer builds its own: `verifyPlugin` already produced one, and rebuilding meant these assertions + # ran against bytes that were never verified — a second build on a fresh runner is not guaranteed to be + # the same artifact. Downloading it also drops a full Gradle setup, JDK provision and compile from the + # critical path. Unsigned and unpublished by design: signing and publishing happen only in release.yml, + # behind a human approval. build: name: Build plugin runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 10 needs: [verify] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Fetch the verified distributable + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - persist-credentials: false - - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} - - - name: Build plugin - run: ./gradlew --no-daemon --stacktrace buildPlugin + name: verified-distribution + path: build/distributions # A claim SECURITY.md makes to users, enforced here rather than trusted: the published artifact # contains no npm code. If this ever fails, either the packaging changed or the claim was false. diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 3aeea02b..1c45b941 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -20,6 +20,13 @@ on: permissions: contents: read +# Weekly, so overlap is unlikely — but a manual dispatch during a scheduled run would have two of these +# racing to file the same issue. Queued rather than cancelled: a drift report half-written is worse than +# one that starts a few minutes late. +concurrency: + group: drift + cancel-in-progress: false + jobs: drift: name: Check protocol drift diff --git a/build.gradle.kts b/build.gradle.kts index ac6c5f7b..e3a0b903 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform import org.jetbrains.intellij.platform.gradle.models.ProductRelease +import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask plugins { kotlin("jvm") version "2.1.20" @@ -311,6 +312,26 @@ intellijPlatform { // 'JetBrains' in the plugin name is a Marketplace naming lint, not an API problem; muting it lets // the verifier proceed to the actual binary-compatibility / internal-API checks we care about. freeArgs = listOf("-mute", "TemplateWordInPluginName") + + // The "zero deprecations" rule, ENFORCED rather than merely written down. + // + // The plugin's default failure level is COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES + + // OVERRIDE_ONLY_API_USAGES — deprecated usages are only REPORTED. So this repo's stated policy + // ("never ship a deprecated or scheduled-for-removal API — treat it as a blocker, not a warning") + // was a promise a human had to keep by reading logs, and a rule that lives only in prose is not a + // rule. Adding DEPRECATED_API_USAGES is what makes the sentence true. + // + // EXPERIMENTAL_API_USAGES is deliberately NOT here, and that is a decision rather than an oversight: + // `DiffTabCleanup` uses `ProjectCloseListener.projectClosingBeforeSave` knowingly, because it is the + // only hook that runs BEFORE the workspace state is written — which is the whole point of it. An + // experimental API is acceptable with a reason; a deprecated one is not acceptable at all, because + // it has an announced removal date and the plugin has to keep working across the IDE range. + failureLevel = listOf( + VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, + VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES, + VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, + VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, + ) ides { // No hardcoded path in the repo: a developer can point the verifier at local IDE installs to skip the // downloads, via -PlocalIdePath=[,…] or the LOCAL_IDE_PATH env var (comma-separated). This is From 79aee8b804bba7fbfa21023c4cf5738f5529eabb Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:02:34 +0200 Subject: [PATCH 16/29] build(deps): group Dependabot updates instead of one PR per bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine open Dependabot PRs, each firing the full pipeline — verifier included, ten minutes and 1.25 GB of IDE downloads apiece — for dependency bumps that are reviewed in seconds. Five of them were SECURITY updates (undici, ip-address, fast-uri, hono, postcss: exactly the `npm audit` findings). Two things about that stream were not understood when this file was written, and both are documented at the setting now: - security updates ignore `open-pull-requests-limit` entirely, which is how five arrived under a limit of three; - the existing group did not cover them, because `applies-to` defaults to version-updates. So each ecosystem now has an explicit `applies-to: security-updates` group. These are transitive devDependencies that are never distributed — `npm audit --omit=dev` reports 0, and the artifact contains zero node_modules entries — so reviewing them one at a time bought nothing. github-actions and gradle had no grouping at all. Actions are pinned by full commit SHA, so a bump is a one-line change per action; grouping them costs no review fidelity. Gradle groups minor and patch only: a MAJOR keeps its own PR deliberately, because that is the ecosystem where a bump can hang the headless suite — the 2.16 -> 2.18 platform-plugin attempt did exactly that, and it deserved its own run and its own decision. Syntax verified against GitHub's Dependabot options reference rather than written from memory, after inventing an action SHA earlier today. --- .github/dependabot.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 43188167..d8e86ac2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,6 +17,14 @@ updates: prefix: build # Conventional Commits — the commit-msg hook and the changelog both depend on it include: scope labels: [dependencies, ci] + groups: + # Every action here is pinned by full commit SHA, so a bump is a one-line SHA change per action and + # reviewing them one PR at a time buys nothing but pipeline runs. + actions: + patterns: ['*'] + security: + applies-to: security-updates + patterns: ['*'] # Build tooling only: vitest/jsdom for the frontend tests, commitlint for the commit gate, and the # Agent SDK as protocol reference. None of it ships (see SECURITY.md), which is exactly why the @@ -35,6 +43,14 @@ updates: dev-tooling: patterns: ['*'] update-types: [minor, patch] + # Security updates are a SEPARATE stream: they ignore `open-pull-requests-limit` entirely, and the + # group above does not cover them because `applies-to` defaults to version-updates. That is how five + # landed at once despite a limit of three — each firing a full pipeline, verifier included, for + # transitive devDependencies that are never distributed (`npm audit --omit=dev` reports 0). + # One PR for the lot: same review, one CI run. + security: + applies-to: security-updates + patterns: ['*'] ignore: # The SDK baseline is not Dependabot's to move. `checkDrift` bumps it as part of a *reconciled* # protocol review — taking the new version without reading the surface diff is how a protocol gap @@ -51,6 +67,16 @@ updates: prefix: build include: scope labels: [dependencies] + groups: + # Patch and minor bumps of build tooling reviewed together. A MAJOR stays on its own PR on purpose: + # this ecosystem is where a bump can hang the headless suite (the 2.16 -> 2.18 platform-plugin + # attempt did exactly that), so a major deserves its own run and its own decision. + build-tooling: + patterns: ['*'] + update-types: [minor, patch] + security: + applies-to: security-updates + patterns: ['*'] ignore: # kotlinx-serialization is provided by the IntelliJ Platform at runtime; the declared version has # to match what the targeted IDEs ship, not the newest release. Bumping it blindly is a From c820ea3ff982ab82207a0ff1b8776faf77a65311 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:04:47 +0200 Subject: [PATCH 17/29] style: format the failureLevel assignment per ktlint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I edited build.gradle.kts and ran verifyPlugin and `help` against it, but not spotlessCheck — so `Static analysis` failed on spotlessKotlinGradleCheck for a purely mechanical reason. The formatter is a gate like any other and running a subset of the gate is the same as not running it. --- build.gradle.kts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e3a0b903..a166de0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -326,12 +326,13 @@ intellijPlatform { // only hook that runs BEFORE the workspace state is written — which is the whole point of it. An // experimental API is acceptable with a reason; a deprecated one is not acceptable at all, because // it has an announced removal date and the plugin has to keep working across the IDE range. - failureLevel = listOf( - VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, - VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES, - VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, - VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, - ) + failureLevel = + listOf( + VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, + VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES, + VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, + VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, + ) ides { // No hardcoded path in the repo: a developer can point the verifier at local IDE installs to skip the // downloads, via -PlocalIdePath=[,…] or the LOCAL_IDE_PATH env var (comma-separated). This is From f5bca7df6098bef507a587e977360cbefe0b2f8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:36:07 +0000 Subject: [PATCH 18/29] build(deps): bump org.junit:junit-bom from 5.11.4 to 6.1.2 Bumps [org.junit:junit-bom](https://github.com/junit-team/junit-framework) from 5.11.4 to 6.1.2. - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r5.11.4...r6.1.2) --- updated-dependencies: - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a166de0a..610c3b6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,7 +77,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") // Unit tests (pure JVM: protocol parsing/building, no IntelliJ Platform fixtures needed). - testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation(platform("org.junit:junit-bom:6.1.2")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") // JUnit4/3 on the COMPILE classpath: the plugin's test executor references JUnit4 API, and @@ -93,7 +93,7 @@ dependencies { } // --- uiTest: RemoteRobot end-to-end (Layer D), gated by -PuiTest.enabled=true --- - "uiTestImplementation"(platform("org.junit:junit-bom:5.11.4")) + "uiTestImplementation"(platform("org.junit:junit-bom:6.1.2")) "uiTestImplementation"("org.junit.jupiter:junit-jupiter") "uiTestRuntimeOnly"("org.junit.platform:junit-platform-launcher") "uiTestImplementation"("com.intellij.remoterobot:remote-robot:0.11.23") From adac8f832b6ce31d34c13acafef39955c59767e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:36:10 +0000 Subject: [PATCH 19/29] build(deps): bump actions/download-artifact in the actions group Bumps the actions group with 1 update: [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b08c6c..17755e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,7 +277,7 @@ jobs: needs: [verify] steps: - name: Fetch the verified distributable - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: verified-distribution path: build/distributions From 57a945620ed5b565967bfd0edbe5690941f449d3 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:54:36 +0200 Subject: [PATCH 20/29] ci: stop re-running every PR on each merge, and cache on topic branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, all aimed at the same thing: the pipeline was spending its time on work nobody needed. DEVELOP NO LONGER REQUIRES BRANCHES TO BE UP TO DATE `strict_required_status_checks_policy` meant every merge into develop invalidated every other open PR, forcing each to update and re-run the entire suite. With two grouped Dependabot PRs that is annoying; with five it makes a release impractical, and the cost is paid on exactly the changes that least deserve scrutiny. What the setting guards against is real — two PRs that are green apart can break together — so it is not simply dropped. It is dropped where a second net exists: CI runs on every push to develop, so a semantic conflict is caught there, on develop, before anything reaches main. `main` KEEPS the strict policy: one merge per release, and it is the merge that publishes. THE VERIFIER RUNS WHERE THE ANSWER MATTERS ~10 minutes and 1.25 GB of IDE downloads, previously on every push to every topic branch. Now on pull requests and on the protected branches. It remains a required check, so nothing merges without it. The loss is early detection mid-branch, which is a genuine cost rather than free savings. TOPIC BRANCHES WRITE THEIR OWN CACHE Read-only was blanket-applied to everything but develop and main, so a topic branch restored the shared cache and saved nothing — every push re-downloaded what the previous one had already fetched. Read-only now applies to pull requests from forks only. Verified against GitHub's cache documentation rather than assumed: "Workflow runs cannot restore caches created for child branches or sibling branches", and a cache created on a pull request is written to the merge ref and "can only be restored by re-runs of the pull request". A topic branch cannot reach what develop reads back; the isolation is the platform's, not ours. NB the ruleset change needs ./scripts/apply-rulesets.sh to take effect. --- .github/rulesets/develop.json | 2 +- .github/workflows/ci.yml | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 5a71a8cf..19cc549a 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -32,7 +32,7 @@ { "type": "required_status_checks", "parameters": { - "strict_required_status_checks_policy": true, + "strict_required_status_checks_policy": false, "do_not_enforce_on_create": false, "required_status_checks": [ { "context": "JVM tests" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b08c6c..7e78dfc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,9 +67,16 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - # Only the trunk writes the shared cache. A PR from a fork must never be able to poison what - # the next build on develop reads back. - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + # Read-only ONLY for pull requests from forks. Every other branch writes its own cache, which is + # what stops a second push from re-downloading 1.25 GB of IDEs it already had. + # + # This is safe without our help, and the previous blanket read-only was more conservative than the + # platform requires. GitHub scopes caches per branch: "Workflow runs cannot restore caches created + # for child branches or sibling branches", and a cache created on a pull request is written to the + # merge ref, so it "can only be restored by re-runs of the pull request". A topic branch therefore + # cannot reach — let alone overwrite — what develop reads back. Forks stay read-only anyway: there + # is no reason to let untrusted code populate anything this repository will later restore. + cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} # Coverage is verified HERE, in the same job and the same Gradle invocation as the tests. # `koverVerify` depends on `:test`, so running it in the separate `Static analysis` job re-ran the whole @@ -110,7 +117,7 @@ jobs: - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -219,6 +226,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 needs: [test, frontend-test] + # The expensive one: ~10 minutes and 1.25 GB of IDE downloads. It runs where the answer is load-bearing — + # on every pull request, and on the protected branches — and NOT on each push to a topic branch, where it + # was re-verifying a commit nobody was about to merge. The gate is unchanged: it is still a required check + # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which + # is a real cost and the reason it ran everywhere until now. + if: >- + github.event_name == 'pull_request' || + github.ref == 'refs/heads/develop' || + github.ref == 'refs/heads/main' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -231,7 +247,7 @@ jobs: - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} # The runner ships with a few GB of preinstalled toolchains we will never use, and the verifier # needs room for multiple extracted IDEs. Reclaiming it is cheaper than debugging a disk-full run. From 9398de2fb41bfa6299a87e448a1cedfb86eba6bf Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:03:11 +0200 Subject: [PATCH 21/29] ci: run the verifier only on develop and main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~10 minutes and 1.25 GB of IDE downloads per run, previously paid on every iteration of a branch nobody was about to merge. It now runs only on pushes to develop and main — not on topic branches, not on pull requests. This REQUIRED dropping "Plugin verifier" and "Build plugin" from the required checks on both rulesets, and that is not a detail: a required check whose job never runs is never reported, so the pull request would wait forever. The two changes have to move together or the branch becomes unmergeable. What still covers a release: release.yml re-runs the FULL gate — verifier included — on the exact tree being published, behind the environment approval, so nothing reaches the Marketplace unverified. What is genuinely lost is catching a binary incompatibility at pull-request time rather than after the merge into develop. A real regression in feedback latency, accepted deliberately. Dependabot also moves to monthly, and majors are no longer bot-proposed in any ecosystem: they are where a bump actually breaks something and where CI is not enough on its own — the platform-plugin 2.16 -> 2.18 attempt passed CI and hung the headless suite locally, inside the platform's own test fixture. Security updates are unaffected by either change. --- .github/dependabot.yml | 36 ++++++++++++++++++++----- .github/rulesets/develop.json | 41 +++++++++++++++++++--------- .github/rulesets/main.json | 50 ++++++++++++++++++++++++----------- .github/workflows/ci.yml | 5 ++-- 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d8e86ac2..e8dee000 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,11 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 5 commit-message: prefix: build # Conventional Commits — the commit-msg hook and the changelog both depend on it @@ -25,6 +28,11 @@ updates: security: applies-to: security-updates patterns: ['*'] + ignore: + # A major of an action can change its inputs or its runtime, and every one here is pinned by commit + # SHA — so the diff is opaque by design and the changelog is the only way to know what moved. + - dependency-name: '*' + update-types: [version-update:semver-major] # Build tooling only: vitest/jsdom for the frontend tests, commitlint for the commit gate, and the # Agent SDK as protocol reference. None of it ships (see SECURITY.md), which is exactly why the @@ -32,8 +40,11 @@ updates: - package-ecosystem: npm directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 3 commit-message: prefix: build @@ -52,6 +63,11 @@ updates: applies-to: security-updates patterns: ['*'] ignore: + # Majors are proposed by a human, not by a bot. They are where a bump actually breaks something, they + # need reading the changelog and running the suite, and a PR that sits open for weeks re-running CI on + # every merge into develop is worse than no PR. Patch and minor keep arriving grouped. + - dependency-name: '*' + update-types: [version-update:semver-major] # The SDK baseline is not Dependabot's to move. `checkDrift` bumps it as part of a *reconciled* # protocol review — taking the new version without reading the surface diff is how a protocol gap # gets silently blessed. @@ -60,8 +76,11 @@ updates: - package-ecosystem: gradle directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 3 commit-message: prefix: build @@ -78,6 +97,11 @@ updates: applies-to: security-updates patterns: ['*'] ignore: + # Majors by hand — and this ecosystem is the cautionary tale: the IntelliJ Platform Gradle Plugin + # 2.16 -> 2.18 bump passed CI and hung the headless suite locally, forever, inside the platform's own + # test fixture. A bot cannot make that call and CI would not have caught it either. + - dependency-name: '*' + update-types: [version-update:semver-major] # kotlinx-serialization is provided by the IntelliJ Platform at runtime; the declared version has # to match what the targeted IDEs ship, not the newest release. Bumping it blindly is a # NoSuchMethodError on a user's IDE, not an upgrade. diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 19cc549a..4c8d903f 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -4,20 +4,28 @@ "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/develop"], + "include": [ + "refs/heads/develop" + ], "exclude": [] } }, "bypass_actors": [], "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, { "type": "pull_request", "parameters": { "_comment": [ - "0, not 1 — see the long note in main.json. GitHub does not let an author approve their own", + "0, not 1 \u2014 see the long note in main.json. GitHub does not let an author approve their own", "pull request, so on a single-maintainer repository requiring an approval makes the branch", "unmergeable rather than well-guarded. Raise it to 1 when a second maintainer exists." ], @@ -26,7 +34,10 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false, - "allowed_merge_methods": ["squash", "merge"] + "allowed_merge_methods": [ + "squash", + "merge" + ] } }, { @@ -35,12 +46,18 @@ "strict_required_status_checks_policy": false, "do_not_enforce_on_create": false, "required_status_checks": [ - { "context": "JVM tests" }, - { "context": "Static analysis" }, - { "context": "Frontend tests" }, - { "context": "Dependency audit" }, - { "context": "Plugin verifier" }, - { "context": "Build plugin" } + { + "context": "JVM tests" + }, + { + "context": "Static analysis" + }, + { + "context": "Frontend tests" + }, + { + "context": "Dependency audit" + } ] } } diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index da0279c7..18b45420 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -4,20 +4,28 @@ "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/main"], + "include": [ + "refs/heads/main" + ], "exclude": [] } }, "bypass_actors": [], "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, { "type": "pull_request", "parameters": { "_comment": [ - "required_approving_review_count is 0 ON PURPOSE, and it is not a weakened gate — it is the", + "required_approving_review_count is 0 ON PURPOSE, and it is not a weakened gate \u2014 it is the", "only value that is not a deadlock. GitHub does not let an author approve their own pull", "request, so on a single-maintainer repository 'require 1 approval' with no bypass actors means", "NOTHING can ever be merged: no direct push, no approval available, no way around it.", @@ -27,7 +35,7 @@ "talked out of. A human approval is a real control when there IS a second human; requiring one", "that cannot exist is theatre that locks the door from the inside.", "", - "RAISE THIS TO 1 the moment a second maintainer has write access — and re-enable", + "RAISE THIS TO 1 the moment a second maintainer has write access \u2014 and re-enable", "require_code_owner_review and require_last_push_approval at the same time, both of which are", "off for the same reason." ], @@ -36,7 +44,9 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": true, - "allowed_merge_methods": ["merge"] + "allowed_merge_methods": [ + "merge" + ] } }, { @@ -45,14 +55,24 @@ "strict_required_status_checks_policy": true, "do_not_enforce_on_create": false, "required_status_checks": [ - { "context": "JVM tests" }, - { "context": "Static analysis" }, - { "context": "Frontend tests" }, - { "context": "Dependency audit" }, - { "context": "Plugin verifier" }, - { "context": "Build plugin" }, - { "context": "CodeQL (java-kotlin)" }, - { "context": "CodeQL (javascript-typescript)" } + { + "context": "JVM tests" + }, + { + "context": "Static analysis" + }, + { + "context": "Frontend tests" + }, + { + "context": "Dependency audit" + }, + { + "context": "CodeQL (java-kotlin)" + }, + { + "context": "CodeQL (javascript-typescript)" + } ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e78dfc5..44223306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,9 +232,8 @@ jobs: # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which # is a real cost and the reason it ran everywhere until now. if: >- - github.event_name == 'pull_request' || - github.ref == 'refs/heads/develop' || - github.ref == 'refs/heads/main' + github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 20184bd0620978f8eb7f806918934dd3e4203669 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:07:02 +0200 Subject: [PATCH 22/29] ci: put the exhaustive gate on the develop -> main door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the previous commit, which moved the verifier in the wrong direction. It ran it only on PUSHES to develop and main and dropped it from main's required checks — so a pull request from develop into main, the merge that publishes, would not have run it at all. That is precisely the door that has to be guarded. The policy now matches the intent: topic branches and pull requests into develop run the fast checks (compile, our own JVM and frontend suites, static analysis, dependency audit) and iterate quickly. Any pull request targeting main also runs the plugin verifier and the artifact assertions, both required again on main alongside the two CodeQL analyses. The verifier is the only thing that catches a BINARY incompatibility across the declared 251 -> 262 range — compiling against 252 proves nothing about 262, which is exactly how the 4.4.1 /login regression shipped. Skipping it on a branch is a latency trade; skipping it on the way to a release would not be. --- .github/rulesets/main.json | 6 ++++++ .github/workflows/ci.yml | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 18b45420..0e7f67c0 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -72,6 +72,12 @@ }, { "context": "CodeQL (javascript-typescript)" + }, + { + "context": "Plugin verifier" + }, + { + "context": "Build plugin" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44223306..d67a40bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,9 +231,16 @@ jobs: # was re-verifying a commit nobody was about to merge. The gate is unchanged: it is still a required check # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which # is a real cost and the reason it ran everywhere until now. + # Where the exhaustive check belongs: the develop -> main door, plus the protected branches themselves. + # + # NOT on topic branches and NOT on pull requests into develop — those iterate constantly and this job is + # ~10 minutes and 1.25 GB of IDE downloads. It DOES run on any pull request targeting main, because that + # is the merge that publishes, and it is the only gate that catches a BINARY incompatibility across the + # 251 -> 262 range (compiling against 252 proves nothing about 262 — see the 4.4.1 /login regression). if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From c66700b7c1c87023db9379e04ecf8614f50adf0e Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:08:52 +0200 Subject: [PATCH 23/29] ci: only the two test suites gate a branch; everything gates main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches now run JVM tests and frontend tests and nothing else. Static analysis and the dependency audit join the plugin verifier behind the develop -> main door, where the exhaustive gate belongs. Required checks match, because they have to: a check whose job does not run is never reported and would block the pull request forever. develop requires the two suites; main requires all eight. The costs, named rather than discovered later: - A formatting, detekt, ESLint or coverage failure now lands ON develop and is fixed by a follow-up commit, instead of being caught in the pull request. That happened today with spotlessKotlinGradleCheck, and the PR is what caught it. - The dependency audit no longer runs on the Dependabot PR that proposes a bump. It runs once that bump is on develop, and again before it can reach main, so nothing ships un-audited — the finding just arrives one merge later. What this buys is the thing that was actually hurting: a branch iterates in about three minutes instead of thirteen, and nothing is promoted to main without the full gate, plus release.yml re-running all of it on the exact published tree. --- .github/rulesets/develop.json | 6 ------ .github/workflows/ci.yml | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 4c8d903f..c808a0df 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -49,14 +49,8 @@ { "context": "JVM tests" }, - { - "context": "Static analysis" - }, { "context": "Frontend tests" - }, - { - "context": "Dependency audit" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d67a40bd..bdd2c632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,14 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 + # Same door as the verifier: pull requests into main, and the protected branches themselves. + # A branch iterating towards develop runs only the two test suites; formatting, lint and coverage are + # settled before anything is promoted. The cost is real and worth naming — a formatting or detekt + # failure now lands ON develop and is fixed by a follow-up commit, instead of being caught in the PR. + if: >- + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -200,6 +208,13 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 + # Same door. NB this is the check that judges exactly what a Dependabot pull request changes, so it no + # longer runs on the PR that proposes the bump — only once that bump is on develop, and again before it + # can reach main. Nothing ships un-audited; the finding simply arrives one merge later. + if: >- + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 30a80b8156b46855ac66666d1bf30ac937703167 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:27:58 +0200 Subject: [PATCH 24/29] ci: trigger on pull requests only, never on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branch with an open pull request already fires `pull_request` on every push to it (the `synchronize` event), so keeping a `push` trigger meant two complete pipelines per commit for identical information. This removes the duplication at its source instead of relying on the concurrency group to cancel one in time. The result is the intended shape: a PR into develop runs the two required suites once, a PR from develop into main runs all eight. Two things this gives up, recorded because each removes something the current setup was leaning on: - There is no longer a CI run on the push a merge into develop creates. That run was the stated justification for dropping the up-to-date requirement on develop: two pull requests that are green apart can break together, and develop's own run was what would have caught it. It is now caught at the pull request into main, where the full gate runs — later, but still before anything is published. - A branch with no open pull request gets no checks at all, and a pull request from a fork is the only path that would ever exercise them for an outside contributor. release.yml is untouched: it carries its own `push: branches: [main]` trigger and still fires on the merge that publishes. --- .github/workflows/ci.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdd2c632..13d93437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,15 +9,23 @@ name: CI on: - push: - # Every working branch gets the same gate. A quality bar that only applies once you open the PR is a - # bar you discover late, when the change is already big and the rework is expensive. - branches: - - develop - - main - - 'feature/**' - - 'bugfix/**' - - 'hotfix/**' + # Pull requests ONLY — there is deliberately no `push` trigger. + # + # A branch with an open PR fires `pull_request` on every push to it (the `synchronize` event), so the + # iteration loop is fully covered, and covered ONCE. Having both triggers meant two complete pipelines per + # commit for identical information; this removes that at the root instead of relying on the concurrency + # group to cancel one of them in time. + # + # Two consequences, recorded because each removes something we were leaning on: + # + # - No CI on the push that a merge into `develop` creates. That run was the stated justification for + # dropping the up-to-date requirement on develop — two pull requests that are green apart can break + # together, and develop's own run was what would have caught it. It is now caught at the pull request + # into `main`, where the full gate runs, rather than immediately after the merge. + # - A branch with no open pull request gets no checks at all. That is the intent: no PR, no promotion. + # + # `release.yml` is unaffected — it carries its own `push: branches: [main]` trigger and still fires on the + # merge that publishes. pull_request: branches: [develop, main] workflow_dispatch: From a0e8a9a59f7a2334e662be6bb14bd938ec9c64ce Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:48:48 +0200 Subject: [PATCH 25/29] ci: run the Gradle and Node jobs in the prebuilt image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the five toolchain jobs at ghcr.io/serialexperimentslainnnn/cc-ci and drops the setup-java and setup-node steps, which provisioned inside a container that already has both. `Build plugin` keeps no container: it only unzips an artifact. GRADLE_USER_HOME is set per job and MUST match the value in the Dockerfile. If the two diverge nothing fails — the run simply re-downloads everything the image already holds, and the image appears to have bought nothing. That silence is the reason it is stated at the setting rather than assumed. NOT VERIFIED against a real image: at the time of writing it has not been built or pushed. Two things have to be true before this can merge, and both fail in ways that look like something else: - the package must be public (or linked to this repo), or every job dies on a 401 that reads like a wrong image name; - the warmed caches must actually be in the image — `docker run --rm IMAGE sh -c 'ls /opt/gradle-home/caches'` answers it in seconds. Also still open: whether gradle/actions/setup-gradle should stay. It restores its own cache over GRADLE_USER_HOME, so it now layers on top of the baked one. That may be a useful increment or redundant work; it needs measuring, not guessing. --- .github/ci-image/Dockerfile | 108 ++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 53 +++++++++--------- 2 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 .github/ci-image/Dockerfile diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile new file mode 100644 index 00000000..e0a6f913 --- /dev/null +++ b/.github/ci-image/Dockerfile @@ -0,0 +1,108 @@ +# CI image for claude-code-native — Fedora 44. +# +# WHY THIS EXISTS +# The expensive part of this pipeline is not compute, it is downloads: `verifyPlugin` pulls ~1.25 GB of +# IntelliJ IDEs on every cold run, and a GitHub runner starts cold every time a branch cannot write its own +# cache. Baking those into an image turns a 10-minute job into a pull plus a couple of minutes. +# +# WHERE TO PUBLISH IT +# ghcr.io, NOT Docker Hub. It sits on the same network as the runners (much faster pulls) and has no +# anonymous pull-rate limit — that limit is a classic cause of a pipeline failing for reasons nobody +# changed. +# +# HOW IT GOES STALE, WHICH IS THE REAL CAVEAT +# `verifyPlugin` resolves IDEs from the EAP/RC channels, so the set it wants MOVES. The day JetBrains +# publishes a new build, the baked copies stop matching and Gradle downloads the new one anyway — the image +# degrades to "no worse than before" rather than breaking. Rebuild it weekly (a scheduled workflow) or +# accept that the saving decays between builds. +# +# docker build -f .github/ci-image/Dockerfile -t ghcr.io/OWNER/cc-ci:latest . +# docker push ghcr.io/OWNER/cc-ci:latest +# +# Used from a workflow as: +# jobs: +# test: +# runs-on: ubuntu-latest +# container: ghcr.io/OWNER/cc-ci:latest +FROM fedora:44 + +# Parallel downloads: dnf defaults to 3, and this image installs a JDK plus a Node toolchain over a link +# that is not the bottleneck. Set before the first transaction so every one of them benefits. +RUN echo "max_parallel_downloads=20" >> /etc/dnf/dnf.conf \ + && echo "fastestmirror=True" >> /etc/dnf/dnf.conf + +# Temurin, not Fedora's OpenJDK. +# +# Fedora 44 no longer packages java-21-openjdk — it has moved on to a newer LTS — and the JDK version is not +# ours to float: build.gradle.kts pins the toolchain to 21 because the IDE runs on JBR 21, which is the +# ceiling. Building on 25 would produce class files no target IDE can load. +# +# Adoptium's repository is the same source the `setup-java` action uses on the GitHub runners, so the image +# and the hosted pipeline compile against the same JDK rather than two different builds of "21". +RUN dnf -y --setopt=install_weak_deps=False install dnf-plugins-core \ + && curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public \ + -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \ + && rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \ + && printf '%s\n' \ + '[Adoptium]' \ + 'name=Adoptium' \ + 'baseurl=https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch' \ + 'enabled=1' \ + 'gpgcheck=1' \ + 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium' \ + > /etc/yum.repos.d/adoptium.repo + +# `git` is required by actions/checkout; `which`/`findutils`/`procps-ng` are assumed present by various +# actions and by Gradle's own probing, and Fedora's base image is minimal enough not to ship them. +# `--setopt=install_weak_deps=False` keeps the image from pulling in recommended-but-unused packages. +RUN dnf -y --setopt=install_weak_deps=False install \ + temurin-21-jdk \ + nodejs npm \ + git unzip zip tar which findutils procps-ng ca-certificates \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# JAVA_HOME is resolved rather than hardcoded: the exact path carries the package's build number and would +# silently break on the next base-image bump. +RUN JH="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" \ + && echo "JAVA_HOME=$JH" >> /etc/environment \ + && ln -sfn "$JH" /opt/java-21 \ + && "$JH/bin/java" -version +# A stable symlink, so JAVA_HOME does not carry Temurin's build number and break on the next image rebuild. +ENV JAVA_HOME=/opt/java-21 +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +# Gradle writes here, and the path must match what the job will use, or the warm caches below are invisible +# to it. Set GRADLE_USER_HOME to the same value in the workflow. +ENV GRADLE_USER_HOME=/opt/gradle-home + +WORKDIR /warmup + +# Only the build definition, on purpose: this layer is invalidated by a dependency change, not by every edit +# to the Kotlin sources. The whole source tree is copied later, in a layer that costs nothing to rebuild. +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties* ./ +COPY package.json package-lock.json ./ + +# Downloads the Gradle distribution itself and resolves the plugin/dependency graph. +RUN ./gradlew --no-daemon --version \ + && ./gradlew --no-daemon dependencies --configuration compileClasspath > /dev/null 2>&1 || true + +# npm dependencies for the frontend tests. `npm ci` needs package-lock.json, which is why it is copied above. +RUN npm ci --no-audit --no-fund + +# The big one. `verifyPlugin` is what pulls the IDEs, and there is no way to fetch them without running it, +# so the full source is needed here. This step is SLOW (~10 minutes) by design — it is paying once, at image +# build time, for what every CI run was paying. +# +# `|| true`: a verification FAILURE must not fail the image build. We are here for the side effect (the +# downloaded IDEs now sitting in GRADLE_USER_HOME), not for the verdict — the verdict is CI's job, on the +# real commit, not on whatever happened to be checked out when the image was cut. +COPY . . +RUN ./gradlew --no-daemon verifyPlugin > /dev/null 2>&1 || true + +# The sources were only ever scaffolding for the warm-up; keeping them would ship a stale copy of the +# repository inside the image, which someone would eventually mistake for the real one. +RUN rm -rf /warmup/* /warmup/.git /warmup/.[!.]* 2>/dev/null || true + +WORKDIR /workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d93437..9e15545f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,18 +59,16 @@ jobs: name: JVM tests runs-on: ubuntu-latest timeout-minutes: 30 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and every run silently re-downloads what the image already has. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up JDK 21 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - # Temurin, not the JetBrains Runtime: the JBR matters for *running* an IDE, not for compiling - # against the platform. The toolchain is pinned to 21 in build.gradle.kts either way. - distribution: temurin - java-version: '21' - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 @@ -113,6 +111,11 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and every run silently re-downloads what the image already has. + GRADLE_USER_HOME: /opt/gradle-home # Same door as the verifier: pull requests into main, and the protected branches themselves. # A branch iterating towards develop runs only the two test suites; formatting, lint and coverage are # settled before anything is promoted. The cost is real and worth naming — a formatting or detekt @@ -126,19 +129,11 @@ jobs: with: persist-credentials: false - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - run: npm ci @@ -180,16 +175,16 @@ jobs: name: Frontend tests runs-on: ubuntu-latest timeout-minutes: 10 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and every run silently re-downloads what the image already has. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm # `npm ci` (not `install`): it installs exactly the committed lockfile and fails if package.json # and the lockfile disagree, which is the only way CI tests the dependency tree that was reviewed. @@ -216,6 +211,11 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and every run silently re-downloads what the image already has. + GRADLE_USER_HOME: /opt/gradle-home # Same door. NB this is the check that judges exactly what a Dependabot pull request changes, so it no # longer runs on the PR that proposes the bump — only once that bump is on develop, and again before it # can reach main. Nothing ships un-audited; the finding simply arrives one merge later. @@ -228,10 +228,6 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - run: npm ci @@ -248,6 +244,11 @@ jobs: name: Plugin verifier runs-on: ubuntu-latest timeout-minutes: 60 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and every run silently re-downloads what the image already has. + GRADLE_USER_HOME: /opt/gradle-home needs: [test, frontend-test] # The expensive one: ~10 minutes and 1.25 GB of IDE downloads. It runs where the answer is load-bearing — # on every pull request, and on the protected branches — and NOT on each push to a topic branch, where it @@ -269,10 +270,6 @@ jobs: with: persist-credentials: false - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: From fbfb64d0a60804ae0380a2ef80b704bbb0a83f66 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:50:26 +0200 Subject: [PATCH 26/29] ci: pull the private image with the run's own token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package stays private. Each container job authenticates with the GITHUB_TOKEN the run already has, so there is no new secret to create, store or rotate, and the credential expires with the job. `packages: read` is granted per job rather than at the top level, keeping the default token read-only on everything else. Without it the pull fails with a 401 that reads like a wrong image name rather than a permission problem — which is exactly the kind of error that gets debugged in the wrong place. One prerequisite this does NOT remove: the package must be linked to this repository, or the token has no grant on it. That is done once, from the package settings, and it is what makes "same account" mean "same permissions" here. --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e15545f..6492e2d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,17 @@ jobs: name: JVM tests runs-on: ubuntu-latest timeout-minutes: 30 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to + # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull + # fails with a 401 that reads like a wrong image name rather than a permission problem. + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read env: # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches # baked into the image are invisible and every run silently re-downloads what the image already has. @@ -111,7 +121,17 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to + # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull + # fails with a 401 that reads like a wrong image name rather than a permission problem. + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read env: # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches # baked into the image are invisible and every run silently re-downloads what the image already has. @@ -175,7 +195,17 @@ jobs: name: Frontend tests runs-on: ubuntu-latest timeout-minutes: 10 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to + # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull + # fails with a 401 that reads like a wrong image name rather than a permission problem. + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read env: # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches # baked into the image are invisible and every run silently re-downloads what the image already has. @@ -211,7 +241,17 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to + # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull + # fails with a 401 that reads like a wrong image name rather than a permission problem. + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read env: # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches # baked into the image are invisible and every run silently re-downloads what the image already has. @@ -244,7 +284,17 @@ jobs: name: Plugin verifier runs-on: ubuntu-latest timeout-minutes: 60 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to + # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull + # fails with a 401 that reads like a wrong image name rather than a permission problem. + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read env: # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches # baked into the image are invisible and every run silently re-downloads what the image already has. From 1249fbb1a816f6890c34d6d101de3802293c740e Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:59:16 +0200 Subject: [PATCH 27/29] fix(ci-image): bake the npm cache, not node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup step wipes /warmup, and `npm ci` had installed node_modules inside it — so the image built the frontend dependencies and deleted them moments later. The warm-up looked like it worked and bought nothing: CI would re-download the whole tree on every run, silently, because nothing fails when a cache is missing. Setting npm_config_cache moves the reusable part to /opt/npm-cache, which the cleanup does not touch. node_modules stays disposable, and that is correct independently of this bug: it must match the package-lock.json of the commit CI checks out, not the one that happened to be current when the image was cut. Found by a question about what that `rm -rf` actually deletes, which is a better review than reading the line I had just written myself. --- .github/ci-image/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile index e0a6f913..c68e0ebc 100644 --- a/.github/ci-image/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -89,6 +89,14 @@ RUN ./gradlew --no-daemon --version \ && ./gradlew --no-daemon dependencies --configuration compileClasspath > /dev/null 2>&1 || true # npm dependencies for the frontend tests. `npm ci` needs package-lock.json, which is why it is copied above. +# +# What is baked is the npm CACHE, not `node_modules`, and the distinction is the whole point: the cleanup +# step below wipes /warmup, so a baked node_modules would be deleted moments after being built — the warm-up +# would look like it worked and buy nothing. `node_modules` also MUST match the package-lock.json of whatever +# commit CI checks out, not the one that happened to be current when the image was cut, so keeping it would +# be wrong even if it survived. The cache is version-addressed and therefore safe to reuse: `npm ci` in CI +# rebuilds node_modules from it without touching the network. +ENV npm_config_cache=/opt/npm-cache RUN npm ci --no-audit --no-fund # The big one. `verifyPlugin` is what pulls the IDEs, and there is no way to fetch them without running it, From 2fad4bc107e79b162c1ab42d11055f24ba696bc0 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 03:35:30 +0200 Subject: [PATCH 28/29] ci: run every job in the CI image and drop the caching action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image was already pulled by most jobs; the remaining ones provisioned their own JDK, Node and Gradle cache and so ran on a toolchain nothing else had used. Now `Build plugin`, the protocol-drift check and the release gate use it too, which is the point of having built it. `gradle/actions/setup-gradle` is removed everywhere rather than set to read-only, because it was not doing the job it appeared to be doing. The warm GRADLE_USER_HOME measures 31 GB — 23 GB of extracted IDE transforms under caches/9.5.1 and 7.7 GB of downloaded IDE artifacts under modules-2 — and an Actions cache entry is capped at 10 GB per repository. It could only ever have stored a fraction, evicted it, and re-downloaded the rest next run. The image has no such ceiling. The trade is explicit and worth stating: refreshing what CI has cached is now a deliberate rebuild-and-push, not something that drifts between runs. Also removes the verifier's `Free disk space` step. Inside a container those paths are the IMAGE's, not the runner's, so it had been freeing nothing while looking like this job's safety margin. The margin now comes from the IDEs being baked: nothing is downloaded or extracted at verify time. Recorded because it is the failure everyone hits once: the private package must be granted Read access to this repository in its own settings. The `packages: read` permission widens what the token may ASK for; it does not authorise it against a package the repo was never linked to, and without the link the pull fails with a bare `denied` that reads like a wrong image name. Not containerised, deliberately: `publish`, which holds the Marketplace token and the signing key and is not a test, and CodeQL, which is weekly and is where a container breaks quietly. Not verified: that the image builds with no network at all. The one attempt failed on uid mapping, which says nothing about CI, where the container runs as root. --- .github/ci-image/Dockerfile | 1 - .github/workflows/ci.yml | 61 ++++++++++++++++++----------------- .github/workflows/drift.yml | 22 ++++++------- .github/workflows/release.yml | 31 ++++++++---------- 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile index c68e0ebc..4556a54f 100644 --- a/.github/ci-image/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -112,5 +112,4 @@ RUN ./gradlew --no-daemon verifyPlugin > /dev/null 2>&1 || true # The sources were only ever scaffolding for the warm-up; keeping them would ship a stale copy of the # repository inside the image, which someone would eventually mistake for the real one. RUN rm -rf /warmup/* /warmup/.git /warmup/.[!.]* 2>/dev/null || true - WORKDIR /workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6492e2d7..1974b807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,11 +59,22 @@ jobs: name: JVM tests runs-on: ubuntu-latest timeout-minutes: 30 + # EVERY job in this file runs in this image, and the image is the ONLY caching mechanism. + # + # `gradle/actions/setup-gradle` used to sit in the heavy jobs and was quietly useless here: the warm + # GRADLE_USER_HOME measures 31 GB (23 GB of extracted IDE transforms under caches/9.5.1, 7.7 GB of the + # downloaded IDE artifacts under modules-2), and a GitHub Actions cache entry is capped at 10 GB per + # repository. It could never have stored what it appeared to be storing — it was saving a partial + # cache, evicting it, and re-downloading the rest on the next run. The image has no such ceiling, and + # the trade is explicit: refreshing what CI has cached now means rebuilding and pushing the image, + # which is a deliberate act rather than something that drifts between runs. container: image: ghcr.io/serialexperimentslainnnn/cc-ci:latest # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to - # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull - # fails with a 401 that reads like a wrong image name rather than a permission problem. + # rotate, and access dies with the job. This requires the package to have been granted Read access to + # THIS repository (package settings -> Manage Actions access): `packages: read` widens what the token + # may ask for, it does not authorise it against a package the repo was never linked to. Without that + # link the pull fails with a bare `denied`, which reads like a wrong image name. credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -80,19 +91,9 @@ jobs: persist-credentials: false - - name: Set up Gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - # Read-only ONLY for pull requests from forks. Every other branch writes its own cache, which is - # what stops a second push from re-downloading 1.25 GB of IDEs it already had. - # - # This is safe without our help, and the previous blanket read-only was more conservative than the - # platform requires. GitHub scopes caches per branch: "Workflow runs cannot restore caches created - # for child branches or sibling branches", and a cache created on a pull request is written to the - # merge ref, so it "can only be restored by re-runs of the pull request". A topic branch therefore - # cannot reach — let alone overwrite — what develop reads back. Forks stay read-only anyway: there - # is no reason to let untrusted code populate anything this repository will later restore. - cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} + # NB there is deliberately no `setup-gradle` step, in this job or any other. See the note at the + # `container:` block above: the image IS the cache, and the action's cache was never doing the job + # it looked like it was doing. # Coverage is verified HERE, in the same job and the same Gradle invocation as the tests. # `koverVerify` depends on `:test`, so running it in the separate `Static analysis` job re-ran the whole @@ -150,11 +151,9 @@ jobs: persist-credentials: false - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - - + # `npm ci` is fast rather than free here: node_modules is NOT baked into the image (it must match the + # lockfile of the commit under test, not the one current when the image was cut), but the npm cache is, + # so this resolves from /opt/npm-cache without touching the network. - run: npm ci # detekt: rule config in config/detekt/detekt.yml, each non-default setting carrying its reasoning @@ -321,16 +320,10 @@ jobs: persist-credentials: false - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - - # The runner ships with a few GB of preinstalled toolchains we will never use, and the verifier - # needs room for multiple extracted IDEs. Reclaiming it is cheaper than debugging a disk-full run. - - name: Free disk space - run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc - df -h / + # The old `Free disk space` step is gone. It deleted /usr/share/dotnet and friends, and inside a + # container those paths are the IMAGE's, not the runner's — it was freeing nothing while looking + # like the safety margin for this job. The margin now comes from the IDEs being baked: this job no + # longer downloads or extracts 1.25 GB, it reads what is already on disk. - name: Verify plugin run: ./gradlew --no-daemon --stacktrace verifyPlugin @@ -366,6 +359,14 @@ jobs: name: Build plugin runs-on: ubuntu-latest timeout-minutes: 10 + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read needs: [verify] steps: - name: Fetch the verified distributable diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 1c45b941..6cdd54b5 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -32,27 +32,23 @@ jobs: name: Check protocol drift runs-on: ubuntu-latest timeout-minutes: 30 + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read + packages: read issues: write # to file the drift report + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: true - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - # Deliberately NOT `npm ci`: checkDrift's whole job is to compare the pinned baseline against the # LATEST published SDK, so it needs the tree to be updatable. It runs `npm update` itself. - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2706eb23..c8c1942f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,33 +103,30 @@ jobs: timeout-minutes: 60 needs: [guard] if: needs.guard.outputs.release == 'true' + # The same image ci.yml uses, for the same reason and with one extra: this gate must run the toolchain + # the branch was green on. Provisioning the JDK and Node here from separate actions meant the release + # gate could pass or fail on a toolchain the pull request never saw. + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: true - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - - run: npm ci - run: npm test env: CI: 'true' - - name: Free disk space - run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc - - run: ./gradlew --no-daemon --stacktrace test verifyPlugin From 04957a8b976f18442852bf6ec5a368ef36ac7fb9 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 04:21:40 +0200 Subject: [PATCH 29/29] ci: drop the verifier IDEs from the image and fix the warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI image was 38.1 GB, 29.1 GB of it the IDEs `verifyPlugin` downloads. Every job in ci.yml pulls its own copy on its own runner, so `Initialize containers` measured 5m37s on a job whose actual work is an 8-second vitest run, and 38 GB on a runner with ~25-30 GB free on the root volume was also flirting with `No space left on device`. The verifier is the only consumer of those IDEs and it runs only on a pull request from develop into main, so it now downloads them when it runs. The set also moves — the verifier resolves from the EAP/RC channels — so the baked copies stopped matching on JetBrains' release schedule, not ours. The warm-up itself was not warming anything. `dependencies --configuration compileClasspath` resolves dependency metadata; it never triggers the artifact transform that EXTRACTS the IntelliJ Platform, which is where the GB are. Measured: it leaves caches/*/transforms at 179 MB with no extracted IDE in it. What warmed the platform was the `verifyPlugin` step, so removing that step alone would have shipped a cold image that re-resolved the platform in every job — and the `> /dev/null 2>&1 || true` meant nothing would have said so. It is now `testClasses`, unredirected and without `|| true`, so a failed warm-up fails the image build. Verified with the network disabled inside the image: `testClasses` compiles in 33s and the 84 frontend tests pass from the baked npm cache. 38.1 GB -> 8.07 GB, 3.61 GB compressed. Also in this change: - .dockerignore: the tree reaching `COPY` goes from 2.09 GB to 3.2 MB (node_modules, build/, .git). `node_modules` is now removed in the same layer `npm ci` creates it, since a later `rm` hides space rather than reclaiming it. - python3 is installed explicitly. It was arriving transitively through dnf-plugins-core, which was itself unused — the Adoptium repo file is written with printf, not dnf config-manager, and curl is already in the base image — and bin/fake-claude, the stand-in the integration tests drive a real ClaudeSession against, is a `#!/usr/bin/env python3` script. Dropping the unused package without naming python3 would have broken the integration suite. - One dnf transaction with tsflags=nodocs, git-core instead of git, /usr/share/locale removed. - codeql.yml: the matrix is split so java-kotlin runs in the image and inherits the warm cache, dropping a setup-java that handed it a different JDK from the rest of the pipeline, while javascript-typescript stays on a bare runner where it is faster. Both display names are unchanged: they are required checks in .github/rulesets/main.json, and a renamed job stops applying its gate silently. - No `:latest` anywhere; the image is `:base`. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 32 +++++++++ .github/ci-image/Dockerfile | 128 +++++++++++++++++++++++----------- .github/workflows/ci.yml | 15 ++-- .github/workflows/codeql.yml | 74 ++++++++++++++------ .github/workflows/drift.yml | 2 +- .github/workflows/release.yml | 2 +- 6 files changed, 182 insertions(+), 71 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..27b79c9b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Build context filter for .github/ci-image/Dockerfile. +# +# The `verify` stage does a `COPY . .` because `verifyPlugin` needs the real sources to resolve which IDEs +# to download. Without this file that COPY was a 2.09 GB layer: it swept in node_modules, build/ and .git, +# none of which the warm-up reads, and all of which stay in the image forever — a later `rm -rf` adds a +# layer, it never reclaims one. +# +# Anything the Gradle build genuinely needs must NOT be listed here. When in doubt, leave it in: a missing +# source file makes the warm-up silently resolve a different IDE set, which is the failure mode that looks +# like the cache "just not working". + +# Reproduced from the lockfile by `npm ci` inside the image, and it MUST match the lockfile of whatever +# commit CI checks out rather than the one current when the image was cut. +node_modules/ + +# Outputs, not inputs. +build/ +out/ +.gradle/ + +# History is not a build input, and it is the single largest thing here after node_modules. +.git/ +.github/ci-image/ + +# Local IDE and editor state. +.idea/ +*.iml + +# Never let a local secret or env file reach a layer. +.env +.env.* +*.log diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile index 4556a54f..12443a1c 100644 --- a/.github/ci-image/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -1,30 +1,45 @@ # CI image for claude-code-native — Fedora 44. # # WHY THIS EXISTS -# The expensive part of this pipeline is not compute, it is downloads: `verifyPlugin` pulls ~1.25 GB of -# IntelliJ IDEs on every cold run, and a GitHub runner starts cold every time a branch cannot write its own -# cache. Baking those into an image turns a 10-minute job into a pull plus a couple of minutes. +# The expensive part of this pipeline is not compute, it is downloads: a cold Gradle resolves and EXTRACTS +# the whole IntelliJ Platform before it compiles a line, and a GitHub runner starts cold every time. Baking +# that into an image turns minutes of download into a pull. +# +# WHAT IS DELIBERATELY *NOT* IN HERE: THE VERIFIER'S IDEs +# This image used to also bake what `verifyPlugin` downloads, and that made it 38.1 GB — 29.1 GB of it +# extracted IDEs. Every job in ci.yml pulls its own copy on its own runner, so a job whose actual work is an +# 8-second vitest run spent 5m37s in `Initialize containers` (measured, not estimated), and 38 GB on a +# runner with ~25-30 GB free on the root volume was flirting with `No space left on device`. +# +# The verifier is the only consumer of those IDEs, and it runs ONLY on a pull request from develop into +# main — a handful of times a month. Baking 29 GB into every job's pull, permanently, to save ten minutes on +# the rarest job in the pipeline is the wrong side of that trade by two orders of magnitude. `verifyPlugin` +# downloads what it needs, when it runs. +# +# There is a second reason, and it is the one that would have bitten silently: the IDE set MOVES. The +# verifier resolves from the EAP/RC channels, so the day JetBrains publishes a new build, the baked copies +# stop matching and Gradle downloads the new one anyway. The saving decayed on JetBrains' release schedule, +# not ours. # # WHERE TO PUBLISH IT # ghcr.io, NOT Docker Hub. It sits on the same network as the runners (much faster pulls) and has no # anonymous pull-rate limit — that limit is a classic cause of a pipeline failing for reasons nobody # changed. # -# HOW IT GOES STALE, WHICH IS THE REAL CAVEAT -# `verifyPlugin` resolves IDEs from the EAP/RC channels, so the set it wants MOVES. The day JetBrains -# publishes a new build, the baked copies stop matching and Gradle downloads the new one anyway — the image -# degrades to "no worse than before" rather than breaking. Rebuild it weekly (a scheduled workflow) or -# accept that the saving decays between builds. +# BUILDING IT — from the repository ROOT, so /.dockerignore applies: # -# docker build -f .github/ci-image/Dockerfile -t ghcr.io/OWNER/cc-ci:latest . -# docker push ghcr.io/OWNER/cc-ci:latest +# docker build -f .github/ci-image/Dockerfile -t ghcr.io/OWNER/cc-ci:base . +# docker push ghcr.io/OWNER/cc-ci:base +# +# Tagged `:base`, not `:latest`, because this repository's own standard is to pin rather than float, and +# because a floating tag makes "which image was that job green on?" unanswerable. # # Used from a workflow as: # jobs: # test: # runs-on: ubuntu-latest -# container: ghcr.io/OWNER/cc-ci:latest -FROM fedora:44 +# container: ghcr.io/OWNER/cc-ci:base +FROM fedora:44 AS base # Parallel downloads: dnf defaults to 3, and this image installs a JDK plus a Node toolchain over a link # that is not the bottleneck. Set before the first transaction so every one of them benefits. @@ -39,8 +54,27 @@ RUN echo "max_parallel_downloads=20" >> /etc/dnf/dnf.conf \ # # Adoptium's repository is the same source the `setup-java` action uses on the GitHub runners, so the image # and the hosted pipeline compile against the same JDK rather than two different builds of "21". -RUN dnf -y --setopt=install_weak_deps=False install dnf-plugins-core \ - && curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public \ +# ONE transaction, not two. The previous first transaction existed only to install `dnf-plugins-core`, which +# was never used: the repository file below is written with `printf`, not with `dnf config-manager`, and +# `curl` is already in the fedora:44 base image (verified: curl-8.18.0). It was ~150 MB of Python stack +# pulled in to run a command nobody ran. +# +# `python3` is now EXPLICIT, and that is a correctness fix rather than a size one. `bin/fake-claude` — the +# deterministic stand-in the integration tests drive a real ClaudeSession against — is a `#!/usr/bin/env +# python3` script. It worked only because `dnf-plugins-core` happened to drag the interpreter in as a +# transitive dependency. Removing the unused package without naming python3 here would have made the +# integration suite fail on a missing interpreter, which is the kind of break that reads as a test bug. +# +# `git-core` rather than `git`: actions/checkout clones, fetches and checks out, and git-core provides +# /usr/bin/git for all of that. The `git` metapackage adds the Perl tooling (git-send-email and friends), +# git-core-doc and perl-libs — ~32 MB nothing in this pipeline invokes. +# +# `which`/`findutils`/`procps-ng` are assumed present by various actions and by Gradle's own probing, and +# Fedora's base image is minimal enough not to ship them. +# +# `install_weak_deps=False` drops recommended-but-unused packages; `tsflags=nodocs` drops the documentation +# that ships inside the ones we do want. +RUN curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public \ -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \ && rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \ && printf '%s\n' \ @@ -50,17 +84,18 @@ RUN dnf -y --setopt=install_weak_deps=False install dnf-plugins-core \ 'enabled=1' \ 'gpgcheck=1' \ 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium' \ - > /etc/yum.repos.d/adoptium.repo - -# `git` is required by actions/checkout; `which`/`findutils`/`procps-ng` are assumed present by various -# actions and by Gradle's own probing, and Fedora's base image is minimal enough not to ship them. -# `--setopt=install_weak_deps=False` keeps the image from pulling in recommended-but-unused packages. -RUN dnf -y --setopt=install_weak_deps=False install \ + > /etc/yum.repos.d/adoptium.repo \ + && dnf -y --setopt=install_weak_deps=False --setopt=tsflags=nodocs install \ temurin-21-jdk \ nodejs npm \ - git unzip zip tar which findutils procps-ng ca-certificates \ + python3 \ + git-core unzip zip tar which findutils procps-ng ca-certificates \ && dnf clean all \ - && rm -rf /var/cache/dnf + && rm -rf /var/cache/dnf \ + # gettext catalogues: translated CLI messages for tools this image only ever runs non-interactively + # under a C locale. NOT /usr/lib/locale, which is the locale DEFINITIONS the JVM and glibc resolve + # against — deleting that would change how the build behaves, not just how it reads. + && rm -rf /usr/share/locale # JAVA_HOME is resolved rather than hardcoded: the exact path carries the package's build number and would # silently break on the next base-image bump. @@ -78,15 +113,35 @@ ENV GRADLE_USER_HOME=/opt/gradle-home WORKDIR /warmup -# Only the build definition, on purpose: this layer is invalidated by a dependency change, not by every edit -# to the Kotlin sources. The whole source tree is copied later, in a layer that costs nothing to rebuild. +# Only the build definition first, on purpose: this layer is invalidated by a dependency change, not by +# every edit to the Kotlin sources. COPY gradle/ gradle/ COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties* ./ COPY package.json package-lock.json ./ -# Downloads the Gradle distribution itself and resolves the plugin/dependency graph. -RUN ./gradlew --no-daemon --version \ - && ./gradlew --no-daemon dependencies --configuration compileClasspath > /dev/null 2>&1 || true +# Downloads the Gradle distribution itself. Kept separate from the warm-up below so a network problem here +# is distinguishable from a build problem there. +RUN ./gradlew --no-daemon --version + +# The sources, needed because the warm-up below compiles. Filtered by /.dockerignore, so this is a few MB +# of Kotlin and resources rather than the 2 GB it used to be with node_modules and build/ swept in. +COPY . . + +# THE WARM-UP, and the reason it is `testClasses` rather than `dependencies`. +# +# It used to be `./gradlew dependencies --configuration compileClasspath > /dev/null 2>&1 || true`, and that +# command does NOT warm this cache. It resolves dependency METADATA; it never triggers the artifact +# transform that EXTRACTS the IntelliJ Platform, which is where the several GB actually are. Measured: that +# command leaves caches/*/transforms at 179 MB with no extracted IDE in it. The image looked warm and every +# job re-downloaded and re-extracted the platform — invisibly, because of the redirect and the `|| true`. +# +# `testClasses` compiles main and test sources, so it resolves AND extracts everything `test`, `detekt`, +# `spotlessCheck`, `buildPlugin` and the CodeQL Kotlin build need. +# +# No `> /dev/null`, and no `|| true`. A warm-up that fails must fail the image build. The old form could not +# report anything: the whole point of this image is the cache, so "the cache step failed but the image is +# fine" is not a state worth being able to reach. +RUN ./gradlew --no-daemon testClasses # npm dependencies for the frontend tests. `npm ci` needs package-lock.json, which is why it is copied above. # @@ -97,19 +152,10 @@ RUN ./gradlew --no-daemon --version \ # be wrong even if it survived. The cache is version-addressed and therefore safe to reuse: `npm ci` in CI # rebuilds node_modules from it without touching the network. ENV npm_config_cache=/opt/npm-cache -RUN npm ci --no-audit --no-fund - -# The big one. `verifyPlugin` is what pulls the IDEs, and there is no way to fetch them without running it, -# so the full source is needed here. This step is SLOW (~10 minutes) by design — it is paying once, at image -# build time, for what every CI run was paying. -# -# `|| true`: a verification FAILURE must not fail the image build. We are here for the side effect (the -# downloaded IDEs now sitting in GRADLE_USER_HOME), not for the verdict — the verdict is CI's job, on the -# real commit, not on whatever happened to be checked out when the image was cut. -COPY . . -RUN ./gradlew --no-daemon verifyPlugin > /dev/null 2>&1 || true +# `node_modules` is removed in the SAME layer that creates it. It is scaffolding — the cache above is what +# survives — and a `rm` in a later layer would not reclaim the space, only hide it. +RUN npm ci --no-audit --no-fund \ + && rm -rf /warmup/node_modules -# The sources were only ever scaffolding for the warm-up; keeping them would ship a stale copy of the -# repository inside the image, which someone would eventually mistake for the real one. -RUN rm -rf /warmup/* /warmup/.git /warmup/.[!.]* 2>/dev/null || true +RUN rm -rf /warmup/* /warmup/.[!.]* 2>/dev/null || true WORKDIR /workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 432e9f0a..8dd2dd26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: # the trade is explicit: refreshing what CI has cached now means rebuilding and pushing the image, # which is a deliberate act rather than something that drifts between runs. container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to # rotate, and access dies with the job. This requires the package to have been granted Read access to # THIS repository (package settings -> Manage Actions access): `packages: read` widens what the token @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull # fails with a 401 that reads like a wrong image name rather than a permission problem. @@ -195,7 +195,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull # fails with a 401 that reads like a wrong image name rather than a permission problem. @@ -241,7 +241,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull # fails with a 401 that reads like a wrong image name rather than a permission problem. @@ -284,7 +284,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + # Same image as every other job, and it does NOT carry the IDEs this job downloads — see the note at + # the top of .github/ci-image/Dockerfile. Baking them made the image 38.1 GB, which every job paid for + # on its own runner, to save ten minutes on the one job that runs least often. + image: ghcr.io/serialexperimentslainnnn/cc-ci:base # The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to # rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull # fails with a 401 that reads like a wrong image name rather than a permission problem. @@ -360,7 +363,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a3a2ffeb..957289f5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,39 +23,48 @@ concurrency: group: codeql-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# The two languages were one matrix, and splitting them is not cosmetic: only ONE of them wants a +# container, and a matrix cannot express "container here, bare runner there" without `fromJSON` gymnastics +# around a `credentials:` block. +# +# java-kotlin needs a JDK and a full Gradle resolution of the IntelliJ Platform, so it runs in +# cc-ci:base and inherits the warm GRADLE_USER_HOME. It used to provision the JDK with +# setup-java and resolve the platform from cold on every run. +# javascript-typescript is `build-mode: none`. It needs no JDK, no Gradle and no npm install — the +# scanner reads the sources. Putting it in the image would add a 6.5 GB pull to a job that +# would use none of it, making it strictly slower. It stays on the bare runner. jobs: - analyze: - name: CodeQL (${{ matrix.language }}) + analyze-kotlin: + name: CodeQL (java-kotlin) runs-on: ubuntu-latest timeout-minutes: 45 + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:base + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read + packages: read # pull the CI image security-events: write # publish findings to the Security tab - strategy: - fail-fast: false - matrix: - include: - - language: java-kotlin - build-mode: manual - - language: javascript-typescript - build-mode: none + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches + # baked into the image are invisible and this job silently re-resolves the whole platform. + GRADLE_USER_HOME: /opt/gradle-home + GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.console=plain steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up JDK 21 - if: matrix.language == 'java-kotlin' - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: temurin - java-version: '21' + # No `setup-java` step: the image already carries the Temurin 21 the rest of the pipeline builds + # with. Provisioning a second JDK here meant CodeQL analysed a build that used a different one. - name: Initialize CodeQL uses: github/codeql-action/init@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} + languages: java-kotlin + build-mode: manual # security-extended over the default pack: this is a security-sensitive plugin with real # users, and the extra precision cost is a few minutes on a free runner. queries: security-extended @@ -63,12 +72,33 @@ jobs: # Manual build rather than autobuild: autobuild guesses, and this project's build resolves the # whole IntelliJ Platform. `classes` compiles main + resources without running tests twice. - name: Build (Kotlin) - if: matrix.language == 'java-kotlin' run: ./gradlew --no-daemon --stacktrace classes - env: - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.console=plain - name: Analyze uses: github/codeql-action/analyze@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 with: - category: /language:${{ matrix.language }} + category: /language:java-kotlin + + analyze-javascript: + name: CodeQL (javascript-typescript) + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 + with: + languages: javascript-typescript + build-mode: none + queries: security-extended + + - name: Analyze + uses: github/codeql-action/analyze@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 + with: + category: /language:javascript-typescript diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 6cdd54b5..93cef912 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8c1942f..0ffd1078 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: # the branch was green on. Provisioning the JDK and Node here from separate actions meant the release # gate could pass or fail on a toolchain the pull request never saw. container: - image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + image: ghcr.io/serialexperimentslainnnn/cc-ci:base credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}