Skip to content

Commit b97979f

Browse files
Write header detailing combined file provenance (configurable with --no-header option)
1 parent ceb97af commit b97979f

File tree

7 files changed

+84
-4
lines changed

7 files changed

+84
-4
lines changed

src/cli.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface ParsedArgs {
1515
backup?: boolean;
1616
indent?: number;
1717
arrayMerge?: "replace" | "concat";
18+
header?: boolean;
1819
inputs: string[];
1920
}
2021

@@ -50,6 +51,9 @@ function mergeArgsWithConfig(args: ParsedArgs, config: ConfigOptions): MergeOpti
5051
backup: args.backup ?? config.backup ?? false,
5152
indent: args.indent ?? config.indent,
5253
arrayMerge: args.arrayMerge ?? config.arrayMerge ?? "replace",
54+
// header default true
55+
// allow CLI to explicitly set header: false via --no-header
56+
header: args.header ?? config.header ?? true,
5357
};
5458
}
5559

@@ -95,6 +99,16 @@ async function run() {
9599
choices: ["replace", "concat"],
96100
default: "replace",
97101
})
102+
.option("header", {
103+
describe: "Prepend a comment header to JSONC/JSON5 outputs (default: true)",
104+
type: "boolean",
105+
default: true,
106+
})
107+
.option("no-header", {
108+
describe: "Alias: do not prepend a comment header to JSONC/JSON5 outputs",
109+
type: "boolean",
110+
default: false,
111+
})
98112
.help("help")
99113
.alias("help", "h")
100114
.version(version)
@@ -125,6 +139,16 @@ async function run() {
125139
})
126140
.parseAsync();
127141

142+
// normalize header arg: prefer explicit --header, else respect --no-header negation
143+
let headerArg: boolean | undefined;
144+
if (typeof argv.header === "boolean") {
145+
headerArg = argv.header;
146+
} else if (argv["no-header"]) {
147+
headerArg = false;
148+
} else {
149+
headerArg = undefined;
150+
}
151+
128152
const args: ParsedArgs = {
129153
out: argv.out,
130154
skipMissing: argv["skip-missing"],
@@ -133,6 +157,9 @@ async function run() {
133157
backup: argv.backup,
134158
indent: argv.indent,
135159
arrayMerge: argv["array-merge"] as "replace" | "concat" | undefined,
160+
// yargs presents boolean negations as the positive name being false
161+
// yargs supports both --header and --no-header; prefer explicit argv.header when present
162+
header: headerArg,
136163
inputs: Array.from(argv._, (value) => String(value)),
137164
};
138165

src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface ConfigOptions {
1010
backup?: boolean;
1111
indent?: number;
1212
arrayMerge?: "replace" | "concat";
13+
header?: boolean;
1314
}
1415

1516
type ConfigKey = keyof ConfigOptions;
@@ -59,6 +60,12 @@ const validators: { [K in ConfigKey]-?: ConfigValidator<K> } = {
5960
}
6061
return value;
6162
},
63+
header: (value, source) => {
64+
if (typeof value !== "boolean") {
65+
throw new Error(`Invalid 'header' value in '${source}'. Expected boolean.`);
66+
}
67+
return value;
68+
},
6269
};
6370

6471
function setConfigValue<K extends ConfigKey>(

src/core.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface MergeOptions {
1313
backup?: boolean; // create .bak file before overwriting
1414
indent?: number; // custom indentation (overrides pretty)
1515
arrayMerge?: "replace" | "concat"; // array merge strategy (default: replace)
16+
header?: boolean; // whether to prepend comment header for JSONC/JSON5 outputs (default: true)
1617
}
1718

1819
export type MergeResult =
@@ -122,6 +123,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
122123
backup = false,
123124
indent,
124125
arrayMerge = "replace",
126+
header,
125127
} = opts;
126128

127129
const inputAbs = resolveInputPaths(inputs, skipMissing);
@@ -136,6 +138,20 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
136138
const spaces = calculateIndentation(indent, pretty);
137139
const text = JSON.stringify(combined, null, spaces);
138140

141+
// For JSONC and JSON5 outputs, prefix a two-line comment header with timestamp
142+
// and list of source files. Use '//' comments which are valid in JSONC/JSON5.
143+
const outExt = outAbs.toLowerCase().split(".").pop();
144+
let finalText = text;
145+
const shouldHeader = header !== false;
146+
147+
if (shouldHeader && (outExt === "jsonc" || outExt === "json5")) {
148+
const timestamp = new Date().toISOString();
149+
// Use original input paths (as provided by the caller) for the header
150+
const inputList = inputs.join(" ");
151+
const header = `// Generated by merge-jsonc on ${timestamp}\n// Result of combining ${inputList}\n`;
152+
finalText = header + text;
153+
}
154+
139155
if (!checkContentChanged(outAbs, text)) {
140156
return { wrote: false, reason: "no_content_change" };
141157
}
@@ -148,7 +164,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
148164
return { wrote: false, reason: "dry_run", preview: text };
149165
}
150166

151-
const backupPath = atomicWrite(outAbs, text, backup);
167+
const backupPath = atomicWrite(outAbs, finalText, backup);
152168

153169
return {
154170
wrote: true,

test/cli-module.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("CLI module", () => {
7272
backup: true,
7373
indent: 4,
7474
arrayMerge: "replace",
75+
header: true,
7576
});
7677
expect(logSpy).toHaveBeenCalledWith(
7778
expect.stringContaining("DRY RUN - would write to result.json")

test/cli.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
22
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
33
import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
44
import { join } from "node:path";
5+
import JSON5 from "json5";
56

67
const TEST_DIR = join(process.cwd(), "test-tmp-cli");
78
const CLI_PATH = join(process.cwd(), "dist/cli.js");
@@ -72,7 +73,16 @@ describe("CLI integration tests", () => {
7273

7374
const readJsonFile = (name: string): Record<string, unknown> => {
7475
const text = readTestFile(name);
75-
const parsed: unknown = JSON.parse(text);
76+
// Strip leading comment header lines (// or #) that may be prepended for JSONC/JSON5 outputs
77+
const lines = text.split(/\r?\n/);
78+
let first = 0;
79+
while (first < lines.length) {
80+
const ln = lines[first];
81+
if (typeof ln !== "string" || !/^\s*(\/\/|#)/.test(ln)) break;
82+
first++;
83+
}
84+
const payload = lines.slice(first).join("\n");
85+
const parsed: unknown = JSON5.parse(payload);
7686
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
7787
throw new Error(`Expected '${name}' to contain a JSON object.`);
7888
}

test/core.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
22
import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
33
import { join } from "node:path";
44
import { mergeJsonc } from "../src/core.js";
5+
import JSON5 from "json5";
56

67
const TEST_DIR = join(process.cwd(), "test-tmp");
78

@@ -29,7 +30,16 @@ describe("mergeJsonc core functionality", () => {
2930

3031
const readJsonObject = (name: string): Record<string, unknown> => {
3132
const text = readTestFile(name);
32-
const parsed: unknown = JSON.parse(text);
33+
// Strip leading comment header lines (// or #) that may be prepended for JSONC/JSON5 outputs
34+
const lines = text.split(/\r?\n/);
35+
let first = 0;
36+
while (first < lines.length) {
37+
const ln = lines[first];
38+
if (typeof ln !== "string" || !/^\s*(\/\/|#)/.test(ln)) break;
39+
first++;
40+
}
41+
const payload = lines.slice(first).join("\n");
42+
const parsed: unknown = JSON5.parse(payload);
3343
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3444
throw new Error(`Expected '${name}' to contain a JSON object.`);
3545
}

test/timing-and-backup.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
22
import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync, utimesSync } from "node:fs";
33
import { join } from "node:path";
44
import { mergeJsonc } from "../src/core.js";
5+
import JSON5 from "json5";
56
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
67

78
const TEST_DIR = join(process.cwd(), "test-tmp-timing");
@@ -62,7 +63,15 @@ describe("Timing and backup functionality", () => {
6263

6364
const readJsonObject = (name: string): Record<string, unknown> => {
6465
const text = readTestFile(name);
65-
const parsed: unknown = JSON.parse(text);
66+
const lines = text.split(/\r?\n/);
67+
let first = 0;
68+
while (first < lines.length) {
69+
const ln = lines[first];
70+
if (typeof ln !== "string" || !/^\s*(\/\/|#)/.test(ln)) break;
71+
first++;
72+
}
73+
const payload = lines.slice(first).join("\n");
74+
const parsed: unknown = JSON5.parse(payload);
6675
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
6776
throw new Error(`Expected '${name}' to contain a JSON object.`);
6877
}

0 commit comments

Comments
 (0)