@intentius/chant 0.31.0 → 0.32.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 (39) hide show
  1. package/dist/cli/command-group.d.ts +134 -0
  2. package/dist/cli/command-group.d.ts.map +1 -0
  3. package/dist/cli/conflict-check.d.ts +1 -1
  4. package/dist/cli/conflict-check.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/graph-ir.d.ts +17 -3
  8. package/dist/graph-ir.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +40 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lifecycle/change-set.d.ts +15 -7
  14. package/dist/lifecycle/change-set.d.ts.map +1 -1
  15. package/dist/lifecycle/live-diff.d.ts +25 -1
  16. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  17. package/dist/managed-fields.d.ts +118 -0
  18. package/dist/managed-fields.d.ts.map +1 -0
  19. package/dist/owner-chain.d.ts +99 -0
  20. package/dist/owner-chain.d.ts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/cli/command-group.test.ts +208 -0
  23. package/src/cli/command-group.ts +199 -0
  24. package/src/cli/conflict-check.test.ts +36 -1
  25. package/src/cli/conflict-check.ts +22 -1
  26. package/src/cli/handlers/lifecycle.ts +5 -0
  27. package/src/cli/main.ts +107 -27
  28. package/src/graph-ir-live.test.ts +40 -0
  29. package/src/graph-ir.ts +32 -7
  30. package/src/index.ts +1 -0
  31. package/src/lexicon.ts +44 -0
  32. package/src/lifecycle/change-set.test.ts +100 -0
  33. package/src/lifecycle/change-set.ts +39 -10
  34. package/src/lifecycle/live-diff.test.ts +88 -0
  35. package/src/lifecycle/live-diff.ts +55 -8
  36. package/src/managed-fields.test.ts +179 -0
  37. package/src/managed-fields.ts +328 -0
  38. package/src/owner-chain.test.ts +97 -0
  39. package/src/owner-chain.ts +128 -0
package/src/cli/main.ts CHANGED
@@ -27,6 +27,8 @@ import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown }
27
27
  import { runGraph } from "./handlers/graph";
28
28
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
29
29
  import { runEmulator } from "./handlers/emulator";
30
+ import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
31
+ import type { LexiconPlugin } from "../lexicon";
30
32
 
31
33
  /**
32
34
  * Long-form flags that are pure booleans in {@link parseArgs} — their branch
@@ -118,31 +120,24 @@ export function parseArgs(args: string[]): ParsedArgs {
118
120
  env: undefined,
119
121
  };
120
122
 
123
+ // chant #1127 — generic joined `--flag=value` support, factored out to
124
+ // ./command-group.ts (chant #1078) so a lexicon's own mounted command can
125
+ // apply the identical splitting discipline to its own flag vocabulary.
126
+ // Every value-taking flag below is matched by an exact `arg === "--flag"`
127
+ // check and then consumes the *next* array element (`args[++i]`) as its
128
+ // value; a joined token like `--env=prod` never matches any of those,
129
+ // doesn't match the trailing positional branch either (it starts with
130
+ // `-`), and used to vanish with no error. Splitting the token at its FIRST
131
+ // `=` and re-dispatching as two array elements makes every flag below see
132
+ // the exact shape it already handles — including a flag like `--param`
133
+ // whose own value legitimately contains `=` (`--param=tier=production`
134
+ // splits to flag `--param`, value `tier=production`, not further split on
135
+ // the second `=`).
136
+ args = splitJoinedFlags(args, BOOLEAN_FLAGS);
137
+
121
138
  let i = 0;
122
139
  while (i < args.length) {
123
- let arg = args[i];
124
-
125
- // chant #1127 — generic joined `--flag=value` support. Every value-taking
126
- // flag below is matched by an exact `arg === "--flag"` check and then
127
- // consumes the *next* array element (`args[++i]`) as its value; a joined
128
- // token like `--env=prod` never matches any of those, doesn't match the
129
- // trailing positional branch either (it starts with `-`), and used to
130
- // vanish with no error. Splitting the token at its FIRST `=` and
131
- // re-dispatching as two array elements makes every flag below see the
132
- // exact shape it already handles — including a flag like `--param`
133
- // whose own value legitimately contains `=` (`--param=tier=production`
134
- // splits to flag `--param`, value `tier=production`, not further split
135
- // on the second `=`).
136
- if (arg.startsWith("--") && arg.includes("=")) {
137
- const eq = arg.indexOf("=");
138
- const flag = arg.slice(0, eq);
139
- const value = arg.slice(eq + 1);
140
- if (BOOLEAN_FLAGS.has(flag)) {
141
- throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
142
- }
143
- args.splice(i, 1, flag, value);
144
- arg = args[i];
145
- }
140
+ const arg = args[i];
146
141
 
147
142
  if (arg === "--help" || arg === "-h") {
148
143
  result.help = true;
@@ -348,9 +343,12 @@ export function parseArgs(args: string[]): ParsedArgs {
348
343
  }
349
344
 
350
345
  /**
351
- * Print help message
346
+ * Print help message. `groups` — lexicon-contributed command groups
347
+ * (chant #1078), best-effort loaded from the current project; composed in
348
+ * below the static command list so `--help` lists every mounted verb group
349
+ * alongside core's own commands.
352
350
  */
353
- function printHelp(): void {
351
+ function printHelp(groups: CommandGroup[] = []): void {
354
352
  console.log(`
355
353
  chant - Declarative infrastructure specification toolkit
356
354
 
@@ -575,6 +573,60 @@ Examples:
575
573
  chant describe myComponent src/
576
574
  chant describe myComponent src/ --format json
577
575
  `);
576
+ const groupsHelp = formatCommandGroupsHelp(groups);
577
+ if (groupsHelp) console.log(groupsHelp);
578
+ }
579
+
580
+ /**
581
+ * Best-effort load the current project's lexicon plugins for a purely
582
+ * read-only lookup (help composition, plugin-command dispatch) — never
583
+ * throws, empty on any failure (no config, no lexicons, not a chant
584
+ * project at all). Mirrors the existing best-effort loading already used
585
+ * for `emulator`/`components status` in {@link main} below.
586
+ */
587
+ async function loadPluginsBestEffort(): Promise<LexiconPlugin[]> {
588
+ try {
589
+ const lexiconNames = await resolveProjectLexicons(resolve("."));
590
+ return await loadPlugins(lexiconNames);
591
+ } catch {
592
+ return [];
593
+ }
594
+ }
595
+
596
+ /**
597
+ * chant #1078 — the lexicon command-group seam's dispatch-time half. Core's
598
+ * own `parseArgs`/`resolveCommand` know nothing about a lexicon's mounted
599
+ * verbs, so this is only ever consulted after BOTH of those have already
600
+ * failed to make sense of the invocation: either `parseArgs` threw on a flag
601
+ * it doesn't recognize (which is *expected* for a mounted command's own
602
+ * vocabulary — core has none), or it parsed fine but `resolveCommand` found
603
+ * no match in the static registry (a mounted command with no extra flags,
604
+ * e.g. `chant kube version`). Either way, a lexicon-mounted command's group
605
+ * name and verb are always the first two CLI tokens (mirrors the `emulator
606
+ * <up|down|status>` compound shape from #920), so `rawArgv` — the untouched
607
+ * `process.argv.slice(2)` — is all this needs; nothing from the partially or
608
+ * fully parsed `ParsedArgs` is used, on purpose, since core's flag-parsing
609
+ * failure or success is irrelevant to a namespace it doesn't own.
610
+ *
611
+ * Returns `undefined` when nothing claims the leading token as a command
612
+ * group at all, so the caller falls back to its own error handling
613
+ * unchanged — a project with no lexicon exposing `commands()` (or none of
614
+ * its plugins matching) is completely unaffected.
615
+ */
616
+ async function tryPluginCommand(rawArgv: string[]): Promise<number | undefined> {
617
+ const [groupName, verbName] = rawArgv;
618
+ if (!groupName || groupName.startsWith("-")) return undefined;
619
+
620
+ const plugins = await loadPluginsBestEffort();
621
+ const rawArgs = rawArgv.slice(2);
622
+ const result = await dispatchCommandGroup(plugins, groupName, verbName, rawArgs);
623
+
624
+ if (result.kind === "no-group") return undefined;
625
+ if (result.kind === "usage-error") {
626
+ console.error(formatError({ message: result.message, hint: result.hint }));
627
+ return 1;
628
+ }
629
+ return result.exitCode;
578
630
  }
579
631
 
580
632
  /**
@@ -688,10 +740,29 @@ const registry: CommandDef[] = [
688
740
  * Main entry point
689
741
  */
690
742
  async function main(): Promise<void> {
691
- const args = parseArgs(process.argv.slice(2));
743
+ const rawArgv = process.argv.slice(2);
744
+
745
+ let args: ParsedArgs;
746
+ try {
747
+ args = parseArgs(rawArgv);
748
+ } catch (err) {
749
+ // chant #1078 — core's parser has no idea what flags a lexicon's own
750
+ // mounted verb accepts, so an "unknown flag" here is expected, not a
751
+ // real error, until we've checked whether this invocation actually
752
+ // targets a command group. `tryPluginCommand` returns `undefined` when
753
+ // nothing claims the leading token, in which case this was a genuine
754
+ // core-flag error and the original is rethrown unchanged.
755
+ const code = await tryPluginCommand(rawArgv);
756
+ if (code !== undefined) {
757
+ await flushAndExit(code);
758
+ return;
759
+ }
760
+ throw err;
761
+ }
692
762
 
693
763
  if (args.help || !args.command) {
694
- printHelp();
764
+ const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
765
+ printHelp(groups);
695
766
  process.exit(args.help ? 0 : 1);
696
767
  }
697
768
 
@@ -742,6 +813,15 @@ async function main(): Promise<void> {
742
813
 
743
814
  const match = resolveCommand(args, registry);
744
815
  if (!match) {
816
+ // chant #1078 — not one of core's own commands; check whether a lexicon
817
+ // mounted a command group under this name before giving up. This is the
818
+ // "parsed fine, matched nothing" trigger for the seam — the flag-error
819
+ // trigger is above, in the `parseArgs` catch block.
820
+ const code = await tryPluginCommand(rawArgv);
821
+ if (code !== undefined) {
822
+ await flushAndExit(code);
823
+ return;
824
+ }
745
825
  console.error(formatError({
746
826
  message: `Unknown command: ${args.command}`,
747
827
  hint: 'Run "chant --help" to see available commands',
@@ -60,6 +60,23 @@ describe("buildLiveGraphIr", () => {
60
60
  expect(node.attrs).toEqual({});
61
61
  });
62
62
 
63
+ // #1077 — owner-reference chain classification
64
+ it("carries runtimeOwner only when the owner chain resolves to a declared entity", () => {
65
+ const ir = buildLiveGraphIr([
66
+ {
67
+ lexicon: "k8s",
68
+ resources: {
69
+ "prod/web-abc": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "declared", entity: "web" } },
70
+ "prod/other": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "foreign" } },
71
+ "prod/plain": { type: "K8s::Core::Pod", status: "Running" },
72
+ },
73
+ },
74
+ ]);
75
+ expect(ir.nodes.find((n) => n.id === "prod/web-abc")!.runtimeOwner).toBe("web");
76
+ expect(ir.nodes.find((n) => n.id === "prod/other")!.runtimeOwner).toBeUndefined();
77
+ expect(ir.nodes.find((n) => n.id === "prod/plain")!.runtimeOwner).toBeUndefined();
78
+ });
79
+
63
80
  it("is deterministic for a fixed observation set", () => {
64
81
  expect(JSON.stringify(buildLiveGraphIr(observations))).toBe(
65
82
  JSON.stringify(buildLiveGraphIr(observations)),
@@ -85,6 +102,18 @@ describe("overlayGraphs (#780 drift overlay)", () => {
85
102
  expect(ir.nodes.map((n) => n.id).sort()).toEqual(["planned-db", "rogue-sg", "web-vpc"]);
86
103
  expect(ir.edges).toHaveLength(1);
87
104
  });
105
+
106
+ // #1077 — a provisioned, undeclared node whose owner chain reaches a
107
+ // declared entity paints `runtime`, not `warn` — it is expected runtime,
108
+ // not a foreign resource needing attention.
109
+ it("classifies a runtime child via _status, distinct from foreign", () => {
110
+ const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "web" };
111
+ const liveWithChild: GraphIR = { nodes: [node("web-vpc"), node("rogue-sg"), podNode], edges: [], groups: {} };
112
+ const ir = overlayGraphs(liveWithChild, declared);
113
+ const statusOf = (id: string) => (ir.nodes.find((n) => n.id === id)!.attrs as { _status?: string })._status;
114
+ expect(statusOf("prod/web-abc")).toBe("runtime");
115
+ expect(statusOf("rogue-sg")).toBe("warn"); // still foreign — no runtimeOwner
116
+ });
88
117
  });
89
118
 
90
119
  describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
@@ -135,6 +164,17 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
135
164
  expect(ir.groups.byLexicon).toEqual({ aws: ["planned-db", "web-vpc"], k8s: ["app-ingress"] });
136
165
  });
137
166
 
167
+ // #1077 — a live, undeclared node whose owner chain reaches a declared
168
+ // entity is appended `runtime`, not `warn`, even though it is just as
169
+ // "foreign" (undeclared) from the declared graph's point of view.
170
+ it("appends a runtime child as `runtime`, distinct from foreign", () => {
171
+ const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "app-ingress" };
172
+ const liveWithChild: GraphIR = { ...live, nodes: [...live.nodes, podNode] };
173
+ const ir = sourceOverlayGraphs(declared, liveWithChild);
174
+ expect(statusOf(ir, "prod/web-abc")).toBe("runtime");
175
+ expect(statusOf(ir, "rogue-sg")).toBe("warn"); // still foreign
176
+ });
177
+
138
178
  it("drops a live edge between two managed nodes — declared edges already cover it", () => {
139
179
  const liveDup: GraphIR = { ...live, edges: [{ from: "app-ingress", to: "web-vpc", kind: "ref", viaAttr: "live-label" }] };
140
180
  const ir = sourceOverlayGraphs(declared, liveDup);
package/src/graph-ir.ts CHANGED
@@ -67,6 +67,15 @@ export interface IRNode {
67
67
  * `owned` = chant-managed. Absent for source-derived IR.
68
68
  */
69
69
  ownership?: "owned" | "foreign";
70
+ /**
71
+ * Live-only, undeclared nodes: the declared entity this node's
72
+ * owner-reference chain resolves to (#1077) — a Pod a declared Deployment's
73
+ * controller created, for instance. Presence of this field is what an
74
+ * overlay reads to paint the node `runtime` instead of `warn`/foreign; a
75
+ * node without it (including every declared, source-derived node) is
76
+ * unaffected.
77
+ */
78
+ runtimeOwner?: string;
70
79
  }
71
80
 
72
81
  /** A directed dependency: `from` references an attribute of `to`. */
@@ -504,6 +513,11 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
504
513
  // information for a painter, and the IR's `ownership` field means "a
505
514
  // verdict was reached" — so only owned/foreign land on the node.
506
515
  if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
516
+ // Owner-reference chain (#1077): only a resolved `declared` root is
517
+ // carried onto the node — the same "a verdict was reached" rule as
518
+ // ownership above, since `unowned`/`foreign`/`unknown` all mean "no
519
+ // declared owner", which is simply the absence of this field.
520
+ if (meta.ownerChain?.root === "declared") node.runtimeOwner = meta.ownerChain.entity;
507
521
  nodes.push(node);
508
522
  (byLexicon[lexicon] ??= []).push(name);
509
523
  // A live lexicon maps to one deployable stack, same as the source IR.
@@ -540,8 +554,9 @@ export function collectUnobserved(observations: LiveObservation[]): Record<strin
540
554
  return out;
541
555
  }
542
556
 
543
- /** Paint status a node carries in an overlay. `neutral` = chant could not look. */
544
- type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
557
+ /** Paint status a node carries in an overlay. `neutral` = chant could not look;
558
+ * `runtime` = live, undeclared, owner chain reaches a declared entity (#1077). */
559
+ type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral" | "runtime";
545
560
 
546
561
  function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
547
562
  return {
@@ -558,7 +573,10 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
558
573
  * Overlay the declared graph on the provisioned one (#780, `chant graph --live
559
574
  * --overlay`) and classify each resource, tagging a `_status` a renderer colours:
560
575
  * - **managed** (declared + provisioned) → `good`
561
- * - **foreign** (provisioned, not declared) `warn`
576
+ * - **runtime** (provisioned, not declared, owner chain reaches a declared
577
+ * entity — #1077) → `runtime`, e.g. a Pod a declared Deployment's
578
+ * controller created — expected, not a foreign resource needing attention
579
+ * - **foreign** (provisioned, not declared, no declared owner) → `warn`
562
580
  * - **pending** (declared, provider confirmed absent) → `accent`
563
581
  * - **unobserved** (declared, chant could not look — #1089) → `neutral`,
564
582
  * plus an `_unobserved` attr carrying the reason
@@ -570,7 +588,9 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
570
588
  const liveIds = new Set(live.nodes.map((n) => n.id));
571
589
  const unobserved = opts?.unobserved ?? {};
572
590
 
573
- const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
591
+ const nodes: IRNode[] = live.nodes.map((n) =>
592
+ tagStatus(n, declaredIds.has(n.id) ? "good" : n.runtimeOwner ? "runtime" : "warn"),
593
+ );
574
594
  for (const n of declared.nodes) {
575
595
  if (liveIds.has(n.id)) continue;
576
596
  const u = unobserved[n.id];
@@ -597,8 +617,10 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
597
617
  * the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
598
618
  * to paint the whole estate "pending", which is the diagram equivalent of
599
619
  * planning a create for something that already exists.
600
- * **Foreign** resources (provisioned, not declared) are appended and tagged
601
- * `warn`, together with any live-reconstructed edges that touch them — a declared
620
+ * **Foreign** resources (provisioned, not declared, no declared owner) are
621
+ * appended and tagged `warn`; a provisioned-but-undeclared resource whose
622
+ * owner chain reaches a declared entity (#1077) is tagged `runtime` instead —
623
+ * both carry any live-reconstructed edges that touch them, since a declared
602
624
  * edge cannot describe an undeclared resource. Declared groups/exports pass
603
625
  * through unchanged; nodes and edges are sorted for deterministic output.
604
626
  */
@@ -620,7 +642,10 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
620
642
  if (obs.ownership) merged.ownership = obs.ownership;
621
643
  return tagStatus(merged, "good");
622
644
  });
623
- for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
645
+ for (const n of live.nodes) {
646
+ if (!foreignIds.has(n.id)) continue;
647
+ nodes.push(tagStatus(n, n.runtimeOwner ? "runtime" : "warn")); // runtime child or foreign
648
+ }
624
649
  nodes.sort((a, b) => a.id.localeCompare(b.id));
625
650
 
626
651
  // Declared edges are the canvas (the cross-substrate topology). Add only the
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ export * from "./import/generator";
50
50
  export * from "./lexicon";
51
51
  export * from "./observation";
52
52
  export * from "./deep-observation";
53
+ export * from "./owner-chain";
53
54
  export * from "./lexicon-integrity";
54
55
  export * from "./lexicon-manifest";
55
56
  export * from "./lexicon-schema";
package/src/lexicon.ts CHANGED
@@ -13,6 +13,12 @@ import type { RuleMeta } from "./audit/catalog";
13
13
  import type { ReferenceCatalog } from "./graph-refs";
14
14
  import type { DescribeResourcesResult } from "./observation";
15
15
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
16
+ import type { OwnerChainVerdict } from "./owner-chain";
17
+ import type { CommandGroup } from "./cli/command-group";
18
+
19
+ // Re-exported so a lexicon can author its command group (#1078) from the
20
+ // same `@intentius/chant/lexicon` entry it imports the plugin contract from.
21
+ export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./cli/command-group";
16
22
 
17
23
  // Re-exported so lexicons can author a reference catalog (#778) from the same
18
24
  // `@intentius/chant/lexicon` entry they import the plugin contract from.
@@ -434,6 +440,24 @@ export interface LexiconPlugin {
434
440
  * account. Absent when the lexicon has no local emulator. */
435
441
  readonly emulator?: EmulatorCapability;
436
442
 
443
+ /**
444
+ * A CLI verb group this lexicon contributes, mounted under `chant <name>
445
+ * <verb>` (#1078). Core learns that a lexicon MAY contribute a command
446
+ * group and learns nothing about what is inside it — it finds the group by
447
+ * name and dispatches to the matched verb's handler wholesale, the same
448
+ * "spec, not behavior" shape as {@link emulator}. Unlike `emulator`, which
449
+ * core itself aggregates across every configured lexicon in one command
450
+ * (`chant emulator up --all`), a command group is owned end-to-end by ONE
451
+ * lexicon: `get -o wide -l app=x --field-selector` is irreducibly
452
+ * Kubernetes vocabulary, not something core could generalize or merge
453
+ * across plugins even if it wanted to. Absent when the lexicon contributes
454
+ * no CLI surface — registering nothing here changes nothing else about how
455
+ * the lexicon behaves; the build/fold path never calls this or invokes any
456
+ * verb's handler, since command dispatch happens only in the CLI's own
457
+ * entry point, never in discovery/build/fold.
458
+ */
459
+ commands?(): CommandGroup;
460
+
437
461
  // ── Optional extensions ───────────────────────────────────
438
462
  /** Return lint rules provided by this lexicon */
439
463
  lintRules?(): LintRule[];
@@ -555,6 +579,13 @@ export interface LexiconPlugin {
555
579
  * `ownership: "unknown"` on what it returns rather than degrading silently —
556
580
  * the change set never escalates `unknown` to a `delete`.
557
581
  *
582
+ * An undeclared entry this method returns may carry {@link
583
+ * ResourceMetadata.ownerChain} (#1077) — set it when the provider's own
584
+ * parent/child graph (Kubernetes `ownerReferences`) shows this object's
585
+ * chain reaching a declared entity, so the diff engine classifies it
586
+ * `runtime` instead of `orphan`. Optional; a lexicon that never sets it
587
+ * keeps every undeclared entry classified `orphan`, unchanged.
588
+ *
558
589
  * `entities` carries the chant-side entity declarations for this lexicon,
559
590
  * keyed by chant entity name (e.g. the export name from a `*.ts` file).
560
591
  * Implementations that need to map cloud-side names back to chant entity
@@ -783,6 +814,19 @@ export interface ResourceMetadata {
783
814
  * a delete, and never escalates `unknown` to one.
784
815
  */
785
816
  ownership?: "owned" | "foreign" | "unknown";
817
+ /**
818
+ * Where this resource's owner-reference chain leads, for a live resource
819
+ * that is not itself declared (#1077). A lexicon that maintains an
820
+ * owner-reference graph (Kubernetes) sets this on an undeclared entry it
821
+ * returns; the diff engine reads `{ root: "declared" }` as `runtime`
822
+ * (a Pod a declared Deployment's controller created) rather than `orphan`.
823
+ * Distinct from {@link ownership}: that is chant's own managed-by marker,
824
+ * this is the provider's native parent/child graph — a runtime child
825
+ * usually carries no chant marker of its own at all. Absent means the
826
+ * lexicon supplies no chain, which is exactly today's behavior: every
827
+ * undeclared live resource stays `orphan`.
828
+ */
829
+ ownerChain?: OwnerChainVerdict;
786
830
  }
787
831
 
788
832
  /**
@@ -106,6 +106,77 @@ describe("buildChangeSet (#118)", () => {
106
106
  expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
107
107
  });
108
108
 
109
+ // ── Owner-reference chain classification (#1077) ──────────────────────────
110
+
111
+ test("undeclared, owner chain reaches a declared entity → runtime, never delete or adopt", () => {
112
+ const cs = buildChangeSet("prod", {
113
+ declared: new Set(["web"]),
114
+ observedNow: {
115
+ web: meta({ type: "K8s::Apps::Deployment" }),
116
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
117
+ },
118
+ observedThen: undefined,
119
+ });
120
+ const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
121
+ expect(e.action).toBe("runtime");
122
+ expect(e.runtimeOwner).toBe("web");
123
+ });
124
+
125
+ test("a runtime child that also carries chant's own ownership marker is still `runtime`, never `delete`", () => {
126
+ // Guards the ordering in buildChangeSet: runtimeOwner must be checked
127
+ // before the ownership marker, in case a runtime child ever inherits the
128
+ // marker (e.g. label propagation from its owner's pod template).
129
+ const cs = buildChangeSet("prod", {
130
+ declared: new Set(),
131
+ observedNow: {
132
+ "prod/web-abc": meta({ ownership: "owned", ownerChain: { root: "declared", entity: "web" } }),
133
+ },
134
+ observedThen: undefined,
135
+ });
136
+ const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
137
+ expect(e.action).toBe("runtime");
138
+ });
139
+
140
+ test("undeclared, unowned → orphan/adopt, not runtime", () => {
141
+ const cs = buildChangeSet("prod", {
142
+ declared: new Set(),
143
+ observedNow: { "prod/standalone": meta({ ownerChain: { root: "unowned" } }) },
144
+ observedThen: undefined,
145
+ });
146
+ const e = cs.entries.find((x) => x.name === "prod/standalone")!;
147
+ expect(e.action).toBe("adopt");
148
+ expect(e.runtimeOwner).toBeUndefined();
149
+ });
150
+
151
+ test("undeclared, foreign root → orphan/adopt, not runtime", () => {
152
+ const cs = buildChangeSet("prod", {
153
+ declared: new Set(),
154
+ observedNow: { "prod/other": meta({ ownerChain: { root: "foreign" } }) },
155
+ observedThen: undefined,
156
+ });
157
+ expect(cs.entries.find((x) => x.name === "prod/other")!.action).toBe("adopt");
158
+ });
159
+
160
+ test("undeclared, unresolved chain (unreadable/cycle/depth) → conservative adopt, not runtime", () => {
161
+ const cs = buildChangeSet("prod", {
162
+ declared: new Set(),
163
+ observedNow: { "prod/mystery": meta({ ownerChain: { root: "unknown" } }) },
164
+ observedThen: undefined,
165
+ });
166
+ const e = cs.entries.find((x) => x.name === "prod/mystery")!;
167
+ expect(e.action).toBe("adopt");
168
+ expect(e.runtimeOwner).toBeUndefined();
169
+ });
170
+
171
+ test("a lexicon with no owner chain at all is unaffected — undeclared stays adopt/delete as before", () => {
172
+ const cs = buildChangeSet("prod", {
173
+ declared: new Set(),
174
+ observedNow: { orphan: meta({ ownership: "owned" }) },
175
+ observedThen: undefined,
176
+ });
177
+ expect(cs.entries.find((x) => x.name === "orphan")!.action).toBe("delete");
178
+ });
179
+
109
180
  test("only in snapshot (gone now, undeclared) → noop", () => {
110
181
  const cs = buildChangeSet("prod", {
111
182
  declared: new Set(),
@@ -148,6 +219,23 @@ describe("summarize / renderChangeSet", () => {
148
219
  expect(out).toContain("ADOPT:");
149
220
  expect(out).toContain("orphan");
150
221
  });
222
+
223
+ test("summarize and render surface the runtime action (#1077)", () => {
224
+ const withRuntime = buildChangeSet("prod", {
225
+ declared: new Set(["web"]),
226
+ observedNow: {
227
+ web: meta({ type: "K8s::Apps::Deployment" }),
228
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
229
+ },
230
+ observedThen: undefined,
231
+ });
232
+ expect(summarize(withRuntime).runtime).toBe(1);
233
+ expect(summarize(withRuntime).adopt).toBe(0);
234
+ const out = renderChangeSet(withRuntime);
235
+ expect(out).toContain("RUNTIME");
236
+ expect(out).toContain("prod/web-abc");
237
+ expect(out).toContain("owned by web");
238
+ });
151
239
  });
152
240
 
153
241
  describe("gitlabMrReport (#329)", () => {
@@ -169,6 +257,18 @@ describe("gitlabMrReport (#329)", () => {
169
257
  expect(gitlabMrReport(cs)).toEqual({ create: 1, update: 1, delete: 1 });
170
258
  });
171
259
 
260
+ test("a runtime child (#1077) is excluded from the widget — never counted as a change", () => {
261
+ const cs = buildChangeSet("prod", {
262
+ declared: new Set(["web"]),
263
+ observedNow: {
264
+ web: meta({ type: "K8s::Apps::Deployment" }),
265
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
266
+ },
267
+ observedThen: undefined,
268
+ });
269
+ expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
270
+ });
271
+
172
272
  test("empty plan reports all zeros", () => {
173
273
  const cs = buildChangeSet("prod", {
174
274
  declared: new Set(),
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * `chant lifecycle diff --live` computes a three-way comparison — declared now /
5
5
  * last snapshot / live now — and prints it. `buildChangeSet` promotes that
6
- * same signal into a classified create/update/delete/adopt/noop set that other
7
- * tooling (reconcile, apply) can act on.
6
+ * same signal into a classified create/update/delete/adopt/runtime/noop set
7
+ * that other tooling (reconcile, apply) can act on.
8
8
  *
9
9
  * Strictly read-only and pure: no I/O, no mutation. The classification reads
10
10
  * ownership from the live marker only (populated downstream); until ownership
@@ -25,12 +25,17 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
25
25
  * snapshot.
26
26
  * - `adopt` — live but undeclared, ownership not established → a candidate to
27
27
  * pull back into source, never an auto-delete.
28
+ * - `runtime` — live but undeclared, and its owner-reference chain reaches a
29
+ * declared entity (#1077): a Pod a declared Deployment's controller
30
+ * created, for instance. Never a delete, never an adopt candidate — it is
31
+ * not drift, just the runtime doing its job. `runtimeOwner` names the
32
+ * declared entity it belongs to.
28
33
  * - `noop` — declared and live with no drift, or already reconciled.
29
34
  * - `unobserved` — declared, and the lexicon could not look (#1089). Not a
30
35
  * proposal at all: it is the plan admitting a hole. Never a create, never a
31
36
  * delete. Read `unobservedReason` for which hole.
32
37
  */
33
- export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
38
+ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
34
39
 
35
40
  /**
36
41
  * Who answers "is this resource chant's?". `unknown` until a live ownership
@@ -69,6 +74,8 @@ export interface ChangeSetEntry {
69
74
  unobservedReason?: UnobservedReason;
70
75
  /** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
71
76
  unobservedDetail?: string;
77
+ /** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
78
+ runtimeOwner?: string;
72
79
  }
73
80
 
74
81
  export interface ChangeSet {
@@ -121,6 +128,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
121
128
  // record chant has to host.
122
129
  const ownership: Ownership = observedNow[name]?.ownership ?? "unknown";
123
130
 
131
+ // Owner-reference chain (#1077), same live-only provenance as ownership
132
+ // above. Only a `declared` root changes the classification; `unknown` is
133
+ // deliberately not escalated (#1168's tri-state precedent — an
134
+ // unconfirmed chain never earns the more confident verdict).
135
+ const runtimeOwner =
136
+ !isDeclared && observedNow[name]?.ownerChain?.root === "declared"
137
+ ? observedNow[name]!.ownerChain!.entity
138
+ : undefined;
139
+
124
140
  let action: ChangeAction;
125
141
  let deltas: AttributeChange[] | undefined;
126
142
 
@@ -140,6 +156,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
140
156
  } else {
141
157
  action = "noop";
142
158
  }
159
+ } else if (live && runtimeOwner) {
160
+ // Live, undeclared, and its owner chain reaches a declared entity
161
+ // (#1077) — expected runtime, never a delete/adopt candidate, checked
162
+ // ahead of the ownership marker below: even a runtime child that
163
+ // happens to carry chant's own marker (label propagation from its
164
+ // owner's template) must never be proposed for deletion.
165
+ action = "runtime";
143
166
  } else if (live) {
144
167
  // Live but undeclared. Only a chant-owned orphan is a safe delete; a
145
168
  // foreign or unknown orphan can be adopted but never auto-deleted.
@@ -162,6 +185,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
162
185
  ...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
163
186
  }
164
187
  : {}),
188
+ ...(runtimeOwner ? { runtimeOwner } : {}),
165
189
  });
166
190
  }
167
191
 
@@ -169,7 +193,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
169
193
  return { env, entries };
170
194
  }
171
195
 
172
- const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
196
+ const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
173
197
 
174
198
  /** Count entries per action. */
175
199
  export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
@@ -178,6 +202,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
178
202
  update: 0,
179
203
  delete: 0,
180
204
  adopt: 0,
205
+ runtime: 0,
181
206
  noop: 0,
182
207
  unobserved: 0,
183
208
  };
@@ -191,10 +216,11 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
191
216
  * GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
192
217
  * UI as "N to add, M to change, K to delete". The format is generic — any tool
193
218
  * that emits this JSON gets the widget — and the chant plan maps onto it
194
- * directly. Only the mutating actions count: `adopt`, `noop` and `unobserved`
195
- * are excluded, since the widget has no column for "live but undeclared", "no
196
- * change", or "could not look" (#1089). The widget is therefore a floor, not a
197
- * complete plan: read the full change set when entities are unobserved.
219
+ * directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
220
+ * `unobserved` are excluded, since the widget has no column for "live but
221
+ * undeclared", "expected runtime child" (#1077), "no change", or "could not
222
+ * look" (#1089). The widget is therefore a floor, not a complete plan: read
223
+ * the full change set when entities are unobserved or classified runtime.
198
224
  *
199
225
  * The widget label reads "Terraform" regardless of producer; that is GitLab's
200
226
  * fixed string, not a claim chant makes.
@@ -223,14 +249,17 @@ export function renderChangeSet(cs: ChangeSet): string {
223
249
  lines.push(
224
250
  action === "unobserved"
225
251
  ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
226
- : `\n${action.toUpperCase()}:`,
252
+ : action === "runtime"
253
+ ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
254
+ : `\n${action.toUpperCase()}:`,
227
255
  );
228
256
  for (const e of group) {
229
257
  const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
230
258
  const why = e.unobservedReason
231
259
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
232
260
  : "";
233
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}`);
261
+ const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
262
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
234
263
  for (const d of e.deltas ?? []) {
235
264
  lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
236
265
  }