@intentius/chant 0.37.0 → 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.
Files changed (62) hide show
  1. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  2. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  4. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/mcp/server.d.ts +26 -2
  10. package/dist/cli/mcp/server.d.ts.map +1 -1
  11. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  12. package/dist/codegen/docs-sections.d.ts.map +1 -1
  13. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  14. package/dist/codegen/docs.d.ts +11 -0
  15. package/dist/codegen/docs.d.ts.map +1 -1
  16. package/dist/lexicon.d.ts +66 -37
  17. package/dist/lexicon.d.ts.map +1 -1
  18. package/dist/live-endpoint.d.ts +21 -22
  19. package/dist/live-endpoint.d.ts.map +1 -1
  20. package/dist/op/emulator-freshness.d.ts +44 -0
  21. package/dist/op/emulator-freshness.d.ts.map +1 -0
  22. package/dist/op/emulator-lifecycle.d.ts +36 -0
  23. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  24. package/dist/op/index.d.ts +4 -2
  25. package/dist/op/index.d.ts.map +1 -1
  26. package/dist/ownership.d.ts +33 -0
  27. package/dist/ownership.d.ts.map +1 -1
  28. package/dist/serializer.d.ts +15 -0
  29. package/dist/serializer.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/audit/catalog.test.ts +58 -6
  32. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  33. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  34. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  35. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  36. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  37. package/src/cli/commands/check-lexicon.ts +157 -26
  38. package/src/cli/handlers/components.test.ts +17 -0
  39. package/src/cli/handlers/components.ts +1 -1
  40. package/src/cli/handlers/emulator.ts +12 -8
  41. package/src/cli/handlers/graph.test.ts +71 -12
  42. package/src/cli/handlers/graph.ts +46 -5
  43. package/src/cli/handlers/lifecycle.test.ts +25 -4
  44. package/src/cli/handlers/lifecycle.ts +9 -3
  45. package/src/cli/mcp/server.test.ts +82 -0
  46. package/src/cli/mcp/server.ts +40 -5
  47. package/src/codegen/docs-rule-scanning.ts +12 -3
  48. package/src/codegen/docs-sections.ts +5 -3
  49. package/src/codegen/docs-sidebar.ts +8 -2
  50. package/src/codegen/docs.ts +19 -0
  51. package/src/lexicon-doc-coverage.test.ts +128 -0
  52. package/src/lexicon-seams.test.ts +113 -0
  53. package/src/lexicon.ts +68 -38
  54. package/src/live-endpoint.test.ts +51 -12
  55. package/src/live-endpoint.ts +32 -33
  56. package/src/op/emulator-declaration.test.ts +63 -0
  57. package/src/op/emulator-freshness.test.ts +135 -0
  58. package/src/op/emulator-freshness.ts +102 -0
  59. package/src/op/emulator-lifecycle.ts +49 -0
  60. package/src/op/index.ts +4 -2
  61. package/src/ownership.ts +41 -0
  62. 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: "src/plugin.ts exists",
101
+ name: "The plugin exposes a Serializer with a name and rule prefix",
78
102
  tier: 1,
79
- pass: existsSync(join(dir, "src/plugin.ts")),
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: "src/serializer.ts exists",
113
+ name: "lintRules() returns at least 1 rule",
84
114
  tier: 1,
85
- pass: existsSync(join(dir, "src/serializer.ts")),
115
+ pass: lintRules.items.length > 0,
116
+ detail: lintRules.error ? `threw: ${lintRules.error}` : `${lintRules.items.length} rule(s)`,
86
117
  });
87
118
 
88
- const ruleFiles = listTsFiles(join(dir, "src/lint/rules"), ["index.ts"]);
119
+ const postSynthChecks = safeList(plugin?.postSynthChecks?.bind(plugin));
89
120
  items.push({
90
- name: "At least 1 lint rule in src/lint/rules/",
121
+ name: "postSynthChecks() returns at least 1 check",
91
122
  tier: 1,
92
- pass: ruleFiles.length > 0,
93
- detail: ruleFiles.length > 0 ? `${ruleFiles.length} rule(s)` : undefined,
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: "At least 1 post-synth check",
158
+ name: "The plugin registers completionProvider",
100
159
  tier: 1,
101
- pass: postSynthFiles.length > 0,
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: "src/lsp/completions.ts exists",
164
+ name: "The plugin registers hoverProvider",
107
165
  tier: 1,
108
- pass: existsSync(join(dir, "src/lsp/completions.ts")),
166
+ pass: registers(plugin, "hoverProvider"),
109
167
  });
110
168
 
111
169
  items.push({
112
- name: "src/lsp/hover.ts exists",
170
+ name: "The plugin registers docs()",
113
171
  tier: 1,
114
- pass: existsSync(join(dir, "src/lsp/hover.ts")),
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.ts has uncommented ${method}`,
386
+ name: `The plugin registers ${method}`,
315
387
  tier: 2,
316
- pass: hasUncommented,
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).map((p) => p.name),
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
- const withEmulator = plugins.filter((p) => p.emulator && (!args.lexicon || p.name === args.lexicon));
52
- if (withEmulator.length === 0) {
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 p of withEmulator) {
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: p.name, name: cap.spec.name, endpoint, env: cap.env(endpoint) });
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: p.name, name: cap.spec.name, endpoint: "", env: {} });
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: p.name, name: cap.spec.name, endpoint, env: endpoint ? cap.env(endpoint) : {} });
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
- discover: () => discoverMock(),
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 () => {