@intentius/chant 0.37.2 → 0.38.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/commands/check-lexicon-mcp.d.ts +44 -0
- package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +26 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/lexicon.d.ts +66 -37
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +21 -22
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/op/emulator-freshness.d.ts +44 -0
- package/dist/op/emulator-freshness.d.ts.map +1 -0
- package/dist/op/emulator-lifecycle.d.ts +36 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/serializer.d.ts +15 -0
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +58 -6
- package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
- package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
- package/src/cli/commands/check-lexicon-mcp.ts +103 -0
- package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
- package/src/cli/commands/check-lexicon-plugin.ts +115 -0
- package/src/cli/commands/check-lexicon.ts +157 -26
- package/src/cli/handlers/components.test.ts +17 -0
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/emulator.ts +12 -8
- package/src/cli/handlers/graph.test.ts +71 -12
- package/src/cli/handlers/graph.ts +46 -5
- package/src/cli/handlers/lifecycle.test.ts +25 -4
- package/src/cli/handlers/lifecycle.ts +9 -3
- package/src/cli/mcp/server.test.ts +82 -0
- package/src/cli/mcp/server.ts +40 -5
- package/src/lexicon-doc-coverage.test.ts +128 -0
- package/src/lexicon-seams.test.ts +113 -0
- package/src/lexicon.ts +68 -38
- package/src/live-endpoint.test.ts +51 -12
- package/src/live-endpoint.ts +32 -33
- package/src/op/emulator-declaration.test.ts +63 -0
- package/src/op/emulator-freshness.test.ts +135 -0
- package/src/op/emulator-freshness.ts +102 -0
- package/src/op/emulator-lifecycle.ts +49 -0
- package/src/op/index.ts +4 -2
- package/src/ownership.ts +41 -0
- package/src/serializer.ts +16 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading a lexicon's plugin so completeness checks can look at what it
|
|
3
|
+
* actually registers (#1342).
|
|
4
|
+
*
|
|
5
|
+
* `check-lexicon`'s checks were almost all existence assertions over the
|
|
6
|
+
* directory: `src/lsp/completions.ts exists`, `At least 1 lint rule in
|
|
7
|
+
* src/lint/rules/`. A file with the right name is not the contract — the
|
|
8
|
+
* contract is the member the plugin exposes, because that is what core
|
|
9
|
+
* dispatches through. helm is the proof: it ships `src/lsp/completions.ts` and
|
|
10
|
+
* `src/lsp/hover.ts`, both with passing tests, exports `helmCompletions` and
|
|
11
|
+
* `helmHover` from its index, and never sets `completionProvider` or
|
|
12
|
+
* `hoverProvider` on the plugin. `cli/lsp/server.ts` dispatches through exactly
|
|
13
|
+
* those fields, so helm's LSP support is unreachable in an editor — while tier 1
|
|
14
|
+
* (the files) and tier 2 (the tests) both passed.
|
|
15
|
+
*
|
|
16
|
+
* Resolution is directory-local rather than by package name: `chant dev
|
|
17
|
+
* check-lexicon <dir>` should work on a lexicon that is not installed, and
|
|
18
|
+
* `loadPlugin` in ../plugins.ts imports `@intentius/chant-lexicon-<name>`,
|
|
19
|
+
* which requires it to be.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "fs";
|
|
23
|
+
import { isAbsolute, join, resolve } from "path";
|
|
24
|
+
import { pathToFileURL } from "url";
|
|
25
|
+
import { isLexiconPlugin, type LexiconPlugin } from "../../lexicon";
|
|
26
|
+
|
|
27
|
+
export interface LoadedLexicon {
|
|
28
|
+
/** The plugin, when the package exported one. */
|
|
29
|
+
plugin?: LexiconPlugin;
|
|
30
|
+
/** Why loading failed, for a check's `detail`. */
|
|
31
|
+
error?: string;
|
|
32
|
+
/** The entry point that was imported, for diagnostics. */
|
|
33
|
+
entry?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The module a lexicon package presents to consumers.
|
|
38
|
+
*
|
|
39
|
+
* Tier 1 requires `exports["."].default` to be `./src/index.ts`, so that is the
|
|
40
|
+
* first choice — it is the module core itself imports. The fallbacks keep this
|
|
41
|
+
* usable on a lexicon that has not reached that check yet.
|
|
42
|
+
*/
|
|
43
|
+
export function pluginEntryFor(dir: string): string | undefined {
|
|
44
|
+
const candidates: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as {
|
|
47
|
+
exports?: { "."?: { default?: string } };
|
|
48
|
+
main?: string;
|
|
49
|
+
};
|
|
50
|
+
for (const declared of [pkg.exports?.["."]?.default, pkg.main]) {
|
|
51
|
+
if (typeof declared === "string") candidates.push(declared);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// no package.json, or unreadable — fall through to the conventional paths
|
|
55
|
+
}
|
|
56
|
+
candidates.push("./src/index.ts", "./src/plugin.ts");
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const path = isAbsolute(candidate) ? candidate : resolve(dir, candidate);
|
|
60
|
+
if (existsSync(path)) return path;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Import a lexicon directory and return the `LexiconPlugin` it exports.
|
|
67
|
+
*
|
|
68
|
+
* Never throws: a lexicon that cannot be loaded is a finding, not a crash, and
|
|
69
|
+
* every caller reports it as a failed check rather than aborting the run.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadLexiconFromDir(dir: string): Promise<LoadedLexicon> {
|
|
72
|
+
const entry = pluginEntryFor(dir);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
return { error: "no importable entry point (looked for package.json exports, src/index.ts, src/plugin.ts)" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let mod: Record<string, unknown>;
|
|
78
|
+
try {
|
|
79
|
+
mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown>;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { entry, error: `import failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const value of Object.values(mod)) {
|
|
85
|
+
if (isLexiconPlugin(value)) return { plugin: value, entry };
|
|
86
|
+
}
|
|
87
|
+
// A default export whose own members are the plugin (some lexicons re-export
|
|
88
|
+
// a namespace rather than the object itself).
|
|
89
|
+
const fallback = (mod.default ?? {}) as Record<string, unknown>;
|
|
90
|
+
for (const value of Object.values(fallback)) {
|
|
91
|
+
if (isLexiconPlugin(value)) return { plugin: value, entry };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { entry, error: "the module exports no LexiconPlugin" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the plugin exposes a callable member under `name`. */
|
|
98
|
+
export function registers(plugin: LexiconPlugin | undefined, name: keyof LexiconPlugin): boolean {
|
|
99
|
+
return typeof plugin?.[name] === "function";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Call a plugin member that returns a list, treating a throw as an empty list.
|
|
104
|
+
*
|
|
105
|
+
* A member that throws is not a registration — it is worse than an absent one,
|
|
106
|
+
* and the check that counts its results should fail rather than the whole run.
|
|
107
|
+
*/
|
|
108
|
+
export function safeList<T>(fn: (() => T[]) | undefined): { items: T[]; error?: string } {
|
|
109
|
+
if (typeof fn !== "function") return { items: [] };
|
|
110
|
+
try {
|
|
111
|
+
return { items: fn() ?? [] };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { items: [], error: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -3,6 +3,9 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { auditIntrinsics } from "./check-lexicon-intrinsics";
|
|
4
4
|
import { checkExamplesBuild } from "./check-lexicon-examples";
|
|
5
5
|
import { auditDocsReachability } from "./check-lexicon-docs";
|
|
6
|
+
import { auditMcpNames } from "./check-lexicon-mcp";
|
|
7
|
+
import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
|
|
8
|
+
import { RULE_CATALOG } from "../../audit/catalog";
|
|
6
9
|
|
|
7
10
|
// ── Types ────────────────────────────────────────────────────────────
|
|
8
11
|
|
|
@@ -73,45 +76,100 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
73
76
|
|
|
74
77
|
// ── Tier 1: Required ───────────────────────────────────────────
|
|
75
78
|
|
|
79
|
+
// #1342 — a capability is present when the plugin exposes it, not when a file
|
|
80
|
+
// with the right name sits on disk. The checks below used to be existence
|
|
81
|
+
// assertions, which is how helm shipped `src/lsp/completions.ts` and
|
|
82
|
+
// `src/lsp/hover.ts` (with tests) while registering neither provider, and
|
|
83
|
+
// passed every tier. `cli/lsp/server.ts` dispatches through the plugin
|
|
84
|
+
// members, so those files were unreachable in an editor.
|
|
85
|
+
const loaded = await loadLexiconFromDir(dir);
|
|
86
|
+
const plugin = loaded.plugin;
|
|
87
|
+
|
|
88
|
+
items.push({
|
|
89
|
+
name: "The package exports a LexiconPlugin",
|
|
90
|
+
tier: 1,
|
|
91
|
+
pass: plugin !== undefined,
|
|
92
|
+
detail: loaded.error ?? (loaded.entry ? `exported by ${basename(loaded.entry)}` : undefined),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const serializer = plugin?.serializer as { name?: unknown; rulePrefix?: unknown; serialize?: unknown } | undefined;
|
|
96
|
+
const serializerOk =
|
|
97
|
+
typeof serializer?.name === "string" &&
|
|
98
|
+
typeof serializer?.rulePrefix === "string" &&
|
|
99
|
+
typeof serializer?.serialize === "function";
|
|
76
100
|
items.push({
|
|
77
|
-
name: "
|
|
101
|
+
name: "The plugin exposes a Serializer with a name and rule prefix",
|
|
78
102
|
tier: 1,
|
|
79
|
-
pass:
|
|
103
|
+
pass: serializerOk,
|
|
104
|
+
detail: serializerOk
|
|
105
|
+
? `${String(serializer?.name)} (${String(serializer?.rulePrefix)})`
|
|
106
|
+
: plugin
|
|
107
|
+
? "serializer is missing name, rulePrefix, or serialize"
|
|
108
|
+
: undefined,
|
|
80
109
|
});
|
|
81
110
|
|
|
111
|
+
const lintRules = safeList(plugin?.lintRules?.bind(plugin));
|
|
82
112
|
items.push({
|
|
83
|
-
name: "
|
|
113
|
+
name: "lintRules() returns at least 1 rule",
|
|
84
114
|
tier: 1,
|
|
85
|
-
pass:
|
|
115
|
+
pass: lintRules.items.length > 0,
|
|
116
|
+
detail: lintRules.error ? `threw: ${lintRules.error}` : `${lintRules.items.length} rule(s)`,
|
|
86
117
|
});
|
|
87
118
|
|
|
88
|
-
const
|
|
119
|
+
const postSynthChecks = safeList(plugin?.postSynthChecks?.bind(plugin));
|
|
89
120
|
items.push({
|
|
90
|
-
name: "
|
|
121
|
+
name: "postSynthChecks() returns at least 1 check",
|
|
91
122
|
tier: 1,
|
|
92
|
-
pass:
|
|
93
|
-
detail:
|
|
123
|
+
pass: postSynthChecks.items.length > 0,
|
|
124
|
+
detail: postSynthChecks.error
|
|
125
|
+
? `threw: ${postSynthChecks.error}`
|
|
126
|
+
: `${postSynthChecks.items.length} check(s)`,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// #1349 — `rulePrefix` exists so ids do not collide when several lexicons are
|
|
130
|
+
// loaded together (forgejo wraps github's rules as `WFJ-GHA0xx` for exactly
|
|
131
|
+
// that reason), and it was checked by nothing. k8s shipped five `ARGO0xx`
|
|
132
|
+
// checks outside its declared `WK8`. Core's cross-cutting ids are exempt:
|
|
133
|
+
// they belong to core, not to whichever lexicon surfaces them.
|
|
134
|
+
const declaredPrefixes = [
|
|
135
|
+
typeof serializer?.rulePrefix === "string" ? serializer.rulePrefix : "",
|
|
136
|
+
...((plugin?.serializer as { extraRulePrefixes?: readonly string[] } | undefined)?.extraRulePrefixes ?? []),
|
|
137
|
+
].filter((p) => p.length > 0);
|
|
138
|
+
const allRuleIds = [
|
|
139
|
+
...lintRules.items.map((r) => (r as { id?: string }).id),
|
|
140
|
+
...postSynthChecks.items.map((c) => (c as { id?: string }).id),
|
|
141
|
+
].filter((id): id is string => typeof id === "string");
|
|
142
|
+
const offPrefix = allRuleIds.filter(
|
|
143
|
+
(id) => !(id in RULE_CATALOG) && !declaredPrefixes.some((p) => id.startsWith(p)),
|
|
144
|
+
);
|
|
145
|
+
items.push({
|
|
146
|
+
name: "Every rule id starts with a declared rule prefix",
|
|
147
|
+
tier: 1,
|
|
148
|
+
pass: declaredPrefixes.length > 0 && offPrefix.length === 0,
|
|
149
|
+
detail:
|
|
150
|
+
offPrefix.length > 0
|
|
151
|
+
? `${offPrefix.length} outside ${declaredPrefixes.join("/")}: ${[...new Set(offPrefix)].slice(0, 6).join(", ")}`
|
|
152
|
+
: declaredPrefixes.length > 0
|
|
153
|
+
? `${allRuleIds.length} id(s) under ${declaredPrefixes.join("/")}`
|
|
154
|
+
: "no rule prefix declared",
|
|
94
155
|
});
|
|
95
156
|
|
|
96
|
-
const postSynthFiles = listTsFiles(join(dir, "src/lint/post-synth"), ["index.ts"])
|
|
97
|
-
.filter((f) => !f.endsWith("-helpers.ts") && f !== "helpers.ts");
|
|
98
157
|
items.push({
|
|
99
|
-
name: "
|
|
158
|
+
name: "The plugin registers completionProvider",
|
|
100
159
|
tier: 1,
|
|
101
|
-
pass:
|
|
102
|
-
detail: postSynthFiles.length > 0 ? `${postSynthFiles.length} check(s)` : undefined,
|
|
160
|
+
pass: registers(plugin, "completionProvider"),
|
|
103
161
|
});
|
|
104
162
|
|
|
105
163
|
items.push({
|
|
106
|
-
name: "
|
|
164
|
+
name: "The plugin registers hoverProvider",
|
|
107
165
|
tier: 1,
|
|
108
|
-
pass:
|
|
166
|
+
pass: registers(plugin, "hoverProvider"),
|
|
109
167
|
});
|
|
110
168
|
|
|
111
169
|
items.push({
|
|
112
|
-
name: "
|
|
170
|
+
name: "The plugin registers docs()",
|
|
113
171
|
tier: 1,
|
|
114
|
-
pass:
|
|
172
|
+
pass: registers(plugin, "docs"),
|
|
115
173
|
});
|
|
116
174
|
|
|
117
175
|
items.push({
|
|
@@ -262,6 +320,24 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
262
320
|
: undefined,
|
|
263
321
|
});
|
|
264
322
|
|
|
323
|
+
// #1341 — core namespaces MCP contributions, and so do the shared helpers and
|
|
324
|
+
// most lexicons, so the names agents actually saw were `gitlab:gitlab:diff`
|
|
325
|
+
// and `chant://azure/chant://lexicon/azure/catalog`. The check is on the
|
|
326
|
+
// registered name rather than the declared one: three authored forms are in
|
|
327
|
+
// use and all of them are fine, but only one registered shape is.
|
|
328
|
+
const mcpNames = await auditMcpNames(dir);
|
|
329
|
+
items.push({
|
|
330
|
+
name: "MCP tools and resources register under one well-formed namespace",
|
|
331
|
+
tier: 1,
|
|
332
|
+
pass: mcpNames.violations.length === 0,
|
|
333
|
+
detail:
|
|
334
|
+
mcpNames.violations.length > 0
|
|
335
|
+
? mcpNames.violations.join(" | ")
|
|
336
|
+
: mcpNames.loaded
|
|
337
|
+
? `${mcpNames.checked} contribution(s) checked`
|
|
338
|
+
: "lexicon could not be loaded — not checked",
|
|
339
|
+
});
|
|
340
|
+
|
|
265
341
|
const hasPluginTest = findFiles(join(dir, "src"), (n) => n === "plugin.test.ts").length > 0;
|
|
266
342
|
items.push({
|
|
267
343
|
name: "plugin.test.ts exists",
|
|
@@ -302,21 +378,76 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
|
|
|
302
378
|
|
|
303
379
|
const pluginContent = readOr(join(dir, "src/plugin.ts"));
|
|
304
380
|
|
|
381
|
+
// #1342 — these were a regex over plugin.ts source text, which passed on a
|
|
382
|
+
// method declared in a form the regex happened to match and on one that
|
|
383
|
+
// throws when called. Ask the plugin instead.
|
|
305
384
|
for (const method of ["mcpTools", "mcpResources", "skills", "detectTemplate", "initTemplates"] as const) {
|
|
306
|
-
// Check for uncommented method: line starts with optional whitespace, then the method name
|
|
307
|
-
// Exclude lines that start with // or * (comment blocks)
|
|
308
|
-
const lines = pluginContent.split("\n");
|
|
309
|
-
const hasUncommented = lines.some((line) => {
|
|
310
|
-
const trimmed = line.trim();
|
|
311
|
-
return trimmed.startsWith(`${method}(`) || trimmed.startsWith(`${method} (`);
|
|
312
|
-
});
|
|
313
385
|
items.push({
|
|
314
|
-
name: `plugin
|
|
386
|
+
name: `The plugin registers ${method}`,
|
|
315
387
|
tier: 2,
|
|
316
|
-
pass:
|
|
388
|
+
pass: registers(plugin, method),
|
|
317
389
|
});
|
|
318
390
|
}
|
|
319
391
|
|
|
392
|
+
// #1346 — `resolveAuditCatalog` contributes nothing for a lexicon that omits
|
|
393
|
+
// the method, silently, so its checks surface in `chant audit` with no title,
|
|
394
|
+
// tier, fix kind, or category. Tier 2 rather than tier 1: the lexicon builds
|
|
395
|
+
// and lints correctly without it; what suffers is one command's output.
|
|
396
|
+
const auditCatalog = (() => {
|
|
397
|
+
try {
|
|
398
|
+
return plugin?.auditCatalog?.() ?? {};
|
|
399
|
+
} catch {
|
|
400
|
+
return {};
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
const uncatalogued = postSynthChecks.items
|
|
404
|
+
.map((c) => (c as { id?: string }).id)
|
|
405
|
+
.filter((id): id is string => typeof id === "string" && !(id in auditCatalog) && !(id in RULE_CATALOG));
|
|
406
|
+
items.push({
|
|
407
|
+
name: "auditCatalog() covers every post-synth check",
|
|
408
|
+
tier: 2,
|
|
409
|
+
pass: uncatalogued.length === 0,
|
|
410
|
+
detail:
|
|
411
|
+
uncatalogued.length > 0
|
|
412
|
+
? `${uncatalogued.length} without metadata: ${uncatalogued.slice(0, 6).join(", ")}`
|
|
413
|
+
: `${Object.keys(auditCatalog).length} entry/entries`,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// #1348 — the marker channel is a claim: declaring `reads: ["exportResources"]`
|
|
417
|
+
// while not implementing `exportResources` promises a verdict from a path
|
|
418
|
+
// that does not exist. The behavioral half lives in the observation
|
|
419
|
+
// conformance suite, which holds a declared path to a real verdict and an
|
|
420
|
+
// undeclared one to `unknown`; this is the static half.
|
|
421
|
+
const channel = plugin?.ownershipChannel;
|
|
422
|
+
const channelProblems: string[] = [];
|
|
423
|
+
if (channel) {
|
|
424
|
+
for (const path of channel.reads) {
|
|
425
|
+
if (!registers(plugin, path as keyof typeof plugin)) {
|
|
426
|
+
channelProblems.push(`declares a marker channel on ${path}, which the plugin does not implement`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const keys = channel.keys as { managedBy?: unknown; stack?: unknown; env?: unknown } | undefined;
|
|
430
|
+
for (const key of ["managedBy", "stack", "env"] as const) {
|
|
431
|
+
if (typeof keys?.[key] !== "string" || (keys[key] as string).length === 0) {
|
|
432
|
+
channelProblems.push(`marker keys are missing ${key}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (channel.reads.length === 0) {
|
|
436
|
+
channelProblems.push("declares marker keys but no read path — nothing can resolve a verdict");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
items.push({
|
|
440
|
+
name: "Any declared ownership channel names paths the plugin implements",
|
|
441
|
+
tier: 2,
|
|
442
|
+
pass: channelProblems.length === 0,
|
|
443
|
+
detail:
|
|
444
|
+
channelProblems.length > 0
|
|
445
|
+
? channelProblems.join("; ")
|
|
446
|
+
: channel
|
|
447
|
+
? `marker on ${channel.reads.join(", ")}`
|
|
448
|
+
: "no marker channel — every verdict must be unknown",
|
|
449
|
+
});
|
|
450
|
+
|
|
320
451
|
const compositeFiles = listTsFiles(join(dir, "src/composites"), ["index.ts"]);
|
|
321
452
|
items.push({
|
|
322
453
|
name: "At least 1 composite in src/composites/",
|
|
@@ -4,6 +4,18 @@ import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
|
|
|
4
4
|
import type { BuildResult } from "../../build";
|
|
5
5
|
import type { ParsedArgs } from "../registry";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The aws emulator capability, as the real plugin declares it. `--live`
|
|
9
|
+
* endpoint injection reads the endpoint var off this rather than off a map
|
|
10
|
+
* keyed by lexicon name (#1345), so a mock that omits it gets no injection —
|
|
11
|
+
* the same thing that would happen in production.
|
|
12
|
+
*/
|
|
13
|
+
const awsEmulatorStub = {
|
|
14
|
+
spec: { name: "chant-floci", image: "floci/floci:1.5.34", containerPort: 4566, healthPath: "/_localstack/health" },
|
|
15
|
+
env: (endpoint: string) => ({ AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test" }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
|
|
7
19
|
const getHeadCommitMock = vi.fn();
|
|
8
20
|
const fetchLifecycleMock = vi.fn();
|
|
9
21
|
const pushLifecycleMock = vi.fn();
|
|
@@ -256,6 +268,7 @@ describe("components handlers", () => {
|
|
|
256
268
|
const plugins: LexiconPlugin[] = [
|
|
257
269
|
createMockPlugin({
|
|
258
270
|
name: "aws",
|
|
271
|
+
emulator: awsEmulatorStub,
|
|
259
272
|
describeResources: staticDescribeResources({ svc: meta() }),
|
|
260
273
|
}),
|
|
261
274
|
];
|
|
@@ -306,6 +319,7 @@ describe("components handlers", () => {
|
|
|
306
319
|
const plugins: LexiconPlugin[] = [
|
|
307
320
|
createMockPlugin({
|
|
308
321
|
name: "aws",
|
|
322
|
+
emulator: awsEmulatorStub,
|
|
309
323
|
describeResources: async () => {
|
|
310
324
|
seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
|
|
311
325
|
return { svc: meta() };
|
|
@@ -339,6 +353,7 @@ describe("components handlers", () => {
|
|
|
339
353
|
const plugins: LexiconPlugin[] = [
|
|
340
354
|
createMockPlugin({
|
|
341
355
|
name: "aws",
|
|
356
|
+
emulator: awsEmulatorStub,
|
|
342
357
|
describeResources: staticDescribeResources({ mystery: meta() }),
|
|
343
358
|
}),
|
|
344
359
|
];
|
|
@@ -391,6 +406,7 @@ describe("components handlers", () => {
|
|
|
391
406
|
const plugins: LexiconPlugin[] = [
|
|
392
407
|
createMockPlugin({
|
|
393
408
|
name: "aws",
|
|
409
|
+
emulator: awsEmulatorStub,
|
|
394
410
|
describeResources: staticDescribeResources({ "search-service-v2": meta() }),
|
|
395
411
|
}),
|
|
396
412
|
];
|
|
@@ -434,6 +450,7 @@ describe("components handlers", () => {
|
|
|
434
450
|
const plugins: LexiconPlugin[] = [
|
|
435
451
|
createMockPlugin({
|
|
436
452
|
name: "aws",
|
|
453
|
+
emulator: awsEmulatorStub,
|
|
437
454
|
describeResources: staticDescribeResources({ svc: meta() }),
|
|
438
455
|
}),
|
|
439
456
|
];
|
|
@@ -350,7 +350,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
|
|
|
350
350
|
const endpointResult = applyLiveEndpoint(
|
|
351
351
|
config.environments,
|
|
352
352
|
environment,
|
|
353
|
-
plugins.filter((p) => p.describeResources || p.describeStackStatus)
|
|
353
|
+
plugins.filter((p) => p.describeResources || p.describeStackStatus),
|
|
354
354
|
);
|
|
355
355
|
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
356
356
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { emulatorLifecycle } from "../../op";
|
|
3
|
+
import { emulatorLifecycle, emulatorsOf } from "../../op";
|
|
4
4
|
import { formatError, formatWarning, formatSuccess } from "../format";
|
|
5
5
|
import type { CommandContext } from "../registry";
|
|
6
6
|
|
|
@@ -48,8 +48,13 @@ export async function runEmulator(ctx: CommandContext): Promise<number> {
|
|
|
48
48
|
return 1;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// One entry per emulator, not per lexicon (#1345): fly ships two — mudflaps
|
|
52
|
+
// for the Machines API and spritzer for Sprites — and reporting a lexicon
|
|
53
|
+
// would have to pick one of them.
|
|
54
|
+
const targets = plugins
|
|
55
|
+
.filter((p) => !args.lexicon || p.name === args.lexicon)
|
|
56
|
+
.flatMap((p) => emulatorsOf(p.emulator).map((cap) => ({ lexicon: p.name, cap })));
|
|
57
|
+
if (targets.length === 0) {
|
|
53
58
|
if (args.json) {
|
|
54
59
|
console.log(JSON.stringify({ emulators: [] }));
|
|
55
60
|
return 0;
|
|
@@ -63,19 +68,18 @@ export async function runEmulator(ctx: CommandContext): Promise<number> {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
const reports: EmulatorReport[] = [];
|
|
66
|
-
for (const
|
|
67
|
-
const cap = p.emulator!;
|
|
71
|
+
for (const { lexicon, cap } of targets) {
|
|
68
72
|
const lc = emulatorLifecycle(cap.spec);
|
|
69
73
|
if (action === "up") {
|
|
70
74
|
const { endpoint } = await lc.up();
|
|
71
|
-
reports.push({ lexicon
|
|
75
|
+
reports.push({ lexicon, name: cap.spec.name, endpoint, env: cap.env(endpoint) });
|
|
72
76
|
} else if (action === "down") {
|
|
73
77
|
await lc.down();
|
|
74
|
-
reports.push({ lexicon
|
|
78
|
+
reports.push({ lexicon, name: cap.spec.name, endpoint: "", env: {} });
|
|
75
79
|
} else {
|
|
76
80
|
const running = await isRunning(cap.spec.name);
|
|
77
81
|
const endpoint = running ? lc.endpoint(cap.spec.containerPort) : "";
|
|
78
|
-
reports.push({ lexicon
|
|
82
|
+
reports.push({ lexicon, name: cap.spec.name, endpoint, env: endpoint ? cap.env(endpoint) : {} });
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|
|
@@ -3,6 +3,18 @@ import type { ParsedArgs } from "../registry";
|
|
|
3
3
|
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
4
4
|
import { AttrRef } from "../../attrref";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The aws emulator capability, as the real plugin declares it. `--live`
|
|
8
|
+
* endpoint injection reads the endpoint var off this rather than off a map
|
|
9
|
+
* keyed by lexicon name (#1345), so a mock that omits it gets no injection —
|
|
10
|
+
* which is the same thing that would happen in production.
|
|
11
|
+
*/
|
|
12
|
+
const awsEmulatorStub = {
|
|
13
|
+
spec: { name: "chant-floci", image: "floci/floci:1.5.34", containerPort: 4566, healthPath: "/_localstack/health" },
|
|
14
|
+
env: (endpoint: string) => ({ AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test" }),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
|
|
6
18
|
const discoverOpsMock = vi.fn();
|
|
7
19
|
vi.mock("../../op/discover", () => ({
|
|
8
20
|
discoverOps: () => discoverOpsMock(),
|
|
@@ -10,7 +22,9 @@ vi.mock("../../op/discover", () => ({
|
|
|
10
22
|
|
|
11
23
|
const discoverMock = vi.fn();
|
|
12
24
|
vi.mock("../../discovery/index", () => ({
|
|
13
|
-
|
|
25
|
+
// Forwards its arguments so a test can assert what the handler passed —
|
|
26
|
+
// `buildParams` in particular (#1359).
|
|
27
|
+
discover: (...a: unknown[]) => discoverMock(...a),
|
|
14
28
|
}));
|
|
15
29
|
|
|
16
30
|
const lintMock = vi.fn();
|
|
@@ -62,11 +76,13 @@ vi.mock("../../components/discover", () => ({
|
|
|
62
76
|
discoverComponents: (...a: unknown[]) => discoverComponentsMock(...a),
|
|
63
77
|
}));
|
|
64
78
|
const loadChantConfigMock = vi.fn();
|
|
79
|
+
const loadChantConfigUpwardMock = vi.fn();
|
|
65
80
|
vi.mock("../../config", async () => {
|
|
66
81
|
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
67
82
|
return {
|
|
68
83
|
...actual,
|
|
69
84
|
loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a),
|
|
85
|
+
loadChantConfigUpward: (...a: unknown[]) => loadChantConfigUpwardMock(...a),
|
|
70
86
|
};
|
|
71
87
|
});
|
|
72
88
|
vi.mock("../../build", () => ({
|
|
@@ -124,6 +140,8 @@ describe("runGraph", () => {
|
|
|
124
140
|
resolveLexMock.mockReset();
|
|
125
141
|
loadChantConfigMock.mockReset();
|
|
126
142
|
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
143
|
+
loadChantConfigUpwardMock.mockReset();
|
|
144
|
+
loadChantConfigUpwardMock.mockResolvedValue({ config: {} });
|
|
127
145
|
});
|
|
128
146
|
|
|
129
147
|
describe("Op graph (default)", () => {
|
|
@@ -186,6 +204,47 @@ describe("runGraph", () => {
|
|
|
186
204
|
expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
|
|
187
205
|
});
|
|
188
206
|
|
|
207
|
+
// #1339 — `chant graph` discovered source with `params.*` empty, so a
|
|
208
|
+
// declaration conditioned on a build parameter always took its default:
|
|
209
|
+
// the graph showed one shape whatever `--param` or a declared `env:`
|
|
210
|
+
// mapping said, while `chant build` on the same source showed another.
|
|
211
|
+
describe("build-time parameters reach discovery (#1359)", () => {
|
|
212
|
+
const withTierParam = (): void => {
|
|
213
|
+
loadChantConfigUpwardMock.mockResolvedValue({
|
|
214
|
+
config: { buildParams: { tier: { type: "string", enum: ["light", "prod"], default: "light" } } },
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
test("--param is resolved and passed to discover", async () => {
|
|
219
|
+
lintClean(); discovered(); withTierParam();
|
|
220
|
+
const exit = await runGraph({
|
|
221
|
+
args: makeArgs({ format: "ir", param: ["tier=prod"] }),
|
|
222
|
+
plugins: [],
|
|
223
|
+
serializers: [],
|
|
224
|
+
});
|
|
225
|
+
expect(exit).toBe(0);
|
|
226
|
+
const [, options] = discoverMock.mock.calls[0] as [string, { buildParams?: Array<{ name: string; value: unknown }> }];
|
|
227
|
+
expect(options.buildParams).toContainEqual(expect.objectContaining({ name: "tier", value: "prod" }));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a declared default is passed too, so discovery never sees an empty params object", async () => {
|
|
231
|
+
lintClean(); discovered(); withTierParam();
|
|
232
|
+
await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
233
|
+
const [, options] = discoverMock.mock.calls[0] as [string, { buildParams?: Array<{ name: string; value: unknown }> }];
|
|
234
|
+
expect(options.buildParams).toContainEqual(expect.objectContaining({ name: "tier", value: "light" }));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("an unresolvable parameter stops the graph rather than emitting a default one", async () => {
|
|
238
|
+
lintClean(); discovered();
|
|
239
|
+
loadChantConfigUpwardMock.mockResolvedValue({
|
|
240
|
+
config: { buildParams: { tier: { type: "string", required: true } } },
|
|
241
|
+
});
|
|
242
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
243
|
+
expect(exit).toBe(1);
|
|
244
|
+
expect(stdoutBuf.join("\n")).toBe("");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
189
248
|
test("lint gate: refuses to emit when source has lint errors", async () => {
|
|
190
249
|
lintMock.mockResolvedValue({ success: false });
|
|
191
250
|
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
@@ -427,7 +486,7 @@ describe("runGraph", () => {
|
|
|
427
486
|
test("loads plugins for --live when ctx.plugins is empty", async () => {
|
|
428
487
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
429
488
|
loadPluginsMock.mockResolvedValue([
|
|
430
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
489
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
431
490
|
]);
|
|
432
491
|
observeMock.mockResolvedValue({
|
|
433
492
|
observations: [{ lexicon: "aws", resources: { "web-vpc": { type: "AWS::EC2::VPC", status: "OK" } } }],
|
|
@@ -448,7 +507,7 @@ describe("runGraph", () => {
|
|
|
448
507
|
test("--at graphs the recorded snapshot without reading the estate", async () => {
|
|
449
508
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
450
509
|
loadPluginsMock.mockResolvedValue([
|
|
451
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}),
|
|
510
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}),
|
|
452
511
|
enrichLiveAttrs: () => Promise.reject(new Error("must not be called on a replay")) },
|
|
453
512
|
]);
|
|
454
513
|
replayMock.mockResolvedValue({
|
|
@@ -473,7 +532,7 @@ describe("runGraph", () => {
|
|
|
473
532
|
hasSnapshotMock.mockResolvedValue(true);
|
|
474
533
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
475
534
|
loadPluginsMock.mockResolvedValue([
|
|
476
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
535
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
477
536
|
]);
|
|
478
537
|
observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
|
|
479
538
|
const errs: string[] = [];
|
|
@@ -489,7 +548,7 @@ describe("runGraph", () => {
|
|
|
489
548
|
hasSnapshotMock.mockResolvedValue(false);
|
|
490
549
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
491
550
|
loadPluginsMock.mockResolvedValue([
|
|
492
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
551
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
493
552
|
]);
|
|
494
553
|
observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
|
|
495
554
|
const errs: string[] = [];
|
|
@@ -515,7 +574,7 @@ describe("runGraph", () => {
|
|
|
515
574
|
test("single-stack project (no components): observeResources gets an empty stacks list", async () => {
|
|
516
575
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
517
576
|
loadPluginsMock.mockResolvedValue([
|
|
518
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
577
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
519
578
|
]);
|
|
520
579
|
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
521
580
|
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
@@ -533,7 +592,7 @@ describe("runGraph", () => {
|
|
|
533
592
|
test("multi-stack component project: resolves each component's cfn-deploy stack(s) and passes them to observeResources", async () => {
|
|
534
593
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
535
594
|
loadPluginsMock.mockResolvedValue([
|
|
536
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
595
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
537
596
|
]);
|
|
538
597
|
discoverComponentsMock.mockResolvedValue({
|
|
539
598
|
errors: [],
|
|
@@ -580,7 +639,7 @@ describe("runGraph", () => {
|
|
|
580
639
|
test("ChantConfig.stacks: observeResources gets every declared stack, with its region and src", async () => {
|
|
581
640
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
582
641
|
loadPluginsMock.mockResolvedValue([
|
|
583
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
642
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
584
643
|
]);
|
|
585
644
|
loadChantConfigMock.mockResolvedValue({
|
|
586
645
|
config: {
|
|
@@ -609,7 +668,7 @@ describe("runGraph", () => {
|
|
|
609
668
|
test("ChantConfig.stacks: a stack also derived from a component is not observed twice", async () => {
|
|
610
669
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
611
670
|
loadPluginsMock.mockResolvedValue([
|
|
612
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
671
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
613
672
|
]);
|
|
614
673
|
discoverComponentsMock.mockResolvedValue({
|
|
615
674
|
errors: [],
|
|
@@ -636,7 +695,7 @@ describe("runGraph", () => {
|
|
|
636
695
|
test("component discovery errors: falls back to the single-stack path with a warning", async () => {
|
|
637
696
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
638
697
|
loadPluginsMock.mockResolvedValue([
|
|
639
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
698
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
640
699
|
]);
|
|
641
700
|
discoverComponentsMock.mockResolvedValue({ errors: [{ message: "bad component" }], sourceFiles: [], components: new Map() });
|
|
642
701
|
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
@@ -667,7 +726,7 @@ describe("runGraph", () => {
|
|
|
667
726
|
});
|
|
668
727
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
669
728
|
loadPluginsMock.mockResolvedValue([
|
|
670
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
729
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
671
730
|
]);
|
|
672
731
|
let seenDuringObserve: string | undefined;
|
|
673
732
|
observeMock.mockImplementation(async () => {
|
|
@@ -688,7 +747,7 @@ describe("runGraph", () => {
|
|
|
688
747
|
});
|
|
689
748
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
690
749
|
loadPluginsMock.mockResolvedValue([
|
|
691
|
-
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
750
|
+
{ name: "aws", serializer: {}, emulator: awsEmulatorStub, describeResources: () => Promise.resolve({}) },
|
|
692
751
|
]);
|
|
693
752
|
let seenDuringObserve: string | undefined;
|
|
694
753
|
observeMock.mockImplementation(async () => {
|