@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.
- 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/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +11 -0
- package/dist/codegen/docs.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/codegen/docs-rule-scanning.ts +12 -3
- package/src/codegen/docs-sections.ts +5 -3
- package/src/codegen/docs-sidebar.ts +8 -2
- package/src/codegen/docs.ts +19 -0
- 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,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 {
|
|
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
|
-
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
|
|
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,
|
|
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("
|
|
81
|
-
expect(
|
|
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
|
});
|
package/src/live-endpoint.ts
CHANGED
|
@@ -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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
});
|