@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.
Files changed (46) hide show
  1. package/dist/build.d.ts +23 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +10 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +1 -1
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/governance.d.ts +1 -1
  10. package/dist/graph-detail.d.ts +31 -13
  11. package/dist/graph-detail.d.ts.map +1 -1
  12. package/dist/lifecycle/change-set.d.ts +7 -0
  13. package/dist/lifecycle/change-set.d.ts.map +1 -1
  14. package/dist/lifecycle/live-diff.d.ts +18 -0
  15. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  16. package/dist/lifecycle/observe.d.ts.map +1 -1
  17. package/dist/lint/component-checks.d.ts +2 -1
  18. package/dist/lint/component-checks.d.ts.map +1 -1
  19. package/dist/observation.d.ts +33 -3
  20. package/dist/observation.d.ts.map +1 -1
  21. package/dist/reconcile.d.ts +5 -5
  22. package/dist/reconcile.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/build.ts +51 -19
  25. package/src/cli/commands/lint.ts +14 -3
  26. package/src/cli/handlers/components.ts +1 -1
  27. package/src/cli/handlers/graph.test.ts +191 -15
  28. package/src/cli/handlers/graph.ts +131 -14
  29. package/src/cli/handlers/lifecycle.ts +8 -2
  30. package/src/codegen/publish-order.test.ts +1 -1
  31. package/src/codegen/release-wiring.test.ts +3 -4
  32. package/src/components/cli-support.test.ts +52 -0
  33. package/src/components/cli-support.ts +13 -2
  34. package/src/governance.test.ts +1 -1
  35. package/src/governance.ts +1 -1
  36. package/src/graph-detail.test.ts +117 -6
  37. package/src/graph-detail.ts +76 -18
  38. package/src/lifecycle/change-set.test.ts +26 -0
  39. package/src/lifecycle/change-set.ts +13 -0
  40. package/src/lifecycle/live-diff.test.ts +35 -0
  41. package/src/lifecycle/live-diff.ts +21 -0
  42. package/src/lifecycle/observe.ts +2 -1
  43. package/src/lint/component-checks.ts +8 -1
  44. package/src/observation.test.ts +54 -7
  45. package/src/observation.ts +53 -13
  46. package/src/reconcile.ts +7 -7
@@ -145,8 +145,19 @@ export interface ComponentGraphResult {
145
145
  }
146
146
 
147
147
  /** Compute the components' dependency graph under `path`, for `chant graph --components`. */
148
- export async function computeComponentGraph(path: string, sandbox?: boolean): Promise<ComponentGraphResult> {
149
- const result = await discoverComponents(path, { sandbox });
148
+ export async function computeComponentGraph(
149
+ path: string,
150
+ sandbox?: boolean,
151
+ buildParams?: BuildParamProvenance[],
152
+ ): Promise<ComponentGraphResult> {
153
+ // #1490 — without this the component graph is always the DEFAULT-parameter
154
+ // graph. `discoverComponents` has honoured `buildParams` since #1108; every
155
+ // caller simply stopped short of passing them, so `--param backups=omit`
156
+ // dropped the CronJob from `chant build` and left the component that
157
+ // describes it in `chant graph --components`. Two commands disagreeing about
158
+ // what the source says, which is the thing #1064 and #1108 each fixed one
159
+ // layer of.
160
+ const result = await discoverComponents(path, { sandbox, buildParams });
150
161
  if (result.errors.length > 0) {
151
162
  return { success: false, order: [], waves: [], edges: [], error: result.errors.map((e) => e.message).join("\n") };
152
163
  }
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { GOVERNANCE_VERBS, isGovernanceVerb, type GovernanceVerb } from "./governance.js";
3
3
  import { renderChangeSet, runReconcile, type ChangeSet, type Cycle } from "./reconcile.js";
4
4
 
5
- describe("governance verbs (#790)", () => {
5
+ describe("governance verbs", () => {
6
6
  it("the runtime list and the type stay in sync", () => {
7
7
  // Compile-time: assigning the list's element type to GovernanceVerb and
8
8
  // back fails if either side gains a member the other lacks.
package/src/governance.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Governance verb vocabulary (#790, epic #787).
2
+ * Governance verb vocabulary .
3
3
  *
4
4
  * `resourceType` on a change-set entry is a free provider-specific string
5
5
  * ("team", "branch-protection", "protected-tag", …) chosen per cycle. That is
@@ -34,8 +34,12 @@ const base: GraphIR = {
34
34
  };
35
35
 
36
36
  describe("applyDetail", () => {
37
- test("T2 (declarables) is the identity", () => {
38
- expect(applyDetail(base, DETAIL.DECLARABLES)).toBe(base);
37
+ test("T2 (declarables) keeps every node and its scalar/reference attrs", () => {
38
+ const ir = applyDetail(base, DETAIL.DECLARABLES);
39
+ expect(ir.nodes.map((n) => n.id)).toEqual(base.nodes.map((n) => n.id));
40
+ expect(ir.edges).toEqual(base.edges);
41
+ // Top-level reference envelopes are part of the resource view.
42
+ expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ network: { $ref: "vpc.id" } });
39
43
  });
40
44
 
41
45
  test("T0 (stacks) collapses to one node per lexicon with cross-lexicon edges", () => {
@@ -55,6 +59,19 @@ describe("applyDetail", () => {
55
59
  expect(ir.edges).toContainEqual({ from: "db", to: "subnet", kind: "ref" });
56
60
  // The intra-gcp edge survives with its label.
57
61
  expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
62
+ // Topology view: a surviving plain node sheds its properties.
63
+ expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({});
64
+ });
65
+
66
+ test("T1 keeps overlay paint on surviving plain nodes", () => {
67
+ const painted: GraphIR = {
68
+ ...base,
69
+ nodes: base.nodes.map((n) =>
70
+ n.id === "subnet" ? { ...n, attrs: { ...n.attrs, _status: "warn" } } : n,
71
+ ),
72
+ };
73
+ const ir = applyDetail(painted, DETAIL.COMPOSITES);
74
+ expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ _status: "warn" });
58
75
  });
59
76
 
60
77
  test("T1 (composites) carries the cross-stack imports forward (survive the collapse as plain nodes)", () => {
@@ -91,16 +108,96 @@ describe("applyDetail", () => {
91
108
  });
92
109
  });
93
110
 
111
+ // #1489 — the k8s collapse: a convention-linked graph whose nodes carry nested
112
+ // property trees (every real k8s resource) must still differentiate across the
113
+ // dial. Modelled on the fountain-ops estate the issue measured: no composites,
114
+ // no `$ref` edges, full `metadata`/`spec` trees on every node.
115
+ describe("detail monotonicity (#1489)", () => {
116
+ const k8sish: GraphIR = {
117
+ nodes: [
118
+ {
119
+ id: "web",
120
+ kind: "K8s::Apps::Deployment",
121
+ lexicon: "k8s",
122
+ attrs: {
123
+ name: "web",
124
+ namespace: "prod",
125
+ uid: "u-1",
126
+ metadata: { labels: { app: "web" } },
127
+ spec: {
128
+ replicas: 3,
129
+ selector: { matchLabels: { app: "web" } },
130
+ template: { spec: { containers: [{ name: "web", image: "nginx:1.27" }] } },
131
+ },
132
+ },
133
+ },
134
+ {
135
+ id: "webSvc",
136
+ kind: "K8s::Core::Service",
137
+ lexicon: "k8s",
138
+ attrs: {
139
+ name: "web",
140
+ namespace: "prod",
141
+ metadata: {},
142
+ spec: { selector: { app: "web" }, ports: [{ port: 80 }] },
143
+ },
144
+ },
145
+ { id: "prodNs", kind: "K8s::Core::Namespace", lexicon: "k8s", attrs: { name: "prod", metadata: {} } },
146
+ ],
147
+ // Label-convention edges — no producer attribute anywhere.
148
+ edges: [{ from: "webSvc", to: "web", kind: "ref", viaAttr: "spec.selector" }],
149
+ groups: { byLexicon: { k8s: ["prodNs", "web", "webSvc"] } },
150
+ };
151
+
152
+ const attrKeys = (ir: GraphIR, id: string): string[] =>
153
+ Object.keys(ir.nodes.find((n) => n.id === id)!.attrs).sort();
154
+
155
+ test("attrs at level n are a subset of attrs at level n+1, strictly growing for a nested spec", () => {
156
+ const t1 = applyDetail(k8sish, DETAIL.COMPOSITES);
157
+ const t2 = applyDetail(k8sish, DETAIL.DECLARABLES);
158
+ const t3 = applyDetail(k8sish, DETAIL.ATTRIBUTES);
159
+ for (const id of ["web", "webSvc", "prodNs"]) {
160
+ const k1 = attrKeys(t1, id);
161
+ const k2 = attrKeys(t2, id);
162
+ const k3 = attrKeys(t3, id);
163
+ // subset at every step…
164
+ expect(k2).toEqual(expect.arrayContaining(k1));
165
+ expect(k3).toEqual(expect.arrayContaining(k2));
166
+ // …and strictly growing: every node here has a nested tree beyond its scalars.
167
+ expect(k1.length).toBeLessThan(k2.length);
168
+ expect(k2.length).toBeLessThan(k3.length);
169
+ }
170
+ // The resource view is identity scalars; the attribute view adds the trees.
171
+ expect(attrKeys(t2, "web")).toEqual(["name", "namespace", "uid"]);
172
+ expect(attrKeys(t3, "web")).toEqual(["metadata", "name", "namespace", "spec", "uid"]);
173
+ expect(t3.nodes.find((n) => n.id === "web")!.attrs.spec).toEqual(
174
+ k8sish.nodes.find((n) => n.id === "web")!.attrs.spec,
175
+ );
176
+ });
177
+
178
+ test("regression: levels 2 and 3 are not byte-identical for a convention-linked k8s graph", () => {
179
+ const t2 = JSON.stringify(applyDetail(k8sish, DETAIL.DECLARABLES));
180
+ const t3 = JSON.stringify(applyDetail(k8sish, DETAIL.ATTRIBUTES));
181
+ expect(t3).not.toEqual(t2);
182
+ expect(t3.length).toBeGreaterThan(t2.length);
183
+ // …and 1 vs 2 differ too: three genuinely distinct zoom stops.
184
+ const t1 = JSON.stringify(applyDetail(k8sish, DETAIL.COMPOSITES));
185
+ expect(t2).not.toEqual(t1);
186
+ expect(t2.length).toBeGreaterThan(t1.length);
187
+ });
188
+ });
189
+
94
190
  // #1489 — an inert --detail 3 names itself instead of silently emitting the
95
- // same bytes as --detail 2 (the k8s lexicon links by name/label convention, so
96
- // its graphs never have a producer attribute to annotate).
191
+ // same bytes as --detail 2. Since the attrs projection landed, T3 adds the
192
+ // property tree as well as edge annotations, so the notice only fires for a
193
+ // graph that has neither: flat attrs AND convention-linked edges.
97
194
  describe("detailInertNotice (#1489)", () => {
98
195
  test("detail 3 that added toAttr annotations: no notice", () => {
99
196
  const detailed = applyDetail(base, DETAIL.ATTRIBUTES);
100
197
  expect(detailInertNotice(base, detailed)).toBeUndefined();
101
198
  });
102
199
 
103
- test("edges without producer attributes: notice names the convention-linking cause", () => {
200
+ test("nested property trees make detail 3 real even without producer attrs: no notice", () => {
104
201
  const labelLinked: GraphIR = {
105
202
  nodes: [
106
203
  { id: "svc", kind: "Service", lexicon: "k8s", attrs: { spec: { selector: { app: "web" } } } },
@@ -110,9 +207,23 @@ describe("detailInertNotice (#1489)", () => {
110
207
  groups: {},
111
208
  };
112
209
  const detailed = applyDetail(labelLinked, DETAIL.ATTRIBUTES);
113
- const notice = detailInertNotice(labelLinked, detailed);
210
+ expect(detailInertNotice(labelLinked, detailed)).toBeUndefined();
211
+ });
212
+
213
+ test("flat attrs + convention-linked edges: notice names both causes", () => {
214
+ const flat: GraphIR = {
215
+ nodes: [
216
+ { id: "svc", kind: "Service", lexicon: "k8s", attrs: { name: "svc" } },
217
+ { id: "web", kind: "Deployment", lexicon: "k8s", attrs: { name: "web" } },
218
+ ],
219
+ edges: [{ from: "svc", to: "web", kind: "ref", viaAttr: "spec.selector" }],
220
+ groups: {},
221
+ };
222
+ const detailed = applyDetail(flat, DETAIL.ATTRIBUTES);
223
+ const notice = detailInertNotice(flat, detailed);
114
224
  expect(notice).toContain("--detail 3");
115
225
  expect(notice).toContain("1 edge(s)");
226
+ expect(notice).toContain("resource view");
116
227
  expect(notice).toContain("identical to --detail 2");
117
228
  });
118
229
 
@@ -5,10 +5,27 @@ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
5
5
  * transform over the base graph IR (no re-discovery), so every emitter and the
6
6
  * painter get them for free. See issue #494 / epic #492.
7
7
  *
8
- * - 0 STACKS — one node per lexicon; edges are cross-lexicon dependencies
9
- * - 1 COMPOSITES — composite instances collapsed to a single node each
10
- * - 2 DECLARABLESevery resource (the base produced by buildGraphIr)
11
- * - 3 ATTRIBUTES declarables plus the producer attribute on each edge
8
+ * The dial moves two things: the graph's shape (what collapses) and each
9
+ * node's attribute payload (how much of the resource reaches the node). The
10
+ * payload is monotonica node's attrs at level n are a subset of its attrs
11
+ * at level n+1 so a consumer stepping through the levels always sees the
12
+ * graph grow (#1489):
13
+ *
14
+ * - 0 STACKS — one node per lexicon; edges are cross-lexicon deps; no attrs
15
+ * - 1 COMPOSITES — composite instances collapsed to a single node each;
16
+ * topology view — attrs carry only overlay paint (`_*`)
17
+ * and composite membership
18
+ * - 2 DECLARABLES — every resource; the resource view of its attrs — scalar
19
+ * fields and reference envelopes, not nested property trees
20
+ * - 3 ATTRIBUTES — declarables carrying the full property tree, plus the
21
+ * producer attribute on each reference edge
22
+ *
23
+ * Before #1489 the dial never touched attrs: every level carried the full
24
+ * projected config, and T3's only delta over T2 (the producer attribute on
25
+ * `$ref`-derived edges) is empty for a lexicon whose resources link by
26
+ * name/label convention — the k8s lexicon end to end — so levels 2 and 3 came
27
+ * out byte-identical and a zoom picker mapped onto them rendered the same
28
+ * graph twice (behold#131).
12
29
  */
13
30
  export type DetailLevel = 0 | 1 | 2 | 3;
14
31
 
@@ -30,8 +47,40 @@ export function applyDetail(ir: GraphIR, level: DetailLevel): GraphIR {
30
47
  return toAttributes(ir);
31
48
  case 2:
32
49
  default:
33
- return ir;
50
+ return toDeclarables(ir);
51
+ }
52
+ }
53
+
54
+ /** Overlay paint (`_status`, `_unobserved`, …) is a verdict about the node,
55
+ * not a property of the resource — it survives every tier above STACKS so a
56
+ * zoomed-out drift view keeps its colours. */
57
+ function paintAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
58
+ const out: Record<string, unknown> = {};
59
+ for (const [k, v] of Object.entries(attrs)) if (k.startsWith("_")) out[k] = v;
60
+ return out;
61
+ }
62
+
63
+ /** A `{ $ref: "producer.attribute" }` envelope — a reference, not a property tree. */
64
+ function isRefEnvelope(v: unknown): boolean {
65
+ return (
66
+ typeof v === "object" &&
67
+ v !== null &&
68
+ !Array.isArray(v) &&
69
+ typeof (v as { $ref?: unknown }).$ref === "string"
70
+ );
71
+ }
72
+
73
+ /** T2's resource view of a node's attrs: overlay paint, scalar fields
74
+ * (identity, status-relevant values), and top-level reference envelopes.
75
+ * Nested property trees — a k8s `spec`/`metadata`, a Tags list — wait for T3. */
76
+ function resourceAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
77
+ const out: Record<string, unknown> = {};
78
+ for (const [k, v] of Object.entries(attrs)) {
79
+ if (k.startsWith("_") || v === null || typeof v !== "object" || isRefEnvelope(v)) {
80
+ out[k] = v;
81
+ }
34
82
  }
83
+ return out;
35
84
  }
36
85
 
37
86
  function edgeSortKey(e: IREdge): string {
@@ -67,7 +116,14 @@ function toStacks(ir: GraphIR): GraphIR {
67
116
  return { nodes, edges: sortEdges([...seen.values()]), groups: {} };
68
117
  }
69
118
 
70
- /** T1collapse each composite instance to one node; internal edges disappear. */
119
+ /** T2every resource, each carrying the resource view of its attrs (the
120
+ * scalar/reference projection; the full property tree is T3's addition). */
121
+ function toDeclarables(ir: GraphIR): GraphIR {
122
+ return { ...ir, nodes: ir.nodes.map((n) => ({ ...n, attrs: resourceAttrs(n.attrs) })) };
123
+ }
124
+
125
+ /** T1 — collapse each composite instance to one node; internal edges disappear.
126
+ * Topology view: surviving plain nodes keep only overlay paint, not properties. */
71
127
  function toComposites(ir: GraphIR): GraphIR {
72
128
  const idMap = new Map<string, string>();
73
129
  for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ?? n.id);
@@ -80,7 +136,7 @@ function toComposites(ir: GraphIR): GraphIR {
80
136
  arr.push(n);
81
137
  instances.set(n.compositeInstance, arr);
82
138
  } else {
83
- plain.push({ ...n });
139
+ plain.push({ ...n, attrs: paintAttrs(n.attrs) });
84
140
  }
85
141
  }
86
142
 
@@ -162,27 +218,29 @@ function findRefAttr(attrs: Record<string, unknown>, producer: string): string |
162
218
  /**
163
219
  * chant #1489 — the message to print when `--detail 3` changed nothing.
164
220
  *
165
- * T3's only addition over T2 is the producer attribute on `$ref`-derived
166
- * edges. A graph whose resources link by name or label convention instead of
167
- * attribute references (the k8s lexicon end to end) has nothing to annotate,
168
- * so levels 2 and 3 come out byte-identical which reads as a broken dial
169
- * from any consumer stepping through the levels (behold#131 was filed over
170
- * exactly this). Accepting a value that changes nothing without saying so is
171
- * the bug; this names it.
221
+ * T3 adds two things over T2: the full property tree on each node and the
222
+ * producer attribute on `$ref`-derived edges. A graph whose nodes carry no
223
+ * properties beyond the resource view AND whose edges reference nothing (they
224
+ * link by name or label convention) gains neither, so levels 2 and 3 come out
225
+ * byte-identical which reads as a broken dial from any consumer stepping
226
+ * through the levels (behold#131 was filed over exactly this). Accepting a
227
+ * value that changes nothing without saying so is the bug; this names it.
172
228
  *
173
229
  * Returns the warning text, or undefined when detail 3 did add something.
174
- * Callers print it through their own sink; the compare is over the two IRs
175
- * the caller already holds, so this stays a pure function.
230
+ * `base` is the pre-detail IR the caller already holds (the same one it fed
231
+ * `applyDetail`); the T2 view to compare against is derived here, so callers
232
+ * don't run the projection twice for a message. Pure — no I/O.
176
233
  */
177
234
  export function detailInertNotice(base: GraphIR, detailed: GraphIR): string | undefined {
178
- if (JSON.stringify(detailed) !== JSON.stringify(base)) return undefined;
235
+ if (JSON.stringify(detailed) !== JSON.stringify(toDeclarables(base))) return undefined;
179
236
  const edges = base.edges.length;
180
237
  const why =
181
238
  edges === 0
182
239
  ? "this graph has no edges at all"
183
240
  : `none of this graph's ${edges} edge(s) reference a producer attribute — they link by name or label convention`;
184
241
  return (
185
- `--detail 3 adds the producer attribute to reference edges, but ${why}, ` +
242
+ `--detail 3 adds each node's full property tree and the producer attribute on reference edges, ` +
243
+ `but every node's properties already fit the resource view and ${why}, ` +
186
244
  `so the output is identical to --detail 2.`
187
245
  );
188
246
  }
@@ -320,6 +320,32 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
320
320
  expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
321
321
  });
322
322
 
323
+ test("a create carries the address the provider confirmed absent, when the lexicon reported one (#1620)", () => {
324
+ const cs = buildChangeSet("prod", {
325
+ declared: new Set(["web"]),
326
+ observedNow: {},
327
+ observedThen: undefined,
328
+ queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
329
+ });
330
+ const e = cs.entries.find((x) => x.name === "web")!;
331
+ // The verdict is unchanged — the address is diagnostic, never load-bearing.
332
+ expect(e.action).toBe("create");
333
+ expect(e.queried).toBe("/apis/apps/v1/namespaces/default/deployments/web");
334
+ });
335
+
336
+ test("an unobserved entry's own queried address wins over the map (#1620)", () => {
337
+ const cs = buildChangeSet("prod", {
338
+ declared: new Set(["web"]),
339
+ observedNow: {},
340
+ observedThen: undefined,
341
+ unobserved: { web: { reason: "read-failed", queried: "from-entry" } },
342
+ queried: { web: "from-map" },
343
+ });
344
+ const e = cs.entries.find((x) => x.name === "web")!;
345
+ expect(e.action).toBe("unobserved");
346
+ expect(e.queried).toBe("from-entry");
347
+ });
348
+
323
349
  test("a returned resource wins over an unobserved claim for the same name", () => {
324
350
  const cs = buildChangeSet("prod", {
325
351
  declared: new Set(["queue"]),
@@ -74,6 +74,13 @@ export interface ChangeSetEntry {
74
74
  unobservedReason?: UnobservedReason;
75
75
  /** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
76
76
  unobservedDetail?: string;
77
+ /**
78
+ * The resolved address the live read was issued against (#1620), when the
79
+ * lexicon reported one. On a `create` it says which address the provider
80
+ * confirmed absent — the line between "not there" and "looked in the wrong
81
+ * place" (a defaulted namespace, an endpoint override, the wrong region).
82
+ */
83
+ queried?: string;
77
84
  /** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
78
85
  runtimeOwner?: string;
79
86
  }
@@ -172,6 +179,11 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
172
179
  action = "noop";
173
180
  }
174
181
 
182
+ // The address the read went to (#1620) — from the unobserved entry when
183
+ // there is one, else the observation's queried map. Diagnostic only; the
184
+ // classification above never reads it.
185
+ const queried = unobservedEntry?.queried ?? input.queried?.[name];
186
+
175
187
  entries.push({
176
188
  name,
177
189
  type,
@@ -185,6 +197,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
185
197
  ...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
186
198
  }
187
199
  : {}),
200
+ ...(queried ? { queried } : {}),
188
201
  ...(runtimeOwner ? { runtimeOwner } : {}),
189
202
  });
190
203
  }
@@ -229,6 +229,41 @@ describe("diffLive", () => {
229
229
  expect(result.unobserved.map((u) => u.name)).toEqual(["crd"]);
230
230
  });
231
231
 
232
+ test("the queried addresses pass through, and a missing entity's address is readable off the result (#1620)", () => {
233
+ const result = diffLive({
234
+ declared: new Set(["web"]),
235
+ observedNow: {},
236
+ observedThen: undefined,
237
+ queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
238
+ });
239
+ // The verdict itself does not move: still a confirmed absence.
240
+ expect(result.missing).toEqual(["web"]);
241
+ // But the row can explain itself — behold renders `queried: <path> → 404`.
242
+ expect(result.queried).toEqual({ web: "/apis/apps/v1/namespaces/default/deployments/web" });
243
+ });
244
+
245
+ test("no queried input → no queried key on the result — other lexicons omitting it stays valid (#1620)", () => {
246
+ const result = diffLive({ declared: new Set(["web"]), observedNow: {}, observedThen: undefined });
247
+ expect("queried" in result).toBe(false);
248
+ });
249
+
250
+ test("an unobserved row carries the address of the failed read, from the entry or the map (#1620)", () => {
251
+ const result = diffLive({
252
+ declared: new Set(["a", "b"]),
253
+ observedNow: {},
254
+ observedThen: undefined,
255
+ unobserved: {
256
+ a: { reason: "read-failed", detail: "HTTP 500", queried: "/api/v1/namespaces/default/services/a" },
257
+ b: { reason: "no-credentials" },
258
+ },
259
+ queried: { b: "/api/v1/namespaces/default/services/b" },
260
+ });
261
+ expect(result.unobserved).toEqual([
262
+ { name: "a", reason: "read-failed", detail: "HTTP 500", queried: "/api/v1/namespaces/default/services/a" },
263
+ { name: "b", reason: "no-credentials", queried: "/api/v1/namespaces/default/services/b" },
264
+ ]);
265
+ });
266
+
232
267
  test("a resource that was returned is never also unobserved", () => {
233
268
  const result = diffLive({
234
269
  declared: new Set(["a"]),
@@ -34,6 +34,8 @@ export interface UnobservedResource {
34
34
  type?: string;
35
35
  reason: UnobservedReason;
36
36
  detail?: string;
37
+ /** The resolved address the failed read was issued against (#1620), when the lexicon reported one. */
38
+ queried?: string;
37
39
  }
38
40
 
39
41
  /**
@@ -84,6 +86,16 @@ export interface LiveDiffResult {
84
86
  * in the observation. Sorted by name.
85
87
  */
86
88
  unobserved: UnobservedResource[];
89
+ /**
90
+ * The resolved query address per entity name (#1620) — what the live read
91
+ * was actually issued against, as the lexicon reported it. Present only when
92
+ * the lexicon supplied addresses; other lexicons omitting it stays valid.
93
+ * This is where a `missing` entry explains itself: `missing` is a bare name
94
+ * list, and `queried[name]` says which address the provider answered 404
95
+ * for — a declared k8s object with no namespace reads from the *defaulted*
96
+ * namespace, and only this field makes that visible.
97
+ */
98
+ queried?: Record<string, string>;
87
99
  }
88
100
 
89
101
  export interface DiffLiveInput {
@@ -99,6 +111,12 @@ export interface DiffLiveInput {
99
111
  * at, so absence from `observedNow` is a confirmed absence.
100
112
  */
101
113
  unobserved?: Record<string, UnobservedEntity>;
114
+ /**
115
+ * Resolved query address per entity name (#1620), as the observation
116
+ * reported it. Passed through to the result and joined onto unobserved rows;
117
+ * never consulted for classification.
118
+ */
119
+ queried?: Record<string, string>;
102
120
  }
103
121
 
104
122
  const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
@@ -223,11 +241,13 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
223
241
 
224
242
  for (const name of unobservedNames) {
225
243
  const entry = unobservedMap[name];
244
+ const queriedAddress = entry.queried ?? input.queried?.[name];
226
245
  unobserved.push({
227
246
  name,
228
247
  ...(entry.type ? { type: entry.type } : {}),
229
248
  reason: entry.reason,
230
249
  ...(entry.detail ? { detail: entry.detail } : {}),
250
+ ...(queriedAddress ? { queried: queriedAddress } : {}),
231
251
  });
232
252
  }
233
253
 
@@ -330,6 +350,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
330
350
  driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
331
351
  unchanged: unchanged.sort(),
332
352
  unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
353
+ ...(input.queried && Object.keys(input.queried).length > 0 ? { queried: input.queried } : {}),
333
354
  };
334
355
  }
335
356
 
@@ -43,7 +43,7 @@ export interface ObserveResult {
43
43
  function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
44
44
  const q = <T>(m: Record<string, T>): Record<string, T> =>
45
45
  Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
46
- return { resources: q(obs.resources), unobserved: q(obs.unobserved) };
46
+ return { resources: q(obs.resources), unobserved: q(obs.unobserved), queried: q(obs.queried) };
47
47
  }
48
48
 
49
49
  /**
@@ -253,6 +253,7 @@ export async function observeResources(
253
253
  {
254
254
  resources: {},
255
255
  unobserved: unobservedAll(entityNames, "read-failed", message, entities),
256
+ queried: {},
256
257
  },
257
258
  environment,
258
259
  entityNames.length,
@@ -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) {