@intentius/chant 0.29.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.
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +23 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +44 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +86 -0
- package/src/cli/handlers/graph.ts +64 -3
- package/src/cli/handlers/lifecycle.test.ts +126 -1
- package/src/cli/handlers/lifecycle.ts +184 -3
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +235 -79
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +88 -18
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir.ts +47 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +59 -0
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/snapshot.ts +6 -11
|
@@ -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
|
+
}
|