confdiff 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +449 -0
- package/dist/diff.d.ts +57 -0
- package/dist/diff.js +430 -0
- package/dist/dirdiff.d.ts +28 -0
- package/dist/dirdiff.js +99 -0
- package/dist/gitdriver.d.ts +25 -0
- package/dist/gitdriver.js +84 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/dist/parse.d.ts +64 -0
- package/dist/parse.js +566 -0
- package/dist/redact.d.ts +39 -0
- package/dist/redact.js +172 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +105 -0
- package/package.json +72 -0
package/dist/diff.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
export function isKeySeg(s) {
|
|
2
|
+
return typeof s === "object" && s !== null && "key" in s && "value" in s;
|
|
3
|
+
}
|
|
4
|
+
/** Canonical string form of a single path segment (used for glob matching and
|
|
5
|
+
* pointers): keyed segments render as `key=value`, everything else as-is. */
|
|
6
|
+
export function segStr(s) {
|
|
7
|
+
if (isKeySeg(s))
|
|
8
|
+
return `${s.key}=${String(s.value)}`;
|
|
9
|
+
return String(s);
|
|
10
|
+
}
|
|
11
|
+
export function typeOf(v) {
|
|
12
|
+
if (v === null)
|
|
13
|
+
return "null";
|
|
14
|
+
if (Array.isArray(v))
|
|
15
|
+
return "array";
|
|
16
|
+
if (v instanceof Date)
|
|
17
|
+
return "date";
|
|
18
|
+
if (typeof v === "object" && v !== null && !isPlainObject(v))
|
|
19
|
+
return "scalar";
|
|
20
|
+
return typeof v;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Only plain objects (and arrays) are treated as containers to recurse into.
|
|
24
|
+
* Exotic objects — Date (from TOML/YAML), RegExp, class instances — are compared
|
|
25
|
+
* as opaque scalars by value, so a date change isn't silently swallowed.
|
|
26
|
+
*/
|
|
27
|
+
function isPlainObject(v) {
|
|
28
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
29
|
+
return false;
|
|
30
|
+
const proto = Object.getPrototypeOf(v);
|
|
31
|
+
return proto === Object.prototype || proto === null;
|
|
32
|
+
}
|
|
33
|
+
/** Comparable representation of a non-plain, non-array value (Date, etc.). */
|
|
34
|
+
function scalarValue(v) {
|
|
35
|
+
if (v instanceof Date)
|
|
36
|
+
return v.getTime();
|
|
37
|
+
if (v !== null && typeof v === "object") {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(v);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return String(v);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
function pathToString(path) {
|
|
48
|
+
let out = "";
|
|
49
|
+
for (const seg of path) {
|
|
50
|
+
if (typeof seg === "number")
|
|
51
|
+
out += `[${seg}]`;
|
|
52
|
+
else if (isKeySeg(seg))
|
|
53
|
+
out += `[${seg.key}=${String(seg.value)}]`;
|
|
54
|
+
else if (out === "")
|
|
55
|
+
out = seg;
|
|
56
|
+
else
|
|
57
|
+
out += `.${seg}`;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function segMatch(pat, seg) {
|
|
62
|
+
if (pat === "*" || pat === "**")
|
|
63
|
+
return true;
|
|
64
|
+
if (pat === seg)
|
|
65
|
+
return true;
|
|
66
|
+
// Support intra-segment wildcards: `*` = any run of chars, `?` = one char
|
|
67
|
+
// (e.g. `*_SECRET`, `db_*`, `item?`). Literal chars are regex-escaped.
|
|
68
|
+
if (!pat.includes("*") && !pat.includes("?"))
|
|
69
|
+
return false;
|
|
70
|
+
const re = "^" +
|
|
71
|
+
pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") +
|
|
72
|
+
"$";
|
|
73
|
+
return new RegExp(re).test(seg);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Split a glob pattern into segment tokens, understanding BOTH dot notation
|
|
77
|
+
* and the bracket notation the tool itself prints for array indices. So
|
|
78
|
+
* `items[0].name`, `items[*].name` and `items.0.name` all tokenize to the same
|
|
79
|
+
* segment list. This is what makes a printed path (e.g. `items[0].name`)
|
|
80
|
+
* round-trippable straight back into --ignore/--only. Inside `[...]` the content
|
|
81
|
+
* is taken verbatim as one token (`0`, `*`, `**`), so an index is never split.
|
|
82
|
+
*/
|
|
83
|
+
function splitPattern(pattern) {
|
|
84
|
+
const tokens = [];
|
|
85
|
+
let cur = "";
|
|
86
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
87
|
+
const ch = pattern[i];
|
|
88
|
+
if (ch === ".") {
|
|
89
|
+
if (cur !== "")
|
|
90
|
+
tokens.push(cur);
|
|
91
|
+
cur = "";
|
|
92
|
+
}
|
|
93
|
+
else if (ch === "[") {
|
|
94
|
+
if (cur !== "")
|
|
95
|
+
tokens.push(cur);
|
|
96
|
+
cur = "";
|
|
97
|
+
let inner = "";
|
|
98
|
+
let j = i + 1;
|
|
99
|
+
while (j < pattern.length && pattern[j] !== "]")
|
|
100
|
+
inner += pattern[j++];
|
|
101
|
+
tokens.push(inner);
|
|
102
|
+
i = j; // skip past the closing "]"
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
cur += ch;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (cur !== "")
|
|
109
|
+
tokens.push(cur);
|
|
110
|
+
return tokens;
|
|
111
|
+
}
|
|
112
|
+
/** Match a path against a glob pattern. `*` = one segment, `**` = zero-or-more segments; within a segment `*`/`?` are wildcards (e.g. `*_SECRET`). Array indices may be written `foo[0]`/`foo[*]` or `foo.0`/`foo.*`. */
|
|
113
|
+
function matchGlob(pattern, path) {
|
|
114
|
+
const pats = splitPattern(pattern);
|
|
115
|
+
const segs = path.map(segStr);
|
|
116
|
+
// simple recursive matcher supporting **
|
|
117
|
+
const rec = (pi, si) => {
|
|
118
|
+
if (pi === pats.length)
|
|
119
|
+
return si === segs.length;
|
|
120
|
+
if (pats[pi] === "**") {
|
|
121
|
+
// ** matches any number of segments
|
|
122
|
+
for (let k = si; k <= segs.length; k++) {
|
|
123
|
+
if (rec(pi + 1, k))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (si >= segs.length)
|
|
129
|
+
return false;
|
|
130
|
+
// Standard one-token-per-segment match (preserves intra-segment wildcards).
|
|
131
|
+
if (segMatch(pats[pi], segs[si]) && rec(pi + 1, si + 1))
|
|
132
|
+
return true;
|
|
133
|
+
// Dotted-key support: a single path segment may itself contain dots (e.g.
|
|
134
|
+
// the k8s annotation key `app.kubernetes.io/name`, or log4j-style
|
|
135
|
+
// properties). Such a key is *rendered* with embedded dots, so let a run of
|
|
136
|
+
// consecutive literal pattern tokens joined by "." match one segment. This
|
|
137
|
+
// makes the tool's own printed path round-trippable back into --ignore/--only.
|
|
138
|
+
// Only literal tokens are joined, so wildcard semantics are unchanged.
|
|
139
|
+
if (!hasWildcard(pats[pi])) {
|
|
140
|
+
let joined = pats[pi];
|
|
141
|
+
for (let j = pi + 1; j < pats.length; j++) {
|
|
142
|
+
if (pats[j] === "**" || hasWildcard(pats[j]))
|
|
143
|
+
break;
|
|
144
|
+
joined += "." + pats[j];
|
|
145
|
+
if (joined === segs[si] && rec(j + 1, si + 1))
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
return rec(0, 0);
|
|
152
|
+
}
|
|
153
|
+
function hasWildcard(tok) {
|
|
154
|
+
return tok.includes("*") || tok.includes("?");
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Public helper: does `path` match ANY of the given glob `patterns`? Uses the
|
|
158
|
+
* same matcher as --ignore/--only (dot + bracket notation, `*`/`**`/`?`, dotted
|
|
159
|
+
* keys). Also treats a bare key-name token (no separators) as matching that key
|
|
160
|
+
* at any depth, so `--redact password` masks `db.password` and `password`.
|
|
161
|
+
*/
|
|
162
|
+
export function matchAnyGlob(path, patterns) {
|
|
163
|
+
for (const p of patterns) {
|
|
164
|
+
if (matchGlob(p, path))
|
|
165
|
+
return true;
|
|
166
|
+
// bare key name -> match that last segment anywhere
|
|
167
|
+
if (!p.includes(".") && !p.includes("[") && path.length > 0) {
|
|
168
|
+
if (segMatch(p, segStr(path[path.length - 1])))
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
function pathSelected(path, opts) {
|
|
175
|
+
const s = pathToString(path);
|
|
176
|
+
if (opts.ignore && opts.ignore.some((p) => matchGlob(p, path)))
|
|
177
|
+
return false;
|
|
178
|
+
if (opts.only && opts.only.length > 0) {
|
|
179
|
+
// keep a path if it matches, or is a prefix of, or is under a selected glob
|
|
180
|
+
return opts.only.some((p) => matchGlob(p, path));
|
|
181
|
+
}
|
|
182
|
+
void s;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
function coerce(v) {
|
|
186
|
+
if (typeof v !== "string")
|
|
187
|
+
return v;
|
|
188
|
+
const t = v.trim();
|
|
189
|
+
if (t === "true")
|
|
190
|
+
return true;
|
|
191
|
+
if (t === "false")
|
|
192
|
+
return false;
|
|
193
|
+
if (t === "null")
|
|
194
|
+
return null;
|
|
195
|
+
if (t !== "" && !Number.isNaN(Number(t)))
|
|
196
|
+
return Number(t);
|
|
197
|
+
return v;
|
|
198
|
+
}
|
|
199
|
+
function scalarEqual(a, b, opts) {
|
|
200
|
+
if (opts.loose) {
|
|
201
|
+
a = coerce(a);
|
|
202
|
+
b = coerce(b);
|
|
203
|
+
}
|
|
204
|
+
const sa = scalarValue(a);
|
|
205
|
+
const sb = scalarValue(b);
|
|
206
|
+
// Object.is keeps NaN == NaN (from YAML `.nan`); the extra `===` treats +0 and
|
|
207
|
+
// -0 as equal — numerically the same config value, so `0 => -0` isn't a change
|
|
208
|
+
// (Object.is alone would report a useless "0 => 0" diff).
|
|
209
|
+
return Object.is(sa, sb) || sa === sb;
|
|
210
|
+
}
|
|
211
|
+
/** Stable-ish key for multiset array comparison. */
|
|
212
|
+
function valueKey(v) {
|
|
213
|
+
return JSON.stringify(v, (_k, val) => {
|
|
214
|
+
// BigInt (lossless large integers) isn't JSON-serialisable; tag it so two
|
|
215
|
+
// distinct big integers get distinct, stable keys.
|
|
216
|
+
if (typeof val === "bigint")
|
|
217
|
+
return `${val.toString()}n`;
|
|
218
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
219
|
+
const sorted = {};
|
|
220
|
+
for (const k of Object.keys(val).sort()) {
|
|
221
|
+
sorted[k] = val[k];
|
|
222
|
+
}
|
|
223
|
+
return sorted;
|
|
224
|
+
}
|
|
225
|
+
return val;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export function diff(a, b, opts = {}) {
|
|
229
|
+
const changes = [];
|
|
230
|
+
walk(a, b, [], changes, opts);
|
|
231
|
+
return changes;
|
|
232
|
+
}
|
|
233
|
+
function walk(a, b, path, out, opts) {
|
|
234
|
+
const ta = typeOf(a);
|
|
235
|
+
const tb = typeOf(b);
|
|
236
|
+
if (ta === "object" && tb === "object") {
|
|
237
|
+
const ao = a;
|
|
238
|
+
const bo = b;
|
|
239
|
+
const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
|
|
240
|
+
for (const k of [...keys].sort()) {
|
|
241
|
+
const childPath = [...path, k];
|
|
242
|
+
const inA = Object.prototype.hasOwnProperty.call(ao, k);
|
|
243
|
+
const inB = Object.prototype.hasOwnProperty.call(bo, k);
|
|
244
|
+
if (inA && !inB) {
|
|
245
|
+
if (pathSelected(childPath, opts))
|
|
246
|
+
out.push({ path: childPath, kind: "remove", oldValue: ao[k] });
|
|
247
|
+
}
|
|
248
|
+
else if (!inA && inB) {
|
|
249
|
+
if (pathSelected(childPath, opts))
|
|
250
|
+
out.push({ path: childPath, kind: "add", newValue: bo[k] });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
walk(ao[k], bo[k], childPath, out, opts);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (ta === "array" && tb === "array") {
|
|
259
|
+
const av = a;
|
|
260
|
+
const bv = b;
|
|
261
|
+
const keyField = opts.arrayKey && opts.arrayKey.length > 0 ? resolveArrayKey(path, av, bv, opts.arrayKey) : undefined;
|
|
262
|
+
if (keyField) {
|
|
263
|
+
diffArrayKeyed(av, bv, path, out, opts, keyField);
|
|
264
|
+
}
|
|
265
|
+
else if (opts.arraySet) {
|
|
266
|
+
diffArraySet(av, bv, path, out, opts);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
diffArrayIndexed(av, bv, path, out, opts);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// At least one side is a scalar (or a container facing a non-matching type).
|
|
274
|
+
// Container-vs-anything mismatch is always a type change.
|
|
275
|
+
const containerA = ta === "object" || ta === "array";
|
|
276
|
+
const containerB = tb === "object" || tb === "array";
|
|
277
|
+
if (containerA || containerB) {
|
|
278
|
+
if (pathSelected(path, opts))
|
|
279
|
+
out.push({ path, kind: "change", oldValue: a, newValue: b, typeChanged: true });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Both scalars. In loose mode we coerce first so "3"==3 and "true"==true.
|
|
283
|
+
if (scalarEqual(a, b, opts))
|
|
284
|
+
return;
|
|
285
|
+
if (pathSelected(path, opts))
|
|
286
|
+
out.push({ path, kind: "change", oldValue: a, newValue: b, typeChanged: ta !== tb });
|
|
287
|
+
}
|
|
288
|
+
function diffArrayIndexed(a, b, path, out, opts) {
|
|
289
|
+
const max = Math.max(a.length, b.length);
|
|
290
|
+
for (let i = 0; i < max; i++) {
|
|
291
|
+
const childPath = [...path, i];
|
|
292
|
+
if (i >= b.length) {
|
|
293
|
+
if (pathSelected(childPath, opts))
|
|
294
|
+
out.push({ path: childPath, kind: "remove", oldValue: a[i] });
|
|
295
|
+
}
|
|
296
|
+
else if (i >= a.length) {
|
|
297
|
+
if (pathSelected(childPath, opts))
|
|
298
|
+
out.push({ path: childPath, kind: "add", newValue: b[i] });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
walk(a[i], b[i], childPath, out, opts);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Is `v` a plain object that carries a scalar-valued `field`? */
|
|
306
|
+
function keyableBy(v, field) {
|
|
307
|
+
if (!isPlainObject(v))
|
|
308
|
+
return false;
|
|
309
|
+
const rec = v;
|
|
310
|
+
if (!Object.prototype.hasOwnProperty.call(rec, field))
|
|
311
|
+
return false;
|
|
312
|
+
const fv = rec[field];
|
|
313
|
+
return fv === null || (typeof fv !== "object" && typeof fv !== "function");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Decide which key field (if any) to use for matching this pair of arrays.
|
|
317
|
+
* Tries each configured entry in order: scoped `pathGlob=field` entries whose
|
|
318
|
+
* glob matches this array's path, plus bare `field` entries that apply
|
|
319
|
+
* everywhere. A field is usable only when EVERY element on both sides is a plain
|
|
320
|
+
* object carrying that field as a scalar AND its values are unique within each
|
|
321
|
+
* side (otherwise keying would be ambiguous, so we fall back to indexed diff).
|
|
322
|
+
*/
|
|
323
|
+
function resolveArrayKey(path, a, b, specs) {
|
|
324
|
+
if (a.length === 0 && b.length === 0)
|
|
325
|
+
return undefined;
|
|
326
|
+
const candidates = [];
|
|
327
|
+
for (const spec of specs) {
|
|
328
|
+
const eq = spec.indexOf("=");
|
|
329
|
+
if (eq >= 0) {
|
|
330
|
+
const glob = spec.slice(0, eq);
|
|
331
|
+
const field = spec.slice(eq + 1);
|
|
332
|
+
if (field && matchGlob(glob, path))
|
|
333
|
+
candidates.push(field);
|
|
334
|
+
}
|
|
335
|
+
else if (spec) {
|
|
336
|
+
candidates.push(spec);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const field of candidates) {
|
|
340
|
+
const all = [...a, ...b];
|
|
341
|
+
if (!all.every((v) => keyableBy(v, field)))
|
|
342
|
+
continue;
|
|
343
|
+
if (uniqueKeyed(a, field) && uniqueKeyed(b, field))
|
|
344
|
+
return field;
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
function uniqueKeyed(arr, field) {
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const v of arr) {
|
|
351
|
+
const k = valueKey(v[field]);
|
|
352
|
+
if (seen.has(k))
|
|
353
|
+
return false;
|
|
354
|
+
seen.add(k);
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
function diffArrayKeyed(a, b, path, out, opts, field) {
|
|
359
|
+
const mapA = new Map();
|
|
360
|
+
const mapB = new Map();
|
|
361
|
+
const order = [];
|
|
362
|
+
const raw = new Map();
|
|
363
|
+
for (const v of a) {
|
|
364
|
+
const fv = v[field];
|
|
365
|
+
const k = valueKey(fv);
|
|
366
|
+
mapA.set(k, v);
|
|
367
|
+
if (!raw.has(k)) {
|
|
368
|
+
raw.set(k, fv);
|
|
369
|
+
order.push(k);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const v of b) {
|
|
373
|
+
const fv = v[field];
|
|
374
|
+
const k = valueKey(fv);
|
|
375
|
+
mapB.set(k, v);
|
|
376
|
+
if (!raw.has(k)) {
|
|
377
|
+
raw.set(k, fv);
|
|
378
|
+
order.push(k);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const k of order) {
|
|
382
|
+
const fv = raw.get(k);
|
|
383
|
+
const seg = { key: field, value: fv };
|
|
384
|
+
const childPath = [...path, seg];
|
|
385
|
+
const inA = mapA.has(k);
|
|
386
|
+
const inB = mapB.has(k);
|
|
387
|
+
if (inA && inB) {
|
|
388
|
+
walk(mapA.get(k), mapB.get(k), childPath, out, opts);
|
|
389
|
+
}
|
|
390
|
+
else if (inA) {
|
|
391
|
+
if (pathSelected(childPath, opts))
|
|
392
|
+
out.push({ path: childPath, kind: "remove", oldValue: mapA.get(k) });
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
if (pathSelected(childPath, opts))
|
|
396
|
+
out.push({ path: childPath, kind: "add", newValue: mapB.get(k) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function diffArraySet(a, b, path, out, opts) {
|
|
401
|
+
const counts = new Map();
|
|
402
|
+
const sample = new Map();
|
|
403
|
+
for (const v of a) {
|
|
404
|
+
const k = valueKey(v);
|
|
405
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
406
|
+
if (!sample.has(k))
|
|
407
|
+
sample.set(k, v);
|
|
408
|
+
}
|
|
409
|
+
for (const v of b) {
|
|
410
|
+
const k = valueKey(v);
|
|
411
|
+
counts.set(k, (counts.get(k) ?? 0) - 1);
|
|
412
|
+
if (!sample.has(k))
|
|
413
|
+
sample.set(k, v);
|
|
414
|
+
}
|
|
415
|
+
for (const [k, c] of counts) {
|
|
416
|
+
if (c > 0) {
|
|
417
|
+
for (let n = 0; n < c; n++)
|
|
418
|
+
if (pathSelected(path, opts))
|
|
419
|
+
out.push({ path: [...path, "{set}"], kind: "remove", oldValue: sample.get(k) });
|
|
420
|
+
}
|
|
421
|
+
else if (c < 0) {
|
|
422
|
+
for (let n = 0; n < -c; n++)
|
|
423
|
+
if (pathSelected(path, opts))
|
|
424
|
+
out.push({ path: [...path, "{set}"], kind: "add", newValue: sample.get(k) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
export function formatPath(path) {
|
|
429
|
+
return pathToString(path) || "(root)";
|
|
430
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Change, type DiffOptions } from "./diff.js";
|
|
2
|
+
export type FileStatus = "added" | "removed" | "changed" | "error";
|
|
3
|
+
export interface FileEntry {
|
|
4
|
+
/** POSIX-style relative path within the compared directories. */
|
|
5
|
+
path: string;
|
|
6
|
+
status: FileStatus;
|
|
7
|
+
/** Semantic changes for a "changed" file. */
|
|
8
|
+
changes?: Change[];
|
|
9
|
+
/** Human-readable error for an "error" file (e.g. parse failure on one side). */
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DirDiffResult {
|
|
13
|
+
files: FileEntry[];
|
|
14
|
+
/** True if any added/removed/changed file was found. */
|
|
15
|
+
changed: boolean;
|
|
16
|
+
/** True if any file could not be parsed on one/both sides (exit 2). */
|
|
17
|
+
errored: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface DirDiffOptions extends DiffOptions {
|
|
20
|
+
csvKey?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function isDirectory(p: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Compare two directory trees file-by-file, matching config files by their
|
|
25
|
+
* relative path and reporting per-file semantic changes. Files present on only
|
|
26
|
+
* one side are reported as added/removed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dirDiff(dirA: string, dirB: string, opts?: DirDiffOptions): DirDiffResult;
|
package/dist/dirdiff.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { diff } from "./diff.js";
|
|
4
|
+
import { parseContent, detectFormat, keyRowsByColumn, isConfigFilename, } from "./parse.js";
|
|
5
|
+
export function isDirectory(p) {
|
|
6
|
+
if (p === "-")
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
return statSync(p).isDirectory();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Recursively collect POSIX-normalized relative paths of config files under `root`. */
|
|
16
|
+
function collectConfigFiles(root) {
|
|
17
|
+
const found = new Set();
|
|
18
|
+
const walk = (dir) => {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
const name = e.name;
|
|
28
|
+
// Skip common noise directories that never hold meaningful config.
|
|
29
|
+
if (e.isDirectory()) {
|
|
30
|
+
if (name === ".git" || name === "node_modules")
|
|
31
|
+
continue;
|
|
32
|
+
walk(join(dir, name));
|
|
33
|
+
}
|
|
34
|
+
else if (e.isFile() || e.isSymbolicLink()) {
|
|
35
|
+
if (isConfigFilename(name)) {
|
|
36
|
+
const rel = relative(root, join(dir, name)).split(sep).join("/");
|
|
37
|
+
found.add(rel);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk(root);
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
function loadValue(absPath, relPath, csvKey) {
|
|
46
|
+
const raw = readFileSync(absPath, "utf8");
|
|
47
|
+
const fmt = detectFormat(relPath, raw);
|
|
48
|
+
let val = parseContent(raw, fmt);
|
|
49
|
+
if (csvKey && fmt === "csv") {
|
|
50
|
+
val = keyRowsByColumn(val, csvKey);
|
|
51
|
+
}
|
|
52
|
+
return val;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Compare two directory trees file-by-file, matching config files by their
|
|
56
|
+
* relative path and reporting per-file semantic changes. Files present on only
|
|
57
|
+
* one side are reported as added/removed.
|
|
58
|
+
*/
|
|
59
|
+
export function dirDiff(dirA, dirB, opts = {}) {
|
|
60
|
+
const filesA = collectConfigFiles(dirA);
|
|
61
|
+
const filesB = collectConfigFiles(dirB);
|
|
62
|
+
const all = new Set([...filesA, ...filesB]);
|
|
63
|
+
const sorted = [...all].sort();
|
|
64
|
+
const diffOpts = {
|
|
65
|
+
ignore: opts.ignore,
|
|
66
|
+
only: opts.only,
|
|
67
|
+
arraySet: opts.arraySet,
|
|
68
|
+
arrayKey: opts.arrayKey,
|
|
69
|
+
loose: opts.loose,
|
|
70
|
+
};
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const rel of sorted) {
|
|
73
|
+
const inA = filesA.has(rel);
|
|
74
|
+
const inB = filesB.has(rel);
|
|
75
|
+
if (inA && !inB) {
|
|
76
|
+
files.push({ path: rel, status: "removed" });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!inA && inB) {
|
|
80
|
+
files.push({ path: rel, status: "added" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Present on both sides — diff the parsed models.
|
|
84
|
+
try {
|
|
85
|
+
const valA = loadValue(join(dirA, rel), rel, opts.csvKey);
|
|
86
|
+
const valB = loadValue(join(dirB, rel), rel, opts.csvKey);
|
|
87
|
+
const changes = diff(valA, valB, diffOpts);
|
|
88
|
+
if (changes.length > 0) {
|
|
89
|
+
files.push({ path: rel, status: "changed", changes });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
files.push({ path: rel, status: "error", error: e.message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const errored = files.some((f) => f.status === "error");
|
|
97
|
+
const changed = files.some((f) => f.status !== "error");
|
|
98
|
+
return { files, changed, errored };
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Default file patterns wired to the confdiff diff driver. */
|
|
2
|
+
export declare const DEFAULT_PATTERNS: string[];
|
|
3
|
+
/** The command git should run as the external diff driver. */
|
|
4
|
+
export declare const DRIVER_COMMAND = "confdiff --git-diff-driver";
|
|
5
|
+
export interface InstallOptions {
|
|
6
|
+
global: boolean;
|
|
7
|
+
patterns: string[];
|
|
8
|
+
/** Injected for testing; defaults to real git via execFileSync. */
|
|
9
|
+
git?: (args: string[]) => string;
|
|
10
|
+
/** Injected for testing; defaults to process.cwd(). */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface InstallResult {
|
|
14
|
+
scope: "global" | "local";
|
|
15
|
+
attributesFile: string;
|
|
16
|
+
added: string[];
|
|
17
|
+
alreadyPresent: string[];
|
|
18
|
+
command: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Configure git to use confdiff as an external diff driver and wire the given
|
|
22
|
+
* file patterns to it. Idempotent: existing `<pattern> diff=confdiff` lines are
|
|
23
|
+
* left untouched and reported as alreadyPresent.
|
|
24
|
+
*/
|
|
25
|
+
export declare function installGitDriver(opts: InstallOptions): InstallResult;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
/** Default file patterns wired to the confdiff diff driver. */
|
|
6
|
+
export const DEFAULT_PATTERNS = [
|
|
7
|
+
"*.json",
|
|
8
|
+
"*.yaml",
|
|
9
|
+
"*.yml",
|
|
10
|
+
"*.toml",
|
|
11
|
+
"*.ini",
|
|
12
|
+
"*.env",
|
|
13
|
+
"*.csv",
|
|
14
|
+
"*.tsv",
|
|
15
|
+
"*.xml",
|
|
16
|
+
];
|
|
17
|
+
/** The command git should run as the external diff driver. */
|
|
18
|
+
export const DRIVER_COMMAND = "confdiff --git-diff-driver";
|
|
19
|
+
function defaultGit(args) {
|
|
20
|
+
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the git attributes file to write pattern lines into.
|
|
24
|
+
* Global scope honours core.attributesFile, falling back to
|
|
25
|
+
* $XDG_CONFIG_HOME/git/attributes (or ~/.config/git/attributes).
|
|
26
|
+
* Local scope uses <repo>/.gitattributes.
|
|
27
|
+
*/
|
|
28
|
+
function resolveAttributesFile(global, git, cwd) {
|
|
29
|
+
if (!global)
|
|
30
|
+
return join(cwd, ".gitattributes");
|
|
31
|
+
let configured = "";
|
|
32
|
+
try {
|
|
33
|
+
configured = git(["config", "--global", "--get", "core.attributesFile"]);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
configured = "";
|
|
37
|
+
}
|
|
38
|
+
if (configured) {
|
|
39
|
+
return configured.replace(/^~(?=\/|$)/, homedir());
|
|
40
|
+
}
|
|
41
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
42
|
+
return join(base, "git", "attributes");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Configure git to use confdiff as an external diff driver and wire the given
|
|
46
|
+
* file patterns to it. Idempotent: existing `<pattern> diff=confdiff` lines are
|
|
47
|
+
* left untouched and reported as alreadyPresent.
|
|
48
|
+
*/
|
|
49
|
+
export function installGitDriver(opts) {
|
|
50
|
+
const git = opts.git ?? defaultGit;
|
|
51
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
52
|
+
const patterns = opts.patterns.length ? opts.patterns : DEFAULT_PATTERNS;
|
|
53
|
+
const configArgs = ["config"];
|
|
54
|
+
if (opts.global)
|
|
55
|
+
configArgs.push("--global");
|
|
56
|
+
git([...configArgs, "diff.confdiff.command", DRIVER_COMMAND]);
|
|
57
|
+
const attributesFile = resolveAttributesFile(opts.global, git, cwd);
|
|
58
|
+
const existing = existsSync(attributesFile) ? readFileSync(attributesFile, "utf8") : "";
|
|
59
|
+
const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
|
|
60
|
+
const added = [];
|
|
61
|
+
const alreadyPresent = [];
|
|
62
|
+
for (const p of patterns) {
|
|
63
|
+
const line = `${p} diff=confdiff`;
|
|
64
|
+
if (existingLines.has(line))
|
|
65
|
+
alreadyPresent.push(p);
|
|
66
|
+
else
|
|
67
|
+
added.push(p);
|
|
68
|
+
}
|
|
69
|
+
if (added.length) {
|
|
70
|
+
let out = existing;
|
|
71
|
+
if (out.length && !out.endsWith("\n"))
|
|
72
|
+
out += "\n";
|
|
73
|
+
out += added.map((p) => `${p} diff=confdiff`).join("\n") + "\n";
|
|
74
|
+
mkdirSync(dirname(attributesFile), { recursive: true });
|
|
75
|
+
writeFileSync(attributesFile, out);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
scope: opts.global ? "global" : "local",
|
|
79
|
+
attributesFile,
|
|
80
|
+
added,
|
|
81
|
+
alreadyPresent,
|
|
82
|
+
command: DRIVER_COMMAND,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { diff, type Change, type ChangeKind, type DiffOptions, type Path, formatPath, typeOf } from "./diff.js";
|
|
2
|
+
export { parseContent, parseEnv, parseCsv, parseXml, keyRowsByColumn, detectFormat, sniff, type Format, type Value, } from "./parse.js";
|
|
3
|
+
export { renderText, renderJson, type RenderOptions } from "./render.js";
|
|
4
|
+
import { type DiffOptions, type Change } from "./diff.js";
|
|
5
|
+
import { type Format } from "./parse.js";
|
|
6
|
+
export interface CompareOptions extends DiffOptions {
|
|
7
|
+
formatA?: Format;
|
|
8
|
+
formatB?: Format;
|
|
9
|
+
filenameA?: string;
|
|
10
|
+
filenameB?: string;
|
|
11
|
+
/** For CSV inputs: match rows by this column instead of by position. */
|
|
12
|
+
csvKey?: string;
|
|
13
|
+
}
|
|
14
|
+
/** High-level helper: compare two raw strings of (possibly different) formats. */
|
|
15
|
+
export declare function compare(a: string, b: string, opts?: CompareOptions): Change[];
|