@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.
- 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/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/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/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- 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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-chain classification (chant #1077).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources()` already lets a lexicon report a live object it never
|
|
5
|
+
* asked about by name — that is how `orphan` has always worked (a resource
|
|
6
|
+
* present in `observedNow` that is not in `declared`). Every existing consumer
|
|
7
|
+
* treats every such object the same way: undeclared, so a delete/adopt
|
|
8
|
+
* candidate.
|
|
9
|
+
*
|
|
10
|
+
* On Kubernetes that conflates two different things. A console-added SNS
|
|
11
|
+
* subscription (the AWS case #1014/#1015 was built for) really is out-of-band
|
|
12
|
+
* drift. A Pod a declared Deployment's controller created is not drift at
|
|
13
|
+
* all — it is the runtime doing its job, and it will be recreated the moment
|
|
14
|
+
* it is deleted. `ownerReferences` is what tells them apart: the Pod's chain
|
|
15
|
+
* of owners terminates at the Deployment, which is declared.
|
|
16
|
+
*
|
|
17
|
+
* This module owns the *category* — the four possible answers to "where does
|
|
18
|
+
* this object's owner chain lead" — and the pure algorithm that walks a chain
|
|
19
|
+
* to one of them. A lexicon supplies the chain (reading `ownerReferences`,
|
|
20
|
+
* possibly across several API reads to walk past an intermediate object chant
|
|
21
|
+
* never declared, e.g. a ReplicaSet between a Pod and its Deployment); this
|
|
22
|
+
* module supplies the bounded, cycle-safe interpretation, so that logic is
|
|
23
|
+
* written and tested once rather than once per lexicon.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Where a live, undeclared resource's owner-reference chain leads.
|
|
27
|
+
*
|
|
28
|
+
* - `declared` — the chain reaches an entity chant's own build declared. This
|
|
29
|
+
* is the whole point of #1077: the diff engine reads this as `runtime`, not
|
|
30
|
+
* `orphan`, and never proposes deleting it.
|
|
31
|
+
* - `unowned` — the resource carries no owner reference at all. A genuinely
|
|
32
|
+
* standalone live object; classifies as `orphan`, unchanged from before this
|
|
33
|
+
* module existed.
|
|
34
|
+
* - `foreign` — the chain fully resolves (every hop was readable, no cycle, no
|
|
35
|
+
* depth bound hit) but terminates at a live root that is not declared.
|
|
36
|
+
* Still `orphan` — it belongs to something real, just not to this build.
|
|
37
|
+
* - `unknown` — some hop could not be resolved: an unreadable owner, a cycle,
|
|
38
|
+
* or the depth bound. Composes with #1168's tri-state precedent: an owner
|
|
39
|
+
* chain chant could not fully verify is not a confirmed anything, so it is
|
|
40
|
+
* never escalated to `declared` and stays routed as `orphan` today, exactly
|
|
41
|
+
* as `foreign`/`unowned` are — never treated as a safer-than-warranted
|
|
42
|
+
* `runtime` classification just because the read was incomplete.
|
|
43
|
+
*/
|
|
44
|
+
export type OwnerChainVerdict = {
|
|
45
|
+
readonly root: "declared";
|
|
46
|
+
readonly entity: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly root: "unowned";
|
|
49
|
+
} | {
|
|
50
|
+
readonly root: "foreign";
|
|
51
|
+
} | {
|
|
52
|
+
readonly root: "unknown";
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* One node in the owner graph a lexicon assembles for {@link classifyOwnerChain}.
|
|
56
|
+
* Keyed externally (in the `nodes` map passed to the walk) by whatever stable
|
|
57
|
+
* identity the lexicon's provider uses — a Kubernetes UID, for instance.
|
|
58
|
+
*/
|
|
59
|
+
export interface OwnerChainNode {
|
|
60
|
+
/**
|
|
61
|
+
* This node's immediate owner, by its key in the same `nodes` map. Omit
|
|
62
|
+
* (`undefined`) when the object carries no owner reference at all — that is
|
|
63
|
+
* how a chain's *starting* node reports `unowned` rather than `unknown`.
|
|
64
|
+
*/
|
|
65
|
+
ownerId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* True when this node's own owner could not be determined — the read
|
|
68
|
+
* failed, was denied, or the object simply could not be fetched. Distinct
|
|
69
|
+
* from having no owner: this says "unknown", not "none".
|
|
70
|
+
*/
|
|
71
|
+
ownerUnreadable?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The declared chant entity name, when this node corresponds to one. A node
|
|
74
|
+
* with this set ends the walk immediately with `{ root: "declared" }` —
|
|
75
|
+
* whatever `ownerId`/`ownerUnreadable` it might also carry is irrelevant,
|
|
76
|
+
* since the chain already reached what it was looking for.
|
|
77
|
+
*/
|
|
78
|
+
declaredEntity?: string;
|
|
79
|
+
}
|
|
80
|
+
/** Default bound on how many owner hops {@link classifyOwnerChain} will walk
|
|
81
|
+
* before giving up conservatively. Kubernetes' own garbage collector does not
|
|
82
|
+
* bound this at all, but a live read has to — a bound this generous is well
|
|
83
|
+
* past any real ownership depth (Pod → ReplicaSet → Deployment is 2 hops) and
|
|
84
|
+
* exists only to turn a corrupt or adversarial chain into `unknown` rather
|
|
85
|
+
* than an infinite walk. */
|
|
86
|
+
export declare const DEFAULT_MAX_OWNER_CHAIN_DEPTH = 12;
|
|
87
|
+
/**
|
|
88
|
+
* Walk the owner chain starting at `startId` through `nodes`, bounded and
|
|
89
|
+
* cycle-safe. Pure — the caller has already done whatever I/O was needed to
|
|
90
|
+
* populate `nodes`; this function only interprets the graph it was given.
|
|
91
|
+
*
|
|
92
|
+
* `nodes` need not contain every ancestor: a node the caller never resolved
|
|
93
|
+
* (because it gave up, hit the caller's own fetch bound, or the read failed)
|
|
94
|
+
* is simply absent from the map, and a reference to an absent node classifies
|
|
95
|
+
* as `unknown` — the conservative answer, same as an explicit
|
|
96
|
+
* `ownerUnreadable`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function classifyOwnerChain(startId: string, nodes: ReadonlyMap<string, OwnerChainNode>, maxDepth?: number): OwnerChainVerdict;
|
|
99
|
+
//# sourceMappingURL=owner-chain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"owner-chain.d.ts","sourceRoot":"","sources":["../src/owner-chain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEjC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;4BAK4B;AAC5B,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,EAC1C,QAAQ,GAAE,MAAsC,GAC/C,iBAAiB,CA2BnB"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
3
|
+
import {
|
|
4
|
+
resolveCommandGroupVerb,
|
|
5
|
+
collectCommandGroups,
|
|
6
|
+
dispatchCommandGroup,
|
|
7
|
+
formatCommandGroupsHelp,
|
|
8
|
+
splitJoinedFlags,
|
|
9
|
+
unknownFlagError,
|
|
10
|
+
RESERVED_COMMAND_NAMES,
|
|
11
|
+
type CommandGroup,
|
|
12
|
+
} from "./command-group";
|
|
13
|
+
|
|
14
|
+
const noopAsync = async () => {};
|
|
15
|
+
|
|
16
|
+
/** Minimal LexiconPlugin — only the fields relevant to a given test. */
|
|
17
|
+
function makePlugin(name: string, group?: CommandGroup): LexiconPlugin {
|
|
18
|
+
const plugin: LexiconPlugin = {
|
|
19
|
+
name,
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
serializer: { name, serialize: () => "" } as any,
|
|
22
|
+
generate: noopAsync,
|
|
23
|
+
validate: noopAsync,
|
|
24
|
+
coverage: noopAsync,
|
|
25
|
+
package: noopAsync,
|
|
26
|
+
};
|
|
27
|
+
if (group) plugin.commands = () => group;
|
|
28
|
+
return plugin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeGroup(overrides: Partial<CommandGroup> = {}): CommandGroup {
|
|
32
|
+
return {
|
|
33
|
+
name: "kube",
|
|
34
|
+
description: "Kubernetes verb group",
|
|
35
|
+
commands: [
|
|
36
|
+
{ name: "get", description: "Get resources", handler: async () => 0 },
|
|
37
|
+
{ name: "version", description: "Print schema version", handler: async () => 0 },
|
|
38
|
+
],
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("resolveCommandGroupVerb", () => {
|
|
44
|
+
test("mounts: finds the group and verb contributed by a plugin", () => {
|
|
45
|
+
const group = makeGroup();
|
|
46
|
+
const plugins = [makePlugin("k8s", group)];
|
|
47
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
48
|
+
expect(result.kind).toBe("matched");
|
|
49
|
+
if (result.kind === "matched") {
|
|
50
|
+
expect(result.plugin.name).toBe("k8s");
|
|
51
|
+
expect(result.group).toBe(group);
|
|
52
|
+
expect(result.command.name).toBe("get");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("no-capability lexicon is unaffected: a plugin with no commands() is skipped", () => {
|
|
57
|
+
const plugins = [makePlugin("aws"), makePlugin("k8s", makeGroup())];
|
|
58
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
59
|
+
expect(result.kind).toBe("matched");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns no-group when nothing claims the namespace", () => {
|
|
63
|
+
const plugins = [makePlugin("aws"), makePlugin("gcp")];
|
|
64
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
65
|
+
expect(result.kind).toBe("no-group");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns no-group for an empty plugin list", () => {
|
|
69
|
+
expect(resolveCommandGroupVerb([], "kube", "get").kind).toBe("no-group");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns unknown-verb when the group matches but the verb doesn't", () => {
|
|
73
|
+
const plugins = [makePlugin("k8s", makeGroup())];
|
|
74
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "bogus");
|
|
75
|
+
expect(result.kind).toBe("unknown-verb");
|
|
76
|
+
if (result.kind === "unknown-verb") {
|
|
77
|
+
expect(result.group.name).toBe("kube");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns no-verb when the group matches and no verb was given", () => {
|
|
82
|
+
const plugins = [makePlugin("k8s", makeGroup())];
|
|
83
|
+
const result = resolveCommandGroupVerb(plugins, "kube", undefined);
|
|
84
|
+
expect(result.kind).toBe("no-verb");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("never invokes a verb's handler while resolving — registration is data, not execution", () => {
|
|
88
|
+
let invoked = false;
|
|
89
|
+
const group: CommandGroup = {
|
|
90
|
+
name: "kube",
|
|
91
|
+
description: "d",
|
|
92
|
+
commands: [{ name: "get", description: "d", handler: async () => { invoked = true; return 0; } }],
|
|
93
|
+
};
|
|
94
|
+
resolveCommandGroupVerb([makePlugin("k8s", group)], "kube", "get");
|
|
95
|
+
expect(invoked).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("collectCommandGroups", () => {
|
|
100
|
+
test("lists groups from plugins in order; skips plugins without one", () => {
|
|
101
|
+
const g1 = makeGroup({ name: "kube" });
|
|
102
|
+
const g2 = makeGroup({ name: "flycmd", description: "Fly verb group" });
|
|
103
|
+
const groups = collectCommandGroups([makePlugin("aws"), makePlugin("k8s", g1), makePlugin("fly", g2)]);
|
|
104
|
+
expect(groups).toEqual([g1, g2]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("empty when no plugin contributes a group — an absent slot changes nothing", () => {
|
|
108
|
+
expect(collectCommandGroups([makePlugin("aws"), makePlugin("gcp")])).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("dispatchCommandGroup", () => {
|
|
113
|
+
test("dispatches: runs the matched verb's handler and returns its exit code", async () => {
|
|
114
|
+
let seenCtx: unknown;
|
|
115
|
+
const group: CommandGroup = {
|
|
116
|
+
name: "kube",
|
|
117
|
+
description: "d",
|
|
118
|
+
commands: [
|
|
119
|
+
{
|
|
120
|
+
name: "get",
|
|
121
|
+
description: "d",
|
|
122
|
+
handler: async (ctx) => {
|
|
123
|
+
seenCtx = ctx;
|
|
124
|
+
return 3;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", group)], "kube", "get", ["pods", "-o", "wide"]);
|
|
130
|
+
expect(result).toEqual({ kind: "ran", exitCode: 3 });
|
|
131
|
+
expect(seenCtx).toEqual({ verb: "get", rawArgs: ["pods", "-o", "wide"] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("propagates a no-group result unchanged", async () => {
|
|
135
|
+
const result = await dispatchCommandGroup([makePlugin("aws")], "kube", "get", []);
|
|
136
|
+
expect(result).toEqual({ kind: "no-group" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("unknown verb produces a usage-error listing the group's real verbs", async () => {
|
|
140
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", makeGroup())], "kube", "bogus", []);
|
|
141
|
+
expect(result.kind).toBe("usage-error");
|
|
142
|
+
if (result.kind === "usage-error") {
|
|
143
|
+
expect(result.message).toMatch(/Unknown kube subcommand: bogus/);
|
|
144
|
+
expect(result.hint).toMatch(/get/);
|
|
145
|
+
expect(result.hint).toMatch(/version/);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("bare group with no verb produces a usage-error, not a crash", async () => {
|
|
150
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", makeGroup())], "kube", undefined, []);
|
|
151
|
+
expect(result.kind).toBe("usage-error");
|
|
152
|
+
if (result.kind === "usage-error") {
|
|
153
|
+
expect(result.message).toMatch(/Usage: chant kube <verb>/);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("formatCommandGroupsHelp", () => {
|
|
159
|
+
test("composes group + verb listing for --help", () => {
|
|
160
|
+
const text = formatCommandGroupsHelp([makeGroup()]);
|
|
161
|
+
expect(text).toMatch(/Lexicon commands:/);
|
|
162
|
+
expect(text).toMatch(/kube/);
|
|
163
|
+
expect(text).toMatch(/get/);
|
|
164
|
+
expect(text).toMatch(/version/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("empty string when there are no groups", () => {
|
|
168
|
+
expect(formatCommandGroupsHelp([])).toBe("");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("splitJoinedFlags (#1127 discipline, reused by mounted commands)", () => {
|
|
173
|
+
test("splits a joined --flag=value token into two elements", () => {
|
|
174
|
+
expect(splitJoinedFlags(["--format=json"])).toEqual(["--format", "json"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("splits only at the first =, preserving a value that itself contains =", () => {
|
|
178
|
+
expect(splitJoinedFlags(["--selector=env=prod"])).toEqual(["--selector", "env=prod"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("leaves non-joined tokens untouched", () => {
|
|
182
|
+
expect(splitJoinedFlags(["get", "pods", "-o", "wide"])).toEqual(["get", "pods", "-o", "wide"]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws when a declared boolean flag is given a joined value", () => {
|
|
186
|
+
expect(() => splitJoinedFlags(["--watch=true"], new Set(["--watch"]))).toThrow(/--watch is a boolean flag/);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("unknownFlagError (mounted-command unknown-flag error)", () => {
|
|
191
|
+
test("produces the same 'Unknown flag' message shape core's own parser uses", () => {
|
|
192
|
+
const err = unknownFlagError("--bogus");
|
|
193
|
+
expect(err.message).toMatch(/^Unknown flag: --bogus/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("accepts a custom hint for the mounted command's own usage", () => {
|
|
197
|
+
const err = unknownFlagError("--bogus", "chant kube version only accepts --format.");
|
|
198
|
+
expect(err.message).toMatch(/only accepts --format/);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("RESERVED_COMMAND_NAMES", () => {
|
|
203
|
+
test("includes every core top-level word a lexicon must not shadow", () => {
|
|
204
|
+
for (const name of ["build", "lint", "run", "emulator", "lifecycle", "components", "serve", "dev", "carve"]) {
|
|
205
|
+
expect(RESERVED_COMMAND_NAMES.has(name)).toBe(true);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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
|
}
|
|
@@ -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}`);
|