diff --git a/.gitignore b/.gitignore index 344d5550..fe334233 100644 --- a/.gitignore +++ b/.gitignore @@ -52,8 +52,18 @@ temp/ .claude/**/settings.local.json # 本地 git worktree(Claude Code 的默认位置)。跟踪它会让工具把一个完整 # 的嵌套 checkout 当成仓库内容 —— 见 biome.json 的 includes 与 #439。 -# 用 **/ 前缀与 biome.json 的 "!!**/.claude/worktrees" 覆盖范围保持一致: -# 在子目录里启动 Claude Code 时 worktree 会落在该子目录的 .claude/ 下。 +# 这里保留 **/ 前缀:在子目录里启动 Claude Code 时 worktree 会落在该子目录的 +# .claude/ 下。biome.json 那条反而必须锚定到项目根(不带 **/)——biome 按绝对 +# 路径匹配遍历根,**/ 会让 worktree 自己也命中排除,从而在 worktree 内部把 +# `biome check .` 变成「检查 0 个文件」并非零退出(#444)。git 没有这个反向失效, +# 因此两边的覆盖范围有意不同,而不是等价保护: +# 子目录下的 worktree(如 packages/x/.claude/worktrees/b)只被 git 忽略,不在 +# biome 的排除范围内——从仓库根跑 `biome check .` 仍会撞上 #439 的 +# nested-root-configuration 中止。已知限制,不是疏漏:`**/` 的反向失效(worktree +# 里所有门禁全灭)比它更严重,而 `!!**/.claude/worktrees/**` 在 biome 2.4.16 上实测 +# 两边都成立,却会被 biome 自己的 useBiomeIgnoreFolder safe fix 改回坏形式, +# `pnpm lint:fix` 一跑就把 bug 写回来——这条实测结论没有回归覆盖,换 biome 版本 +# 后要重新验证再采信。真出现子目录 worktree 时,把它移到仓库根的 .claude/worktrees/。 **/.claude/worktrees/ .turbo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 488a5b2d..8eca767c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. ### Fixed +- **tooling**: `pnpm lint` works again from inside a worktree under `.claude/worktrees/`. The exclusion added for #439 was `**/`-prefixed, and Biome matches the traversal root by absolute path — so when Biome ran *from* such a worktree the root matched its own exclusion and `biome check .` reported "Checked 0 files" and exited non-zero, taking the pre-commit and pre-push hooks with it. The pattern is now anchored to the project root, which still keeps Biome out of a nested checkout at the root (the #439 failure). `.gitignore` deliberately keeps `**/`: git has no equivalent reverse failure, so it can afford the wider match. The two are therefore not equivalent protection — a worktree under a subdirectory's `.claude/` is git-ignored but *not* excluded from Biome, so a root `biome check .` still hits the #439 abort there; move such a worktree to the repo-root `.claude/worktrees/`. Both directions of the root case are pinned by a test that runs the real binary against a synthetic project root. (#444) - **tooling**: A git worktree under `.claude/worktrees/` — Claude Code's default location — no longer breaks the local quality gates, which also made the pre-commit and pre-push hooks unusable without `--no-verify`. The worktree is a full nested checkout, and it broke two gates independently: its own `biome.json` made root `biome check .` abort with a nested-root-configuration error before checking any file, and `git ls-files --others` reported it as a single trailing-slash entry — the way it reports an untracked *nested repository*, rather than recursing as it does for an ordinary directory — which `listPublicClaudeAssets()` then read with `readFileSync`, throwing `EISDIR`. Fixed on both levers, since Biome sets no `vcs.useIgnoreFile` and never consults `.gitignore`: a `**/.claude/worktrees/` ignore rule plus a matching `biome.json` `includes` exclusion. Directory entries are also dropped at the point where assets are read, rather than inside the listing helper, so a stray nested repository elsewhere under `.claude/` still trips the public-prefix and single-entrypoint assertions instead of being silently skipped — while no longer aborting the suite with `EISDIR`. (#439) ## [2.2.0] - 2026-07-30 diff --git a/biome.json b/biome.json index 99545afa..0655f693 100644 --- a/biome.json +++ b/biome.json @@ -59,7 +59,7 @@ "!!**/skills", "!!**/docs", "!!**/benchmarks", - "!!**/.claude/worktrees" + "!!.claude/worktrees" ] } } diff --git a/scripts/tests/workflow-rules.test.mjs b/scripts/tests/workflow-rules.test.mjs index 0429ad9d..479ad722 100644 --- a/scripts/tests/workflow-rules.test.mjs +++ b/scripts/tests/workflow-rules.test.mjs @@ -1,6 +1,17 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { readFileSync, statSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; import { assertGitWorkspaceRootMatchesCwd, getChangedFiles } from '../workflows/contract-check.mjs'; import { @@ -506,14 +517,26 @@ test('local Claude state markdown remains ignored', () => { // are pinned here: git must ignore the path, and Biome must not descend into // it (Biome sets no vcs.useIgnoreFile, so .gitignore alone does not stop the // nested-config error, which aborts the whole run before any file is checked). -// See #439. +// See #439, and #444 for why Biome's exclusion is anchored while git's is not. test('a git worktree under .claude/worktrees does not break the local gates', () => { assert.doesNotThrow(() => execFileSync('git', ['check-ignore', '-q', '.claude/worktrees/example-branch']), ); const biomeConfig = JSON.parse(readFileSync('biome.json', 'utf8')); - assert.ok(biomeConfig.files.includes.includes('!!**/.claude/worktrees')); + assert.ok( + biomeConfig.files.includes.includes('!!.claude/worktrees'), + 'biome must exclude .claude/worktrees, or a nested checkout aborts the whole run', + ); + // Anchored, not `**/`-prefixed. Biome matches the traversal root by absolute + // path, so a `**/.claude/worktrees` pattern also matches the worktree itself + // when biome runs from inside one — `biome check .` then ignores everything + // and exits non-zero, killing every local gate in the worktree (#444). The + // behavioural test below pins both directions. + assert.ok( + !biomeConfig.files.includes.some((pattern) => /^!!\*\*\/\.claude\/worktrees/u.test(pattern)), + 'a `**/`-prefixed worktrees exclusion matches the traversal root and disables lint inside a worktree (#444)', + ); // Assert the filter against synthetic input: with the ignore rule in place // git no longer emits a directory entry, so listPublicClaudeAssets() cannot @@ -524,6 +547,84 @@ test('a git worktree under .claude/worktrees does not break the local gates', () ); }); +// The config assertion above pins the pattern's *shape*; this pins what the +// shape is for, by running the real binary against a synthetic project root. +// Both directions matter and they pull against each other: excluding the +// worktree hard enough to survive its nested biome.json (#439) is what made a +// `**/` pattern also swallow the worktree when it *is* the traversal root +// (#444). Skipped when the binary is absent (no `pnpm install`) or on Windows, +// where the shim name differs — CI runs this on Linux. +const biomeBin = join('node_modules', '.bin', 'biome'); +test('biome ignores a nested worktree from the root but still checks one from inside', { + skip: process.platform === 'win32' || !existsSync(biomeBin) ? 'biome binary unavailable' : false, +}, () => { + const biomeAbsolute = join(process.cwd(), biomeBin); + // The probe below reads "did biome traverse here?" off a planted + // noDoubleEquals diagnostic. Turning that rule off in biome.json would make + // the worktree run report nothing and fail as if traversal had regressed — + // the misdirected error message #439 and #444 are both about. Fail on the + // real cause instead. The `suspicious` group already disables five rules, so + // this is not a hypothetical edit. + // Biome accepts three ways to switch the probe off — `"off"`, `{ level: + // "off" }`, and dropping the recommended preset at either level — so check + // all of them rather than the one spelling in use today. + const linterRules = JSON.parse(readFileSync('biome.json', 'utf8')).linter?.rules; + const probeRule = linterRules?.suspicious?.noDoubleEquals; + const probeMessage = + 'this test probes traversal via a planted noDoubleEquals diagnostic; pick another enabled rule if it gets disabled'; + assert.notEqual( + typeof probeRule === 'string' ? probeRule : probeRule?.level, + 'off', + probeMessage, + ); + assert.notEqual(linterRules?.recommended, false, probeMessage); + assert.notEqual(linterRules?.suspicious?.recommended, false, probeMessage); + const root = mkdtempSync(join(tmpdir(), 'frontagent-worktree-lint-')); + try { + const worktree = join(root, '.claude', 'worktrees', 'example-branch'); + mkdirSync(worktree, { recursive: true }); + // Both project roots get the real config — the behaviour under test is a + // property of biome.json, so a hand-written stub would not be evidence. + copyFileSync('biome.json', join(root, 'biome.json')); + copyFileSync('biome.json', join(worktree, 'biome.json')); + // The root file is clean; the worktree file carries one recommended-rule + // error. Which run reports it is the traversal evidence — a file count + // would also be satisfied by biome checking the config files alone. + writeFileSync(join(root, 'sample.ts'), "export const sample = 'root';\n"); + writeFileSync( + join(worktree, 'sample.ts'), + 'export function sample(a: unknown, b: unknown) {\n return a == b;\n}\n', + ); + + const fromRoot = spawnSync(biomeAbsolute, ['check', '.'], { cwd: root, encoding: 'utf8' }); + const rootOutput = `${fromRoot.stdout}${fromRoot.stderr}`; + // The nested biome.json is itself a root config: without the exclusion + // biome aborts with a nested-root-configuration error before checking + // anything, which is the #439 failure this must keep out. + assert.equal(fromRoot.status, 0, `biome failed at the root checkout:\n${rootOutput}`); + assert.doesNotMatch( + rootOutput, + /noDoubleEquals/u, + `the root run must not descend into the worktree (#439):\n${rootOutput}`, + ); + + const fromWorktree = spawnSync(biomeAbsolute, ['check', '.'], { + cwd: worktree, + encoding: 'utf8', + }); + const worktreeOutput = `${fromWorktree.stdout}${fromWorktree.stderr}`; + // With a `**/` pattern this run reports "Checked 0 files" and exits 1 — + // the gate looks like it ran and failed, without inspecting anything. + assert.match( + worktreeOutput, + /noDoubleEquals/u, + `worktree contents must still be checked (#444):\n${worktreeOutput}`, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('Claude reusable assets are public while local state stays private', () => { const publicClaudeAssets = listPublicClaudeAssets();