@intentius/chant 0.31.0 → 0.33.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 (63) 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/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/cli/handlers/search.d.ts +58 -0
  8. package/dist/cli/handlers/search.d.ts.map +1 -0
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/config.d.ts +3 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/graph-declared.d.ts +20 -0
  15. package/dist/graph-declared.d.ts.map +1 -0
  16. package/dist/graph-effective.d.ts +25 -0
  17. package/dist/graph-effective.d.ts.map +1 -0
  18. package/dist/graph-ir.d.ts +17 -3
  19. package/dist/graph-ir.d.ts.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +49 -0
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/change-set.d.ts +15 -7
  25. package/dist/lifecycle/change-set.d.ts.map +1 -1
  26. package/dist/lifecycle/live-diff.d.ts +25 -1
  27. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  28. package/dist/lifecycle/observe.d.ts +14 -6
  29. package/dist/lifecycle/observe.d.ts.map +1 -1
  30. package/dist/managed-fields.d.ts +118 -0
  31. package/dist/managed-fields.d.ts.map +1 -0
  32. package/dist/owner-chain.d.ts +99 -0
  33. package/dist/owner-chain.d.ts.map +1 -0
  34. package/package.json +1 -1
  35. package/src/cli/command-group.test.ts +208 -0
  36. package/src/cli/command-group.ts +199 -0
  37. package/src/cli/conflict-check.test.ts +36 -1
  38. package/src/cli/conflict-check.ts +22 -1
  39. package/src/cli/handlers/graph.test.ts +1 -1
  40. package/src/cli/handlers/graph.ts +33 -11
  41. package/src/cli/handlers/lifecycle.ts +5 -0
  42. package/src/cli/handlers/search.test.ts +113 -0
  43. package/src/cli/handlers/search.ts +263 -0
  44. package/src/cli/main.ts +114 -27
  45. package/src/cli/registry.ts +4 -0
  46. package/src/config.ts +3 -0
  47. package/src/graph-declared.ts +33 -0
  48. package/src/graph-effective.test.ts +97 -0
  49. package/src/graph-effective.ts +110 -0
  50. package/src/graph-ir-live.test.ts +40 -0
  51. package/src/graph-ir.ts +32 -7
  52. package/src/index.ts +1 -0
  53. package/src/lexicon.ts +50 -1
  54. package/src/lifecycle/change-set.test.ts +100 -0
  55. package/src/lifecycle/change-set.ts +39 -10
  56. package/src/lifecycle/live-diff.test.ts +88 -0
  57. package/src/lifecycle/live-diff.ts +55 -8
  58. package/src/lifecycle/observe.test.ts +66 -2
  59. package/src/lifecycle/observe.ts +79 -18
  60. package/src/managed-fields.test.ts +179 -0
  61. package/src/managed-fields.ts +328 -0
  62. package/src/owner-chain.test.ts +97 -0
  63. package/src/owner-chain.ts +128 -0
@@ -0,0 +1,199 @@
1
+ import type { LexiconPlugin } from "../lexicon";
2
+
3
+ /**
4
+ * The lexicon command-group seam (chant #1078).
5
+ *
6
+ * A lexicon may contribute one CLI verb group, mounted under `chant <name>
7
+ * <verb>` (e.g. `chant kube get`). Core's only job is to find the group and
8
+ * call the matched verb's handler — it never inspects, validates, or
9
+ * special-cases what a verb does. That is the whole point: `get -o wide -l
10
+ * app=x --field-selector` is Kubernetes vocabulary, not something core could
11
+ * generalize even if it tried (see #1078's motivating case, consumed by
12
+ * #1079's `chant kube`).
13
+ *
14
+ * This is a DIFFERENT shape from `LexiconPlugin.emulator` (#920): the
15
+ * emulator capability is DATA that core itself aggregates across every
16
+ * configured lexicon (`chant emulator up --all` loops every plugin with an
17
+ * `emulator`). A command group is BEHAVIOR owned end-to-end by one lexicon —
18
+ * core dispatches to it wholesale and never loops or merges across plugins.
19
+ * The two capabilities are not layers of the same thing; migrating
20
+ * `emulator` onto this seam would be a worse fit, not a simplification.
21
+ */
22
+
23
+ /** Context handed to a mounted command's handler. */
24
+ export interface CommandGroupContext {
25
+ /** The verb invoked, e.g. `"get"` for `chant kube get pods`. */
26
+ verb: string;
27
+ /**
28
+ * Every CLI token after the group name and verb, unparsed — e.g. `chant
29
+ * kube get pods -o wide` hands `["pods", "-o", "wide"]`. Core does not
30
+ * interpret these: it has no vocabulary for a lexicon's own verbs. A
31
+ * handler that wants #1127's joined-`--flag=value` splitting and
32
+ * unknown-flag rejection can reuse {@link splitJoinedFlags} /
33
+ * {@link unknownFlagError} from this module for the same discipline core's
34
+ * own parser applies, scoped to whatever flags this verb actually accepts.
35
+ */
36
+ rawArgs: string[];
37
+ }
38
+
39
+ /** One verb within a lexicon-contributed command group. */
40
+ export interface CommandGroupCommand {
41
+ /** Verb name, e.g. `"get"`, `"logs"`, `"version"`. */
42
+ name: string;
43
+ /** One-line description shown in `chant --help` and in usage errors. */
44
+ description: string;
45
+ /** Runs the verb. Returns the process exit code. */
46
+ handler: (ctx: CommandGroupContext) => Promise<number>;
47
+ }
48
+
49
+ /**
50
+ * A CLI verb group contributed by a lexicon (chant #1078). Mounted under
51
+ * `chant <name> <verb>`. Returned from {@link LexiconPlugin.commands}.
52
+ */
53
+ export interface CommandGroup {
54
+ /** Namespace this group mounts under, e.g. `"kube"` for `chant kube <verb>`. */
55
+ name: string;
56
+ /** One-line description shown in `chant --help`'s composed listing. */
57
+ description: string;
58
+ /** Verbs in this group. */
59
+ commands: CommandGroupCommand[];
60
+ }
61
+
62
+ /**
63
+ * Top-level command words core's own static registry already owns
64
+ * (`packages/core/src/cli/main.ts`'s `registry`). A lexicon's `commands()`
65
+ * group name colliding with one of these is always unreachable — core's own
66
+ * registry is resolved first, unconditionally — so `checkConflicts`
67
+ * (./conflict-check.ts) treats a collision as a hard, loud failure at
68
+ * plugin-load time rather than a silently-ignored command group. Hand
69
+ * maintained alongside the registry; update both together.
70
+ */
71
+ export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set([
72
+ "build", "lint", "list", "describe", "import", "audit", "migrate", "carve",
73
+ "init", "update", "doctor", "dev", "run", "graph", "vendor", "lifecycle",
74
+ "lc", "components", "emulator", "serve",
75
+ ]);
76
+
77
+ /**
78
+ * chant #1127 — split a joined `--flag=value` token into two array elements
79
+ * (`--flag`, `value`), the same discipline core's own `parseArgs` applies,
80
+ * generalized so a lexicon's mounted command can reuse it for its own flag
81
+ * vocabulary instead of reimplementing the split. Throws the same shape of
82
+ * error as core's parser when `flag` is declared boolean but was given a
83
+ * value — a boolean has nothing to assign, and silently reinterpreting the
84
+ * joined value as the next positional would be exactly the silent misparse
85
+ * #1127 closed for core's own flags.
86
+ */
87
+ export function splitJoinedFlags(args: string[], booleanFlags: ReadonlySet<string> = new Set()): string[] {
88
+ const out: string[] = [];
89
+ for (const arg of args) {
90
+ if (arg.startsWith("--") && arg.includes("=")) {
91
+ const eq = arg.indexOf("=");
92
+ const flag = arg.slice(0, eq);
93
+ const value = arg.slice(eq + 1);
94
+ if (booleanFlags.has(flag)) {
95
+ throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
96
+ }
97
+ out.push(flag, value);
98
+ } else {
99
+ out.push(arg);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Same "Unknown flag" error shape core's own `parseArgs` throws (#1127), for
107
+ * a mounted command's own flag vocabulary — core doesn't know that
108
+ * vocabulary, so it can't produce this error itself; the handler does, using
109
+ * this helper for a consistent message.
110
+ */
111
+ export function unknownFlagError(flag: string, hint = `Run "chant --help" to see supported flags.`): Error {
112
+ return new Error(`Unknown flag: ${flag}\n${hint}`);
113
+ }
114
+
115
+ /** Result of looking up a command group + verb among loaded plugins. */
116
+ export type CommandGroupLookup =
117
+ | { kind: "no-group" }
118
+ | { kind: "no-verb"; group: CommandGroup }
119
+ | { kind: "unknown-verb"; group: CommandGroup }
120
+ | { kind: "matched"; plugin: LexiconPlugin; group: CommandGroup; command: CommandGroupCommand };
121
+
122
+ /**
123
+ * Find the plugin (if any) whose `commands()` group is named `groupName`,
124
+ * and the verb within it named `verbName`. Pure — does no I/O, calls
125
+ * `plugin.commands()` at most once per plugin (registration, not execution:
126
+ * this never invokes a verb's handler).
127
+ */
128
+ export function resolveCommandGroupVerb(
129
+ plugins: readonly LexiconPlugin[],
130
+ groupName: string,
131
+ verbName: string | undefined,
132
+ ): CommandGroupLookup {
133
+ for (const plugin of plugins) {
134
+ const group = plugin.commands?.();
135
+ if (!group || group.name !== groupName) continue;
136
+ if (verbName === undefined) return { kind: "no-verb", group };
137
+ const command = group.commands.find((c) => c.name === verbName);
138
+ if (!command) return { kind: "unknown-verb", group };
139
+ return { kind: "matched", plugin, group, command };
140
+ }
141
+ return { kind: "no-group" };
142
+ }
143
+
144
+ /** Every command group contributed by the given loaded plugins, in plugin order. */
145
+ export function collectCommandGroups(plugins: readonly LexiconPlugin[]): CommandGroup[] {
146
+ const groups: CommandGroup[] = [];
147
+ for (const plugin of plugins) {
148
+ const group = plugin.commands?.();
149
+ if (group) groups.push(group);
150
+ }
151
+ return groups;
152
+ }
153
+
154
+ /** Result of {@link dispatchCommandGroup}. */
155
+ export type CommandGroupDispatch =
156
+ | { kind: "no-group" }
157
+ | { kind: "usage-error"; message: string; hint: string }
158
+ | { kind: "ran"; exitCode: number };
159
+
160
+ /**
161
+ * Resolve `groupName`/`verbName` against the loaded plugins and, if matched,
162
+ * run the verb's handler with `rawArgs`. Returns `{ kind: "no-group" }` when
163
+ * nothing claims `groupName` at all — the caller's cue to fall back to its
164
+ * own "unknown command" handling — and a printable usage error when the
165
+ * group matched but the verb didn't (or was omitted).
166
+ */
167
+ export async function dispatchCommandGroup(
168
+ plugins: readonly LexiconPlugin[],
169
+ groupName: string,
170
+ verbName: string | undefined,
171
+ rawArgs: string[],
172
+ ): Promise<CommandGroupDispatch> {
173
+ const lookup = resolveCommandGroupVerb(plugins, groupName, verbName);
174
+ if (lookup.kind === "no-group") return { kind: "no-group" };
175
+ if (lookup.kind === "matched") {
176
+ const exitCode = await lookup.command.handler({ verb: verbName as string, rawArgs });
177
+ return { kind: "ran", exitCode };
178
+ }
179
+ const verbs = lookup.group.commands.map((c) => ` ${c.name.padEnd(14)} ${c.description}`).join("\n");
180
+ const message =
181
+ lookup.kind === "unknown-verb"
182
+ ? `Unknown ${groupName} subcommand: ${verbName}`
183
+ : `Usage: chant ${groupName} <verb> [args...]`;
184
+ return { kind: "usage-error", message, hint: `Available verbs:\n${verbs}` };
185
+ }
186
+
187
+ /**
188
+ * Render the `--help` section listing every lexicon-contributed command
189
+ * group. Empty string when there are none, so a caller can splice it in
190
+ * unconditionally without an extra length check.
191
+ */
192
+ export function formatCommandGroupsHelp(groups: readonly CommandGroup[]): string {
193
+ if (groups.length === 0) return "";
194
+ const lines = groups.flatMap((g) => [
195
+ ` ${g.name.padEnd(20)} ${g.description}`,
196
+ ...g.commands.map((c) => ` ${g.name} ${c.name.padEnd(Math.max(1, 17 - g.name.length))}${c.description}`),
197
+ ]);
198
+ return `Lexicon commands:\n${lines.join("\n")}\n`;
199
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { checkConflicts } from "./conflict-check";
3
- import type { LexiconPlugin } from "../lexicon";
3
+ import type { LexiconPlugin, CommandGroup } from "../lexicon";
4
4
 
5
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
6
  const mockSerializer = { name: "test", serialize: () => ({}) } as any;
@@ -14,6 +14,7 @@ function makePlugin(
14
14
  skills?: { name: string }[];
15
15
  mcpTools?: { name: string }[];
16
16
  mcpResources?: { uri: string }[];
17
+ commandGroup?: CommandGroup;
17
18
  } = {},
18
19
  ): LexiconPlugin {
19
20
  const plugin: LexiconPlugin = {
@@ -57,6 +58,11 @@ function makePlugin(
57
58
  (plugin as any).mcpTools = () => tools;
58
59
  }
59
60
 
61
+ if (opts.commandGroup) {
62
+ const group = opts.commandGroup;
63
+ plugin.commands = () => group;
64
+ }
65
+
60
66
  if (opts.mcpResources) {
61
67
  const resources = opts.mcpResources.map((r) => ({
62
68
  uri: r.uri,
@@ -202,6 +208,35 @@ describe("checkConflicts", () => {
202
208
  expect(report.warnings.filter((w) => w.type === "mcp-resource")).toHaveLength(0);
203
209
  });
204
210
 
211
+ // -----------------------------------------------------------------------
212
+ // Command-group name conflicts (hard, chant #1078)
213
+ // -----------------------------------------------------------------------
214
+
215
+ test("detects two lexicons claiming the same command-group name as a hard conflict", () => {
216
+ const plugins = [
217
+ makePlugin("k8s", { commandGroup: { name: "kube", description: "d", commands: [] } }),
218
+ makePlugin("fly", { commandGroup: { name: "kube", description: "d2", commands: [] } }),
219
+ ];
220
+ const report = checkConflicts(plugins);
221
+ expect(report.conflicts).toEqual([{ type: "command-group-name", key: "kube", plugins: ["k8s", "fly"] }]);
222
+ expect(report.warnings).toHaveLength(0);
223
+ });
224
+
225
+ test("detects a command-group name colliding with a reserved core command word", () => {
226
+ const plugins = [makePlugin("rogue", { commandGroup: { name: "build", description: "d", commands: [] } })];
227
+ const report = checkConflicts(plugins);
228
+ expect(report.conflicts).toEqual([{ type: "command-group-name", key: "build", plugins: ["rogue"] }]);
229
+ });
230
+
231
+ test("no conflict for a single lexicon's own, non-reserved command-group name", () => {
232
+ const plugins = [
233
+ makePlugin("k8s", { commandGroup: { name: "kube", description: "d", commands: [] } }),
234
+ makePlugin("aws"),
235
+ ];
236
+ const report = checkConflicts(plugins);
237
+ expect(report.conflicts.filter((c) => c.type === "command-group-name")).toHaveLength(0);
238
+ });
239
+
205
240
  // -----------------------------------------------------------------------
206
241
  // Combined
207
242
  // -----------------------------------------------------------------------
@@ -1,7 +1,8 @@
1
1
  import type { LexiconPlugin } from "../lexicon";
2
+ import { RESERVED_COMMAND_NAMES } from "./command-group";
2
3
 
3
4
  export interface ConflictEntry {
4
- type: "rule-id" | "skill-name" | "mcp-tool" | "mcp-resource";
5
+ type: "rule-id" | "skill-name" | "mcp-tool" | "mcp-resource" | "command-group-name";
5
6
  key: string;
6
7
  plugins: string[];
7
8
  }
@@ -85,5 +86,25 @@ export function checkConflicts(plugins: LexiconPlugin[]): ConflictReport {
85
86
  }
86
87
  }
87
88
 
89
+ // Check command-group name conflicts (hard, chant #1078). Two shapes:
90
+ // two lexicons claiming the same group name (only one would ever be
91
+ // reachable, silently), or one lexicon claiming a name core's own static
92
+ // registry already owns (permanently unreachable — core resolves its own
93
+ // registry first, unconditionally). Either is a silent-shadowing bug
94
+ // class, so both are hard conflicts rather than warnings.
95
+ const commandGroupNames = new Map<string, string[]>();
96
+ for (const plugin of plugins) {
97
+ const group = plugin.commands?.();
98
+ if (!group) continue;
99
+ const existing = commandGroupNames.get(group.name) ?? [];
100
+ existing.push(plugin.name);
101
+ commandGroupNames.set(group.name, existing);
102
+ }
103
+ for (const [name, owners] of commandGroupNames) {
104
+ if (owners.length > 1 || RESERVED_COMMAND_NAMES.has(name)) {
105
+ conflicts.push({ type: "command-group-name", key: name, plugins: owners });
106
+ }
107
+ }
108
+
88
109
  return { conflicts, warnings };
89
110
  }
@@ -490,7 +490,7 @@ describe("runGraph", () => {
490
490
  expect(exit).toBe(0);
491
491
  expect(observeMock).toHaveBeenCalledTimes(1);
492
492
  const [, , , opts] = observeMock.mock.calls[0];
493
- expect((opts as { stacks: string[] }).stacks.sort()).toEqual([
493
+ expect((opts as { stacks: Array<{name:string}> }).stacks.map((x)=>x.name).sort()).toEqual([
494
494
  "loom-local-a-loom-backend",
495
495
  "loom-local-a-loom-backend-jobs",
496
496
  "loom-local-a-loom-db",
@@ -3,6 +3,7 @@ import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build } from "../../build";
5
5
  import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
6
+ import { buildDeclaredPerStack } from "../../graph-declared";
6
7
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
7
8
  import { observeResources } from "../../lifecycle/observe";
8
9
  import { loadChantConfig, environmentNames } from "../../config";
@@ -126,14 +127,21 @@ async function runGraphLive(
126
127
  const componentsDiscovery = await discoverComponents(resolve(args.src ?? config.sourceDir ?? "."), {
127
128
  sandbox: args.sandbox,
128
129
  });
129
- const stacks = new Set<string>();
130
+ const stacks: Array<{ name: string; region?: string; src?: string }> = [];
131
+ const seenStacks = new Set<string>();
130
132
  if (componentsDiscovery.errors.length === 0) {
131
133
  for (const { component } of componentsDiscovery.components.values()) {
132
- for (const stack of cfnDeployStacks(component.deploy)) stacks.add(stack);
134
+ for (const stack of cfnDeployStacks(component.deploy)) {
135
+ if (!seenStacks.has(stack)) { seenStacks.add(stack); stacks.push({ name: stack }); }
136
+ }
133
137
  }
134
138
  } else {
135
139
  console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
136
140
  }
141
+ // ChantConfig.stacks (with optional per-stack region) — multi-region estates.
142
+ for (const declared of config.stacks ?? []) {
143
+ if (!seenStacks.has(declared.name)) { seenStacks.add(declared.name); stacks.push({ name: declared.name, region: declared.region, src: declared.src }); }
144
+ }
137
145
 
138
146
  // #1166 — an environment can declare its own endpoint (a local emulator like
139
147
  // Floci), so this read is self-sufficient even when the ambient shell never
@@ -174,7 +182,7 @@ async function runGraphLive(
174
182
  for (const p of observing) {
175
183
  if (!p.enrichLiveAttrs) continue;
176
184
  try {
177
- const enriched = await p.enrichLiveAttrs({ environment, owned: true });
185
+ const enriched = await p.enrichLiveAttrs({ environment, owned: true, stacks });
178
186
  ir = {
179
187
  ...ir,
180
188
  nodes: ir.nodes.map((n) =>
@@ -205,19 +213,33 @@ async function runGraphLive(
205
213
  // so cross-substrate topology survives; live status joined per node.
206
214
  // - live (#780): provisioned graph is the canvas — reconstructed live edges.
207
215
  if (args.overlay) {
208
- const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
209
- if (declared.errors.length === 0) {
210
- const declaredIr = buildGraphIr(declared.entities, projectPath);
211
- // Declared nodes chant could not read are painted `neutral`, not
212
- // `accent`/pending (#1089) a wrong-cluster or unsupported-kind read
213
- // must not draw the estate as "not deployed yet".
214
- const overlayOpts = { unobserved: collectUnobserved(observations) };
216
+ // Declared nodes chant could not read are painted `neutral`, not
217
+ // `accent`/pending (#1089) a wrong-cluster or unsupported-kind read must
218
+ // not draw the estate as "not deployed yet".
219
+ const overlayOpts = { unobserved: collectUnobserved(observations) };
220
+ // Multi-stack (#1162): the live `ir` keys nodes by `${stack}::${logicalId}`,
221
+ // so the declared canvas must qualify identically build each stack's src
222
+ // scoped rather than a flat whole-project discovery (whose disambiguated
223
+ // names never match the observed bare ids). Fall back to the flat build when
224
+ // no stack carries a `src`.
225
+ const scopedStacks = stacks.filter((s) => s.src);
226
+ if (scopedStacks.length > 0) {
227
+ const declaredIr = await buildDeclaredPerStack(scopedStacks, projectPath);
215
228
  ir =
216
229
  args.overlayAnchor === "live"
217
230
  ? overlayGraphs(ir, declaredIr, overlayOpts)
218
231
  : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
219
232
  } else {
220
- console.error(formatWarning({ message: "overlay: source has discovery errors showing the provisioned graph without the declared overlay" }));
233
+ const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
234
+ if (declared.errors.length === 0) {
235
+ const declaredIr = buildGraphIr(declared.entities, projectPath);
236
+ ir =
237
+ args.overlayAnchor === "live"
238
+ ? overlayGraphs(ir, declaredIr, overlayOpts)
239
+ : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
240
+ } else {
241
+ console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
242
+ }
221
243
  }
222
244
  }
223
245
 
@@ -857,6 +857,7 @@ function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiff
857
857
  `${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
858
858
  `${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
859
859
  `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged` +
860
+ (diff.runtimeChildren.length > 0 ? `, ${diff.runtimeChildren.length} runtime` : "") +
860
861
  (diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "");
861
862
 
862
863
  console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
@@ -877,6 +878,10 @@ function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiff
877
878
  console.log(formatBold("\nORPHAN (in cloud, not declared):"));
878
879
  for (const name of diff.orphan) console.log(` - ${name}`);
879
880
  }
881
+ if (diff.runtimeChildren.length > 0) {
882
+ console.log(formatBold("\nRUNTIME (owned by a declared resource; not drift, not an orphan — #1077):"));
883
+ for (const r of diff.runtimeChildren) console.log(` - ${r.name} (${r.type}) — owned by ${r.owner}`);
884
+ }
880
885
  if (diff.disappeared.length > 0) {
881
886
  console.log(formatBold("\nDISAPPEARED (in last snapshot, gone now):"));
882
887
  for (const name of diff.disappeared) console.log(` - ${name}`);
@@ -0,0 +1,113 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { __searchInternals } from "./search";
3
+
4
+ const { parseQuery, matchTerm, formatRow, explain, describeTerm } = __searchInternals;
5
+
6
+ function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
7
+ return { id, kind, lexicon: "aws", attrs } as never;
8
+ }
9
+
10
+ describe("search query parsing", () => {
11
+ test("splits bare words, keyed terms, and quoted phrases", () => {
12
+ const terms = parseQuery('kind:EC2::Instance tag:Name=Public "public subnet"');
13
+ expect(terms).toEqual([
14
+ { kind: "kind", a: "EC2::Instance" },
15
+ { kind: "tag", a: "Name", b: "Public" },
16
+ { kind: "word", a: "public subnet" },
17
+ ]);
18
+ });
19
+ });
20
+
21
+ describe("search matching", () => {
22
+ const inst = node("webServer", "AWS::EC2::Instance", {
23
+ physicalId: "i-abc",
24
+ Tags: [{ Key: "Name", Value: "Public" }],
25
+ MapPublicIpOnLaunch: true,
26
+ });
27
+
28
+ test("kind: is substring on the resource kind", () => {
29
+ expect(matchTerm(inst, { kind: "kind", a: "EC2::Instance" })).toBe(true);
30
+ expect(matchTerm(inst, { kind: "kind", a: "SecurityGroup" })).toBe(false);
31
+ });
32
+
33
+ test("tag: matches Key with optional Value substring", () => {
34
+ expect(matchTerm(inst, { kind: "tag", a: "Name", b: "Pub" })).toBe(true);
35
+ expect(matchTerm(inst, { kind: "tag", a: "Name", b: "Private" })).toBe(false);
36
+ expect(matchTerm(inst, { kind: "tag", a: "Owner" })).toBe(false);
37
+ });
38
+
39
+ test("attr: matches presence or value substring", () => {
40
+ expect(matchTerm(inst, { kind: "attr", a: "MapPublicIpOnLaunch", b: "true" })).toBe(true);
41
+ expect(matchTerm(inst, { kind: "attr", a: "MapPublicIpOnLaunch" })).toBe(true);
42
+ expect(matchTerm(inst, { kind: "attr", a: "Nonexistent" })).toBe(false);
43
+ });
44
+
45
+ test("bare word searches id, kind, and attr values", () => {
46
+ expect(matchTerm(inst, { kind: "word", a: "webserver" })).toBe(true);
47
+ expect(matchTerm(inst, { kind: "word", a: "i-abc" })).toBe(true);
48
+ expect(matchTerm(inst, { kind: "word", a: "nope" })).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("search formatting", () => {
53
+ test("compact row with live physical id, skipping object placeholders", () => {
54
+ const live = node("webServer", "AWS::EC2::Instance", { physicalId: "i-abc" });
55
+ expect(formatRow(live, [])).toBe("webServer AWS::EC2::Instance i-abc");
56
+ const src = node("webServer", "AWS::EC2::Instance", { InstanceId: { $ref: "webServer.InstanceId" } });
57
+ expect(formatRow(src, [])).toBe("webServer AWS::EC2::Instance");
58
+ });
59
+
60
+ test("--show adds named primitive attributes only", () => {
61
+ const n = node("web", "AWS::EC2::Instance", { physicalId: "i-1", InstanceType: "t3.micro", Tags: [{}] });
62
+ expect(formatRow(n, ["InstanceType", "Tags"])).toBe("web AWS::EC2::Instance i-1 InstanceType=t3.micro");
63
+ });
64
+ });
65
+
66
+ describe("search edge traversal", () => {
67
+ const ir = {
68
+ nodes: [
69
+ node("webServer", "AWS::EC2::Instance", { physicalId: "i-1" }),
70
+ node("privSubnet", "AWS::EC2::Subnet", { MapPublicIpOnLaunch: false }),
71
+ node("pubSubnet", "AWS::EC2::Subnet", { MapPublicIpOnLaunch: true }),
72
+ node("privServer", "AWS::EC2::Instance", { physicalId: "i-2" }),
73
+ ],
74
+ edges: [
75
+ { from: "webServer", to: "pubSubnet", kind: "ref", viaAttr: "SubnetId" },
76
+ { from: "privServer", to: "privSubnet", kind: "ref", viaAttr: "SubnetId" },
77
+ ],
78
+ } as never;
79
+
80
+ test("->attr resolves the instance→subnet→public join", () => {
81
+ const byId = new Map((ir as { nodes: { id: string }[] }).nodes.map((n) => [n.id, n]));
82
+ const terms = parseQuery("kind:EC2::Instance ->attr:MapPublicIpOnLaunch=true");
83
+ const matches = (ir as { nodes: never[] }).nodes.filter((n) =>
84
+ terms.every((t) => matchTerm(n as never, t, ir, byId as never)),
85
+ );
86
+ expect(matches.map((n: { id: string }) => n.id)).toEqual(["webServer"]);
87
+ });
88
+
89
+ test("parses -> and <- into directional edge terms", () => {
90
+ expect(parseQuery("->kind:Subnet")).toEqual([{ kind: "edge", a: "", dir: "out", sub: { kind: "kind", a: "Subnet" } }]);
91
+ expect(parseQuery("<-kind:Instance")).toEqual([{ kind: "edge", a: "", dir: "in", sub: { kind: "kind", a: "Instance" } }]);
92
+ });
93
+
94
+ test("--explain footer: universe count + why the non-match was excluded", () => {
95
+ const byId = new Map((ir as { nodes: { id: string }[] }).nodes.map((n) => [n.id, n]));
96
+ const query = "kind:EC2::Instance ->attr:MapPublicIpOnLaunch=true";
97
+ const terms = parseQuery(query);
98
+ const matches = (ir as { nodes: never[] }).nodes.filter((n) => terms.every((t) => matchTerm(n as never, t, ir, byId as never)));
99
+ const lines: string[] = [];
100
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
101
+ explain(terms as never, matches as never, ir, byId as never, query);
102
+ spy.mockRestore();
103
+ // 1 of 2 Instances matched (webServer public, privServer excluded).
104
+ expect(lines[0]).toContain("1 of 2 AWS::EC2::Instance matched");
105
+ expect(lines.join("\n")).toContain("excluded privServer");
106
+ expect(lines.join("\n")).toContain("MapPublicIpOnLaunch=true");
107
+ });
108
+
109
+ test("describeTerm renders an edge term with direction and no-such-edge reason", () => {
110
+ expect(describeTerm({ kind: "edge", a: "", dir: "out", sub: { kind: "attr", a: "MapPublicIpOnLaunch", b: "true" } } as never))
111
+ .toBe("→attr:MapPublicIpOnLaunch=true (no such edge)");
112
+ });
113
+ });