@intentius/chant 0.44.1 → 0.44.3
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/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +10 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/governance.d.ts +1 -1
- package/dist/graph-detail.d.ts +31 -13
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +7 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +18 -0
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +2 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/observation.d.ts +33 -3
- package/dist/observation.d.ts.map +1 -1
- package/dist/reconcile.d.ts +5 -5
- package/dist/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +51 -19
- package/src/cli/commands/lint.ts +14 -3
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.test.ts +191 -15
- package/src/cli/handlers/graph.ts +131 -14
- package/src/cli/handlers/lifecycle.ts +8 -2
- package/src/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +3 -4
- package/src/components/cli-support.test.ts +52 -0
- package/src/components/cli-support.ts +13 -2
- package/src/governance.test.ts +1 -1
- package/src/governance.ts +1 -1
- package/src/graph-detail.test.ts +117 -6
- package/src/graph-detail.ts +76 -18
- package/src/lifecycle/change-set.test.ts +26 -0
- package/src/lifecycle/change-set.ts +13 -0
- package/src/lifecycle/live-diff.test.ts +35 -0
- package/src/lifecycle/live-diff.ts +21 -0
- package/src/lifecycle/observe.ts +2 -1
- package/src/lint/component-checks.ts +8 -1
- package/src/observation.test.ts +54 -7
- package/src/observation.ts +53 -13
- package/src/reconcile.ts +7 -7
package/src/observation.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
29
29
|
|
|
30
30
|
describe("normalizeObservation", () => {
|
|
31
31
|
test("a bare map means 'I looked at everything'", () => {
|
|
32
|
-
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {} });
|
|
32
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {} });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("the envelope carries both halves", () => {
|
|
@@ -37,11 +37,27 @@ describe("normalizeObservation", () => {
|
|
|
37
37
|
expect(normalizeObservation(value)).toEqual({
|
|
38
38
|
resources: { a: meta() },
|
|
39
39
|
unobserved: { b: { reason: "read-failed" } },
|
|
40
|
+
queried: {},
|
|
40
41
|
});
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
test("undefined normalizes to
|
|
44
|
-
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {} });
|
|
44
|
+
test("undefined normalizes to empty maps", () => {
|
|
45
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {} });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the envelope carries the queried addresses through normalization (#1620)", () => {
|
|
49
|
+
const value = observation({}, {}, { web: "/apis/apps/v1/namespaces/default/deployments/web" });
|
|
50
|
+
expect(normalizeObservation(value).queried).toEqual({
|
|
51
|
+
web: "/apis/apps/v1/namespaces/default/deployments/web",
|
|
52
|
+
});
|
|
53
|
+
// Additive metadata only: an entity in `queried` and neither map is still
|
|
54
|
+
// OBSERVED-ABSENT — the tri-state does not shift.
|
|
55
|
+
expect(normalizeObservation(value).resources).toEqual({});
|
|
56
|
+
expect(normalizeObservation(value).unobserved).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the envelope omits an empty queried map (#1620)", () => {
|
|
60
|
+
expect(observation({ a: meta() }, {}, {})).toEqual({ observation: "v1", resources: { a: meta() } });
|
|
45
61
|
});
|
|
46
62
|
|
|
47
63
|
test("an entity literally named `observation` cannot be mistaken for the envelope", () => {
|
|
@@ -68,8 +84,8 @@ describe("unobservedAll", () => {
|
|
|
68
84
|
describe("mergeObservations (multi-stack)", () => {
|
|
69
85
|
test("present beats not-observed beats absent", () => {
|
|
70
86
|
const merged = mergeObservations([
|
|
71
|
-
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } } },
|
|
72
|
-
{ resources: { a: meta() }, unobserved: {} },
|
|
87
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {} },
|
|
88
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
73
89
|
]);
|
|
74
90
|
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
75
91
|
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
@@ -77,11 +93,19 @@ describe("mergeObservations (multi-stack)", () => {
|
|
|
77
93
|
|
|
78
94
|
test("an entity nobody looked for in any stack stays absent", () => {
|
|
79
95
|
const merged = mergeObservations([
|
|
80
|
-
{ resources: { a: meta() }, unobserved: {} },
|
|
81
|
-
{ resources: { b: meta() }, unobserved: {} },
|
|
96
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
97
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {} },
|
|
82
98
|
]);
|
|
83
99
|
expect(merged.unobserved).toEqual({});
|
|
84
100
|
});
|
|
101
|
+
|
|
102
|
+
test("queried addresses union across stacks (#1620)", () => {
|
|
103
|
+
const merged = mergeObservations([
|
|
104
|
+
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" } },
|
|
105
|
+
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" } },
|
|
106
|
+
]);
|
|
107
|
+
expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
|
|
108
|
+
});
|
|
85
109
|
});
|
|
86
110
|
|
|
87
111
|
describe("reason totality", () => {
|
|
@@ -98,6 +122,12 @@ describe("reason totality", () => {
|
|
|
98
122
|
formatUnobserved("widget", { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no mapping" }),
|
|
99
123
|
).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
|
|
100
124
|
});
|
|
125
|
+
|
|
126
|
+
test("formatUnobserved appends the queried address when the entry carries one (#1620)", () => {
|
|
127
|
+
expect(
|
|
128
|
+
formatUnobserved("web", { reason: "read-failed", detail: "HTTP 500", queried: "/apis/apps/v1/namespaces/default/deployments/web" }),
|
|
129
|
+
).toBe("web — read failed: HTTP 500 [queried /apis/apps/v1/namespaces/default/deployments/web]");
|
|
130
|
+
});
|
|
101
131
|
});
|
|
102
132
|
|
|
103
133
|
describe("boundedConcurrently", () => {
|
|
@@ -163,6 +193,23 @@ describe("observeEntities harness (#1201)", () => {
|
|
|
163
193
|
});
|
|
164
194
|
});
|
|
165
195
|
|
|
196
|
+
test("collects the queried address from every variant — the absent one especially (#1620)", async () => {
|
|
197
|
+
const result = await observeEntities(
|
|
198
|
+
[entity("a"), entity("b"), entity("c")],
|
|
199
|
+
adapterOf({
|
|
200
|
+
a: { present: meta(), queried: "region-1/a" },
|
|
201
|
+
b: { absent: true, queried: "region-1/b" },
|
|
202
|
+
c: { unobserved: { reason: "read-failed", detail: "boom" }, queried: "region-1/c" },
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
// The absent entity stays in neither map — additive metadata, tri-state unshifted.
|
|
206
|
+
expect(Object.keys(result.resources)).toEqual(["a"]);
|
|
207
|
+
expect(Object.keys(result.unobserved ?? {})).toEqual(["c"]);
|
|
208
|
+
expect(result.queried).toEqual({ a: "region-1/a", b: "region-1/b", c: "region-1/c" });
|
|
209
|
+
// The unobserved entry carries its own copy, so a row renders without a join.
|
|
210
|
+
expect(result.unobserved?.c.queried).toBe("region-1/c");
|
|
211
|
+
});
|
|
212
|
+
|
|
166
213
|
test("a bind failure marks every entity NOT-OBSERVED with the typed reason and declared type", async () => {
|
|
167
214
|
const result = await observeEntities(
|
|
168
215
|
[entity("a", "AWS::S3::Bucket"), entity("b", "AWS::S3::Bucket")],
|
package/src/observation.ts
CHANGED
|
@@ -79,6 +79,14 @@ export interface UnobservedEntity {
|
|
|
79
79
|
reason: UnobservedReason;
|
|
80
80
|
/** Human-readable detail: the command that failed, the missing binding key, the unsupported kind. */
|
|
81
81
|
detail?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The resolved address the read was issued against (#1620) — for k8s the
|
|
84
|
+
* request path (`/apis/apps/v1/namespaces/default/deployments/web`), for
|
|
85
|
+
* other substrates whatever names the endpoint/region/account actually
|
|
86
|
+
* asked. Optional and purely diagnostic: it never changes the verdict, it
|
|
87
|
+
* lets a consumer tell "looked in the wrong place" from "not there".
|
|
88
|
+
*/
|
|
89
|
+
queried?: string;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/**
|
|
@@ -92,6 +100,18 @@ export interface ObservationResult {
|
|
|
92
100
|
resources: Record<string, ResourceMetadata>;
|
|
93
101
|
/** NOT-OBSERVED, keyed by chant entity name. Omit or leave empty when everything asked about was looked at. */
|
|
94
102
|
unobserved?: Record<string, UnobservedEntity>;
|
|
103
|
+
/**
|
|
104
|
+
* The resolved query address per declared entity (#1620), keyed by chant
|
|
105
|
+
* entity name — what was actually asked of the provider, whatever the
|
|
106
|
+
* verdict came back as. Additive metadata over the tri-state, never part of
|
|
107
|
+
* it: classification still reads only `resources` and `unobserved`, and an
|
|
108
|
+
* entity in neither map is still OBSERVED-ABSENT whether or not it appears
|
|
109
|
+
* here. This map is the only place an ABSENT entity can carry its address —
|
|
110
|
+
* absence is spelled "in neither map", so there is no row to hang it on —
|
|
111
|
+
* which is exactly the entry that lets a consumer see that a defaulted
|
|
112
|
+
* namespace, endpoint or region was read, not the one the resource lives in.
|
|
113
|
+
*/
|
|
114
|
+
queried?: Record<string, string>;
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/**
|
|
@@ -100,10 +120,12 @@ export interface ObservationResult {
|
|
|
100
120
|
*/
|
|
101
121
|
export type DescribeResourcesResult = Record<string, ResourceMetadata> | ObservationResult;
|
|
102
122
|
|
|
103
|
-
/** Normalized form every consumer works with.
|
|
123
|
+
/** Normalized form every consumer works with. All maps always present. */
|
|
104
124
|
export interface NormalizedObservation {
|
|
105
125
|
resources: Record<string, ResourceMetadata>;
|
|
106
126
|
unobserved: Record<string, UnobservedEntity>;
|
|
127
|
+
/** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
|
|
128
|
+
queried: Record<string, string>;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
/** True when `value` is the versioned {@link ObservationResult} envelope. */
|
|
@@ -122,11 +144,13 @@ export function isObservationResult(value: unknown): value is ObservationResult
|
|
|
122
144
|
export function observation(
|
|
123
145
|
resources: Record<string, ResourceMetadata>,
|
|
124
146
|
unobserved?: Record<string, UnobservedEntity>,
|
|
147
|
+
queried?: Record<string, string>,
|
|
125
148
|
): ObservationResult {
|
|
126
149
|
return {
|
|
127
150
|
observation: "v1",
|
|
128
151
|
resources,
|
|
129
152
|
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
153
|
+
...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
|
|
130
154
|
};
|
|
131
155
|
}
|
|
132
156
|
|
|
@@ -137,11 +161,11 @@ export function observation(
|
|
|
137
161
|
* {@link unobservedAll} rather than returning nothing.
|
|
138
162
|
*/
|
|
139
163
|
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
140
|
-
if (!value) return { resources: {}, unobserved: {} };
|
|
164
|
+
if (!value) return { resources: {}, unobserved: {}, queried: {} };
|
|
141
165
|
if (isObservationResult(value)) {
|
|
142
|
-
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
|
|
166
|
+
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {}, queried: value.queried ?? {} };
|
|
143
167
|
}
|
|
144
|
-
return { resources: value, unobserved: {} };
|
|
168
|
+
return { resources: value, unobserved: {}, queried: {} };
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
/**
|
|
@@ -180,14 +204,16 @@ export function unobservedAll(
|
|
|
180
204
|
export function mergeObservations(parts: Iterable<NormalizedObservation>): NormalizedObservation {
|
|
181
205
|
const resources: Record<string, ResourceMetadata> = {};
|
|
182
206
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
207
|
+
const queried: Record<string, string> = {};
|
|
183
208
|
for (const part of parts) {
|
|
184
209
|
Object.assign(resources, part.resources);
|
|
185
210
|
Object.assign(unobserved, part.unobserved);
|
|
211
|
+
Object.assign(queried, part.queried);
|
|
186
212
|
}
|
|
187
213
|
// Present wins: a stack that could not be read does not un-observe a resource
|
|
188
214
|
// another stack returned.
|
|
189
215
|
for (const name of Object.keys(resources)) delete unobserved[name];
|
|
190
|
-
return { resources, unobserved };
|
|
216
|
+
return { resources, unobserved, queried };
|
|
191
217
|
}
|
|
192
218
|
|
|
193
219
|
/** One-line human phrasing of a reason, for CLI output. */
|
|
@@ -206,10 +232,11 @@ export function unobservedReasonText(reason: UnobservedReason): string {
|
|
|
206
232
|
}
|
|
207
233
|
}
|
|
208
234
|
|
|
209
|
-
/** `name — reason (detail)`, the shared rendering for CLI and plan output. */
|
|
235
|
+
/** `name — reason (detail) [queried address]`, the shared rendering for CLI and plan output. */
|
|
210
236
|
export function formatUnobserved(name: string, entry: UnobservedEntity): string {
|
|
211
237
|
const base = `${name}${entry.type ? ` (${entry.type})` : ""} — ${unobservedReasonText(entry.reason)}`;
|
|
212
|
-
|
|
238
|
+
const detailed = entry.detail ? `${base}: ${entry.detail}` : base;
|
|
239
|
+
return entry.queried ? `${detailed} [queried ${entry.queried}]` : detailed;
|
|
213
240
|
}
|
|
214
241
|
|
|
215
242
|
/* ------------------------------------------------------------------------- *
|
|
@@ -246,11 +273,16 @@ export interface DeclaredEntity {
|
|
|
246
273
|
* - `present` — a key in `resources`.
|
|
247
274
|
* - `absent` — in neither map (the provider was asked and reported it missing).
|
|
248
275
|
* - `unobserved` — a typed NOT-OBSERVED (unsupported kind, filtered, read error).
|
|
276
|
+
*
|
|
277
|
+
* Every variant may carry `queried` (#1620): the resolved address the read was
|
|
278
|
+
* issued against. The harness collects it into the result's `queried` map (and
|
|
279
|
+
* onto the unobserved entry), so even an absent verdict — which records
|
|
280
|
+
* nothing else — says where the provider was asked.
|
|
249
281
|
*/
|
|
250
282
|
export type EntityObservation =
|
|
251
|
-
| { present: ResourceMetadata }
|
|
252
|
-
| { absent: true }
|
|
253
|
-
| { unobserved: { reason: UnobservedReason; detail?: string } };
|
|
283
|
+
| { present: ResourceMetadata; queried?: string }
|
|
284
|
+
| { absent: true; queried?: string }
|
|
285
|
+
| { unobserved: { reason: UnobservedReason; detail?: string }; queried?: string };
|
|
254
286
|
|
|
255
287
|
/** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
|
|
256
288
|
export interface ObserverAdapter<Client> {
|
|
@@ -338,6 +370,7 @@ export async function observeEntities<Client>(
|
|
|
338
370
|
|
|
339
371
|
const resources: Record<string, ResourceMetadata> = {};
|
|
340
372
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
373
|
+
const queried: Record<string, string> = {};
|
|
341
374
|
const run = adapter.concurrently ?? ((items, fn) => boundedConcurrently(items, fn));
|
|
342
375
|
|
|
343
376
|
await run(declared, async (entity) => {
|
|
@@ -352,13 +385,20 @@ export async function observeEntities<Client>(
|
|
|
352
385
|
},
|
|
353
386
|
};
|
|
354
387
|
}
|
|
388
|
+
if (result.queried) queried[entity.name] = result.queried;
|
|
355
389
|
if ("present" in result) {
|
|
356
390
|
resources[entity.name] = result.present;
|
|
357
391
|
} else if ("unobserved" in result) {
|
|
358
|
-
unobserved[entity.name] = {
|
|
392
|
+
unobserved[entity.name] = {
|
|
393
|
+
type: entity.type,
|
|
394
|
+
...result.unobserved,
|
|
395
|
+
...(result.queried ? { queried: result.queried } : {}),
|
|
396
|
+
};
|
|
359
397
|
}
|
|
360
|
-
// `absent`:
|
|
398
|
+
// `absent`: the verdict records nothing — in neither map is how the
|
|
399
|
+
// contract spells absence — but its address, when the adapter supplied
|
|
400
|
+
// one, lands in `queried` (#1620) so the absence can explain itself.
|
|
361
401
|
});
|
|
362
402
|
|
|
363
|
-
return observation(resources, unobserved);
|
|
403
|
+
return observation(resources, unobserved, queried);
|
|
364
404
|
}
|
package/src/reconcile.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the plan renderer, and the guardrail framework (rename resolution + a removal
|
|
8
8
|
* cap + a pluggable check runner).
|
|
9
9
|
*
|
|
10
|
-
* A
|
|
10
|
+
* A consumer application (e.g. github-warden) builds its provider-specific resource diffing,
|
|
11
11
|
* live-state types, and domain guardrails on top of this, and drives them with
|
|
12
12
|
* the generic `runReconcile` loop + `Cycle` interface (below). It complements
|
|
13
13
|
* chant's `ownership.ts` marker contract: ownership markers make a `delete`
|
|
@@ -41,7 +41,7 @@ export interface ChangeSetEntry {
|
|
|
41
41
|
/** High-level resource category (e.g. "team", "member", "branch-protection"). */
|
|
42
42
|
resourceType: string;
|
|
43
43
|
/**
|
|
44
|
-
* Cross-provider governance category
|
|
44
|
+
* Cross-provider governance category. `resourceType` stays the
|
|
45
45
|
* provider-specific display string; the verb is the shared grammar SCM and
|
|
46
46
|
* cloud plans group by. Stamped by `runReconcile` from the cycle's `verb`.
|
|
47
47
|
*/
|
|
@@ -221,7 +221,7 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
221
221
|
for (const kind of ORDER) {
|
|
222
222
|
let group = byKind[kind];
|
|
223
223
|
if (group.length === 0) continue;
|
|
224
|
-
// Verb-aware grouping
|
|
224
|
+
// Verb-aware grouping: when entries carry governance verbs, order
|
|
225
225
|
// each section by verb (vocabulary order, unverbed entries last) so mixed
|
|
226
226
|
// plans read category-by-category. Stable, and line format is unchanged —
|
|
227
227
|
// a verbless change set renders exactly as before.
|
|
@@ -417,8 +417,8 @@ export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
|
417
417
|
/** Human-readable name, e.g. "branch-protection". */
|
|
418
418
|
name: string;
|
|
419
419
|
/**
|
|
420
|
-
* Cross-provider governance category this cycle reconciles
|
|
421
|
-
* SCM
|
|
420
|
+
* Cross-provider governance category this cycle reconciles. Every
|
|
421
|
+
* SCM reconciler cycles stamp one; cloud cycles must. Optional
|
|
422
422
|
* only so provider-external Cycle implementations don't break.
|
|
423
423
|
*/
|
|
424
424
|
verb?: GovernanceVerb;
|
|
@@ -436,7 +436,7 @@ export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
|
|
|
436
436
|
/** Per-cycle outcome recorded in the run result. */
|
|
437
437
|
export interface CycleResult {
|
|
438
438
|
name: string;
|
|
439
|
-
/** The cycle's governance verb
|
|
439
|
+
/** The cycle's governance verb, when it stamps one. */
|
|
440
440
|
verb?: GovernanceVerb;
|
|
441
441
|
/** Scope id this result is for (e.g. an org login). */
|
|
442
442
|
org: string;
|
|
@@ -560,7 +560,7 @@ export async function runReconcile<TClient, TConfig, TLive, TScope = unknown>(
|
|
|
560
560
|
}
|
|
561
561
|
|
|
562
562
|
const changeSet = diffFn(scopeId, desired, live, diffOptions);
|
|
563
|
-
// Stamp the cycle's governance verb
|
|
563
|
+
// Stamp the cycle's governance verb onto entries that don't
|
|
564
564
|
// carry one, so provider diffs stay verb-unaware.
|
|
565
565
|
if (cycle.verb) {
|
|
566
566
|
for (const e of changeSet.entries) e.verb ??= cycle.verb;
|