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
111 changes: 111 additions & 0 deletions packages/runtime/src/__tests__/builtin-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,117 @@ describe('builtin write tools path containment', () => {
});
});

describe('builtin FormatJson (file in place)', () => {
async function writeInput(root: string, name: string, content: string): Promise<string> {
const path = join(root, name);
await writeFile(path, content, 'utf8');
return name;
}

async function runFormatJson(args: { path: string; sort_keys?: boolean }, root: string) {
const t = tool('FormatJson');
return (await runTool(t, args, root)) as {
ok: boolean;
path: string;
valid: boolean;
error?: string;
bytesBefore: number;
bytesAfter?: number;
byteDelta: number;
changed: boolean;
};
}

test('happy path: validates and rewrites a minified JSON file in place', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const input = '{"b":1,"a":[2,3],"c":{"d":true}}';
const name = await writeInput(root, 'data.json', input);

const result = await runFormatJson({ path: name }, root);

const onDisk = await readFile(join(root, name), 'utf8');
expect(onDisk).toBe(JSON.stringify(JSON.parse(input), null, 2));
expect(result.ok).toBe(true);
expect(result.valid).toBe(true);
expect(result.changed).toBe(true);
expect(result.bytesBefore).toBe(Buffer.byteLength(input, 'utf8'));
expect(result.bytesAfter).toBe(Buffer.byteLength(onDisk, 'utf8'));
expect(result.byteDelta).toBe((result.bytesAfter ?? 0) - result.bytesBefore);
});

test('sort_keys: true orders object keys lexicographically', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'data.json', '{"z":1,"a":2,"m":3}');

const result = await runFormatJson({ path: name, sort_keys: true }, root);

const onDisk = await readFile(join(root, name), 'utf8');
expect(onDisk).toBe('{\n "a": 2,\n "m": 3,\n "z": 1\n}');
expect(result.changed).toBe(true);
});

test('sort_keys: true preserves __proto__ as a data property', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'data.json', '{"__proto__":{"polluted":true},"a":1}');

await runFormatJson({ path: name, sort_keys: true }, root);

const parsed = JSON.parse(await readFile(join(root, name), 'utf8')) as Record<string, unknown>;
expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true);
expect(parsed['__proto__']).toEqual({ polluted: true });
expect(parsed.a).toBe(1);
});

test('sort_keys: true sorts nested objects recursively', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'data.json', '{"outer":{"z":1,"a":2},"list":[{"b":1,"a":2}]}');

await runFormatJson({ path: name, sort_keys: true }, root);

const parsed = JSON.parse(await readFile(join(root, name), 'utf8'));
expect(Object.keys(parsed.outer)).toEqual(['a', 'z']);
expect(Object.keys(parsed.list[0])).toEqual(['a', 'b']);
});

test('invalid JSON returns a structured error diagnostic (no write, byteDelta 0)', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'data.json', 'not json');

const result = await runFormatJson({ path: name }, root);

expect(result.ok).toBe(false);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/FormatJson: invalid JSON/);
expect(result.byteDelta).toBe(0);
expect(result.changed).toBe(false);
// File is left untouched on invalid input.
expect(await readFile(join(root, name), 'utf8')).toBe('not json');
});

test('already-canonical content reports changed: false with zero byte delta', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'empty.json', '{}');

const result = await runFormatJson({ path: name }, root);

expect(result.changed).toBe(false);
expect(result.byteDelta).toBe(0);
expect(await readFile(join(root, name), 'utf8')).toBe('{}');
});

test('handles unicode and special characters in strings', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-formatjson-'));
const name = await writeInput(root, 'data.json', '{"emoji":"🎉","cjk":"你好"}');

const result = await runFormatJson({ path: name }, root);

const onDisk = await readFile(join(root, name), 'utf8');
expect(result.valid).toBe(true);
expect(onDisk).toContain('🎉');
expect(onDisk).toContain('你好');
});
});

async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
Expand Down
65 changes: 65 additions & 0 deletions packages/runtime/src/builtin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,57 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT
});
},
},
{
name: 'FormatJson',
description:
'Validate and normalize a JSON file in place. Reads the file at `path`, '
+ 'parses it (throwing a parse-error hint on invalid JSON), optionally sorts '
+ 'object keys lexicographically, and rewrites it with canonical 2-space '
+ 'indentation. Returns only a diagnostic (valid + byte delta) — the content '
+ 'is never round-tripped back through the prompt. Useful for config hygiene '
+ 'after a Write.',
parameters: z.object({
path: z.string().describe('Path to the JSON file to validate and normalize, relative to the session cwd.'),
sort_keys: z.boolean().optional()
.describe('Sort object keys lexicographically; default false.'),
}),
permissionRequired: true,
executionFacts,
impl: async ({ path, sort_keys }, { cwd }) => {
const { key } = await executor.writeLockKey({ cwd, path });
return await withFileWriteLock(key, async () => {
const { path: resolvedPath } = await executor.resolveExistingPath({ cwd, path, label: 'FormatJson' });
const { content: original } = await executor.readFile({ cwd, path: resolvedPath });
const bytesBefore = Buffer.byteLength(original, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(original);
} catch (e) {
return {
ok: false,
valid: false,
error: `FormatJson: invalid JSON: ${(e as Error).message}`,
path: resolvedPath,
bytesBefore,
byteDelta: 0,
changed: false,
};
}
const value = sort_keys ? sortKeysDeep(parsed) : parsed;
const formatted = JSON.stringify(value, null, 2);
const { bytes: bytesAfter } = await executor.writeFile({ cwd, path: resolvedPath, content: formatted });
return {
ok: true,
path: resolvedPath,
valid: true,
bytesBefore,
bytesAfter,
byteDelta: bytesAfter - bytesBefore,
changed: formatted !== original,
};
});
},
},
{
name: 'Glob',
description:
Expand Down Expand Up @@ -236,3 +287,17 @@ function assertRelativeGlobPattern(pattern: string): void {
throw new Error('Glob pattern must stay inside session cwd');
}
}

// Object.fromEntries creates own data properties, so special keys like
// "__proto__" are preserved instead of triggering the inherited setter.
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep((value as Record<string, unknown>)[key])]),
);
}
return value;
}
Loading