flecto 2.0.0 → 3.0.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/src/differ.js CHANGED
@@ -1,14 +1,61 @@
1
+ import { annotateEncryptedChanges } from './encrypted.js';
2
+ import { diffDocumentKeys, stripDocumentPrefix } from './documents.js';
3
+
1
4
  /**
2
5
  * @typedef {{ type: 'added' | 'removed' | 'changed', path: string, before?: unknown, after?: unknown, note?: string }} ChangeEvent
3
6
  */
4
7
 
8
+ /**
9
+ * Where an event's path carries a synthetic multi-document prefix, the same
10
+ * path without it. Recorded non-enumerably so the event stays byte-identical
11
+ * everywhere it is serialized — JSON output, webhook payloads, snapshots.
12
+ */
13
+ const SECRET_MATCH_PATH = Symbol.for('flecto.secretMatchPath');
14
+
15
+ /**
16
+ * The path to match secret-looking *key names* against.
17
+ *
18
+ * For an ordinary file this is just `event.path`. For a multi-document file it
19
+ * is the path with the document identity removed, because that identity is a
20
+ * resource name — user data — and a Deployment called `token-service` must not
21
+ * make every value inside it read as a secret. See documents.js.
22
+ * @param {ChangeEvent} event
23
+ * @returns {string}
24
+ */
25
+ export function secretMatchPath(event) {
26
+ const stripped = /** @type {Record<string | symbol, unknown>} */ (event)?.[SECRET_MATCH_PATH];
27
+ return typeof stripped === 'string' ? stripped : (event?.path ?? '');
28
+ }
29
+
30
+ /**
31
+ * @param {ChangeEvent[]} events
32
+ * @param {readonly string[]} documentKeys
33
+ * @returns {ChangeEvent[]}
34
+ */
35
+ function tagSecretMatchPaths(events, documentKeys) {
36
+ if (documentKeys.length === 0) return events;
37
+ for (const event of events) {
38
+ const stripped = stripDocumentPrefix(event.path, documentKeys);
39
+ if (stripped === event.path) continue;
40
+ Object.defineProperty(event, SECRET_MATCH_PATH, {
41
+ value: stripped,
42
+ enumerable: false,
43
+ writable: false,
44
+ configurable: true,
45
+ });
46
+ }
47
+ return events;
48
+ }
49
+
5
50
  /**
6
51
  * Checks whether a value is a plain object (not array, not null).
7
52
  * @param {unknown} v
8
53
  * @returns {v is Record<string, unknown>}
9
54
  */
10
55
  function isPlainObject(v) {
11
- return v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
57
+ const prototype = Object.getPrototypeOf(v);
58
+ return prototype === Object.prototype || prototype === null;
12
59
  }
13
60
 
14
61
  /**
@@ -61,10 +108,9 @@ function makeIgnoreMatcher(patterns) {
61
108
  }
62
109
 
63
110
  if (keyAnywhere.size > 0) {
111
+ const pathParts = splitPathParts(path);
64
112
  for (const key of keyAnywhere) {
65
- if (path === key) return true;
66
- if (path.includes(`.${key}`)) return true;
67
- if (path.includes(`].${key}`)) return true;
113
+ if (pathParts.includes(key)) return true;
68
114
  }
69
115
  }
70
116
 
@@ -139,7 +185,7 @@ function matchParts(patternParts, pathParts) {
139
185
  * @param {unknown} after
140
186
  * @param {string} path
141
187
  * @param {ChangeEvent[]} events accumulator
142
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
188
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
143
189
  */
144
190
  function diffValues(before, after, path, events, options = {}) {
145
191
  const beforeIsObj = isPlainObject(before);
@@ -200,7 +246,7 @@ function diffValues(before, after, path, events, options = {}) {
200
246
  * @param {Record<string, unknown>} after
201
247
  * @param {string} basePath
202
248
  * @param {ChangeEvent[]} events
203
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
249
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
204
250
  */
205
251
  function diffObjects(before, after, basePath, events, options = {}) {
206
252
  const beforeKeys = new Set(Object.keys(before));
@@ -245,43 +291,90 @@ function identityKey(item, idKey) {
245
291
  }
246
292
 
247
293
  /**
248
- * Diff arrays by identity key when configured; otherwise by index.
294
+ * @param {unknown[]} items
295
+ * @param {string} idKey
296
+ * @returns {Map<string, { value: unknown, index: number }> | null}
297
+ */
298
+ function identityMap(items, idKey) {
299
+ /** @type {Map<string, { value: unknown, index: number }>} */
300
+ const map = new Map();
301
+ for (let i = 0; i < items.length; i++) {
302
+ const key = identityKey(items[i], idKey);
303
+ if (key == null || map.has(key)) return null;
304
+ map.set(key, { value: items[i], index: i });
305
+ }
306
+ return map;
307
+ }
308
+
309
+ /**
310
+ * Select a configured identity key, or auto-detect id then name.
311
+ * @param {unknown[]} before
312
+ * @param {unknown[]} after
313
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean }} options
314
+ * @returns {string | null}
315
+ */
316
+ function resolveArrayIdKey(before, after, options) {
317
+ // An explicit key always enables identity matching, even when arrayIdentity
318
+ // is off (e.g. .flectorc arrayId:false plus CLI --array-id-key).
319
+ const configured = options.arrayIdKey ? String(options.arrayIdKey) : null;
320
+ if (configured) return configured;
321
+
322
+ if (options.arrayIdentity === false) return null;
323
+
324
+ for (const candidate of ['id', 'name']) {
325
+ if (identityMap(before, candidate) && identityMap(after, candidate)) {
326
+ return candidate;
327
+ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Produce a stable JSON signature for order-insensitive array comparison.
334
+ * Object keys are sorted so their insertion order does not affect equality.
335
+ * Non-JSON values fall back to their identity, avoiding serialization errors.
336
+ * @param {unknown} value
337
+ * @returns {unknown}
338
+ */
339
+ function arraySignature(value) {
340
+ try {
341
+ const canonicalize = (item) => {
342
+ if (Array.isArray(item)) return item.map(canonicalize);
343
+ if (
344
+ !isPlainObject(item) ||
345
+ (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
346
+ ) {
347
+ return item;
348
+ }
349
+ return Object.fromEntries(
350
+ Object.keys(item)
351
+ .sort()
352
+ .map((key) => [key, canonicalize(item[key])])
353
+ );
354
+ };
355
+ const signature = JSON.stringify(canonicalize(value));
356
+ return signature === undefined ? value : signature;
357
+ } catch {
358
+ return value;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Diff arrays by configured or auto-detected identity key; otherwise by index.
249
364
  * @param {unknown[]} before
250
365
  * @param {unknown[]} after
251
366
  * @param {string} basePath
252
367
  * @param {ChangeEvent[]} events
253
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
368
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
254
369
  */
255
370
  function diffArrays(before, after, basePath, events, options = {}) {
256
- const idKey = options.arrayIdKey ? String(options.arrayIdKey) : null;
371
+ const idKey = resolveArrayIdKey(before, after, options);
257
372
 
258
373
  if (idKey) {
259
- /** @type {Map<string, { value: unknown, index: number }>} */
260
- const beforeMap = new Map();
261
- /** @type {Map<string, { value: unknown, index: number }>} */
262
- const afterMap = new Map();
263
- let canUseIdentity = true;
264
-
265
- for (let i = 0; i < before.length; i++) {
266
- const key = identityKey(before[i], idKey);
267
- if (key == null || beforeMap.has(key)) {
268
- canUseIdentity = false;
269
- break;
270
- }
271
- beforeMap.set(key, { value: before[i], index: i });
272
- }
273
- if (canUseIdentity) {
274
- for (let i = 0; i < after.length; i++) {
275
- const key = identityKey(after[i], idKey);
276
- if (key == null || afterMap.has(key)) {
277
- canUseIdentity = false;
278
- break;
279
- }
280
- afterMap.set(key, { value: after[i], index: i });
281
- }
282
- }
374
+ const beforeMap = identityMap(before, idKey);
375
+ const afterMap = identityMap(after, idKey);
283
376
 
284
- if (canUseIdentity) {
377
+ if (beforeMap && afterMap) {
285
378
  for (const [key, afterItem] of afterMap) {
286
379
  const childPath = `${basePath}[${JSON.stringify(key)}]`;
287
380
  if (!beforeMap.has(key)) {
@@ -302,19 +395,22 @@ function diffArrays(before, after, basePath, events, options = {}) {
302
395
 
303
396
  if (options.arrayIgnoreOrder) {
304
397
  // Order-insensitive without id key: multiset compare via JSON signatures
305
- const beforeSigs = before.map((v) => JSON.stringify(v));
306
- const afterSigs = after.map((v) => JSON.stringify(v));
307
- /** @type {Map<string, number>} */
398
+ /** @type {Map<unknown, { count: number, value: unknown }>} */
308
399
  const counts = new Map();
309
- for (const s of beforeSigs) counts.set(s, (counts.get(s) ?? 0) + 1);
310
- for (const s of afterSigs) {
311
- const n = counts.get(s) ?? 0;
312
- if (n > 0) counts.set(s, n - 1);
313
- else events.push({ type: 'added', path: `${basePath}[*]`, after: JSON.parse(s) });
400
+ for (const value of before) {
401
+ const signature = arraySignature(value);
402
+ const entry = counts.get(signature);
403
+ if (entry) entry.count++;
404
+ else counts.set(signature, { count: 1, value });
405
+ }
406
+ for (const value of after) {
407
+ const entry = counts.get(arraySignature(value));
408
+ if (entry?.count > 0) entry.count--;
409
+ else events.push({ type: 'added', path: `${basePath}[*]`, after: value });
314
410
  }
315
- for (const [s, n] of counts) {
316
- for (let i = 0; i < n; i++) {
317
- events.push({ type: 'removed', path: `${basePath}[*]`, before: JSON.parse(s) });
411
+ for (const entry of counts.values()) {
412
+ for (let i = 0; i < entry.count; i++) {
413
+ events.push({ type: 'removed', path: `${basePath}[*]`, before: entry.value });
318
414
  }
319
415
  }
320
416
  return;
@@ -338,7 +434,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
338
434
  * Accepts any JSON-like values at the root (object/array/scalar/null).
339
435
  * @param {unknown} before
340
436
  * @param {unknown} after
341
- * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
437
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
342
438
  * @returns {ChangeEvent[]}
343
439
  */
344
440
  export function diffTrees(before, after, options = {}) {
@@ -347,6 +443,7 @@ export function diffTrees(before, after, options = {}) {
347
443
  const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
348
444
  const diffOpts = {
349
445
  arrayIdKey: options.arrayIdKey ?? null,
446
+ arrayIdentity: options.arrayIdentity !== false,
350
447
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
351
448
  };
352
449
 
@@ -360,5 +457,15 @@ export function diffTrees(before, after, options = {}) {
360
457
  diffValues(before, after, '<root>', events, diffOpts);
361
458
  }
362
459
 
363
- return events.filter(e => !ignore(e.path));
460
+ // Encrypted files carry signals a key-by-key walk cannot express: the file
461
+ // gaining or losing encryption, and a MAC that moved on its own. This is a
462
+ // no-op — the same array, untouched — when neither side is encrypted.
463
+ // Ignore patterns are applied afterwards so `--ignore` silences the derived
464
+ // paths exactly like any other.
465
+ const annotated = annotateEncryptedChanges(before, after, events).filter(e => !ignore(e.path));
466
+
467
+ // Multi-document paths carry a resource name in front. Record what secret-name
468
+ // matching should look at, once, here — the renderers see events and nothing
469
+ // else, and a resource name must never decide whether a value gets masked.
470
+ return tagSecretMatchPaths(annotated, diffDocumentKeys(before, after));
364
471
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Carrying the "this tree is a multi-document wrapper" signal.
3
+ *
4
+ * A `---`-separated YAML file parses to a synthetic object keyed by document
5
+ * identity (see `parseYamlStream`), so every path inside a document gains a
6
+ * `Kind/namespace/name` prefix. Several subsystems need to know which leading
7
+ * path segments are those synthetic keys rather than real configuration keys —
8
+ * most importantly secret-name matching, which must never look at a resource
9
+ * name, because a resource *named* `token-service` is not a secret.
10
+ *
11
+ * The parser is the only thing that can answer this without guessing: it
12
+ * invented the keys. Rather than re-deriving them downstream from the shape of
13
+ * the keys — a heuristic that misfires the first time somebody writes a config
14
+ * whose top-level keys genuinely look like `Kind/ns/name` — the parser records
15
+ * them here and everything else reads them back.
16
+ *
17
+ * The record is a non-enumerable symbol property, so it is invisible to
18
+ * `Object.keys`, `JSON.stringify`, spread, and `assert.deepEqual`: a marked tree
19
+ * is byte-for-byte the same snapshot, diff, and payload it was before. The flip
20
+ * side is that it does not survive a JSON round trip, so a tree read back out of
21
+ * a snapshot file carries the keys only if the snapshot recorded them.
22
+ * `documentKeysOf` distinguishes the two: `[]` means "known not to be a
23
+ * wrapper", `null` means "provenance unknown".
24
+ */
25
+
26
+ /** Where the synthetic document keys are recorded on a parsed tree. */
27
+ const DOCUMENT_KEYS = Symbol.for('flecto.documentKeys');
28
+
29
+ /** @type {readonly string[]} */
30
+ const NONE = Object.freeze([]);
31
+
32
+ /**
33
+ * Record the synthetic document keys the parser invented for a tree.
34
+ *
35
+ * Returns the tree itself — marking is a side effect on an object Flecto just
36
+ * created, never a copy. Non-object roots (a scalar YAML document, an opaque
37
+ * age blob) cannot carry the mark and are returned unchanged.
38
+ * @template T
39
+ * @param {T} tree
40
+ * @param {readonly string[] | null | undefined} keys
41
+ * @returns {T}
42
+ */
43
+ export function withDocumentKeys(tree, keys) {
44
+ if (tree === null || typeof tree !== 'object') return tree;
45
+ Object.defineProperty(tree, DOCUMENT_KEYS, {
46
+ value: keys == null ? null : Object.freeze([...keys]),
47
+ enumerable: false,
48
+ writable: false,
49
+ configurable: true,
50
+ });
51
+ return tree;
52
+ }
53
+
54
+ /**
55
+ * The synthetic document keys recorded on a tree.
56
+ * @param {unknown} tree
57
+ * @returns {readonly string[] | null} `[]` when the tree is known to be a single
58
+ * document, `null` when nothing recorded its provenance
59
+ */
60
+ export function documentKeysOf(tree) {
61
+ if (tree === null || typeof tree !== 'object') return null;
62
+ const keys = /** @type {Record<string | symbol, unknown>} */ (tree)[DOCUMENT_KEYS];
63
+ return Array.isArray(keys) ? /** @type {readonly string[]} */ (keys) : null;
64
+ }
65
+
66
+ /**
67
+ * The document keys covering either side of a diff, longest first.
68
+ *
69
+ * A document present on only one side still prefixes the paths of its own
70
+ * additions or removals, so the union is what a path can start with. Longest
71
+ * first so that if one identity is a prefix of another — `app` and `app.web` —
72
+ * a path is attributed to the more specific one.
73
+ * @param {unknown} before
74
+ * @param {unknown} after
75
+ * @returns {readonly string[]}
76
+ */
77
+ export function diffDocumentKeys(before, after) {
78
+ const merged = [...(documentKeysOf(before) ?? NONE), ...(documentKeysOf(after) ?? NONE)];
79
+ if (merged.length === 0) return NONE;
80
+ return [...new Set(merged)].sort((a, b) => b.length - a.length);
81
+ }
82
+
83
+ /**
84
+ * Drop a leading document-identity segment from a diff path.
85
+ *
86
+ * Matching is by whole segment against the keys the parser actually invented,
87
+ * not by pattern, so `Deployment/prod/token-service.spec.replicas` becomes
88
+ * `spec.replicas` while an ordinary key that merely begins with the same text
89
+ * is left alone. Returns the path unchanged when no key applies, which is the
90
+ * single-document case and therefore the common one.
91
+ * @param {string} path
92
+ * @param {readonly string[] | null | undefined} keys tried in order; see
93
+ * {@link diffDocumentKeys} for why callers pass them longest first
94
+ * @returns {string}
95
+ */
96
+ export function stripDocumentPrefix(path, keys) {
97
+ if (!keys || keys.length === 0 || !path) return path;
98
+ for (const key of keys) {
99
+ if (!key || !path.startsWith(key)) continue;
100
+ if (path.length === key.length) return '';
101
+ const next = path[key.length];
102
+ if (next === '.') return path.slice(key.length + 1);
103
+ if (next === '[') return path.slice(key.length);
104
+ }
105
+ return path;
106
+ }