Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ AGENTS.md already exists. Overwrite? [y]es / [n]o / [a]ll / [s]kip-all / [m]erge

| File type | Merge behaviour |
|---|---|
| Markdown (`AGENTS.md`, `CLAUDE.md`, agent prompts, command files) | Structured merge by top-level heading. User-edited sections are preserved. Sections marked `<!-- agents-workflows:managed -->` are updated by the generator. |
| JSON (`.claude/settings.json`, Codex config) | Deep-merge with array union (de-duplicated). User wins on scalar conflicts for non-managed keys. |
| Any other format | Falls back to yes / no / all / skip; no structured merge option is offered. |
| Managed Markdown (`AGENTS.md`, `CLAUDE.md`, nested `AGENTS.md`) | Replaces the generated managed block and preserves user-authored content after `<!-- agents-workflows:managed-end -->`. |
| JSON (`.claude/settings.json`) | Deep-merge with array union (de-duplicated). User wins on scalar conflicts except generator-controlled safety keys. |
| Any other format | Falls back to yes / no / all / skip; no structured merge option is offered. In `--merge-strategy=merge` mode, files without merge support are skipped and listed in the update summary; use `--merge-strategy=overwrite` or `--yes` to refresh them. |

### CI usage

Expand Down
4 changes: 3 additions & 1 deletion src/cli/update-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ export async function updateCommand(

logger.success(`Updated ${writeResult.writtenPaths.length} file(s).`);
if (writeResult.skippedPaths.length > 0) {
logger.warn(`${writeResult.skippedPaths.length} existing Markdown file(s) were left unchanged.`);
logger.warn(
`${writeResult.skippedPaths.length} existing file(s) were left unchanged: ${writeResult.skippedPaths.join(', ')}`,
);
}
});
}
Expand Down
10 changes: 6 additions & 4 deletions src/generator/generate-root-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { renderTemplate } from '../utils/template-renderer.js';
import type { StackConfig } from '../schema/stack-config.js';
import type { WorkspaceStack } from '../schema/workspace-stack.js';
import type { GeneratorContext, GeneratedFile } from './types.js';
import { mergeManagedTail } from './managed-sentinel.js';
import { mergeJson } from './merge-json.js';

/**
* Generate root-level configuration and documentation files based on the provided stack configuration.
Expand All @@ -22,10 +24,10 @@ export async function generateRootConfig(

if (config.targets.claudeCode) {
const claudeMd = await renderTemplate('config/CLAUDE.md.ejs', context);
files.push({ path: 'CLAUDE.md', content: claudeMd });
files.push({ path: 'CLAUDE.md', content: claudeMd, merge: mergeManagedTail });

const settings = await renderTemplate('config/settings.json.ejs', context);
files.push({ path: '.claude/settings.json', content: settings });
files.push({ path: '.claude/settings.json', content: settings, merge: mergeJson });
}

if (config.targets.codexCli) {
Expand All @@ -37,7 +39,7 @@ export async function generateRootConfig(
}

const agentsMd = await renderTemplate('config/AGENTS.md.ejs', context);
files.push({ path: 'AGENTS.md', content: agentsMd });
files.push({ path: 'AGENTS.md', content: agentsMd, merge: mergeManagedTail });

const agentsDeploymentMd = await renderTemplate('config/AGENTS-DEPLOYMENT.md.ejs', context);
files.push({ path: 'AGENTS-DEPLOYMENT.md', content: agentsDeploymentMd });
Expand Down Expand Up @@ -123,6 +125,6 @@ async function emitNestedAgentsFiles(options: {

const content = await renderTemplate('config/workspace-AGENTS.md.ejs', { workspace });
const filePath = posixPath.join(workspace.path, 'AGENTS.md');
files.push({ path: filePath, content });
files.push({ path: filePath, content, merge: mergeManagedTail });
}
}
34 changes: 25 additions & 9 deletions src/generator/merge-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ export interface JsonObject {
export type JsonArray = JsonValue[];

/**
* Keys controlled by the generator; incoming value wins for these keys.
* Extend this array as new managed keys are identified.
* Legacy key-level managed list. Prefer MANAGED_JSON_PATHS for new entries so
* common leaf names such as "mode" do not become generator-controlled globally.
*/
export const MANAGED_JSON_KEYS: readonly string[] = [];

const MANAGED_JSON_PATHS: readonly string[] = [
'permissions.disableBypassPermissionsMode',
'sandbox.autoAllowBashIfSandboxed',
'sandbox.enabled',
'sandbox.mode',
];

function isJsonObject(value: JsonValue): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
Expand All @@ -32,7 +39,9 @@ function sortKeys(value: JsonValue): JsonValue {
}
return value;
}
const sorted: JsonObject = {};
// Null-prototype objects prevent special keys like "__proto__" from
// mutating prototypes while we recursively copy parsed JSON data.
const sorted = Object.create(null) as JsonObject;
for (const key of Object.keys(value).sort()) {
sorted[key] = sortKeys(value[key]);
}
Expand Down Expand Up @@ -88,28 +97,35 @@ function mergeArrays(existing: JsonArray, incoming: JsonArray): JsonArray {
return unionMixedArrays(existing, incoming);
}

function deepMerge(existing: JsonValue, incoming: JsonValue, key: string): JsonValue {
function isManagedJsonPath(path: string): boolean {
return MANAGED_JSON_PATHS.includes(path);
}

function deepMerge(existing: JsonValue, incoming: JsonValue, key: string, path: string): JsonValue {
if (isJsonObject(existing) && isJsonObject(incoming)) {
return mergeObjects(existing, incoming);
return mergeObjects(existing, incoming, path);
}
if (Array.isArray(existing) && Array.isArray(incoming)) {
return mergeArrays(existing as JsonArray, incoming as JsonArray);
}
// Scalar conflict: user (existing) wins unless key is managed
if (MANAGED_JSON_KEYS.includes(key)) {
if (MANAGED_JSON_KEYS.includes(key) || isManagedJsonPath(path)) {
return incoming;
}
return existing;
}

function mergeObjects(existing: JsonObject, incoming: JsonObject): JsonObject {
const result: JsonObject = {};
function mergeObjects(existing: JsonObject, incoming: JsonObject, basePath = ''): JsonObject {
// See sortKeys: keep intermediate merge objects null-prototype so attacker-
// controlled JSON keys are treated as data, not prototype setters.
const result = Object.create(null) as JsonObject;
const allKeys = new Set([...Object.keys(existing), ...Object.keys(incoming)]);
for (const key of allKeys) {
const childPath = basePath.length > 0 ? `${basePath}.${key}` : key;
const inExisting = Object.prototype.hasOwnProperty.call(existing, key);
const inIncoming = Object.prototype.hasOwnProperty.call(incoming, key);
if (inExisting && inIncoming) {
result[key] = deepMerge(existing[key], incoming[key], key);
result[key] = deepMerge(existing[key], incoming[key], key, childPath);
} else if (inExisting) {
result[key] = existing[key];
} else {
Expand Down
5 changes: 2 additions & 3 deletions src/generator/write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,8 @@ export async function writeFileSafe(input: WriteFileInput): Promise<WriteFileRes
await performWrite(path, merged);
return { status: 'merged', path };
}
logger.warn(`No merge function provided for ${label}; overwriting instead.`);
await performWrite(path, content);
return { status: 'written', path };
logger.warn(`No merge function provided for ${label}; skipping to avoid overwriting.`);
return { status: 'skipped', path };
}

const diff = renderUnifiedDiff({ path: label, before: existing, after: content });
Expand Down
35 changes: 35 additions & 0 deletions tests/generator/generate-all.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { generateAll } from '../../src/generator/index.js';
import { mergeManagedTail } from '../../src/generator/managed-sentinel.js';
import { mergeJson } from '../../src/generator/merge-json.js';
import { makeStackConfig } from './fixtures.js';
import { CODE_REVIEWER_MAX_LINES } from './code-reviewer-config.js';

Expand Down Expand Up @@ -71,6 +73,38 @@ describe('generateAll', () => {
}
});

it('attaches merge callbacks to generated root files with supported merge semantics', async () => {
const files = await generateAll(makeConfig());
const filesByPath = new Map(files.map((file) => [file.path, file]));

expect(filesByPath.get('CLAUDE.md')?.merge).toBe(mergeManagedTail);
expect(filesByPath.get('AGENTS.md')?.merge).toBe(mergeManagedTail);
expect(filesByPath.get('.claude/settings.json')?.merge).toBe(mergeJson);
});

it('attaches a managed-tail merge callback to nested AGENTS.md files', async () => {
const config = makeConfig();
config.monorepo = {
isRoot: true,
tool: 'pnpm',
workspaces: [
{
path: 'api',
language: 'python',
runtime: 'python',
framework: null,
packageManager: 'uv',
commands: { typeCheck: null, test: 'pytest', lint: null, build: null },
},
],
};

const files = await generateAll(config);
const nestedAgentsMd = files.find((file) => file.path === 'api/AGENTS.md');

expect(nestedAgentsMd?.merge).toBe(mergeManagedTail);
});

it('renders configured project structure paths in CLAUDE.md and AGENTS.md', async () => {
const config = makeConfig({
paths: {
Expand Down Expand Up @@ -182,6 +216,7 @@ describe('generateAll', () => {
expect(nestedAgents?.content).toContain('mypy .');
expect(nestedAgents?.content).toContain('agents-workflows:managed-start');
expect(nestedAgents?.content).toContain('agents-workflows:managed-end');
expect(nestedAgents?.merge).toBe(mergeManagedTail);
});

it('renders the configured primary branch in workflow and git guidance', async () => {
Expand Down
52 changes: 52 additions & 0 deletions tests/generator/merge-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,58 @@ describe('mergeJson', () => {
expect(result.outer.shared).toBe('user');
});

it('preserves __proto__ as data without changing the merged object prototype', () => {
const existing = '{"__proto__":{"polluted":true},"safe":"user"}';
const incoming = json({ generated: true });

const result = parse(mergeJson({ existing, incoming })) as Record<string, unknown>;
const protoDescriptor = Object.getOwnPropertyDescriptor(result, '__proto__');

expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype).not.toHaveProperty('polluted');
expect(protoDescriptor?.value).toEqual({ polluted: true });
expect(result.generated).toBe(true);
});

it('lets generator-controlled hard safety scalars win during conflicts', () => {
const existing = json({
permissions: {
defaultMode: 'default',
disableBypassPermissionsMode: 'enable',
},
sandbox: {
enabled: false,
mode: 'danger-full-access',
},
});
const incoming = json({
permissions: {
defaultMode: 'acceptEdits',
disableBypassPermissionsMode: 'disable',
},
sandbox: {
enabled: true,
mode: 'workspace-write',
},
});

const result = parse(mergeJson({ existing, incoming })) as {
permissions: {
defaultMode: string;
disableBypassPermissionsMode: string;
};
sandbox: {
enabled: boolean;
mode: string;
};
};

expect(result.permissions.defaultMode).toBe('default');
expect(result.permissions.disableBypassPermissionsMode).toBe('disable');
expect(result.sandbox.enabled).toBe(true);
expect(result.sandbox.mode).toBe('workspace-write');
});

it('scalar conflict — user value wins', () => {
const existing = json({ x: 1 });
const incoming = json({ x: 2 });
Expand Down
10 changes: 5 additions & 5 deletions tests/generator/write-file-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,21 @@ describe('writeFileSafe — session overrides and special cases', () => {
await expect(readFile(path, 'utf-8')).resolves.toBe('base|patch');
});

it('falls back to overwrite with a warn when override is merge but no merge fn provided', async () => {
it('skips with a warn when override is merge but no merge fn provided', async () => {
const prompt = makePrompt('n');
configureWriteSession({ override: 'merge' });
const path = join(tmpDir, 'file.md');
await writeFile(path, 'old', 'utf-8');

const result = await writeFileSafe({ path, content: 'new' });

expect(result).toEqual({ status: 'written', path });
expect(result).toEqual({ status: 'skipped', path });
expect(prompt).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalled();
await expect(readFile(path, 'utf-8')).resolves.toBe('new');
await expect(readFile(path, 'utf-8')).resolves.toBe('old');
});

it('S4: fallback-overwrite warn message contains merge and overwriting', async () => {
it('S4: fallback-skip warn message contains merge and skipping', async () => {
makePrompt('n');
configureWriteSession({ override: 'merge' });
const path = join(tmpDir, 'file.md');
Expand All @@ -89,7 +89,7 @@ describe('writeFileSafe — session overrides and special cases', () => {
await writeFileSafe({ path, content: 'new' });

expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/merge.*overwriting/i),
expect.stringMatching(/merge.*skipping/i),
);
});

Expand Down
Loading