@intentius/chant 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/config.d.ts +46 -4
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/discovery/fold-import.d.ts +153 -17
  8. package/dist/discovery/fold-import.d.ts.map +1 -1
  9. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  10. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  11. package/dist/env.d.ts +5 -2
  12. package/dist/env.d.ts.map +1 -1
  13. package/dist/fold/fold.d.ts +12 -0
  14. package/dist/fold/fold.d.ts.map +1 -1
  15. package/dist/graph-ir.d.ts +29 -4
  16. package/dist/graph-ir.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/kubectl-context.d.ts +27 -0
  20. package/dist/kubectl-context.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +31 -6
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/change-set.d.ts +26 -5
  24. package/dist/lifecycle/change-set.d.ts.map +1 -1
  25. package/dist/lifecycle/live-diff.d.ts +25 -1
  26. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  27. package/dist/lifecycle/observe.d.ts +4 -2
  28. package/dist/lifecycle/observe.d.ts.map +1 -1
  29. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  30. package/dist/lifecycle/status.d.ts +26 -1
  31. package/dist/lifecycle/status.d.ts.map +1 -1
  32. package/dist/lifecycle/types.d.ts +8 -0
  33. package/dist/lifecycle/types.d.ts.map +1 -1
  34. package/dist/live-endpoint.d.ts +92 -0
  35. package/dist/live-endpoint.d.ts.map +1 -0
  36. package/dist/observation.d.ts +123 -0
  37. package/dist/observation.d.ts.map +1 -0
  38. package/dist/stack-output.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/handlers/components.test.ts +63 -4
  41. package/src/cli/handlers/components.ts +78 -35
  42. package/src/cli/handlers/graph.test.ts +69 -6
  43. package/src/cli/handlers/graph.ts +61 -27
  44. package/src/cli/handlers/lifecycle.test.ts +285 -6
  45. package/src/cli/handlers/lifecycle.ts +297 -185
  46. package/src/config.test.ts +75 -0
  47. package/src/config.ts +61 -3
  48. package/src/discovery/fold-composite.test.ts +594 -0
  49. package/src/discovery/fold-import.ts +987 -43
  50. package/src/discovery/sandbox/config-wire.ts +3 -2
  51. package/src/env.test.ts +12 -0
  52. package/src/env.ts +12 -4
  53. package/src/fold/fold.ts +12 -2
  54. package/src/graph-ir-live.test.ts +28 -1
  55. package/src/graph-ir.ts +68 -12
  56. package/src/index.ts +1 -0
  57. package/src/kubectl-context.ts +81 -0
  58. package/src/lexicon.ts +41 -6
  59. package/src/lifecycle/change-set.test.ts +93 -1
  60. package/src/lifecycle/change-set.ts +65 -13
  61. package/src/lifecycle/live-diff.test.ts +39 -0
  62. package/src/lifecycle/live-diff.ts +51 -5
  63. package/src/lifecycle/observe.test.ts +74 -3
  64. package/src/lifecycle/observe.ts +82 -22
  65. package/src/lifecycle/snapshot.test.ts +39 -1
  66. package/src/lifecycle/snapshot.ts +34 -9
  67. package/src/lifecycle/status.test.ts +89 -8
  68. package/src/lifecycle/status.ts +53 -3
  69. package/src/lifecycle/types.ts +8 -0
  70. package/src/live-endpoint.test.ts +115 -0
  71. package/src/live-endpoint.ts +148 -0
  72. package/src/observation.test.ts +96 -0
  73. package/src/observation.ts +213 -0
  74. package/src/stack-output.test.ts +55 -0
  75. package/src/stack-output.ts +41 -20
@@ -27,8 +27,9 @@
27
27
  * ## What `ChantConfig` legally holds
28
28
  *
29
29
  * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
30
- * (`lexicons`, `capabilities`, `environments`), strings (`sourceDir`), nested
31
- * plain objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
30
+ * (`lexicons`, `capabilities`), strings (`sourceDir`), an array of strings or
31
+ * plain `{ name, endpoint }` objects (`environments`, #1166), nested plain
32
+ * objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
32
33
  * `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
33
34
  * plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
34
35
  * are a severity string or a `[severity, options]` tuple, and whose `plugins`
package/src/env.test.ts CHANGED
@@ -40,6 +40,18 @@ describe("unknownEnvError", () => {
40
40
  test("rejects an undeclared env with a clear message", () => {
41
41
  expect(unknownEnvError("stage", ["dev", "prod"])).toMatch(/Unknown environment "stage".*dev, prod/);
42
42
  });
43
+
44
+ // #1166 — `environments` entries may now be `{ name, endpoint }`; validation
45
+ // reduces to names either way, unaffected by an object entry's endpoint.
46
+ test("accepts an object-form declared env, mixed with bare strings", () => {
47
+ expect(unknownEnvError("floci", ["prod", { name: "floci", endpoint: "http://localhost:4566" }])).toBeUndefined();
48
+ });
49
+
50
+ test("rejects an undeclared env against a mixed string/object list, naming both", () => {
51
+ expect(
52
+ unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
53
+ ).toMatch(/Unknown environment "stage".*prod, floci/);
54
+ });
43
55
  });
44
56
 
45
57
  describe("env-aware discovery (#505)", () => {
package/src/env.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  * pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
19
19
  */
20
20
 
21
+ import { environmentNames, type EnvironmentDeclaration } from "./config";
22
+
21
23
  /** The environment variable the CLI sets from `--env`. */
22
24
  export const ENV_VAR = "CHANT_ENV";
23
25
 
@@ -30,10 +32,16 @@ export function env(fallback?: string): string | undefined {
30
32
  * Validate a requested environment against the project's declared `environments`
31
33
  * (`chant.config`). Returns an error message for an unknown env, or `undefined`
32
34
  * when it's valid (or when the project declares no environments, in which case
33
- * any name is accepted).
35
+ * any name is accepted). `declared` entries may be a bare name or `{ name,
36
+ * endpoint }` (#1166) — {@link environmentNames} reduces either to the names
37
+ * this checks against.
34
38
  */
35
- export function unknownEnvError(requested: string | undefined, declared: string[] | undefined): string | undefined {
39
+ export function unknownEnvError(
40
+ requested: string | undefined,
41
+ declared: EnvironmentDeclaration[] | undefined,
42
+ ): string | undefined {
36
43
  if (!requested || !declared || declared.length === 0) return undefined;
37
- if (declared.includes(requested)) return undefined;
38
- return `Unknown environment "${requested}". Declared environments: ${declared.join(", ")}.`;
44
+ const names = environmentNames(declared) ?? [];
45
+ if (names.includes(requested)) return undefined;
46
+ return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
39
47
  }
package/src/fold/fold.ts CHANGED
@@ -298,8 +298,18 @@ export function collectConsts(sourceFile: ts.SourceFile): Map<string, ts.Express
298
298
  return consts;
299
299
  }
300
300
 
301
- /** A property/element key foldable without execution: identifier, string, or numeric literal. */
302
- function propName(node: ts.PropertyName): string {
301
+ /**
302
+ * A property/element key foldable without execution: identifier, string, or
303
+ * numeric literal.
304
+ *
305
+ * Exported for chant #1023's composite-factory interpreter
306
+ * (../discovery/fold-import.ts), which walks object literals itself — a
307
+ * factory body may construct a resource inside one, which {@link fold} has no
308
+ * case for — and must reject a computed key with the identical message
309
+ * {@link fold} would, rather than growing a second, silently divergent copy of
310
+ * this rule.
311
+ */
312
+ export function propName(node: ts.PropertyName): string {
303
313
  if (isLiteralPropertyName(node)) return node.text;
304
314
  throw foldError(node, computedPropertyNameMessage(node));
305
315
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
2
+ import { buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
3
3
 
4
4
  // A fixture "snapshot" — what a lexicon's describeResources() returns for a live
5
5
  // environment (managed-only). Two AWS resources; the subnet references the VPC by
@@ -140,4 +140,31 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
140
140
  const ir = sourceOverlayGraphs(declared, liveDup);
141
141
  expect(ir.edges.filter((e) => e.from === "app-ingress" && e.to === "web-vpc")).toHaveLength(1);
142
142
  });
143
+
144
+ // #1089 — "not deployed yet" is a claim the read has to support.
145
+ it("paints a declared node nobody could read `neutral`, not `accent`", () => {
146
+ const ir = sourceOverlayGraphs(declared, live, {
147
+ unobserved: { "planned-db": { reason: "no-binding", detail: "no kubectl context" } },
148
+ });
149
+ const node = ir.nodes.find((x) => x.id === "planned-db")!;
150
+ expect((node.attrs as { _status?: string })._status).toBe("neutral");
151
+ expect((node.attrs as { _unobserved?: string })._unobserved).toBe("no-binding");
152
+ });
153
+
154
+ it("still paints a confirmed-absent declared node `accent`", () => {
155
+ const ir = sourceOverlayGraphs(declared, live, { unobserved: {} });
156
+ expect((ir.nodes.find((x) => x.id === "planned-db")!.attrs as { _status?: string })._status).toBe("accent");
157
+ });
158
+ });
159
+
160
+ describe("collectUnobserved (#1089)", () => {
161
+ it("unions every observation's holes", () => {
162
+ expect(
163
+ collectUnobserved([
164
+ { lexicon: "aws", resources: {}, unobserved: { a: { reason: "read-failed" } } },
165
+ { lexicon: "k8s", resources: {} },
166
+ { lexicon: "gcp", resources: {}, unobserved: { b: { reason: "no-binding" } } },
167
+ ]),
168
+ ).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
169
+ });
143
170
  });
package/src/graph-ir.ts CHANGED
@@ -6,6 +6,7 @@ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
6
6
  import { getProvenance } from "./provenance";
7
7
  import { INTRINSIC_MARKER } from "./intrinsic";
8
8
  import type { ResourceMetadata } from "./lexicon";
9
+ import type { UnobservedEntity } from "./observation";
9
10
 
10
11
  /**
11
12
  * Graph IR — the engine-neutral, lint-gated representation of a project's
@@ -419,6 +420,12 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
419
420
  export interface LiveObservation {
420
421
  lexicon: string;
421
422
  resources: Record<string, ResourceMetadata>;
423
+ /**
424
+ * Declared entities the lexicon could not observe (#1089), keyed by name.
425
+ * They are not live nodes — but they are not confirmed-absent either, so the
426
+ * overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
427
+ */
428
+ unobserved?: Record<string, UnobservedEntity>;
422
429
  }
423
430
 
424
431
  /**
@@ -446,7 +453,10 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
446
453
  attrs: meta.attributes ?? {},
447
454
  };
448
455
  if (meta.physicalId) node.physicalId = meta.physicalId;
449
- if (meta.ownership) node.ownership = meta.ownership;
456
+ // `unknown` is a legitimate verdict on the metadata (#1089) but carries no
457
+ // information for a painter, and the IR's `ownership` field means "a
458
+ // verdict was reached" — so only owned/foreign land on the node.
459
+ if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
450
460
  nodes.push(node);
451
461
  (byLexicon[lexicon] ??= []).push(name);
452
462
  // A live lexicon maps to one deployable stack, same as the source IR.
@@ -465,22 +475,60 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
465
475
  return { nodes, edges: [], groups };
466
476
  }
467
477
 
478
+ /** How an overlay learns which declared nodes were never looked at (#1089). */
479
+ export interface OverlayOptions {
480
+ /**
481
+ * Declared entities the observation could not read, keyed by name (union of
482
+ * every {@link LiveObservation}'s `unobserved`). They are tagged `neutral`
483
+ * instead of `accent`: "not yet provisioned" is a claim the read never
484
+ * supported.
485
+ */
486
+ unobserved?: Record<string, UnobservedEntity>;
487
+ }
488
+
489
+ /** The union of every observation's unobserved entities — the input to the overlays. */
490
+ export function collectUnobserved(observations: LiveObservation[]): Record<string, UnobservedEntity> {
491
+ const out: Record<string, UnobservedEntity> = {};
492
+ for (const o of observations) Object.assign(out, o.unobserved ?? {});
493
+ return out;
494
+ }
495
+
496
+ /** Paint status a node carries in an overlay. `neutral` = chant could not look. */
497
+ type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
498
+
499
+ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
500
+ return {
501
+ ...n,
502
+ attrs: {
503
+ ...n.attrs,
504
+ _status: status,
505
+ ...(unobserved ? { _unobserved: unobserved.reason } : {}),
506
+ },
507
+ };
508
+ }
509
+
468
510
  /**
469
511
  * Overlay the declared graph on the provisioned one (#780, `chant graph --live
470
512
  * --overlay`) and classify each resource, tagging a `_status` a renderer colours:
471
513
  * - **managed** (declared + provisioned) → `good`
472
514
  * - **foreign** (provisioned, not declared) → `warn`
473
- * - **pending** (declared, not yet provisioned) → `accent`
515
+ * - **pending** (declared, provider confirmed absent) → `accent`
516
+ * - **unobserved** (declared, chant could not look — #1089) → `neutral`,
517
+ * plus an `_unobserved` attr carrying the reason
474
518
  * Live nodes keep their edges/containment; pending nodes are appended (they have
475
519
  * no live edges). Sorted; the live groups pass through unchanged.
476
520
  */
477
- export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
521
+ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
478
522
  const declaredIds = new Set(declared.nodes.map((n) => n.id));
479
523
  const liveIds = new Set(live.nodes.map((n) => n.id));
480
- const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
524
+ const unobserved = opts?.unobserved ?? {};
481
525
 
482
- const nodes: IRNode[] = live.nodes.map((n) => tagged(n, declaredIds.has(n.id) ? "good" : "warn"));
483
- for (const n of declared.nodes) if (!liveIds.has(n.id)) nodes.push(tagged(n, "accent"));
526
+ const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
527
+ for (const n of declared.nodes) {
528
+ if (liveIds.has(n.id)) continue;
529
+ const u = unobserved[n.id];
530
+ nodes.push(u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent"));
531
+ }
484
532
  nodes.sort((a, b) => a.id.localeCompare(b.id));
485
533
 
486
534
  return { ...live, nodes };
@@ -497,27 +545,35 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
497
545
  * Each declared node is classified against live observation and tagged `_status`:
498
546
  * - **managed** (declared + provisioned) → `good`, carrying the observed
499
547
  * `physicalId` / `ownership` onto the declared node
500
- * - **pending** (declared, not provisioned) → `accent`
548
+ * - **pending** (declared, provider confirmed absent) → `accent`
549
+ * - **unobserved** (declared, chant could not look — #1089) → `neutral`, with
550
+ * the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
551
+ * to paint the whole estate "pending", which is the diagram equivalent of
552
+ * planning a create for something that already exists.
501
553
  * **Foreign** resources (provisioned, not declared) are appended and tagged
502
554
  * `warn`, together with any live-reconstructed edges that touch them — a declared
503
555
  * edge cannot describe an undeclared resource. Declared groups/exports pass
504
556
  * through unchanged; nodes and edges are sorted for deterministic output.
505
557
  */
506
- export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR): GraphIR {
558
+ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: OverlayOptions): GraphIR {
507
559
  const liveById = new Map(live.nodes.map((n) => [n.id, n]));
508
560
  const declaredIds = new Set(declared.nodes.map((n) => n.id));
509
561
  const foreignIds = new Set(live.nodes.filter((n) => !declaredIds.has(n.id)).map((n) => n.id));
510
- const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
562
+ const unobserved = opts?.unobserved ?? {};
511
563
 
512
564
  const nodes: IRNode[] = declared.nodes.map((n) => {
513
565
  const obs = liveById.get(n.id);
514
- if (!obs) return tagged(n, "accent"); // pending — declared, not provisioned
566
+ if (!obs) {
567
+ const u = unobserved[n.id];
568
+ // unobserved — declared, and nobody looked; not "pending"
569
+ return u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent");
570
+ }
515
571
  const merged: IRNode = { ...n }; // managed — carry the observed identity
516
572
  if (obs.physicalId) merged.physicalId = obs.physicalId;
517
573
  if (obs.ownership) merged.ownership = obs.ownership;
518
- return tagged(merged, "good");
574
+ return tagStatus(merged, "good");
519
575
  });
520
- for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagged(n, "warn")); // foreign
576
+ for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
521
577
  nodes.sort((a, b) => a.id.localeCompare(b.id));
522
578
 
523
579
  // Declared edges are the canvas (the cross-substrate topology). Add only the
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export * from "./lint/discover";
48
48
  export * from "./import/parser";
49
49
  export * from "./import/generator";
50
50
  export * from "./lexicon";
51
+ export * from "./observation";
51
52
  export * from "./lexicon-integrity";
52
53
  export * from "./lexicon-manifest";
53
54
  export * from "./lexicon-schema";
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { exec } from "node:child_process";
24
24
  import { promisify } from "node:util";
25
+ import type { UnobservedReason } from "./observation";
25
26
 
26
27
  const execAsync = promisify(exec);
27
28
 
@@ -124,3 +125,83 @@ export async function resolveClusterTarget(
124
125
 
125
126
  return { context: bound, source: "bound" };
126
127
  }
128
+
129
+ // ── kubectl read outcomes (#1089) ───────────────────────────────────────────
130
+
131
+ /**
132
+ * What a failed `kubectl get` actually proved. Shared by the k8s and gcp
133
+ * lexicons, which read through the same kubectl path and used to collapse every
134
+ * non-zero exit into "not there" — so an expired token, a downed API server, or
135
+ * an uninstalled CRD all classified as `create`.
136
+ */
137
+ export type KubectlReadOutcome =
138
+ /** The API server answered and the object is not there. Safe to plan a create. */
139
+ | { kind: "absent" }
140
+ /** The read proved nothing about the object's existence. */
141
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
142
+
143
+ /** Pull whatever the child process actually said out of an exec rejection. */
144
+ function execErrorText(err: unknown): string {
145
+ if (typeof err === "object" && err !== null) {
146
+ const e = err as { stderr?: unknown; message?: unknown };
147
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
148
+ if (stderr) return stderr;
149
+ if (typeof e.message === "string") return e.message.trim();
150
+ }
151
+ return String(err);
152
+ }
153
+
154
+ /** Collapse kubectl's noise to one line for a plan/diff entry. */
155
+ function firstLine(text: string, max = 200): string {
156
+ const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
157
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
158
+ }
159
+
160
+ /**
161
+ * Classify a `kubectl get` failure into the observation tri-state (#1089).
162
+ *
163
+ * Only a genuine `NotFound` from the API server — or a kind the server does not
164
+ * serve at all, where no instance can exist — establishes absence. Auth,
165
+ * connectivity, and unresolvable contexts establish nothing, and must reach the
166
+ * change set as NOT-OBSERVED rather than as an empty result.
167
+ */
168
+ export function classifyKubectlFailure(err: unknown): KubectlReadOutcome {
169
+ const text = execErrorText(err);
170
+ const lower = text.toLowerCase();
171
+
172
+ // The object was looked for and is not there.
173
+ if (lower.includes("notfound") || /error from server \(notfound\)/.test(lower) || lower.includes("not found")) {
174
+ return { kind: "absent" };
175
+ }
176
+ // The cluster serves no such kind, so no instance of it can exist there. The
177
+ // usual cause is a CRD this same plan has not applied yet — a real absence,
178
+ // and the case a create is for.
179
+ if (
180
+ lower.includes("the server doesn't have a resource type") ||
181
+ lower.includes("the server could not find the requested resource")
182
+ ) {
183
+ return { kind: "absent" };
184
+ }
185
+ if (
186
+ lower.includes("unauthorized") ||
187
+ lower.includes("forbidden") ||
188
+ lower.includes("you must be logged in") ||
189
+ lower.includes("invalid bearer token") ||
190
+ lower.includes("credentials")
191
+ ) {
192
+ return { kind: "unobserved", reason: "no-credentials", detail: firstLine(text) };
193
+ }
194
+ if (
195
+ lower.includes("unable to connect to the server") ||
196
+ lower.includes("connection refused") ||
197
+ lower.includes("no configuration has been provided") ||
198
+ lower.includes("did you specify the right host or port") ||
199
+ lower.includes("context was not found") ||
200
+ /context ".*" does not exist/.test(lower) ||
201
+ lower.includes("no such host") ||
202
+ lower.includes("i/o timeout")
203
+ ) {
204
+ return { kind: "unobserved", reason: "no-binding", detail: firstLine(text) };
205
+ }
206
+ return { kind: "unobserved", reason: "read-failed", detail: firstLine(text) };
207
+ }
package/src/lexicon.ts CHANGED
@@ -11,11 +11,23 @@ import type { DriverComponent } from "./components/driver";
11
11
  import type { EmulatorCapability } from "./op/emulator-lifecycle";
12
12
  import type { RuleMeta } from "./audit/catalog";
13
13
  import type { ReferenceCatalog } from "./graph-refs";
14
+ import type { DescribeResourcesResult } from "./observation";
14
15
 
15
16
  // Re-exported so lexicons can author a reference catalog (#778) from the same
16
17
  // `@intentius/chant/lexicon` entry they import the plugin contract from.
17
18
  export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
18
19
 
20
+ // The observation contract (#1089), re-exported from the same entry so a
21
+ // lexicon's `describeResources` can report NOT-OBSERVED without a second
22
+ // import path. Runtime helpers live in `@intentius/chant/observation`.
23
+ export type {
24
+ DescribeResourcesResult,
25
+ ObservationResult,
26
+ NormalizedObservation,
27
+ UnobservedEntity,
28
+ UnobservedReason,
29
+ } from "./observation";
30
+
19
31
  /**
20
32
  * Manifest for a packaged lexicon — metadata embedded in the tarball.
21
33
  *
@@ -507,6 +519,27 @@ export interface LexiconPlugin {
507
519
  * Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
508
520
  * AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
509
521
  *
522
+ * **The observation contract (#1089).** Returning nothing for a declared
523
+ * entity is a claim, and there are two different claims to make. Either the
524
+ * provider was asked and reported the resource absent — which is what lets
525
+ * the change set propose `create` — or the lexicon never looked, which must
526
+ * not. An implementation that has a "did not look" case (no reader for the
527
+ * kind, the read errored, no credentials, no cluster binding) must return the
528
+ * {@link ObservationResult} envelope and name those entities in `unobserved`
529
+ * with a total {@link UnobservedReason}. Warning on stderr is not enough: a
530
+ * warning is invisible to `lifecycle plan`, which is where the wrong `create`
531
+ * gets proposed. Returning the bare `name → ResourceMetadata` map is still
532
+ * valid and means "everything I was asked about, I looked at".
533
+ *
534
+ * Throwing is the whole-lexicon failure (see the k8s cluster-binding refusal,
535
+ * #1100): core catches it and marks every declared entity NOT-OBSERVED with
536
+ * `read-failed`, so a failed read is never a list of creates.
537
+ *
538
+ * Ownership verdicts are total (#1089). When `owned` is requested and the
539
+ * lexicon has no marker channel on this path, it must stamp
540
+ * `ownership: "unknown"` on what it returns rather than degrading silently —
541
+ * the change set never escalates `unknown` to a `delete`.
542
+ *
510
543
  * `entities` carries the chant-side entity declarations for this lexicon,
511
544
  * keyed by chant entity name (e.g. the export name from a `*.ts` file).
512
545
  * Implementations that need to map cloud-side names back to chant entity
@@ -536,7 +569,7 @@ export interface LexiconPlugin {
536
569
  * everything.
537
570
  */
538
571
  owned?: boolean;
539
- }): Promise<Record<string, ResourceMetadata>>;
572
+ }): Promise<DescribeResourcesResult>;
540
573
 
541
574
  /**
542
575
  * Report the live status of one deploy unit by its deployed name. Opt-in.
@@ -683,12 +716,14 @@ export interface ResourceMetadata {
683
716
  /** Cloud-assigned output properties */
684
717
  attributes?: Record<string, unknown>;
685
718
  /**
686
- * Live ownership verdict from the resource's marker (#119/#120), when the
687
- * lexicon could determine it. `owned` = carries chant's marker; `foreign` =
688
- * no marker. Absent = the lexicon has no marker channel here. The change set
689
- * reads thisnever the snapshot to decide whether an orphan is a delete.
719
+ * Live ownership verdict from the resource's marker (#119/#120). `owned` =
720
+ * carries chant's marker; `foreign` = no marker; `unknown` = the lexicon has
721
+ * no marker channel on this read path and says so rather than degrading
722
+ * silently (#1089verdicts are total). Absent is read as `unknown`. The
723
+ * change set reads this — never the snapshot — to decide whether an orphan is
724
+ * a delete, and never escalates `unknown` to one.
690
725
  */
691
- ownership?: "owned" | "foreign";
726
+ ownership?: "owned" | "foreign" | "unknown";
692
727
  }
693
728
 
694
729
  /**
@@ -17,7 +17,7 @@ describe("buildChangeSet (#118)", () => {
17
17
  });
18
18
  const e = cs.entries.find((x) => x.name === "bucket")!;
19
19
  expect(e.action).toBe("create");
20
- expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
20
+ expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
21
21
  expect(e.ownership).toBe("unknown");
22
22
  });
23
23
 
@@ -187,3 +187,95 @@ describe("gitlabMrReport (#329)", () => {
187
187
  expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
188
188
  });
189
189
  });
190
+
191
+ // ── The observation tri-state (#1089) ───────────────────────────────────────
192
+
193
+ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
194
+ test("declared and not observed → unobserved, never create", () => {
195
+ const cs = buildChangeSet("prod", {
196
+ declared: new Set(["crd-widget"]),
197
+ observedNow: {},
198
+ observedThen: undefined,
199
+ unobserved: {
200
+ "crd-widget": {
201
+ type: "K8s::Example::Widget",
202
+ reason: "unsupported-kind",
203
+ detail: "no kubectl mapping",
204
+ },
205
+ },
206
+ });
207
+ const e = cs.entries.find((x) => x.name === "crd-widget")!;
208
+ expect(e.action).toBe("unobserved");
209
+ expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: false });
210
+ expect(e.unobservedReason).toBe("unsupported-kind");
211
+ expect(e.type).toBe("K8s::Example::Widget");
212
+ });
213
+
214
+ test("the same entity, confirmed absent, still classifies as create", () => {
215
+ const cs = buildChangeSet("prod", {
216
+ declared: new Set(["crd-widget"]),
217
+ observedNow: {},
218
+ observedThen: undefined,
219
+ });
220
+ expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
221
+ });
222
+
223
+ test("a returned resource wins over an unobserved claim for the same name", () => {
224
+ const cs = buildChangeSet("prod", {
225
+ declared: new Set(["queue"]),
226
+ observedNow: { queue: meta() },
227
+ observedThen: undefined,
228
+ unobserved: { queue: { reason: "read-failed" } },
229
+ });
230
+ const e = cs.entries.find((x) => x.name === "queue")!;
231
+ expect(e.action).toBe("noop");
232
+ expect(e.evidence.observed).toBe(true);
233
+ });
234
+
235
+ test("an unobserved entity that is in the snapshot is not read as gone", () => {
236
+ const cs = buildChangeSet("prod", {
237
+ declared: new Set(["queue"]),
238
+ observedNow: {},
239
+ observedThen: { queue: meta() },
240
+ unobserved: { queue: { reason: "no-credentials" } },
241
+ });
242
+ const e = cs.entries.find((x) => x.name === "queue")!;
243
+ expect(e.action).toBe("unobserved");
244
+ expect(e.evidence.inSnapshot).toBe(true);
245
+ });
246
+
247
+ test("an unobserved entity is never a delete, even with an owned marker in the snapshot", () => {
248
+ const cs = buildChangeSet("prod", {
249
+ declared: new Set(),
250
+ observedNow: {},
251
+ observedThen: { legacy: meta({ ownership: "owned" }) },
252
+ unobserved: { legacy: { reason: "read-failed" } },
253
+ });
254
+ expect(cs.entries.find((x) => x.name === "legacy")!.action).toBe("unobserved");
255
+ });
256
+
257
+ test("summarize and render surface the hole", () => {
258
+ const cs = buildChangeSet("prod", {
259
+ declared: new Set(["a"]),
260
+ observedNow: {},
261
+ observedThen: undefined,
262
+ unobserved: { a: { reason: "no-binding", detail: "no kubectl context for prod" } },
263
+ });
264
+ expect(summarize(cs).unobserved).toBe(1);
265
+ expect(summarize(cs).create).toBe(0);
266
+ const out = renderChangeSet(cs);
267
+ expect(out).toContain("UNOBSERVED");
268
+ expect(out).toContain("no binding for this environment");
269
+ expect(out).toContain("no kubectl context for prod");
270
+ });
271
+
272
+ test("the GitLab widget excludes unobserved — its three columns cannot express a hole", () => {
273
+ const cs = buildChangeSet("prod", {
274
+ declared: new Set(["a"]),
275
+ observedNow: {},
276
+ observedThen: undefined,
277
+ unobserved: { a: { reason: "read-failed" } },
278
+ });
279
+ expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
280
+ });
281
+ });