diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index e319ae085ab3..9c9fa23841a2 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -56,12 +56,24 @@ Fork Workflow section pointing to .fork/AGENTS.md, which defines the branch model (work from custom, never touch main) and the placement ladder for fork changes. + CLAUDE.md is a Tier-4 inline edit the fork owns outright, and it + deliberately diverges from the blob upstream ships. Upstream's committed + symlink target carries a trailing newline, so it names a file that cannot + exist and CLAUDE.md resolves to nothing — upstream fixed that in 5e13f5357 + and reintroduced it in 6891c77d3, so it recurs. The fork's blob is the + 9-byte target with no newline. Resolve any sync conflict on CLAUDE.md by + keeping the fork's side; taking upstream's restores the bug and silently + strips the rules from every Claude agent. It is the one Tier-4 edit that + cannot carry the usual begin/end fence comments: a symlink's blob is its + target verbatim, so a comment marker inside it would break the very link + the marker exists to describe. The watch entry is the whole fence. tier: 4 files: - .fork/AGENTS.md shadows: [] watch: - AGENTS.md + - CLAUDE.md verify: - apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553f..47dc3e3d863c 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts index 773b02340278..49d625749394 100644 --- a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts +++ b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts @@ -5,8 +5,13 @@ * AGENTS.md is upstream-owned, so an upstream rewrite could silently drop the * fenced Fork Workflow section in a "clean" sync — and with it, every future * agent's knowledge of the branch rules. Fail loudly instead. + * + * CLAUDE.md is watched for the same reason and owned by the fork: its blob + * deliberately differs from upstream's, which carries a trailing newline that + * breaks the link. Never resolve a sync conflict on it by taking upstream. */ +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -26,9 +31,47 @@ describe("fork guard: fork-workflow-docs", () => { }); it("keeps CLAUDE.md aliased to AGENTS.md so Claude agents get the same rules", () => { - const link = NodeFS.lstatSync(NodePath.join(repoRoot, "CLAUDE.md")); - expect(link.isSymbolicLink()).toBe(true); - // The committed symlink blob carries a trailing newline in its target. - expect(NodeFS.readlinkSync(NodePath.join(repoRoot, "CLAUDE.md")).trim()).toBe("AGENTS.md"); + // Assert the committed object, not the working tree. What an agent gets + // is whatever the clone materializes from this blob, and a checkout with + // core.symlinks=false writes a regular file instead — so a working-tree + // check reds against a perfectly correct commit. The blob is also where + // the bug lives: no trim, because a symlink's target is its blob + // verbatim, and a trailing newline aims it at a filename that cannot + // exist. That is exactly the state the old `.trim()` assertion + // normalized away, holding its value while upstream fixed the newline in + // 5e13f5357 and reintroduced it in 6891c77d3. + const lsTree = NodeChildProcess.execSync("git ls-tree HEAD -- CLAUDE.md", { + cwd: repoRoot, + encoding: "utf8", + }); + expect(lsTree.split(/\s+/u)[0]).toBe("120000"); + const target = NodeChildProcess.execSync("git cat-file -p HEAD:CLAUDE.md", { + cwd: repoRoot, + encoding: "utf8", + }); + expect(target).toBe("AGENTS.md"); + }); + + it("resolves CLAUDE.md to the rules rather than merely pointing at them", () => { + // The promise in the manifest is that an agent opening CLAUDE.md learns + // the fork rules. That is a statement about what reading it produces, not + // about how the link is spelled, so read through it. This covers what the + // committed-blob check above cannot: a conflicted or dirty working tree, + // where HEAD still holds the fork's good blob. + const claudePath = NodePath.join(repoRoot, "CLAUDE.md"); + if (!NodeFS.lstatSync(claudePath).isSymbolicLink()) { + // core.symlinks=false checkout — git wrote the target as a regular + // file. Read-through is not a property this checkout can have, and the + // commit is still correct, so assert the fallback shape rather than a + // false red. + expect(NodeFS.readFileSync(claudePath, "utf8")).toBe("AGENTS.md"); + return; + } + // Precondition, so a broken link fails as an assertion rather than an + // ENOENT thrown out of readFileSync. + expect(NodeFS.existsSync(claudePath)).toBe(true); + const claude = NodeFS.readFileSync(claudePath, "utf8"); + const agents = NodeFS.readFileSync(NodePath.join(repoRoot, "AGENTS.md"), "utf8"); + expect(claude).toBe(agents); }); });