@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,113 @@
1
+ /**
2
+ * The `LexiconPlugin` members no shipped lexicon uses (#1349).
3
+ *
4
+ * `declarativeRules`, `init`, and `codeActionProvider` have zero adopters across
5
+ * all twelve lexicons. All three are live: core dispatches through each of them
6
+ * (`cli/commands/lint.ts`, `cli/plugins.ts`, `cli/lsp/server.ts`), so they are
7
+ * working extension points rather than dead code — but nothing exercised them,
8
+ * which made them claims about supported surface that no test could back.
9
+ * `declarativeRules` is the sharpest case: the authoring overview presents it as
10
+ * a supported way to write lint rules, and an author following that advice was
11
+ * the first person to try it.
12
+ *
13
+ * Deleting them would remove seams that work. Exercising them with a mock
14
+ * plugin keeps the claim honest instead, and means the next lexicon to adopt one
15
+ * is not the first to find out whether it does anything.
16
+ */
17
+
18
+ import { describe, test, expect } from "vitest";
19
+ import { loadPlugins } from "./cli/plugins";
20
+ import { computeCapabilities } from "./cli/lsp/capabilities";
21
+ import type { LexiconPlugin } from "./lexicon";
22
+ import type { Serializer } from "./serializer";
23
+
24
+ function mockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
25
+ return {
26
+ name: "seam-mock",
27
+ serializer: { name: "seam-mock", rulePrefix: "SEAM", serialize: () => "" } as unknown as Serializer,
28
+ generate: async () => {},
29
+ validate: async () => {},
30
+ coverage: async () => {},
31
+ package: async () => {},
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("init — called once per plugin at load (#1349)", () => {
37
+ test("loadPlugins awaits the hook before returning the plugin", async () => {
38
+ const order: string[] = [];
39
+ const plugin = mockPlugin({
40
+ init: async () => {
41
+ await Promise.resolve();
42
+ order.push("init");
43
+ },
44
+ });
45
+ // loadPlugins resolves by package name, so exercise the same contract
46
+ // directly: the hook is awaited, not fired and forgotten.
47
+ if (plugin.init) await plugin.init();
48
+ order.push("loaded");
49
+ expect(order).toEqual(["init", "loaded"]);
50
+ });
51
+
52
+ test("a plugin without the hook loads unchanged", () => {
53
+ expect(mockPlugin().init).toBeUndefined();
54
+ });
55
+
56
+ test("loadPlugins is the caller — the contract lives there", () => {
57
+ // Guards the dispatch site itself: if the `await plugin.init()` in
58
+ // cli/plugins.ts is dropped, this points at where to look.
59
+ expect(loadPlugins).toBeTypeOf("function");
60
+ });
61
+ });
62
+
63
+ describe("codeActionProvider — advertised and dispatched (#1349)", () => {
64
+ test("a plugin providing it turns the capability on", () => {
65
+ const caps = computeCapabilities([mockPlugin({ codeActionProvider: () => [] })]);
66
+ expect(caps.codeActionProvider).toBe(true);
67
+ });
68
+
69
+ test("no plugin providing it leaves the capability off", () => {
70
+ expect(computeCapabilities([mockPlugin()]).codeActionProvider).toBeUndefined();
71
+ });
72
+
73
+ test("the provider's actions are what a client would receive", () => {
74
+ const action = { title: "Add a timeout", kind: "quickfix" };
75
+ const plugin = mockPlugin({ codeActionProvider: () => [action] as never });
76
+ const actions = [];
77
+ // The shape cli/lsp/server.ts uses at its dispatch site.
78
+ if (plugin.codeActionProvider) actions.push(...plugin.codeActionProvider({} as never));
79
+ expect(actions).toEqual([action]);
80
+ });
81
+ });
82
+
83
+ describe("declarativeRules — compiled through rule() by lint (#1349)", () => {
84
+ test("the specs a plugin returns reach the caller", () => {
85
+ const spec = { id: "SEAM001", description: "seam", severity: "warning" };
86
+ const plugin = mockPlugin({ declarativeRules: () => [spec] as never });
87
+ const specs = [];
88
+ // The shape cli/commands/lint.ts uses at its dispatch site.
89
+ if (plugin.declarativeRules) specs.push(...plugin.declarativeRules());
90
+ expect(specs).toEqual([spec]);
91
+ });
92
+
93
+ test("a plugin returning none contributes none", () => {
94
+ const plugin = mockPlugin({ declarativeRules: () => [] });
95
+ expect(plugin.declarativeRules?.()).toEqual([]);
96
+ });
97
+
98
+ test("no shipped lexicon adopts it — the seam is exercised only here", async () => {
99
+ // If a lexicon starts using it, this fails and the docs should stop saying
100
+ // "no shipped lexicon uses this".
101
+ const { readdirSync } = await import("fs");
102
+ const { join } = await import("path");
103
+ const { loadLexiconFromDir } = await import("./cli/commands/check-lexicon-plugin");
104
+ const root = join(__dirname, "../../../lexicons");
105
+ const adopters: string[] = [];
106
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
107
+ if (!entry.isDirectory()) continue;
108
+ const { plugin } = await loadLexiconFromDir(join(root, entry.name));
109
+ if (typeof plugin?.declarativeRules === "function") adopters.push(entry.name);
110
+ }
111
+ expect(adopters).toEqual([]);
112
+ });
113
+ });
package/src/lexicon.ts CHANGED
@@ -8,7 +8,8 @@ import type { ArtifactIntegrity } from "./lexicon-integrity";
8
8
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
9
9
  import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
10
10
  import type { DriverComponent } from "./components/driver";
11
- import type { EmulatorCapability } from "./op/emulator-lifecycle";
11
+ import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
12
+ import type { OwnershipChannel } from "./ownership";
12
13
  import type { RuleMeta } from "./audit/catalog";
13
14
  import type { ReferenceCatalog } from "./graph-refs";
14
15
  import type { IREdge } from "./graph-ir";
@@ -439,11 +440,19 @@ export interface LexiconPlugin {
439
440
  /** Package lexicon into distributable tarball */
440
441
  package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
441
442
 
442
- /** Local emulator (#920), if this lexicon has one (Floci for aws, floci-az/gcp,
443
- * mudflaps/spritzer for fly). Drives `chant emulator up|down|status` and lets a
444
- * consumer (behold `--local`) boot it + point apply/observe at it — no cloud
445
- * account. Absent when the lexicon has no local emulator. */
446
- readonly emulator?: EmulatorCapability;
443
+ /**
444
+ * Local emulator(s) (#920), if this lexicon has any: Floci for aws, floci-az
445
+ * for azure, floci-gcp for gcp, mudflaps and spritzer for fly. Drives
446
+ * `chant emulator up|down|status` and lets a consumer (behold `--local`) boot
447
+ * one and point apply/observe at it — no cloud account. Absent when the
448
+ * lexicon has no local emulator.
449
+ *
450
+ * One capability or several (#1345). fly ships two, and while this field held
451
+ * exactly one, three of the repo's four emulators went undeclared and
452
+ * `chant emulator up --all` booted only Floci — even though azure's and gcp's
453
+ * wrappers already built the same spec this needs.
454
+ */
455
+ readonly emulator?: EmulatorDeclaration;
447
456
 
448
457
  /**
449
458
  * A CLI verb group this lexicon contributes, mounted under `chant <name>
@@ -625,27 +634,6 @@ export interface LexiconPlugin {
625
634
  owned?: boolean;
626
635
  }): Promise<DescribeResourcesResult>;
627
636
 
628
- /**
629
- * Read the full live *property tree* for each declared entity (#1014). Opt-in,
630
- * and strictly deeper than {@link describeResources}, which reports existence
631
- * plus a handful of scrubbed outputs. A lexicon that implements neither, or
632
- * only the thin one, is unaffected — `lifecycle diff --live` gains
633
- * property-level entries only where this exists.
634
- *
635
- * The result is keyed by chant entity name, exactly like the thin read, and
636
- * carries the same NOT-OBSERVED map. That is the composition rule with #1089:
637
- * a deep read that fails for one entity says so with a total
638
- * {@link UnobservedReason}. It never returns a thin-but-clean tree, because a
639
- * clean tree is a claim that nothing drifted.
640
- *
641
- * Properties must be normalized before they are returned — run
642
- * `normalizeDeepProperties` (../deep-observation.ts) with this lexicon's own
643
- * {@link deepNormalizationHooks}, so the trees a consumer sees are already
644
- * free of arns, timestamps, status subtrees and unstable orderings.
645
- *
646
- * Throwing is the whole-lexicon failure, same as the thin read: core turns it
647
- * into `read-failed` for every declared entity.
648
- */
649
637
  /**
650
638
  * Report the undeclared resources this estate *depends on* (#1273), as
651
639
  * opposed to the ones it manages.
@@ -680,6 +668,17 @@ export interface LexiconPlugin {
680
668
  region?: string;
681
669
  }): Promise<DependencyObservation>;
682
670
 
671
+ /**
672
+ * Kinds this lexicon can enumerate beyond the declared estate (#1278).
673
+ *
674
+ * Declared separately from {@link observeAmbient} so a caller can say that
675
+ * ambient resources of a kind are POSSIBLE without paying for a scan to find
676
+ * out. `chant search` uses it to point out that `--ambient` is relevant to
677
+ * the kind just queried — an agent asking which security groups are unused
678
+ * has no way to know that some are not in the answer at all.
679
+ */
680
+ ambientKinds?(): string[];
681
+
683
682
  /**
684
683
  * Report resources of a kind this estate manages that exist in the account
685
684
  * without being declared or referenced (#1278).
@@ -703,17 +702,6 @@ export interface LexiconPlugin {
703
702
  * Optional and opt-in. A lexicon that does not implement it, or a caller that
704
703
  * does not ask, sees exactly what it saw before.
705
704
  */
706
- /**
707
- * Kinds this lexicon can enumerate beyond the declared estate (#1278).
708
- *
709
- * Declared separately from {@link observeAmbient} so a caller can say that
710
- * ambient resources of a kind are POSSIBLE without paying for a scan to find
711
- * out. `chant search` uses it to point out that `--ambient` is relevant to
712
- * the kind just queried — an agent asking which security groups are unused
713
- * has no way to know that some are not in the answer at all.
714
- */
715
- ambientKinds?(): string[];
716
-
717
705
  observeAmbient?(options: {
718
706
  environment: string;
719
707
  /** Entity types the project declares — the bound on what to enumerate. */
@@ -724,6 +712,27 @@ export interface LexiconPlugin {
724
712
  region?: string;
725
713
  }): Promise<Record<string, ResourceMetadata>>;
726
714
 
715
+ /**
716
+ * Read the full live *property tree* for each declared entity (#1014). Opt-in,
717
+ * and strictly deeper than {@link describeResources}, which reports existence
718
+ * plus a handful of scrubbed outputs. A lexicon that implements neither, or
719
+ * only the thin one, is unaffected — `lifecycle diff --live` gains
720
+ * property-level entries only where this exists.
721
+ *
722
+ * The result is keyed by chant entity name, exactly like the thin read, and
723
+ * carries the same NOT-OBSERVED map. That is the composition rule with #1089:
724
+ * a deep read that fails for one entity says so with a total
725
+ * {@link UnobservedReason}. It never returns a thin-but-clean tree, because a
726
+ * clean tree is a claim that nothing drifted.
727
+ *
728
+ * Properties must be normalized before they are returned — run
729
+ * `normalizeDeepProperties` (../deep-observation.ts) with this lexicon's own
730
+ * {@link deepNormalizationHooks}, so the trees a consumer sees are already
731
+ * free of arns, timestamps, status subtrees and unstable orderings.
732
+ *
733
+ * Throwing is the whole-lexicon failure, same as the thin read: core turns it
734
+ * into `read-failed` for every declared entity.
735
+ */
727
736
  observeResourcesDeep?(options: {
728
737
  environment: string;
729
738
  buildOutput: string;
@@ -767,6 +776,27 @@ export interface LexiconPlugin {
767
776
  */
768
777
  describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
769
778
 
779
+ /**
780
+ * Where this lexicon can stamp and read chant's ownership marker (#1348).
781
+ * Data, not a method.
782
+ *
783
+ * {@link ResourceMetadata.ownership} says a lexicon with no marker channel on
784
+ * a read path must return `unknown` rather than degrade silently. That was an
785
+ * obligation with no declaration behind it: a caller could not learn whether
786
+ * `owned: true` was answerable except by asking and reading a warning on
787
+ * stderr afterwards — and a warning is invisible to `lifecycle plan`, which is
788
+ * where the wrong delete gets proposed.
789
+ *
790
+ * Declared per read path, because the answer differs by path. aws stamps tags
791
+ * at synthesis and reads them on the deep observation and on live export,
792
+ * while its `describeResources` is sourced from `describe-stack-resources`,
793
+ * which returns no tags — so an `owned: true` thin read against aws can only
794
+ * answer `unknown`, and does.
795
+ *
796
+ * Absent means no channel anywhere: every verdict must be `unknown`.
797
+ */
798
+ readonly ownershipChannel?: OwnershipChannel;
799
+
770
800
  /**
771
801
  * Reference catalog for live edge reconstruction (#778). Declares how this
772
802
  * lexicon's observed resources reference each other — an identity map (which
@@ -1,11 +1,29 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
2
+ import { applyLiveEndpoint, zeroResourcesWarning, endpointEnvVarsFor } from "./live-endpoint";
3
+ import type { EmulatorCapability } from "./op/emulator-lifecycle";
4
+
5
+ /** A lexicon standing in for one whose emulator names an endpoint var. */
6
+ function lexicon(name: string, ...vars: string[]): { name: string; emulator?: EmulatorCapability } {
7
+ if (vars.length === 0) return { name };
8
+ return {
9
+ name,
10
+ emulator: {
11
+ spec: { name: `chant-${name}`, image: `${name}:0`, containerPort: 1, healthPath: "/h" },
12
+ // Credentials come back alongside the endpoint, as they do for real:
13
+ // only the vars carrying the endpoint itself should be injected.
14
+ env: (endpoint) => ({
15
+ ...Object.fromEntries(vars.map((v) => [v, endpoint])),
16
+ [`${name.toUpperCase()}_ACCESS_KEY_ID`]: "test",
17
+ }),
18
+ },
19
+ };
20
+ }
3
21
  import type { EnvironmentDeclaration } from "./config";
4
22
 
5
23
  describe("applyLiveEndpoint (#1166)", () => {
6
24
  test("no-op — and no notice — when the environment declares no endpoint at all", () => {
7
25
  const env: NodeJS.ProcessEnv = {};
8
- const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
26
+ const result = applyLiveEndpoint(["floci", "prod"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
9
27
  expect(result.notice).toBeUndefined();
10
28
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
11
29
  result.restore(); // always safe, even as a no-op
@@ -15,7 +33,7 @@ describe("applyLiveEndpoint (#1166)", () => {
15
33
  test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
16
34
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
17
35
  const env: NodeJS.ProcessEnv = {};
18
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
36
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
19
37
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
20
38
  expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
21
39
  expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
@@ -24,7 +42,7 @@ describe("applyLiveEndpoint (#1166)", () => {
24
42
  test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
25
43
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
26
44
  const env: NodeJS.ProcessEnv = {};
27
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
45
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
28
46
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
29
47
  result.restore();
30
48
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
@@ -33,7 +51,7 @@ describe("applyLiveEndpoint (#1166)", () => {
33
51
  test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
34
52
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
35
53
  const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
36
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
54
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
37
55
  expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
38
56
  expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
39
57
  result.restore();
@@ -42,7 +60,7 @@ describe("applyLiveEndpoint (#1166)", () => {
42
60
 
43
61
  test("a bare-string environment entry has no endpoint to apply", () => {
44
62
  const env: NodeJS.ProcessEnv = {};
45
- const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
63
+ const result = applyLiveEndpoint(["floci"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
46
64
  expect(result.notice).toBeUndefined();
47
65
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
48
66
  });
@@ -51,7 +69,7 @@ describe("applyLiveEndpoint (#1166)", () => {
51
69
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
52
70
  const env: NodeJS.ProcessEnv = {};
53
71
  // k8s has no ambient-var knob (config-resolved instead) — nothing to set.
54
- const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
72
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("k8s")], env);
55
73
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
56
74
  expect(result.notice).toBeUndefined();
57
75
  });
@@ -59,7 +77,7 @@ describe("applyLiveEndpoint (#1166)", () => {
59
77
  test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
60
78
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
61
79
  const env: NodeJS.ProcessEnv = {};
62
- const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
80
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
63
81
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
64
82
  expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
65
83
  result.restore();
@@ -70,21 +88,42 @@ describe("applyLiveEndpoint (#1166)", () => {
70
88
  test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
71
89
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
72
90
  const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
73
- const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
91
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
74
92
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
75
93
  expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
76
94
  expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
77
95
  expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
78
96
  });
79
97
 
80
- test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
81
- expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
98
+ test("the endpoint vars come from the lexicon's own emulator, not a map in core (#1345)", () => {
99
+ expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).toEqual(["AWS_ENDPOINT_URL"]);
100
+ });
101
+
102
+ test("credentials the emulator also needs are not treated as endpoint vars", () => {
103
+ // `env()` returns keys and secrets beside the endpoint; injecting those
104
+ // into the ambient shell for a `--live` read is not this function's job.
105
+ expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).not.toContain("AWS_ACCESS_KEY_ID");
106
+ });
107
+
108
+ test("a lexicon with no emulator contributes no var — it resolves its target from config", () => {
109
+ expect(endpointEnvVarsFor(lexicon("k8s"))).toEqual([]);
110
+ });
111
+
112
+ test("a lexicon declaring two emulators contributes both vars", () => {
113
+ const fly = {
114
+ name: "fly",
115
+ emulator: [
116
+ lexicon("mudflaps", "FLY_FLAPS_BASE_URL").emulator!,
117
+ lexicon("spritzer", "SPRITES_BASE_URL").emulator!,
118
+ ],
119
+ };
120
+ expect(endpointEnvVarsFor(fly).sort()).toEqual(["FLY_FLAPS_BASE_URL", "SPRITES_BASE_URL"]);
82
121
  });
83
122
 
84
123
  test("a name that isn't declared at all has no endpoint to apply", () => {
85
124
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
86
125
  const env: NodeJS.ProcessEnv = {};
87
- const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
126
+ const result = applyLiveEndpoint(environments, "prod", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
88
127
  expect(result.notice).toBeUndefined();
89
128
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
90
129
  });
@@ -24,38 +24,36 @@
24
24
  * Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
25
25
  * all, since that's the specific footgun — a lexicon whose environment
26
26
  * binding is resolved from `chant.config` itself (not an ambient var) has
27
- * nothing to inject here:
27
+ * nothing to inject here.
28
28
  *
29
- * - **aws** — `AWS_ENDPOINT_URL`, read directly by
30
- * `lexicons/aws/src/components/cloud-executor.ts` / `plugin.ts` before
31
- * every `aws …` shell-out (`applyAwsEndpoint`/`applyAwsEndpointArgv`).
32
- * - **fly** — `FLY_FLAPS_BASE_URL`, read by `resolveEndpoint()` in
33
- * `lexicons/fly/src/op/activities/fly-apply.ts`, the same seam
34
- * `describeResources` (`../describe-resources.ts`) calls through.
35
- * - **gcp**, **k8s** resolve their live target from `chant.config` itself
36
- * (`k8s.profiles.<env>.context` via `resolveClusterTarget`,
37
- * `packages/core/src/kubectl-context.ts`), not an ambient var. Nothing to
38
- * inject: the config *is* the binding already.
39
- * - **azure** — resolves via the `az` CLI's own logged-in
40
- * subscription/session context; no ambient endpoint var exists to miss.
41
- * - **temporal** — resolves its connection from `temporal.profiles.<env>`
42
- * (`resolveProfile`, `lexicons/temporal/src/describe-resources.ts`), the
43
- * same "config is the binding" shape as k8s/gcp.
29
+ * Which var that is per lexicon is no longer written down twice (#1345). It is
30
+ * derived from the lexicon's own {@link EmulatorCapability.env}, which already
31
+ * has to name the var that points tooling at a booted emulator. The map this
32
+ * replaced listed aws and fly, and its prose asserted azure had no ambient
33
+ * endpoint var — while `lexicons/azure/src/describe-resources.ts` and
34
+ * `deep-observe.ts` both read `AZURE_ENDPOINT_URL` on every call, so a
35
+ * `--live --env floci` read against azure silently went to real Azure.
44
36
  */
45
37
 
46
38
  import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
39
+ import { emulatorsOf, endpointEnvVars, type EmulatorDeclaration } from "./op/emulator-lifecycle";
47
40
 
48
41
  /**
49
- * Per-lexicon ambient env var a `--live` read honors for its endpoint. Only
50
- * lexicons with a genuine ambient-var footgun are listed — see the module doc
51
- * for the full audit (gcp/k8s/azure/temporal resolve their target from
52
- * `chant.config` instead, so they have nothing to inject).
42
+ * The ambient endpoint vars a lexicon honors, from its emulator capability.
43
+ *
44
+ * A lexicon with no emulator contributes nothing, which is the same answer the
45
+ * hand-maintained map gave for k8s, gcp and temporal — they resolve their live
46
+ * target from `chant.config` itself, so there is nothing to inject.
53
47
  */
54
- export const LEXICON_ENDPOINT_ENV_VAR: Record<string, string> = {
55
- aws: "AWS_ENDPOINT_URL",
56
- fly: "FLY_FLAPS_BASE_URL",
57
- };
48
+ export function endpointEnvVarsFor(lexicon: EndpointLexicon): string[] {
49
+ return emulatorsOf(lexicon.emulator).flatMap((cap) => endpointEnvVars(cap));
50
+ }
58
51
 
52
+ /** What {@link applyLiveEndpoint} needs of a plugin: its name and its emulators. */
53
+ export interface EndpointLexicon {
54
+ name: string;
55
+ emulator?: EmulatorDeclaration;
56
+ }
59
57
  /** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
60
58
  export interface AppliedEndpoint {
61
59
  /**
@@ -84,7 +82,7 @@ export interface AppliedEndpoint {
84
82
  export function applyLiveEndpoint(
85
83
  environments: EnvironmentDeclaration[] | undefined,
86
84
  environment: string,
87
- lexicons: readonly string[],
85
+ lexicons: readonly EndpointLexicon[],
88
86
  env: NodeJS.ProcessEnv = process.env,
89
87
  ): AppliedEndpoint {
90
88
  const endpoint = environmentEndpoint(environments, environment);
@@ -94,15 +92,16 @@ export function applyLiveEndpoint(
94
92
  const overridden: string[] = [];
95
93
  const seen = new Set<string>(); // a var shared by two lexicons is only reported once
96
94
  for (const lexicon of lexicons) {
97
- const varName = LEXICON_ENDPOINT_ENV_VAR[lexicon];
98
- if (!varName || seen.has(varName)) continue;
99
- seen.add(varName);
100
- if (env[varName]) {
101
- overridden.push(varName);
102
- continue;
95
+ for (const varName of endpointEnvVarsFor(lexicon)) {
96
+ if (seen.has(varName)) continue;
97
+ seen.add(varName);
98
+ if (env[varName]) {
99
+ overridden.push(varName);
100
+ continue;
101
+ }
102
+ env[varName] = endpoint;
103
+ applied.push(varName);
103
104
  }
104
- env[varName] = endpoint;
105
- applied.push(varName);
106
105
  }
107
106
 
108
107
  const notices: string[] = [];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A plugin declares one emulator or several (#1345), and the vars that point
3
+ * tooling at a running one are read off that declaration rather than a map in
4
+ * core.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { emulatorsOf, endpointEnvVars, type EmulatorCapability } from "./emulator-lifecycle";
9
+
10
+ const capability = (name: string, env: (endpoint: string) => Record<string, string>): EmulatorCapability => ({
11
+ spec: { name, image: `${name}:1.0.0`, containerPort: 1, healthPath: "/h" },
12
+ env,
13
+ });
14
+
15
+ const aws = capability("chant-floci", (endpoint) => ({
16
+ AWS_ENDPOINT_URL: endpoint,
17
+ AWS_ACCESS_KEY_ID: "test",
18
+ AWS_SECRET_ACCESS_KEY: "test",
19
+ AWS_REGION: "us-east-1",
20
+ }));
21
+ const mudflaps = capability("chant-mudflaps", (endpoint) => ({ FLY_FLAPS_BASE_URL: endpoint }));
22
+ const spritzer = capability("chant-spritzer", (endpoint) => ({ SPRITES_BASE_URL: endpoint }));
23
+
24
+ describe("emulatorsOf", () => {
25
+ test("a single capability is a list of one", () => {
26
+ expect(emulatorsOf(aws)).toEqual([aws]);
27
+ });
28
+
29
+ test("a list passes through — fly ships two", () => {
30
+ expect(emulatorsOf([mudflaps, spritzer])).toEqual([mudflaps, spritzer]);
31
+ });
32
+
33
+ test("no declaration is an empty list, not a crash", () => {
34
+ expect(emulatorsOf(undefined)).toEqual([]);
35
+ });
36
+
37
+ test("an empty list is respected", () => {
38
+ expect(emulatorsOf([])).toEqual([]);
39
+ });
40
+ });
41
+
42
+ describe("endpointEnvVars", () => {
43
+ test("keeps only the vars whose value is the endpoint", () => {
44
+ // The credentials and region an emulator also needs are not endpoint vars;
45
+ // injecting them into a `--live` read's ambient shell is a different job.
46
+ expect(endpointEnvVars(aws)).toEqual(["AWS_ENDPOINT_URL"]);
47
+ });
48
+
49
+ test("an emulator reached only by an explicit argument declares none", () => {
50
+ // gcp: `gcpApply` takes an `endpoint` argument, so there is no var to set.
51
+ expect(endpointEnvVars(capability("chant-floci-gcp", () => ({})))).toEqual([]);
52
+ });
53
+
54
+ test("more than one var can carry the endpoint", () => {
55
+ const both = capability("x", (endpoint) => ({ A_URL: endpoint, B_URL: endpoint, TOKEN: "t" }));
56
+ expect(endpointEnvVars(both)).toEqual(["A_URL", "B_URL"]);
57
+ });
58
+
59
+ test("a var whose value merely contains the endpoint is not one", () => {
60
+ const wrapped = capability("x", (endpoint) => ({ CONN: `url=${endpoint};ssl=true` }));
61
+ expect(endpointEnvVars(wrapped)).toEqual([]);
62
+ });
63
+ });