Skip to content

fix(ci): align the CLAUDE.md contract with the symlink, unbreaking main#244

Merged
edheltzel merged 1 commit into
mainfrom
fix/claude-md-symlink-contract
Jul 15, 2026
Merged

fix(ci): align the CLAUDE.md contract with the symlink, unbreaking main#244
edheltzel merged 1 commit into
mainfrom
fix/claude-md-symlink-contract

Conversation

@edheltzel

Copy link
Copy Markdown
Owner

main is red. 5336d5f ("claude.md is a symlink to agents.md") changed CLAUDE.md to a symlink but left the test and docs asserting the old @AGENTS.md shim.

(fail) CLAUDE.md is a shim that @-imports AGENTS.md (no duplicated content)
Expected substring or pattern: /^@AGENTS\.md\s*$/m

5336d5f = CI failure · 58cb1d1 = success. This unblocks it.

Why both assertions broke

Reading CLAUDE.md now follows the link and returns all of AGENTS.md, so:

  • expect(body).toMatch(/^@AGENTS\.md\s*$/m) — the import line no longer exists
  • expect(body).not.toContain('.agents/atlas/artifacts/') — it does now; that's AGENTS.md's own content

The fix: assert the link, not the content

The contract being defended is unchanged — "CLAUDE.md must never carry a second copy of the guide." A symlink satisfies it by construction: there is literally one file, so content cannot drift. A content assertion here would just be asserting AGENTS.md against itself.

const link = lstatSync(join(repoRoot, 'CLAUDE.md'));
expect(link.isSymbolicLink()).toBe(true);
expect(readlinkSync(join(repoRoot, 'CLAUDE.md'))).toBe('AGENTS.md');
expect(read('CLAUDE.md')).toBe(read('AGENTS.md'));

Plus a guard for the symlink's real failure mode

With core.symlinks=false (a common Windows default), git materializes CLAUDE.md as a plain file whose entire content is the literal string AGENTS.md — and Claude Code would load that as the whole guide, silently, with no error. The @-import shim had no such failure mode, so this risk is new and worth a test:

test('CLAUDE.md did not degrade into a literal-path text file', () => {
  const body = read('CLAUDE.md');
  expect(body.trim()).not.toBe('AGENTS.md');
  expect(body).toContain('.agents/atlas/artifacts/');
});

Scope note: this affects contributors only. The CLAUDE.md Recall installs for users is generated from templates/CLAUDE.md.template and is untouched by this.

Docs realigned

They described a mechanism that no longer exists:

  • AGENTS.md:3, :37, :105 — "thin shim that @-imports" → "symlink"
  • AGENTS.mdAgent Context Files — documents the symlink, the /init hazard (now "replaces the symlink with a copy"), and the core.symlinks=false limitation
  • docs/releasing.md pre-flight — this mattered: it told maintainers to run cat CLAUDE.md and confirm it prints "the @AGENTS.md import — nothing else." That now prints the entire guide, so the pre-flight would fail every release. Changed to ls -l CLAUDE.md (expects CLAUDE.md -> AGENTS.md); recovery is ln -sf AGENTS.md CLAUDE.md.

Verification

tests/commands/scout-workflow.test.ts: 27 pass / 0 fail. bun run lint clean. New pre-flight command verified against the real file:

$ ls -l CLAUDE.md
lrwxr-xr-x  CLAUDE.md -> AGENTS.md

Blocks #242 and the v0.9.4 patch release — merge this first.

5336d5f made CLAUDE.md a symlink to AGENTS.md (git mode 120000) but left the
test and docs asserting the old `@AGENTS.md` shim, so main has been red since:

  (fail) CLAUDE.md is a shim that @-imports AGENTS.md (no duplicated content)
  Expected substring or pattern: /^@agentS\.md\s*$/m

Reading CLAUDE.md now follows the link and yields all of AGENTS.md, so both
assertions failed: the regex, and `not.toContain('.agents/atlas/artifacts/')`.

The contract the test defends is unchanged -- "CLAUDE.md must never carry a
second copy of the guide" -- but a symlink satisfies it by construction: there
is literally one file, so content cannot drift. So assert the LINK, not the
content. A content assertion here would just be asserting AGENTS.md twice.

Adds a guard for the symlink's known failure mode: with core.symlinks=false (a
common Windows default) git materializes CLAUDE.md as a plain file whose whole
content is the literal string "AGENTS.md", and Claude Code would silently load
that as the entire guide. The new test fails loudly instead. This affects only
contributors -- the CLAUDE.md installed for users comes from
templates/CLAUDE.md.template and is unaffected.

Docs realigned (they described a mechanism that no longer exists):
- AGENTS.md:3, :37, :105 -- "thin shim that @-imports" -> "symlink"
- AGENTS.md Agent Context Files -- documents the symlink, the /init hazard, and
  the core.symlinks=false limitation
- docs/releasing.md pre-flight -- `cat CLAUDE.md` would now print the whole
  guide, so the check is `ls -l CLAUDE.md`; recovery is `ln -sf AGENTS.md CLAUDE.md`
@edheltzel
edheltzel merged commit 2e2564e into main Jul 15, 2026
2 checks passed
@edheltzel
edheltzel deleted the fix/claude-md-symlink-contract branch July 15, 2026 18:02
edheltzel added a commit that referenced this pull request Jul 15, 2026
…g-regressions

* origin/main:
  fix(ci): align the CLAUDE.md contract with the symlink, unbreaking main (#244)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant