diff --git a/CHANGELOG.md b/CHANGELOG.md index b98a2ac..b924a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Fixed +- **`charter adf compile --target claude --write --mode compiled` no longer carries the template's empty `## Environment` placeholder into the retained block** (`#364`). A `CLAUDE.md` written by any bootstrap since 0.3.3 (the 1.9.3 upgrade path included) holds `## Environment` with only the `` comment. When you had added a second `## Environment` of your own, converting the pointer to compiled output carried both, comment included, and reported `kept ## Environment, ## Environment`. Each retained section is now compared with the pointer templates on its own (ignoring CRLF, whitespace at line ends and blank lines around the body), so the untouched placeholder is dropped as template text and only your section is carried. A placeholder you wrote under is yours and is carried as before. A retained block that an earlier compile already wrote is copied verbatim as always: if yours holds the placeholder, delete it from the block by hand. - **`charter adf migrate` and `charter bootstrap` wrote the generic "This project uses [ADF]…" pointer instead of the named template when run from the CLI** (`#355`). This hit `GEMINI.md`, `AGENTS.md`, `.cursorrules` and `copilot-instructions.md` in the default mode, and `CLAUDE.md` too under `--replace`. `adf-migrate` built its template table when the module loaded, and the CLI loads it while `adf.ts` is still initialising (`index` → `doctor` → `adf` → `adf-migrate`), so every entry was `undefined`. The template is now looked up when the pointer is written. Unit tests that imported `adf-migrate` directly never saw the cycle. - **`charter adf populate`, and so every `charter bootstrap`, re-added stack constraints to a `CONSTRAINTS` section you had edited** (`#371`; 2.1.0 listed this as still open under `#354`). Deleting a bullet from `core.adf` and re-running bootstrap brought a stack rule back (for example the Cloudflare `No Node.js-specific APIs in Worker handlers` rule), because populate appended any detected stack constraint it did not find. Populate now remembers what it has added or offered in a new file, `.ai/.populate.json` (`{ "version": 1, "constraints": { "core.adf": { "added": [...], "offered": [...] } } }`, sorted, written only when it changes). Commit it with the rest of `.ai/`; bootstrap lists it under what it created, and its `git add .ai` next step already includes it. The rules: - A constraint recorded as added or offered is never added again, whether it is still in the section or you deleted it. Deleting the one rule populate added to a plain Worker repo now sticks, and a deleted `CONSTRAINTS` section is not recreated once the file has a record. diff --git a/packages/cli/src/__tests__/adf-retained.test.ts b/packages/cli/src/__tests__/adf-retained.test.ts index e9f1924..5bd81cd 100644 --- a/packages/cli/src/__tests__/adf-retained.test.ts +++ b/packages/cli/src/__tests__/adf-retained.test.ts @@ -430,6 +430,88 @@ describe('compile --write --mode compiled carries retained sections', () => { }); }); +// ============================================================================ +// 3b. the template's ## Environment placeholder is template text (#364) +// ============================================================================ + +describe('the untouched ## Environment placeholder is never carried (#364)', () => { + // CLAUDE.md exactly as `npx @stackbilt/cli@1.9.3 bootstrap --yes` writes it + // (published tarball, default preset). The placeholder is unchanged since 1.9.3. + const V193_CLAUDE_MD = [ + '# CLAUDE.md', '', + '> **DO NOT add rules, constraints, or context to this file.**', + '> This file is auto-managed by Charter. All project rules live in `.ai/`.', + '> New rules should be added to the appropriate `.ai/*.adf` module.', + '> See `.ai/manifest.adf` for the module routing manifest.', '', + '## Session Start', '', + 'Call `charter_context` (MCP tool) before any other action. It returns the live constraint surface — open work, recent decisions, and metric ceilings — so you act within current project state rather than reconstructing it from scratch.', '', + 'If `charter serve` is not running, read `.ai/context.snapshot.json` directly or run `charter context-refresh` to generate it.', '', + '## Module Index', + '', + '| Module | Triggers | Load |', + '|--------|----------|------|', + '| core.adf | _(always loaded)_ | DEFAULT |', + '| state.adf | _(always loaded)_ | DEFAULT |', + '| frontend.adf | React, CSS, UI | ON_DEMAND |', + '| backend.adf | API, Node, DB | ON_DEMAND |', + '', '', + '## Environment', + '', '', + ].join('\n'); + const PLACEHOLDER = ''; + const USER_ENV = '## Environment\n- Windows host; use Git Bash.'; + const withUserEnv = `${V193_CLAUDE_MD}\n${USER_ENV}\n`; + + it('the 1.9.3 file still holds the current template\'s placeholder', () => { + expect(POINTER_CLAUDE_MD_HYBRID).toContain(`## Environment\n${PLACEHOLDER}\n`); + expect(V193_CLAUDE_MD).toContain(`## Environment\n${PLACEHOLDER}\n`); + }); + + it('placeholder only: nothing is carried', () => { + expect(composeCompiled(FRESH, V193_CLAUDE_MD, 'CLAUDE.md')).toMatchObject({ output: FRESH, retainedSections: [], retainedSource: 'none' }); + }); + + it('placeholder plus a user ## Environment: only the user section is carried', () => { + const c = composeCompiled(FRESH, withUserEnv, 'CLAUDE.md'); + expect(c).toMatchObject({ retainedSource: 'pointer', retainedSections: ['## Environment'], body: USER_ENV }); + expect(c.output).toBe(`${FRESH.trimEnd()}\n\n${block(USER_ENV)}`); + expect(c.output).not.toContain(PLACEHOLDER); + expect(linesConvertWouldDrop(withUserEnv, c.output)).toEqual([]); + }); + + it('a placeholder the user wrote under is the user\'s: both sections are carried', () => { + const edited = withUserEnv.replace(`${PLACEHOLDER}\n`, `${PLACEHOLDER}\n- WSL for builds\n`); + const c = composeCompiled(FRESH, edited, 'CLAUDE.md'); + expect(c.retainedSections).toEqual(['## Environment', '## Environment']); + expect(c.body).toBe(`## Environment\n${PLACEHOLDER}\n- WSL for builds\n\n${USER_ENV}`); + }); + + it('CRLF line endings and trailing spaces on the placeholder change nothing', () => { + const crlf = withUserEnv.replace(`${PLACEHOLDER}\n`, `${PLACEHOLDER} \n`).replace(/\n/g, '\r\n'); + const c = composeCompiled(FRESH, crlf, 'CLAUDE.md'); + expect(c).toMatchObject({ retainedSource: 'pointer', retainedSections: ['## Environment'], body: USER_ENV }); + expect(composeCompiled(FRESH, V193_CLAUDE_MD.replace(/\n/g, '\r\n'), 'CLAUDE.md').output).toBe(FRESH); + }); + + it('compile --write --mode compiled on the 1.9.3 upgrade path writes only the user section', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', withUserEnv); + const r = await runJson(['compile', '--target', 'claude', '--write', '--mode', 'compiled']); + expect(r.code).toBe(0); + expect(r.json.targets[0]).toMatchObject({ status: 'written', previousMode: 'pointer', retainedSections: ['## Environment'], retainedSource: 'pointer' }); + expect(read('CLAUDE.md')).toBe(`${(await fresh('claude')).trimEnd()}\n\n${block(USER_ENV)}`); + }); + + it('compile --write --mode compiled on an untouched 1.9.3 file writes the bare render', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', V193_CLAUDE_MD.replace(/\n/g, '\r\n')); + const r = await runJson(['compile', '--target', 'claude', '--write', '--mode', 'compiled']); + expect(r.code).toBe(0); + expect(r.json.targets[0]).toMatchObject({ status: 'written', retainedSections: [] }); + expect(read('CLAUDE.md')).toBe(await fresh('claude')); + }); +}); + // ============================================================================ // 4. vendor restore (K2, K3) // ============================================================================ diff --git a/packages/cli/src/commands/adf-compile.ts b/packages/cli/src/commands/adf-compile.ts index 2628b98..1be5dba 100644 --- a/packages/cli/src/commands/adf-compile.ts +++ b/packages/cli/src/commands/adf-compile.ts @@ -256,52 +256,66 @@ function pointerTemplatesFor(filename: string): string[] { return byFilename[filename] ?? []; } +/** A retained section's identity: its heading and its body, line ends trimmed. */ +function sectionKey(heading: string, bodyLines: readonly string[]): string { + return `${heading}\n${bodyLines.map(l => l.trimEnd()).join('\n').trim()}`; +} + /** - * Split a retained-sections block (from readRetainedSections) into heading → body. - * Charter-managed sentinel blocks are stripped first, so a populated - * `## Module Index` compares equal to the empty one in the template. + * The retained sections of `content` (from readRetainedSections), in order. + * A heading that occurs twice gives two entries (#364). Charter-managed + * sentinel blocks are stripped first, so a populated `## Module Index` + * compares equal to the empty one in the template. */ -function retainedSectionMap(content: string): Map { - const block = readRetainedSections(stripCharterSentinels(content)); - const sections = new Map(); +function retainedSectionKeys(content: string): Array<{ heading: string; key: string }> { + const block = readRetainedSections(stripCharterSentinels(content.replace(/\r\n/g, '\n'))); + const sections: Array<{ heading: string; key: string }> = []; let heading: string | null = null; let body: string[] = []; + const flush = (): void => { + if (heading) sections.push({ heading, key: sectionKey(heading, body) }); + }; for (const line of block.split('\n')) { if (line.trim().startsWith('## ')) { - if (heading) sections.set(heading, body.join('\n').trim()); + flush(); heading = line.trim(); body = []; continue; } if (heading) body.push(line); } - if (heading) sections.set(heading, body.join('\n').trim()); + flush(); return sections; } +/** + * True when a retained section (heading plus body lines) is, line ends aside, + * one a pointer template for this file carries untouched, such as the + * `## Environment` placeholder with its HTML comment. It is template text, not + * the user's, so it is never carried into a retained block (#364). + */ +export function isTemplateRetainedSection(filename: string, heading: string, bodyLines: readonly string[]): boolean { + const key = sectionKey(heading, bodyLines); + return pointerTemplatesFor(filename).some(t => retainedSectionKeys(t).some(s => s.key === key)); +} + /** * Headings whose retained content differs from every pointer template for this * file — i.e. notes a user wrote into the `## Environment` slot the templates * invite them to use, or an operational `## ... Protocol` block that `adf tidy` - * deliberately keeps in the vendor file (#198). + * deliberately keeps in the vendor file (#198). Each section is judged on its + * own: a heading listed twice is divergent when either copy is (#364). * * No `.ai/` module holds these sections, so converting a pointer to compiled * output carries them into its retained block (adf-retained.ts, #303). */ export function divergentRetainedHeadings(existing: string, filename: string): string[] { - const templates = pointerTemplatesFor(filename); - const pristine = new Set(); - for (const template of templates) { - for (const [heading, body] of retainedSectionMap(template)) { - pristine.add(`${heading}\n${body}`); - } - } - + const pristine = new Set(pointerTemplatesFor(filename).flatMap(t => retainedSectionKeys(t).map(s => s.key))); const divergent: string[] = []; - for (const [heading, body] of retainedSectionMap(existing)) { - if (!pristine.has(`${heading}\n${body}`)) divergent.push(heading); + for (const { heading, key } of retainedSectionKeys(existing)) { + if (!pristine.has(key) && !divergent.includes(heading)) divergent.push(heading); } return divergent; } diff --git a/packages/cli/src/commands/adf-retained.ts b/packages/cli/src/commands/adf-retained.ts index b7b1e45..262d522 100644 --- a/packages/cli/src/commands/adf-retained.ts +++ b/packages/cli/src/commands/adf-retained.ts @@ -31,7 +31,7 @@ import * as path from 'node:path'; import { readRetainedSections } from './adf-tidy'; import { inferVendorMode, linesRestoreWouldDrop } from './adf-vendor'; import type { VendorFile } from './adf-vendor'; -import { divergentRetainedHeadings } from './adf-compile'; +import { divergentRetainedHeadings, isTemplateRetainedSection } from './adf-compile'; export const RETAINED_START = ''; export const RETAINED_END = ''; @@ -422,8 +422,12 @@ export function composeCompiled(fresh: string, existing: string | null, file: st body = joinSections(found.carry.map(take)); source = 'legacy'; } else if (mode === 'pointer') { + // Each section is judged on its own: an untouched template placeholder is + // not carried even when the user added a second section of that name (#364). const divergent = new Set(divergentRetainedHeadings(normalized, file).filter(h => h !== '## Module Index')); - body = joinSections(sectionsOf(lines, fullCode).filter(s => divergent.has(s.heading)).map(take)); + body = joinSections(sectionsOf(lines, fullCode) + .filter(s => divergent.has(s.heading) && !isTemplateRetainedSection(file, s.heading, lines.slice(s.start + 1, s.end))) + .map(take)); source = 'pointer'; } else { body = joinSections(sectionsOf(lines, fullCode).filter(s => isRetained(s.heading) && s.heading !== '## Module Index').map(take));