@intentius/chant 0.30.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.
- package/dist/cli/command-group.d.ts +134 -0
- package/dist/cli/command-group.d.ts.map +1 -0
- package/dist/cli/conflict-check.d.ts +1 -1
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts +16 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +18 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +40 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +15 -7
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +118 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/dist/owner-chain.d.ts +99 -0
- package/dist/owner-chain.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/command-group.test.ts +208 -0
- package/src/cli/command-group.ts +199 -0
- package/src/cli/conflict-check.test.ts +36 -1
- package/src/cli/conflict-check.ts +22 -1
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/main.ts +107 -27
- package/src/codegen/generate.ts +25 -0
- package/src/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +22 -2
- package/src/lexicon.ts +44 -0
- package/src/lifecycle/change-set.test.ts +100 -0
- package/src/lifecycle/change-set.ts +39 -10
- package/src/lifecycle/live-diff.test.ts +88 -0
- package/src/lifecycle/live-diff.ts +55 -8
- package/src/managed-fields.test.ts +179 -0
- package/src/managed-fields.ts +328 -0
- package/src/owner-chain.test.ts +97 -0
- package/src/owner-chain.ts +128 -0
|
@@ -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
|
}
|
|
@@ -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}`);
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
package/src/codegen/generate.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface GenerateResult {
|
|
|
27
27
|
properties: number;
|
|
28
28
|
enums: number;
|
|
29
29
|
warnings: Array<{ file: string; error: string }>;
|
|
30
|
+
/**
|
|
31
|
+
* Additional generated files, keyed by filename, produced by the optional
|
|
32
|
+
* {@link GeneratePipelineConfig.generateExtraArtifacts} hook. They come out
|
|
33
|
+
* of the same parse a lexicon's types and registry come out of, which is the
|
|
34
|
+
* point: an artifact derived here cannot drift from the types, the way a
|
|
35
|
+
* hand-maintained table beside them can (chant #1074's operation surface is
|
|
36
|
+
* the first of these).
|
|
37
|
+
*/
|
|
38
|
+
extraArtifacts?: Record<string, string>;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -68,6 +77,14 @@ export interface GeneratePipelineConfig<T extends ParsedResult> {
|
|
|
68
77
|
/** Generate runtime index with factory exports. */
|
|
69
78
|
generateRuntimeIndex: (results: T[], naming: NamingStrategy) => string;
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Optional extra artifacts from the same parsed results — filename → content.
|
|
82
|
+
* Used when a lexicon needs a second derived table alongside the registry and
|
|
83
|
+
* the types, and needs it to come from the same pass so the three cannot
|
|
84
|
+
* skew.
|
|
85
|
+
*/
|
|
86
|
+
generateExtraArtifacts?: (results: T[], naming: NamingStrategy) => Record<string, string>;
|
|
87
|
+
|
|
71
88
|
/** Optional pre-parse hook (patches, overlays, extra resources, etc.). */
|
|
72
89
|
augmentSchemas?: (
|
|
73
90
|
schemas: Map<string, Buffer>,
|
|
@@ -161,6 +178,13 @@ export async function generatePipeline<T extends ParsedResult>(
|
|
|
161
178
|
log("Generating runtime index...");
|
|
162
179
|
const indexTS = config.generateRuntimeIndex(results, naming);
|
|
163
180
|
|
|
181
|
+
let extraArtifacts: Record<string, string> | undefined;
|
|
182
|
+
if (config.generateExtraArtifacts) {
|
|
183
|
+
log("Generating extra artifacts...");
|
|
184
|
+
extraArtifacts = config.generateExtraArtifacts(results, naming);
|
|
185
|
+
log(`Generated ${Object.keys(extraArtifacts).length} extra artifact(s)`);
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
// Count stats
|
|
165
189
|
let resourceCount = 0;
|
|
166
190
|
let propertyCount = 0;
|
|
@@ -179,6 +203,7 @@ export async function generatePipeline<T extends ParsedResult>(
|
|
|
179
203
|
properties: propertyCount,
|
|
180
204
|
enums: enumCount,
|
|
181
205
|
warnings,
|
|
206
|
+
...(extraArtifacts ? { extraArtifacts } : {}),
|
|
182
207
|
};
|
|
183
208
|
}
|
|
184
209
|
|
|
@@ -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
|
-
|
|
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
|
-
* - **
|
|
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) =>
|
|
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
|
|
601
|
-
* `warn
|
|
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)
|
|
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/kubectl-context.ts
CHANGED
|
@@ -72,8 +72,18 @@ export interface ResolvedClusterTarget {
|
|
|
72
72
|
source: "bound" | "ambient";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* How the resolver learns which context is ambient. The default shells
|
|
77
|
+
* `kubectl config current-context`; the k8s lexicon's typed API client
|
|
78
|
+
* (chant #1074) supplies one that reads the parsed kubeconfig instead, so a
|
|
79
|
+
* client that never needs the `kubectl` binary does not acquire a dependency
|
|
80
|
+
* on it just to check the binding. Both answer the same question, so the
|
|
81
|
+
* refusal semantics below are identical either way.
|
|
82
|
+
*/
|
|
83
|
+
export type AmbientContextReader = () => Promise<string | undefined>;
|
|
84
|
+
|
|
75
85
|
/** Reads `kubectl config current-context`. Returns undefined if unset or kubectl fails. */
|
|
76
|
-
|
|
86
|
+
const currentAmbientContext: AmbientContextReader = async () => {
|
|
77
87
|
try {
|
|
78
88
|
const { stdout } = await execAsync("kubectl config current-context");
|
|
79
89
|
const trimmed = stdout.trim();
|
|
@@ -81,6 +91,15 @@ async function currentAmbientContext(): Promise<string | undefined> {
|
|
|
81
91
|
} catch {
|
|
82
92
|
return undefined;
|
|
83
93
|
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Options for {@link resolveClusterTarget}. */
|
|
97
|
+
export interface ResolveClusterTargetOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Override how the ambient context is read. Defaults to
|
|
100
|
+
* `kubectl config current-context`.
|
|
101
|
+
*/
|
|
102
|
+
ambientContext?: AmbientContextReader;
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
/**
|
|
@@ -105,6 +124,7 @@ export async function resolveClusterTarget(
|
|
|
105
124
|
config: Record<string, unknown>,
|
|
106
125
|
environment: string,
|
|
107
126
|
lexiconName: string,
|
|
127
|
+
options: ResolveClusterTargetOptions = {},
|
|
108
128
|
): Promise<ResolvedClusterTarget> {
|
|
109
129
|
const k8sConfig = config.k8s as K8sConfigShape | undefined;
|
|
110
130
|
const bound = k8sConfig?.profiles?.[environment]?.context;
|
|
@@ -118,7 +138,7 @@ export async function resolveClusterTarget(
|
|
|
118
138
|
return { source: "ambient" };
|
|
119
139
|
}
|
|
120
140
|
|
|
121
|
-
const ambient = await currentAmbientContext();
|
|
141
|
+
const ambient = await (options.ambientContext ?? currentAmbientContext)();
|
|
122
142
|
if (ambient && ambient !== bound) {
|
|
123
143
|
throw new ClusterBindingMismatchError(environment, bound, ambient);
|
|
124
144
|
}
|
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
|
/**
|