fix: migrate JSON helpers to jsonc-parser for format-preserving in-place edits

- Replace hand-rolled `JSON.parse`/`JSON.stringify` round-trips in `json.ts` with `jsonc-parser` `modify` + `applyEdits` so comments, trailing commas, indentation, and key ordering are preserved across all `setJsonPath`, `unsetJsonPath`, `mergeJson`, and `addPackageDependency` calls
- Add `setJsonPathSegments` / `unsetJsonPathSegments` for callers whose keys contain `.` (tsconfig `paths` aliases like `"@acme/ui.foo/*"` would be mis-split by the dot-path API); export both from `packages/codemods/src/index.ts`
- Refactor `set-tsconfig-paths` to use the new segment-aware helpers and drop its local `parseJsonc` + `applyEdits` usage — the builtin no longer performs raw `fs.readFileSync`/`writeFileSync` itself
- Add `jsonc-parser` to `apps/cli/package.json` (was already a transitive dep; now explicit)
- Add `"format preservation (JSONC)"` test suite covering comment/trailing-comma survival, key-ordering stability under `addPackageDependency`, dotted-key segment addressing, and no-op behavior when a parent key is absent
This commit is contained in:
2026-05-26 22:34:18 -04:00
parent cf233f8275
commit e3f5b977ba
6 changed files with 200 additions and 97 deletions
+1
View File
@@ -49,6 +49,7 @@
"@clack/prompts": "^1.4.0",
"citty": "^0.2.2",
"handlebars": "^4.7.9",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"semver": "^7.8.1",
"ts-morph": "^28.0.0",
@@ -2,9 +2,15 @@ import fs from "node:fs";
import path from "node:path";
import { assertSafeRelativePath } from "@stanza/registry";
import { applyEdits, modify, parse, type ParseError, printParseErrorCode } from "jsonc-parser";
import type { Codemod } from "../index";
import {
type Codemod,
readJson,
setJsonPath,
setJsonPathSegments,
unsetJsonPath,
unsetJsonPathSegments,
} from "../index";
/**
* Merge entries into a `tsconfig.json`'s `compilerOptions.paths` map.
@@ -55,8 +61,7 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
);
}
let text = fs.readFileSync(abs, "utf8");
const root = parseJsonc(text, rel);
const root = readRoot(abs);
const compilerOptions = isRecord(root.compilerOptions) ? root.compilerOptions : {};
const existing = isRecord(compilerOptions.paths) ? compilerOptions.paths : {};
@@ -91,12 +96,11 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
}
if (compilerOptions.baseUrl === undefined) {
text = applyEdits(text, modify(text, ["compilerOptions", "baseUrl"], ".", FORMAT));
setJsonPath(abs, "compilerOptions.baseUrl", ".");
}
for (const [key, val] of Object.entries(args.paths)) {
text = applyEdits(text, modify(text, ["compilerOptions", "paths", key], val, FORMAT));
setJsonPathSegments(abs, ["compilerOptions", "paths", key], val);
}
fs.writeFileSync(abs, text, "utf8");
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
@@ -109,43 +113,32 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
return { touchedFiles: [] };
}
let text = fs.readFileSync(abs, "utf8");
const root = parseJsonc(text, rel);
const root = readRoot(abs);
const compilerOptions = isRecord(root.compilerOptions) ? root.compilerOptions : {};
const paths = isRecord(compilerOptions.paths) ? compilerOptions.paths : {};
let changed = false;
for (const [key, val] of Object.entries(args.paths)) {
if (paths[key] !== undefined && arraysEqual(toArray(paths[key]), val)) {
text = applyEdits(text, modify(text, ["compilerOptions", "paths", key], undefined, FORMAT));
unsetJsonPathSegments(abs, ["compilerOptions", "paths", key]);
changed = true;
}
}
if (changed) {
// If the paths map ends up empty after removal, drop it entirely.
const after = parseJsonc(text, rel);
const after = readRoot(abs);
const afterCo = isRecord(after.compilerOptions) ? after.compilerOptions : {};
if (isRecord(afterCo.paths) && Object.keys(afterCo.paths).length === 0) {
text = applyEdits(text, modify(text, ["compilerOptions", "paths"], undefined, FORMAT));
unsetJsonPath(abs, "compilerOptions.paths");
}
fs.writeFileSync(abs, text, "utf8");
}
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
};
const FORMAT = { formattingOptions: { tabSize: 2, insertSpaces: true } } as const;
function parseJsonc(text: string, rel: string): Record<string, unknown> {
const errors: ParseError[] = [];
const value = parse(text, errors, { allowTrailingComma: true });
if (errors.length > 0) {
const first = errors[0]!;
throw new Error(
`set-tsconfig-paths: ${rel} is not valid JSON/JSONC (${printParseErrorCode(first.error)} at offset ${first.offset}).`,
);
}
return isRecord(value) ? value : {};
function readRoot(file: string): Record<string, unknown> {
const v = readJson(file);
return isRecord(v) ? v : {};
}
function resolveFilePath(
+2
View File
@@ -29,6 +29,8 @@ export {
mergeJson,
setJsonPath,
unsetJsonPath,
setJsonPathSegments,
unsetJsonPathSegments,
addPackageDependency,
removePackageDependency,
addPackageScript,
+59 -1
View File
@@ -4,7 +4,15 @@ import path from "node:path";
import { describe, expect, it, beforeEach } from "vite-plus/test";
import { addPackageDependency, addPackageScript, mergeJson, removePackageDependency } from "./json";
import {
addPackageDependency,
addPackageScript,
mergeJson,
removePackageDependency,
setJsonPath,
setJsonPathSegments,
unsetJsonPathSegments,
} from "./json";
let tmp: string;
beforeEach(() => {
@@ -70,3 +78,53 @@ describe("addPackageScript", () => {
expect(out.scripts["db:migrate"]).toBe("drizzle-kit migrate");
});
});
describe("format preservation (JSONC)", () => {
it("keeps comments + trailing commas through a setJsonPath edit", () => {
const p = path.join(tmp, "tsconfig.json");
fs.writeFileSync(
p,
`{
// Editor hints
"compilerOptions": {
"strict": true, // load-bearing
},
}
`,
);
setJsonPath(p, "compilerOptions.target", "ES2022");
const text = fs.readFileSync(p, "utf8");
expect(text).toContain("// Editor hints");
expect(text).toContain("// load-bearing");
expect(text).toContain('"target": "ES2022"');
});
it("preserves user key ordering when adding a dep", () => {
const p = writePkg({
name: "x",
// Intentional ordering — keys would be alphabetized by a JSON.stringify
// round-trip but jsonc-parser appends rather than rebuilds.
version: "0.0.0",
dependencies: { react: "^18", zustand: "^4" },
});
addPackageDependency(p, "better-auth", "^1.0.0");
const text = fs.readFileSync(p, "utf8");
expect(text.indexOf('"version"')).toBeLessThan(text.indexOf('"dependencies"'));
// New dep lands after the existing ones, not interleaved alphabetically.
expect(text.indexOf('"react"')).toBeLessThan(text.indexOf('"better-auth"'));
expect(text.indexOf('"zustand"')).toBeLessThan(text.indexOf('"better-auth"'));
});
it("setJsonPathSegments handles keys containing `.` (tsconfig paths aliases)", () => {
const p = writePkg({ compilerOptions: { paths: {} } });
setJsonPathSegments(p, ["compilerOptions", "paths", "@acme/ui.next/*"], ["./src/*"]);
const out = JSON.parse(fs.readFileSync(p, "utf8"));
expect(out.compilerOptions.paths["@acme/ui.next/*"]).toEqual(["./src/*"]);
});
it("unsetJsonPathSegments no-ops on a missing parent key", () => {
const p = writePkg({ name: "x" });
// devDependencies doesn't exist — should not throw.
expect(() => unsetJsonPathSegments(p, ["devDependencies", "vitest"])).not.toThrow();
});
});
+117 -71
View File
@@ -1,97 +1,140 @@
import fs from "node:fs";
import { applyEdits, modify, parse, type ParseError, printParseErrorCode } from "jsonc-parser";
/** Narrow an unknown JSON value to a plain object (excludes arrays and null). */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readJson(path: string): unknown {
return JSON.parse(fs.readFileSync(path, "utf8"));
const FORMAT = { formattingOptions: { tabSize: 2, insertSpaces: true } } as const;
function parseJsonc(text: string, file: string): unknown {
const errors: ParseError[] = [];
const value = parse(text, errors, { allowTrailingComma: true });
if (errors.length > 0) {
const first = errors[0]!;
throw new Error(
`${file}: invalid JSON/JSONC (${printParseErrorCode(first.error)} at offset ${first.offset}).`,
);
}
return value;
}
/** Read a JSON file expected to hold an object, falling back to `{}` otherwise. */
function readRecord(path: string): Record<string, unknown> {
const value = readJson(path);
return isRecord(value) ? value : {};
function ensureTrailingNewline(s: string): string {
return s.endsWith("\n") ? s : s + "\n";
}
export function writeJson(path: string, value: unknown): void {
fs.writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8");
function readText(file: string): string {
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "{}";
}
/**
* Deep-merge an object into the JSON at `path`. Arrays are replaced, not merged.
* Returns the dot-paths that were created or changed (for region claiming).
* Apply one surgical edit at a structured path. Preserves comments,
* trailing commas, indentation, and key ordering outside the edit point —
* the whole reason we use jsonc-parser instead of round-tripping through
* JSON.parse/stringify.
*/
export function mergeJson(path: string, patch: Record<string, unknown>): string[] {
function modifyAtPath(file: string, path: (string | number)[], value: unknown): void {
const text = readText(file);
let edits;
try {
edits = modify(text, path, value, FORMAT);
} catch (err) {
// jsonc-parser throws "Can not delete in empty document" when asked to
// unset a path whose parent doesn't exist. That's a no-op for us.
if (value === undefined && err instanceof Error && /Can not delete/.test(err.message)) {
return;
}
throw err;
}
if (edits.length === 0) return;
fs.writeFileSync(file, ensureTrailingNewline(applyEdits(text, edits)), "utf8");
}
export function readJson(file: string): unknown {
return parseJsonc(fs.readFileSync(file, "utf8"), file);
}
/** Read a JSON file expected to hold an object, falling back to `{}` otherwise. */
function readRecord(file: string): Record<string, unknown> {
if (!fs.existsSync(file)) return {};
const value = readJson(file);
return isRecord(value) ? value : {};
}
/**
* Overwrite a JSON file end-to-end. Loses any existing comments/formatting,
* so prefer `setJsonPath` / `addPackageDependency` for in-place updates.
*/
export function writeJson(file: string, value: unknown): void {
fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n", "utf8");
}
/**
* Deep-merge `patch` into the JSON at `file`. Arrays replace (not merge).
* Returns the dot-paths that were created or changed (for region claiming).
*
* Walks the patch tree and emits one surgical edit per leaf change, so
* unchanged keys and any comments/trailing commas in the file survive.
*
* Caveat: dot-paths in the return value (and the path the edits target) are
* split on `.` — a key name like `"lodash.merge"` would be addressed as two
* segments. Callers that pass such keys should use `setJsonPath` with a
* dot-free leaf, or route through `addPackageDependency` (which uses a
* structured path internally).
*/
export function mergeJson(file: string, patch: Record<string, unknown>): string[] {
const existing = readRecord(file);
const touched: string[] = [];
const merged = mergeRecurse(readRecord(path), patch, "", touched);
writeJson(path, merged);
const visit = (
target: Record<string, unknown>,
source: Record<string, unknown>,
prefix: string,
): void => {
for (const [k, v] of Object.entries(source)) {
const dotPath = prefix ? `${prefix}.${k}` : k;
const here = target[k];
if (isRecord(v) && isRecord(here)) {
visit(here, v, dotPath);
continue;
}
if (here !== v) {
touched.push(dotPath);
modifyAtPath(file, dotPath.split("."), v);
}
}
};
visit(existing, patch, "");
return touched;
}
function mergeRecurse(
target: Record<string, unknown>,
source: Record<string, unknown>,
prefix: string,
touched: string[],
): Record<string, unknown> {
const out: Record<string, unknown> = { ...target };
for (const [k, v] of Object.entries(source)) {
const path = prefix ? `${prefix}.${k}` : k;
const existing = out[k];
if (isRecord(v) && isRecord(existing)) {
out[k] = mergeRecurse(existing, v, path, touched);
} else {
if (out[k] !== v) touched.push(path);
out[k] = v;
}
}
return out;
}
export function setJsonPath(file: string, dotPath: string, value: unknown): void {
const root = readRecord(file);
setPath(root, dotPath, value);
writeJson(file, root);
modifyAtPath(file, dotPath.split("."), value);
}
export function unsetJsonPath(file: string, dotPath: string): void {
const root = readRecord(file);
unsetPath(root, dotPath);
writeJson(file, root);
if (!fs.existsSync(file)) return;
modifyAtPath(file, dotPath.split("."), undefined);
}
function setPath(root: Record<string, unknown>, dotPath: string, value: unknown): void {
const parts = dotPath.split(".");
const last = parts.pop();
// dotPath is non-empty by caller contract, so `parts` was non-empty.
if (last === undefined) return;
let node = root;
for (const p of parts) {
const next = node[p];
if (isRecord(next)) {
node = next;
} else {
const created: Record<string, unknown> = {};
node[p] = created;
node = created;
}
}
node[last] = value;
/**
* Structured-path variants for callers whose keys may contain `.` (e.g.
* tsconfig `paths` aliases like `"@acme/ui.foo"`). The dot-path API would
* mis-split those into multiple segments.
*/
export function setJsonPathSegments(
file: string,
segments: (string | number)[],
value: unknown,
): void {
modifyAtPath(file, segments, value);
}
function unsetPath(root: Record<string, unknown>, dotPath: string): void {
const parts = dotPath.split(".");
const last = parts.pop();
if (last === undefined) return;
let node = root;
for (const p of parts) {
const next = node[p];
if (!isRecord(next)) return;
node = next;
}
delete node[last];
export function unsetJsonPathSegments(file: string, segments: (string | number)[]): void {
if (!fs.existsSync(file)) return;
modifyAtPath(file, segments, undefined);
}
export function addPackageDependency(
@@ -101,14 +144,17 @@ export function addPackageDependency(
options: { dev?: boolean } = {},
): void {
const key = options.dev ? "devDependencies" : "dependencies";
setJsonPath(packageJsonPath, `${key}.${name}`, range);
// Structured path so dep names containing `.` (e.g. `lodash.merge`) stay
// a single segment instead of being interpreted as nested objects.
modifyAtPath(packageJsonPath, [key, name], range);
}
export function removePackageDependency(packageJsonPath: string, name: string): void {
unsetJsonPath(packageJsonPath, `dependencies.${name}`);
unsetJsonPath(packageJsonPath, `devDependencies.${name}`);
if (!fs.existsSync(packageJsonPath)) return;
modifyAtPath(packageJsonPath, ["dependencies", name], undefined);
modifyAtPath(packageJsonPath, ["devDependencies", name], undefined);
}
export function addPackageScript(packageJsonPath: string, name: string, command: string): void {
setJsonPath(packageJsonPath, `scripts.${name}`, command);
modifyAtPath(packageJsonPath, ["scripts", name], command);
}
+3
View File
@@ -49,6 +49,9 @@ importers:
handlebars:
specifier: ^4.7.9
version: 4.7.9
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
picocolors:
specifier: ^1.1.1
version: 1.1.1