@intentius/chant 0.44.1 → 0.44.2

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 (41) hide show
  1. package/dist/cli/commands/lint.d.ts +10 -0
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/components/cli-support.d.ts +1 -1
  5. package/dist/components/cli-support.d.ts.map +1 -1
  6. package/dist/governance.d.ts +1 -1
  7. package/dist/graph-detail.d.ts +31 -13
  8. package/dist/graph-detail.d.ts.map +1 -1
  9. package/dist/lifecycle/change-set.d.ts +7 -0
  10. package/dist/lifecycle/change-set.d.ts.map +1 -1
  11. package/dist/lifecycle/live-diff.d.ts +18 -0
  12. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  13. package/dist/lifecycle/observe.d.ts.map +1 -1
  14. package/dist/lint/component-checks.d.ts +2 -1
  15. package/dist/lint/component-checks.d.ts.map +1 -1
  16. package/dist/observation.d.ts +33 -3
  17. package/dist/observation.d.ts.map +1 -1
  18. package/dist/reconcile.d.ts +5 -5
  19. package/dist/reconcile.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/cli/commands/lint.ts +14 -3
  22. package/src/cli/handlers/components.ts +1 -1
  23. package/src/cli/handlers/graph.ts +32 -7
  24. package/src/cli/handlers/lifecycle.ts +8 -2
  25. package/src/codegen/publish-order.test.ts +1 -1
  26. package/src/codegen/release-wiring.test.ts +3 -4
  27. package/src/components/cli-support.test.ts +52 -0
  28. package/src/components/cli-support.ts +13 -2
  29. package/src/governance.test.ts +1 -1
  30. package/src/governance.ts +1 -1
  31. package/src/graph-detail.test.ts +117 -6
  32. package/src/graph-detail.ts +76 -18
  33. package/src/lifecycle/change-set.test.ts +26 -0
  34. package/src/lifecycle/change-set.ts +13 -0
  35. package/src/lifecycle/live-diff.test.ts +35 -0
  36. package/src/lifecycle/live-diff.ts +21 -0
  37. package/src/lifecycle/observe.ts +2 -1
  38. package/src/lint/component-checks.ts +8 -1
  39. package/src/observation.test.ts +54 -7
  40. package/src/observation.ts +53 -13
  41. package/src/reconcile.ts +7 -7
@@ -25,6 +25,7 @@
25
25
  import type { Component } from "../components/component";
26
26
  import type { DiscoveredComponent } from "../components/discover";
27
27
  import { discoverComponents } from "../components/discover";
28
+ import type { BuildParamProvenance } from "../provenance";
28
29
  import type { RollbackPolicy } from "../components/capability";
29
30
  import type { Severity } from "./rule";
30
31
 
@@ -105,10 +106,16 @@ export async function runComponentChecks(
105
106
  checks: ComponentCheck[],
106
107
  registryContext?: Pick<ComponentCheckContext, "knownKinds" | "rollbackPolicies">,
107
108
  sandbox?: boolean,
109
+ buildParams?: BuildParamProvenance[],
108
110
  ): Promise<ComponentCheckDiagnostic[]> {
109
111
  if (checks.length === 0) return [];
110
112
 
111
- const result = await discoverComponents(path, { sandbox });
113
+ // #1490 this import runs BEFORE the caller's own component discovery, and
114
+ // an ES module is evaluated once per path. Whatever parameters are in effect
115
+ // here are the ones every later reader sees, however carefully that reader
116
+ // resolves its own. Passing them at the second call and not this one left
117
+ // the graph on defaults while the CLI reported the values it had resolved.
118
+ const result = await discoverComponents(path, { sandbox, buildParams });
112
119
  const diagnostics: ComponentCheckDiagnostic[] = [];
113
120
 
114
121
  for (const err of result.errors) {
@@ -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 two empty maps", () => {
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")],
@@ -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. Both maps always present. */
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
- return entry.detail ? `${base}: ${entry.detail}` : base;
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] = { type: entity.type, ...result.unobserved };
392
+ unobserved[entity.name] = {
393
+ type: entity.type,
394
+ ...result.unobserved,
395
+ ...(result.queried ? { queried: result.queried } : {}),
396
+ };
359
397
  }
360
- // `absent`: record nothing — in neither map is how the contract spells absence.
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 "warden" (e.g. github-warden) builds its provider-specific resource diffing,
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 (#790). `resourceType` stays the
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 (#790): when entries carry governance verbs, order
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 (#790). Every
421
- * SCM warden cycle stamps one; cloud cycles (epic #787 C2) must. Optional
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 (#790), when it stamps one. */
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 (#790) onto entries that don't
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;