@intentius/chant 0.28.0 → 0.30.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.
Files changed (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -0,0 +1,489 @@
1
+ /**
2
+ * The deep observation contract (#1014) — what a lexicon's
3
+ * `observeResourcesDeep()` is allowed to mean, and how a live property tree is
4
+ * normalized before anything diffs it.
5
+ *
6
+ * `describeResources()` (./observation.ts) answers *whether* a declared entity
7
+ * exists and carries a handful of scrubbed outputs. That is thin by design, and
8
+ * it is why `lifecycle diff --live` only fires on a changed status, a replaced
9
+ * physical id, or a changed stack output. The drift people actually care about
10
+ * — a hand-edited security-group rule, an inline policy added in the console, a
11
+ * flipped bucket setting — lives one level down, in properties nobody was
12
+ * reading.
13
+ *
14
+ * This module adds the second, optional read: a normalized live property tree
15
+ * per declared entity. It composes with the tri-state contract rather than
16
+ * replacing it — {@link DeepObservationResult} carries the same
17
+ * {@link UnobservedEntity} map, so a deep read that fails for one entity is
18
+ * NOT-OBSERVED with a reason. A thin deep result is never allowed to pass for a
19
+ * clean one.
20
+ *
21
+ * ## The hard half is noise, not reading
22
+ *
23
+ * A raw live model is mostly fields nobody declared and nobody changed: arns,
24
+ * timestamps, generation counters, status subtrees, values the API filled in,
25
+ * fields a controller owns, and orderings that carry no meaning (tag order,
26
+ * policy statement order). Diffing that raw is all noise. So the contract is
27
+ * three parts, and a lexicon implements the last two:
28
+ *
29
+ * 1. **The normalization pass** (here, shared) — canonical key order,
30
+ * hook-driven array order, secret masking, non-JSON values collapsed to
31
+ * {@link UNRESOLVED}.
32
+ * 2. **The pruning hook** ({@link DeepNormalizationHooks.prune}) — the
33
+ * lexicon names its own read-only / server-populated / controller-managed /
34
+ * provider-defaulted fields.
35
+ * 3. **The ordering hook** ({@link DeepNormalizationHooks.orderKey}) — the
36
+ * lexicon names which arrays are sets, and by what key they canonicalize.
37
+ *
38
+ * Both hooks are applied to the *declared* tree and the *live* tree with the
39
+ * same rules, so the two sides are compared in the same shape. A hook sees
40
+ * which side it is normalizing ({@link DeepNode.side}) and whether the same path
41
+ * exists on the other side ({@link DeepNode.counterpart}) — that second flag is
42
+ * what makes default subtraction expressible: a provider default is only noise
43
+ * when nobody declared the property.
44
+ */
45
+
46
+ import type { UnobservedEntity } from "./observation";
47
+
48
+ /**
49
+ * A live property tree for one declared entity, already normalized by the
50
+ * lexicon's hooks.
51
+ */
52
+ export interface DeepResourceObservation {
53
+ /** Entity type (e.g. `AWS::S3::Bucket`) — the same string `ResourceMetadata.type` carries. */
54
+ type: string;
55
+ /** Provider-assigned physical id, when the reader knows it. Correlates to `ResourceMetadata.physicalId`. */
56
+ physicalId?: string;
57
+ /** The normalized live property tree. JSON-safe. */
58
+ properties: Record<string, unknown>;
59
+ }
60
+
61
+ /**
62
+ * The deep observation envelope. Explicitly versioned and discriminated by
63
+ * `deepObservation: "v1"`, for the same reason {@link
64
+ * import("./observation").ObservationResult} is: the shape is a wire format
65
+ * consumers branch on.
66
+ *
67
+ * There is deliberately no bare-map alternative here. `describeResources()`
68
+ * accepts one for backward compatibility with lexicons written before #1089;
69
+ * `observeResourcesDeep()` is new, so every implementation states its holes
70
+ * from day one.
71
+ */
72
+ export interface DeepObservationResult {
73
+ /** Discriminant + wire version. */
74
+ readonly deepObservation: "v1";
75
+ /** OBSERVED-PRESENT, keyed by chant entity name. */
76
+ resources: Record<string, DeepResourceObservation>;
77
+ /**
78
+ * NOT-OBSERVED, keyed by chant entity name — the entities whose *properties*
79
+ * could not be read, with a total reason. An entity here may well exist and
80
+ * may well be reported present by the thin `describeResources()`: the two
81
+ * reads have independent verdicts, and a deep hole is a hole in the property
82
+ * surface, not a claim about existence.
83
+ */
84
+ unobserved?: Record<string, UnobservedEntity>;
85
+ }
86
+
87
+ /** Normalized form every consumer works with. Both maps always present. */
88
+ export interface NormalizedDeepObservation {
89
+ resources: Record<string, DeepResourceObservation>;
90
+ unobserved: Record<string, UnobservedEntity>;
91
+ }
92
+
93
+ /** True when `value` is the versioned {@link DeepObservationResult} envelope. */
94
+ export function isDeepObservationResult(value: unknown): value is DeepObservationResult {
95
+ return (
96
+ typeof value === "object" &&
97
+ value !== null &&
98
+ (value as { deepObservation?: unknown }).deepObservation === "v1"
99
+ );
100
+ }
101
+
102
+ /** Build a {@link DeepObservationResult}. Lexicons use this rather than writing the discriminant by hand. */
103
+ export function deepObservation(
104
+ resources: Record<string, DeepResourceObservation>,
105
+ unobserved?: Record<string, UnobservedEntity>,
106
+ ): DeepObservationResult {
107
+ return {
108
+ deepObservation: "v1",
109
+ resources,
110
+ ...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Normalize a deep result. `undefined` (a lexicon that returned nothing at all)
116
+ * normalizes to two empty maps — which reads as "every declared entity's
117
+ * properties were read and there were none", so a reader that means "I could
118
+ * not look" must say so with `unobservedAll()` rather than returning nothing.
119
+ */
120
+ export function normalizeDeepObservation(
121
+ value: DeepObservationResult | undefined,
122
+ ): NormalizedDeepObservation {
123
+ if (!value) return { resources: {}, unobserved: {} };
124
+ return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
125
+ }
126
+
127
+ // ── Normalization pass ──────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * The placeholder a value takes when it is not JSON data — a class instance
131
+ * (an unevaluated intrinsic like `Sub`/`Ref` in a declared tree), a function, a
132
+ * symbol. The diff skips any path whose *declared* value is this: chant cannot
133
+ * know what `Fn::Sub` resolves to without deploying, and guessing would report
134
+ * every interpolated property as permanent drift.
135
+ */
136
+ export const UNRESOLVED = "<chant:unresolved>";
137
+
138
+ /** Value a masked (secret-bearing) property takes. Matches the thin path's convention. */
139
+ export const MASKED = "[REDACTED]";
140
+
141
+ /**
142
+ * Property-name patterns that mark a value secret-bearing. Deliberately narrow
143
+ * and key-name based: broadening this to e.g. `/key/i` would mask an AWS tag's
144
+ * `Key` field and every `*KeyName` reference, which is how masking turns into
145
+ * its own drift signal.
146
+ *
147
+ * Shared with the thin snapshot path (`./lifecycle/snapshot.ts`), which warns
148
+ * on the same names — one list, so the two paths cannot disagree about what
149
+ * counts as a secret.
150
+ */
151
+ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
152
+ /password/i,
153
+ /secret/i,
154
+ /token/i,
155
+ /private.?key/i,
156
+ /credential/i,
157
+ /connection.?string/i,
158
+ ];
159
+
160
+ /** True when a property name looks secret-bearing (see {@link SENSITIVE_KEY_PATTERNS}). */
161
+ export function isSensitiveKey(key: string): boolean {
162
+ return SENSITIVE_KEY_PATTERNS.some((p) => p.test(key));
163
+ }
164
+
165
+ /** Which tree a hook is being invoked on. */
166
+ export type DeepSide = "declared" | "live";
167
+
168
+ /** One node the normalization pass offers to {@link DeepNormalizationHooks.prune}. */
169
+ export interface DeepNode {
170
+ /** Entity type being normalized (e.g. `AWS::S3::Bucket`). */
171
+ entityType: string;
172
+ /** Exact path from the property-tree root, array indices included: `Tags[0].Key`. */
173
+ path: string;
174
+ /**
175
+ * The same path with array indices erased: `Tags[].Key`. Hooks match on this
176
+ * — an index is an artifact of the read, never part of the rule.
177
+ */
178
+ pattern: string;
179
+ /** Object key, or the array index as a string. */
180
+ key: string;
181
+ /** The raw value at this node, before recursion. */
182
+ value: unknown;
183
+ /** Which tree is being normalized. */
184
+ side: DeepSide;
185
+ /**
186
+ * Whether this path also exists on the other tree — i.e. a live property that
187
+ * was declared, or a declared property that came back live. Matched on the
188
+ * exact path or the index-erased pattern, so `Tags[0].Value` counts as
189
+ * declared when source declares any `Tags[].Value` (array order is not
190
+ * canonical until *after* normalization, so an exact-index match would be a
191
+ * coin flip inside arrays).
192
+ *
193
+ * `"unknown"` when the pass is running one-sided — a reader normalizing its
194
+ * own output before returning it has no declared tree to consult. It is a
195
+ * third state on purpose: a hook that treats "I wasn't told" as "not
196
+ * declared" would prune a *declared* property out of the live tree at read
197
+ * time, and the drift on it could never be reported.
198
+ *
199
+ * This is what makes provider-default subtraction expressible without a third
200
+ * hook: a defaulted value is noise only when nobody declared the property
201
+ * (`side === "live" && counterpart === "absent"`).
202
+ */
203
+ counterpart: "present" | "absent" | "unknown";
204
+ }
205
+
206
+ /** One array element the normalization pass offers to {@link DeepNormalizationHooks.orderKey}. */
207
+ export interface DeepArrayElement {
208
+ entityType: string;
209
+ /** Index-erased path of the containing array: `Tags`, `Policy.Statement`. */
210
+ pattern: string;
211
+ /** Exact path of the containing array. */
212
+ path: string;
213
+ /** The element, already normalized (pruned, key-sorted, masked). */
214
+ element: unknown;
215
+ index: number;
216
+ side: DeepSide;
217
+ }
218
+
219
+ /**
220
+ * The two hooks a lexicon supplies to opt into deep observation. Data on the
221
+ * plugin, not methods on the read — the same rules must apply to the declared
222
+ * tree, which no reader ever touches.
223
+ */
224
+ export interface DeepNormalizationHooks {
225
+ /**
226
+ * Return true to drop this node (and everything under it) from the tree.
227
+ *
228
+ * This is where the lexicon names its noise classes: read-only and
229
+ * server-populated fields (arns, timestamps, generation counters, status
230
+ * subtrees), controller-managed fields, and provider defaults — the last of
231
+ * those gated on `!node.counterpart` so a declared property is never pruned
232
+ * out from under the diff. See {@link DeepNode.counterpart}.
233
+ */
234
+ prune?(node: DeepNode): boolean;
235
+ /**
236
+ * Return a canonical sort key for one element of an array, or `undefined` to
237
+ * leave that array's order alone.
238
+ *
239
+ * An array is reordered only when *every* element yields a key — a partial
240
+ * answer is treated as "I don't know how to canonicalize this", which keeps
241
+ * an order-significant list (a pipeline's stages, a CIDR precedence list) in
242
+ * the order the provider returned it.
243
+ */
244
+ orderKey?(element: DeepArrayElement): string | undefined;
245
+ }
246
+
247
+ /** Everything the pass needs besides the tree itself. */
248
+ export interface NormalizeDeepOptions {
249
+ entityType: string;
250
+ side: DeepSide;
251
+ hooks?: DeepNormalizationHooks;
252
+ /**
253
+ * Paths present on the other tree, as produced by {@link deepPathSet}. Drives
254
+ * {@link DeepNode.counterpart}. Omit and every node reports `"unknown"` —
255
+ * the honest answer for a one-sided normalization.
256
+ */
257
+ counterpartPaths?: ReadonlySet<string>;
258
+ }
259
+
260
+ /** True for a value the pass will walk into rather than treat as a leaf. */
261
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
262
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
263
+ const proto = Object.getPrototypeOf(value) as unknown;
264
+ return proto === Object.prototype || proto === null;
265
+ }
266
+
267
+ /** True for a value that can be compared and serialized as-is. */
268
+ function isJsonPrimitive(value: unknown): boolean {
269
+ return (
270
+ value === null ||
271
+ typeof value === "string" ||
272
+ typeof value === "number" ||
273
+ typeof value === "boolean"
274
+ );
275
+ }
276
+
277
+ function joinPath(parent: string, key: string): string {
278
+ return parent ? `${parent}.${key}` : key;
279
+ }
280
+
281
+ function joinIndex(parent: string, index: number): string {
282
+ return `${parent}[${index}]`;
283
+ }
284
+
285
+ function joinPattern(parent: string): string {
286
+ return `${parent}[]`;
287
+ }
288
+
289
+ /**
290
+ * Every path in a raw tree — both the exact form (`Tags[0].Value`) and the
291
+ * index-erased pattern (`Tags[].Value`) — for {@link
292
+ * NormalizeDeepOptions.counterpartPaths}. The two forms never collide (`[]` vs
293
+ * `[0]`), so one set answers both questions.
294
+ *
295
+ * Computed on the *raw* tree, before pruning: whether a property was declared
296
+ * cannot depend on whether the pruning rules kept it.
297
+ */
298
+ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
299
+ const out = new Set<string>();
300
+ const walk = (value: unknown, path: string, pattern: string): void => {
301
+ if (path) {
302
+ out.add(path);
303
+ out.add(pattern);
304
+ }
305
+ if (Array.isArray(value)) {
306
+ value.forEach((el, i) => walk(el, joinIndex(path, i), joinPattern(pattern)));
307
+ return;
308
+ }
309
+ if (isPlainObject(value)) {
310
+ for (const [k, v] of Object.entries(value)) walk(v, joinPath(path, k), joinPath(pattern, k));
311
+ }
312
+ };
313
+ walk(tree, "", "");
314
+ return out;
315
+ }
316
+
317
+ /**
318
+ * Normalize one property tree: prune by hook, mask secret-bearing leaves,
319
+ * canonicalize key order, canonicalize array order where the hook knows how,
320
+ * and collapse anything that isn't JSON data to {@link UNRESOLVED}.
321
+ *
322
+ * Pure and total. Key order is canonicalized unconditionally because JSON
323
+ * object order is not semantic and a provider is free to return it differently
324
+ * on every read; array order is canonicalized only where the lexicon says the
325
+ * array is a set, because list order often *is* semantic.
326
+ */
327
+ export function normalizeDeepProperties(
328
+ tree: Record<string, unknown>,
329
+ options: NormalizeDeepOptions,
330
+ ): Record<string, unknown> {
331
+ const { entityType, side, hooks, counterpartPaths } = options;
332
+
333
+ const prune = (path: string, pattern: string, key: string, value: unknown): boolean => {
334
+ if (!hooks?.prune) return false;
335
+ return hooks.prune({
336
+ entityType,
337
+ path,
338
+ pattern,
339
+ key,
340
+ value,
341
+ side,
342
+ counterpart: !counterpartPaths
343
+ ? "unknown"
344
+ : counterpartPaths.has(path) || counterpartPaths.has(pattern)
345
+ ? "present"
346
+ : "absent",
347
+ });
348
+ };
349
+
350
+ const normalizeValue = (value: unknown, path: string, pattern: string, key: string): unknown => {
351
+ if (isSensitiveKey(key)) return MASKED;
352
+ if (isJsonPrimitive(value)) return value;
353
+
354
+ if (Array.isArray(value)) {
355
+ const elements: unknown[] = [];
356
+ for (let i = 0; i < value.length; i++) {
357
+ const elPath = joinIndex(path, i);
358
+ const elPattern = joinPattern(pattern);
359
+ if (prune(elPath, elPattern, String(i), value[i])) continue;
360
+ elements.push(normalizeValue(value[i], elPath, elPattern, String(i)));
361
+ }
362
+ return orderElements(elements, path, pattern);
363
+ }
364
+
365
+ if (isPlainObject(value)) {
366
+ const out: Record<string, unknown> = {};
367
+ for (const childKey of Object.keys(value).sort()) {
368
+ const childPath = joinPath(path, childKey);
369
+ const childPattern = joinPath(pattern, childKey);
370
+ const childValue = value[childKey];
371
+ if (childValue === undefined) continue;
372
+ if (prune(childPath, childPattern, childKey, childValue)) continue;
373
+ out[childKey] = normalizeValue(childValue, childPath, childPattern, childKey);
374
+ }
375
+ return out;
376
+ }
377
+
378
+ // A class instance (an unevaluated intrinsic), a function, a symbol, a
379
+ // bigint — not JSON data, and not something the diff may guess at.
380
+ return UNRESOLVED;
381
+ };
382
+
383
+ const orderElements = (elements: unknown[], path: string, pattern: string): unknown[] => {
384
+ if (!hooks?.orderKey || elements.length < 2) return elements;
385
+ const keyed: Array<{ key: string; element: unknown }> = [];
386
+ for (let i = 0; i < elements.length; i++) {
387
+ const key = hooks.orderKey({
388
+ entityType,
389
+ path,
390
+ pattern,
391
+ element: elements[i],
392
+ index: i,
393
+ side,
394
+ });
395
+ // A partial answer is not an answer — leave the order alone.
396
+ if (key === undefined) return elements;
397
+ keyed.push({ key, element: elements[i] });
398
+ }
399
+ keyed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
400
+ return keyed.map((k) => k.element);
401
+ };
402
+
403
+ const root = normalizeValue(tree, "", "", "");
404
+ return isPlainObject(root) ? root : {};
405
+ }
406
+
407
+ /** Longest order key that may appear inside a path segment before it stops being readable. */
408
+ const MAX_KEYED_SEGMENT = 60;
409
+
410
+ /** Context {@link flattenDeepProperties} needs to address set-like arrays by key. */
411
+ export interface FlattenDeepOptions {
412
+ entityType: string;
413
+ side: DeepSide;
414
+ hooks?: DeepNormalizationHooks;
415
+ }
416
+
417
+ /**
418
+ * Flatten a normalized tree to `path → leaf value`. Leaves are JSON primitives,
419
+ * {@link UNRESOLVED}, and empty containers (an empty object/array is itself a
420
+ * value — `Tags: []` differs from no `Tags` at all).
421
+ *
422
+ * Arrays the ordering hook can key are addressed **by that key**
423
+ * (`Tags[#env].Value`) rather than by position. Positional paths would make the
424
+ * diff shift-sensitive: one tag added in the console renames every tag after
425
+ * it, so a single new tag reports as a change to every other tag and a
426
+ * baseline entry stops matching the moment the set changes. Keying holds a
427
+ * property's identity still while the set around it moves.
428
+ *
429
+ * Positional paths remain for arrays the hook cannot key, arrays whose keys
430
+ * collide, and keys too long to read.
431
+ */
432
+ export function flattenDeepProperties(
433
+ tree: Record<string, unknown>,
434
+ options?: FlattenDeepOptions,
435
+ ): Map<string, unknown> {
436
+ const out = new Map<string, unknown>();
437
+
438
+ const indexSegments = (elements: unknown[], path: string, pattern: string): string[] => {
439
+ const positional = elements.map((_, i) => joinIndex(path, i));
440
+ if (!options?.hooks?.orderKey) return positional;
441
+ const keys: string[] = [];
442
+ for (let i = 0; i < elements.length; i++) {
443
+ const key = options.hooks.orderKey({
444
+ entityType: options.entityType,
445
+ path,
446
+ pattern,
447
+ element: elements[i],
448
+ index: i,
449
+ side: options.side,
450
+ });
451
+ if (key === undefined || key.length > MAX_KEYED_SEGMENT) return positional;
452
+ keys.push(key);
453
+ }
454
+ if (new Set(keys).size !== keys.length) return positional;
455
+ return keys.map((k) => `${path}[#${k}]`);
456
+ };
457
+
458
+ const walk = (value: unknown, path: string, pattern: string): void => {
459
+ if (Array.isArray(value)) {
460
+ if (value.length === 0) {
461
+ out.set(path, []);
462
+ return;
463
+ }
464
+ const segments = indexSegments(value, path, pattern);
465
+ value.forEach((el, i) => walk(el, segments[i], joinPattern(pattern)));
466
+ return;
467
+ }
468
+ if (isPlainObject(value)) {
469
+ const keys = Object.keys(value);
470
+ if (keys.length === 0) {
471
+ out.set(path, {});
472
+ return;
473
+ }
474
+ for (const k of keys) walk(value[k], joinPath(path, k), joinPath(pattern, k));
475
+ return;
476
+ }
477
+ out.set(path, value);
478
+ };
479
+ for (const [k, v] of Object.entries(tree)) walk(v, k, k);
480
+ return out;
481
+ }
482
+
483
+ /** Structural equality over JSON-shaped values, order-sensitive (post-canonicalization). */
484
+ export function deepValueEqual(a: unknown, b: unknown): boolean {
485
+ if (a === b) return true;
486
+ if (a === null || b === null || a === undefined || b === undefined) return false;
487
+ if (typeof a !== "object" || typeof b !== "object") return false;
488
+ return JSON.stringify(a) === JSON.stringify(b);
489
+ }