mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -29,6 +29,8 @@ export {
|
||||
mergeJson,
|
||||
setJsonPath,
|
||||
unsetJsonPath,
|
||||
setJsonPathSegments,
|
||||
unsetJsonPathSegments,
|
||||
addPackageDependency,
|
||||
removePackageDependency,
|
||||
addPackageScript,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user