@intentius/chant 0.28.0 → 0.29.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/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +31 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +34 -9
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +55 -0
- package/src/stack-output.ts +41 -20
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation contract itself (#1089) — the normalizer, the envelope
|
|
3
|
+
* discriminant, and the multi-stack merge.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
UNOBSERVED_REASONS,
|
|
8
|
+
formatUnobserved,
|
|
9
|
+
isObservationResult,
|
|
10
|
+
isUnobservedReason,
|
|
11
|
+
mergeObservations,
|
|
12
|
+
normalizeObservation,
|
|
13
|
+
observation,
|
|
14
|
+
unobservedAll,
|
|
15
|
+
unobservedReasonText,
|
|
16
|
+
} from "./observation";
|
|
17
|
+
import type { ResourceMetadata } from "./lexicon";
|
|
18
|
+
|
|
19
|
+
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
20
|
+
type: "Fake::Resource",
|
|
21
|
+
status: "OK",
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("normalizeObservation", () => {
|
|
26
|
+
test("a bare map means 'I looked at everything'", () => {
|
|
27
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {} });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the envelope carries both halves", () => {
|
|
31
|
+
const value = observation({ a: meta() }, { b: { reason: "read-failed" } });
|
|
32
|
+
expect(normalizeObservation(value)).toEqual({
|
|
33
|
+
resources: { a: meta() },
|
|
34
|
+
unobserved: { b: { reason: "read-failed" } },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("undefined normalizes to two empty maps", () => {
|
|
39
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {} });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an entity literally named `observation` cannot be mistaken for the envelope", () => {
|
|
43
|
+
const bare = { observation: meta({ type: "Odd::Name" }) };
|
|
44
|
+
expect(isObservationResult(bare)).toBe(false);
|
|
45
|
+
expect(normalizeObservation(bare).resources.observation.type).toBe("Odd::Name");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the envelope omits an empty unobserved map", () => {
|
|
49
|
+
expect(observation({ a: meta() }, {})).toEqual({ observation: "v1", resources: { a: meta() } });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("unobservedAll", () => {
|
|
54
|
+
test("marks every named entity with one reason, carrying declared types", () => {
|
|
55
|
+
const entities = new Map([["a", { entityType: "AWS::S3::Bucket" }]]);
|
|
56
|
+
expect(unobservedAll(["a", "b"], "no-credentials", "token expired", entities)).toEqual({
|
|
57
|
+
a: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
|
|
58
|
+
b: { reason: "no-credentials", detail: "token expired" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("mergeObservations (multi-stack)", () => {
|
|
64
|
+
test("present beats not-observed beats absent", () => {
|
|
65
|
+
const merged = mergeObservations([
|
|
66
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } } },
|
|
67
|
+
{ resources: { a: meta() }, unobserved: {} },
|
|
68
|
+
]);
|
|
69
|
+
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
70
|
+
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("an entity nobody looked for in any stack stays absent", () => {
|
|
74
|
+
const merged = mergeObservations([
|
|
75
|
+
{ resources: { a: meta() }, unobserved: {} },
|
|
76
|
+
{ resources: { b: meta() }, unobserved: {} },
|
|
77
|
+
]);
|
|
78
|
+
expect(merged.unobserved).toEqual({});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("reason totality", () => {
|
|
83
|
+
test("every reason has human text and passes the guard", () => {
|
|
84
|
+
for (const reason of UNOBSERVED_REASONS) {
|
|
85
|
+
expect(isUnobservedReason(reason)).toBe(true);
|
|
86
|
+
expect(unobservedReasonText(reason).length).toBeGreaterThan(0);
|
|
87
|
+
}
|
|
88
|
+
expect(isUnobservedReason("made-up")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("formatUnobserved names the entity, the type and the detail", () => {
|
|
92
|
+
expect(
|
|
93
|
+
formatUnobserved("widget", { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no mapping" }),
|
|
94
|
+
).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation contract (#1089) — what a lexicon's `describeResources()`
|
|
3
|
+
* is allowed to mean.
|
|
4
|
+
*
|
|
5
|
+
* Before this module there were two ways for an observation to return nothing
|
|
6
|
+
* for a declared entity and no way to tell them apart:
|
|
7
|
+
*
|
|
8
|
+
* 1. The provider was asked and said the resource does not exist.
|
|
9
|
+
* 2. The lexicon never looked — no reader for that kind, the read errored,
|
|
10
|
+
* no credentials, no cluster/subscription binding.
|
|
11
|
+
*
|
|
12
|
+
* Both arrived at the change set as "absent", and absent + declared classifies
|
|
13
|
+
* as `create`. So `chant lifecycle plan` proposed creating a Kubernetes CRD
|
|
14
|
+
* that already existed in the cluster, with nothing in the plan to say the tool
|
|
15
|
+
* had simply not looked (a warn on stderr is not a signal in a change set).
|
|
16
|
+
*
|
|
17
|
+
* The contract is a tri-state, per declared entity:
|
|
18
|
+
*
|
|
19
|
+
* - **OBSERVED-PRESENT** — a key in `resources`. The provider returned it.
|
|
20
|
+
* - **OBSERVED-ABSENT** — in neither map. The lexicon looked and the provider
|
|
21
|
+
* reported it missing. This is the only shape that may become a `create`.
|
|
22
|
+
* - **NOT-OBSERVED** — a key in `unobserved`, carrying a total
|
|
23
|
+
* {@link UnobservedReason}. Never a `create`, never a `delete`; consumers
|
|
24
|
+
* surface it and stop.
|
|
25
|
+
*
|
|
26
|
+
* Compatibility: `describeResources()` may still return the bare
|
|
27
|
+
* `name → ResourceMetadata` map it always did — that means "everything I was
|
|
28
|
+
* asked about, I looked at" and normalizes to an empty `unobserved`. Reporting
|
|
29
|
+
* NOT-OBSERVED requires the explicitly versioned {@link ObservationResult}
|
|
30
|
+
* envelope, which is discriminated by its literal `observation: "v1"` field (a
|
|
31
|
+
* bare map's value at any key is a `ResourceMetadata` object, never that
|
|
32
|
+
* string, so the two shapes can never be confused).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ResourceMetadata } from "./lexicon";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a declared entity was not observed. Total: a lexicon that cannot observe
|
|
39
|
+
* an entity must pick one of these, and consumers may switch exhaustively.
|
|
40
|
+
*
|
|
41
|
+
* - `read-failed` — the provider was reached and the read errored (a non-zero
|
|
42
|
+
* CLI exit, a 5xx, an unparseable response).
|
|
43
|
+
* - `no-credentials` — no usable credentials/authorization for the target.
|
|
44
|
+
* - `no-binding` — the environment resolves to no concrete target (no kubectl
|
|
45
|
+
* context, no subscription, no stack, no endpoint).
|
|
46
|
+
* - `unsupported-kind` — the lexicon has no reader for this entity type (the
|
|
47
|
+
* K8s CRD case, Azure's nested ARM types). The resource may well exist.
|
|
48
|
+
* - `filtered` — the entity was reached but withheld by a caller-requested
|
|
49
|
+
* filter (`owned: true` against a resource carrying no chant marker). The
|
|
50
|
+
* result deliberately says nothing about it, which is still not absence: a
|
|
51
|
+
* declared resource that exists but is foreign must not classify as `create`.
|
|
52
|
+
*/
|
|
53
|
+
export type UnobservedReason =
|
|
54
|
+
| "read-failed"
|
|
55
|
+
| "no-credentials"
|
|
56
|
+
| "no-binding"
|
|
57
|
+
| "unsupported-kind"
|
|
58
|
+
| "filtered";
|
|
59
|
+
|
|
60
|
+
/** Every legal {@link UnobservedReason}, for validation and conformance checks. */
|
|
61
|
+
export const UNOBSERVED_REASONS: readonly UnobservedReason[] = [
|
|
62
|
+
"read-failed",
|
|
63
|
+
"no-credentials",
|
|
64
|
+
"no-binding",
|
|
65
|
+
"unsupported-kind",
|
|
66
|
+
"filtered",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** True when `value` is a legal {@link UnobservedReason}. */
|
|
70
|
+
export function isUnobservedReason(value: unknown): value is UnobservedReason {
|
|
71
|
+
return typeof value === "string" && (UNOBSERVED_REASONS as readonly string[]).includes(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One declared entity the lexicon could not observe, and why. */
|
|
75
|
+
export interface UnobservedEntity {
|
|
76
|
+
/** Declared entity type, when the lexicon knows it (it usually does — the entity is declared). */
|
|
77
|
+
type?: string;
|
|
78
|
+
/** Total verdict. */
|
|
79
|
+
reason: UnobservedReason;
|
|
80
|
+
/** Human-readable detail: the command that failed, the missing binding key, the unsupported kind. */
|
|
81
|
+
detail?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The observation envelope — a `describeResources()` return value that can say
|
|
86
|
+
* "I did not look at this one". Explicitly versioned: `observation: "v1"`.
|
|
87
|
+
*/
|
|
88
|
+
export interface ObservationResult {
|
|
89
|
+
/** Discriminant + wire version. Distinguishes the envelope from the bare `name → ResourceMetadata` map. */
|
|
90
|
+
readonly observation: "v1";
|
|
91
|
+
/** OBSERVED-PRESENT, keyed by chant entity name. */
|
|
92
|
+
resources: Record<string, ResourceMetadata>;
|
|
93
|
+
/** NOT-OBSERVED, keyed by chant entity name. Omit or leave empty when everything asked about was looked at. */
|
|
94
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What `describeResources()` may return: the bare map (pre-#1089, still valid —
|
|
99
|
+
* "I looked at everything") or the {@link ObservationResult} envelope.
|
|
100
|
+
*/
|
|
101
|
+
export type DescribeResourcesResult = Record<string, ResourceMetadata> | ObservationResult;
|
|
102
|
+
|
|
103
|
+
/** Normalized form every consumer works with. Both maps always present. */
|
|
104
|
+
export interface NormalizedObservation {
|
|
105
|
+
resources: Record<string, ResourceMetadata>;
|
|
106
|
+
unobserved: Record<string, UnobservedEntity>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when `value` is the versioned {@link ObservationResult} envelope. */
|
|
110
|
+
export function isObservationResult(value: unknown): value is ObservationResult {
|
|
111
|
+
return (
|
|
112
|
+
typeof value === "object" &&
|
|
113
|
+
value !== null &&
|
|
114
|
+
(value as { observation?: unknown }).observation === "v1"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build an {@link ObservationResult}. Lexicons use this rather than writing the
|
|
120
|
+
* discriminant by hand.
|
|
121
|
+
*/
|
|
122
|
+
export function observation(
|
|
123
|
+
resources: Record<string, ResourceMetadata>,
|
|
124
|
+
unobserved?: Record<string, UnobservedEntity>,
|
|
125
|
+
): ObservationResult {
|
|
126
|
+
return {
|
|
127
|
+
observation: "v1",
|
|
128
|
+
resources,
|
|
129
|
+
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize either accepted return shape. `undefined` (a lexicon that returned
|
|
135
|
+
* nothing at all) normalizes to two empty maps — which reads as "everything was
|
|
136
|
+
* observed absent", so callers that mean "the read failed" must say so with
|
|
137
|
+
* {@link unobservedAll} rather than returning nothing.
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
140
|
+
if (!value) return { resources: {}, unobserved: {} };
|
|
141
|
+
if (isObservationResult(value)) {
|
|
142
|
+
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
|
|
143
|
+
}
|
|
144
|
+
return { resources: value, unobserved: {} };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Mark every named entity NOT-OBSERVED with one reason — the whole-lexicon
|
|
149
|
+
* failure case (the provider CLI is missing, the cluster binding refused, the
|
|
150
|
+
* credentials are gone). Core applies this when `describeResources()` throws,
|
|
151
|
+
* so a thrown read degrades to an honest "did not look" for each declared
|
|
152
|
+
* entity instead of an empty map that classifies as N creates.
|
|
153
|
+
*/
|
|
154
|
+
export function unobservedAll(
|
|
155
|
+
names: Iterable<string>,
|
|
156
|
+
reason: UnobservedReason,
|
|
157
|
+
detail?: string,
|
|
158
|
+
types?: Map<string, { entityType: string }> | Record<string, string>,
|
|
159
|
+
): Record<string, UnobservedEntity> {
|
|
160
|
+
const typeOf = (name: string): string | undefined => {
|
|
161
|
+
if (!types) return undefined;
|
|
162
|
+
if (types instanceof Map) return types.get(name)?.entityType;
|
|
163
|
+
return types[name];
|
|
164
|
+
};
|
|
165
|
+
const out: Record<string, UnobservedEntity> = {};
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
const type = typeOf(name);
|
|
168
|
+
out[name] = { reason, ...(type ? { type } : {}), ...(detail ? { detail } : {}) };
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Union several observations of the same lexicon (the multi-stack read, where
|
|
175
|
+
* `describeResources` runs once per stack). Precedence is
|
|
176
|
+
* present > not-observed > absent: a resource found in any stack is present; an
|
|
177
|
+
* entity nobody could look at stays not-observed; an entity every reader looked
|
|
178
|
+
* for and did not find is absent.
|
|
179
|
+
*/
|
|
180
|
+
export function mergeObservations(parts: Iterable<NormalizedObservation>): NormalizedObservation {
|
|
181
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
182
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
183
|
+
for (const part of parts) {
|
|
184
|
+
Object.assign(resources, part.resources);
|
|
185
|
+
Object.assign(unobserved, part.unobserved);
|
|
186
|
+
}
|
|
187
|
+
// Present wins: a stack that could not be read does not un-observe a resource
|
|
188
|
+
// another stack returned.
|
|
189
|
+
for (const name of Object.keys(resources)) delete unobserved[name];
|
|
190
|
+
return { resources, unobserved };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One-line human phrasing of a reason, for CLI output. */
|
|
194
|
+
export function unobservedReasonText(reason: UnobservedReason): string {
|
|
195
|
+
switch (reason) {
|
|
196
|
+
case "read-failed":
|
|
197
|
+
return "read failed";
|
|
198
|
+
case "no-credentials":
|
|
199
|
+
return "no credentials";
|
|
200
|
+
case "no-binding":
|
|
201
|
+
return "no binding for this environment";
|
|
202
|
+
case "unsupported-kind":
|
|
203
|
+
return "no reader for this resource kind";
|
|
204
|
+
case "filtered":
|
|
205
|
+
return "withheld by the --owned filter";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `name — reason (detail)`, the shared rendering for CLI and plan output. */
|
|
210
|
+
export function formatUnobserved(name: string, entry: UnobservedEntity): string {
|
|
211
|
+
const base = `${name}${entry.type ? ` (${entry.type})` : ""} — ${unobservedReasonText(entry.reason)}`;
|
|
212
|
+
return entry.detail ? `${base}: ${entry.detail}` : base;
|
|
213
|
+
}
|
package/src/stack-output.test.ts
CHANGED
|
@@ -97,6 +97,61 @@ describe("stackOutput", () => {
|
|
|
97
97
|
expect(out.lexicon).toBe("aws");
|
|
98
98
|
vi.resetModules();
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
// chant #1152 — a CloudFormation Parameter carries no attributes at all, so
|
|
102
|
+
// `Ref(param)` (an intrinsic wrapping the Parameter Declarable directly, not
|
|
103
|
+
// one of its attributes) could never contain a nested AttrRef for the old
|
|
104
|
+
// AttrRef-only anchor search to find. Before the fix that meant `firstAttrRef`
|
|
105
|
+
// always came back empty for this shape and the output's `lexicon` silently
|
|
106
|
+
// became `"unknown"` — not a thrown error (a Parameter-wrapping `Ref` already
|
|
107
|
+
// carries the `Intrinsic` marker, so the input-validation guard always
|
|
108
|
+
// accepted it), but `"unknown"` isn't any real lexicon's partition, so the
|
|
109
|
+
// output was silently dropped from every serializer's `Outputs` section.
|
|
110
|
+
const param: Declarable = {
|
|
111
|
+
lexicon: "aws",
|
|
112
|
+
entityType: "AWS::CloudFormation::Parameter",
|
|
113
|
+
[DECLARABLE_MARKER]: true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
test("derives lexicon from a Declarable referenced directly by a wrapping intrinsic (Ref(param))", () => {
|
|
117
|
+
const ref = {
|
|
118
|
+
[INTRINSIC_MARKER]: true as const,
|
|
119
|
+
target: param,
|
|
120
|
+
toJSON: () => ({ Ref: "environment" }),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const out = stackOutput(ref, { exportName: "EnvironmentName" });
|
|
124
|
+
expect(out.lexicon).toBe("aws");
|
|
125
|
+
expect(out.sourceRef).toBe(ref);
|
|
126
|
+
expect(out.exportName).toBe("EnvironmentName");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("derives lexicon from a Declarable referenced directly, nested inside a further intrinsic (Sub interpolating Ref(param))", () => {
|
|
130
|
+
const ref = {
|
|
131
|
+
[INTRINSIC_MARKER]: true as const,
|
|
132
|
+
target: param,
|
|
133
|
+
toJSON: () => ({ Ref: "environment" }),
|
|
134
|
+
};
|
|
135
|
+
const sub = {
|
|
136
|
+
[INTRINSIC_MARKER]: true as const,
|
|
137
|
+
values: [ref],
|
|
138
|
+
toJSON: () => ({ "Fn::Sub": "env-${environment}" }),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const out = stackOutput(sub);
|
|
142
|
+
expect(out.lexicon).toBe("aws");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("an explicit options.lexicon still wins over a Declarable-derived anchor", () => {
|
|
146
|
+
const ref = {
|
|
147
|
+
[INTRINSIC_MARKER]: true as const,
|
|
148
|
+
target: param,
|
|
149
|
+
toJSON: () => ({ Ref: "environment" }),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const out = stackOutput(ref, { lexicon: "gcp" });
|
|
153
|
+
expect(out.lexicon).toBe("gcp");
|
|
154
|
+
});
|
|
100
155
|
});
|
|
101
156
|
|
|
102
157
|
describe("isStackOutput", () => {
|
package/src/stack-output.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* it via `nestedStack().outputs.name`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
9
|
+
import { DECLARABLE_MARKER, isDeclarable, type Declarable } from "./declarable";
|
|
10
10
|
import { AttrRef } from "./attrref";
|
|
11
11
|
import { isIntrinsic, type Intrinsic } from "./intrinsic";
|
|
12
12
|
import { isAttrRefLike } from "./utils";
|
|
@@ -35,25 +35,47 @@ export interface StackOutput extends Declarable {
|
|
|
35
35
|
readonly exportName?: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
/** What a wrapping intrinsic's lexicon gets derived from: either a nested
|
|
39
|
+
* AttrRef (borrow its parent's `.lexicon`) or a Declarable referenced
|
|
40
|
+
* directly (use its own `.lexicon`). Two shapes, not one, because
|
|
41
|
+
* `Ref(someEntity)` (chant #1152) wraps the entity itself — a
|
|
42
|
+
* CloudFormation Parameter carries no attributes at all, so `Ref(param)`
|
|
43
|
+
* can never contain a nested AttrRef for a single-shape search to find. */
|
|
44
|
+
type Anchor = { readonly kind: "attrRef"; readonly ref: AttrRef } | { readonly kind: "declarable"; readonly entity: Declarable };
|
|
45
|
+
|
|
46
|
+
/** Find the first AttrRef or directly-referenced Declarable anywhere inside a
|
|
47
|
+
* value (walking intrinsics/objects/arrays), so a wrapping intrinsic can
|
|
48
|
+
* still borrow its source's lexicon. Duck-type, not `instanceof` (chant
|
|
49
|
+
* #1137): a lexicon built against a separate copy of `@intentius/chant`
|
|
50
|
+
* produces AttrRefs/Declarables that fail `instanceof` here but carry the
|
|
51
|
+
* same shape (`isDeclarable`'s `Symbol.for` marker is already dual-copy-safe
|
|
52
|
+
* on its own) — missing one would make the walk recurse into its own
|
|
53
|
+
* (unhelpful) fields instead of stopping on it, silently failing to find the
|
|
54
|
+
* anchor. */
|
|
55
|
+
function findAnchor(value: unknown, seen = new Set<unknown>()): Anchor | undefined {
|
|
46
56
|
if (value === null || typeof value !== "object" || seen.has(value)) return undefined;
|
|
47
|
-
if (isAttrRefLike(value)) return value;
|
|
57
|
+
if (isAttrRefLike(value)) return { kind: "attrRef", ref: value };
|
|
58
|
+
if (isDeclarable(value)) return { kind: "declarable", entity: value };
|
|
48
59
|
seen.add(value);
|
|
49
60
|
const children = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>);
|
|
50
61
|
for (const child of children) {
|
|
51
|
-
const found =
|
|
62
|
+
const found = findAnchor(child, seen);
|
|
52
63
|
if (found) return found;
|
|
53
64
|
}
|
|
54
65
|
return undefined;
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/** Derive the lexicon an anchor belongs to: an AttrRef borrows its (deref'd)
|
|
69
|
+
* parent's `.lexicon`; a directly-referenced Declarable reports its own. */
|
|
70
|
+
function anchorLexicon(anchor: Anchor | undefined): string {
|
|
71
|
+
if (!anchor) return "unknown";
|
|
72
|
+
if (anchor.kind === "declarable") return anchor.entity.lexicon;
|
|
73
|
+
const parent = anchor.ref.parent.deref();
|
|
74
|
+
return parent && typeof (parent as Record<string, unknown>).lexicon === "string"
|
|
75
|
+
? ((parent as Record<string, unknown>).lexicon as string)
|
|
76
|
+
: "unknown";
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
/**
|
|
58
80
|
* Type guard for StackOutput.
|
|
59
81
|
*/
|
|
@@ -106,16 +128,15 @@ export function stackOutput(
|
|
|
106
128
|
);
|
|
107
129
|
}
|
|
108
130
|
// Derive lexicon from the referenced entity — for a bare AttrRef, its parent;
|
|
109
|
-
// for an intrinsic (Join etc.), the first AttrRef
|
|
110
|
-
//
|
|
111
|
-
//
|
|
131
|
+
// for an intrinsic (Join etc.), the first AttrRef or directly-referenced
|
|
132
|
+
// Declarable nested inside it (chant #1152: `Ref(param)`/`Ref(resource)`
|
|
133
|
+
// wrap the entity itself, not one of its attributes). A foreign-copy
|
|
134
|
+
// AttrRef/Declarable failing raw `instanceof` here would fall to
|
|
135
|
+
// `findAnchor`, which (before its own #1137 fix) would also miss it —
|
|
112
136
|
// silently anchoring on nothing and recording `lexicon: "unknown"`.
|
|
113
|
-
const anchor
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
? (parent as Record<string, unknown>).lexicon as string
|
|
117
|
-
: "unknown";
|
|
118
|
-
const lexicon = options?.lexicon ?? derived;
|
|
137
|
+
const anchor: Anchor | undefined =
|
|
138
|
+
typeof ref === "string" ? undefined : isAttrRefLike(ref) ? { kind: "attrRef", ref } : findAnchor(ref);
|
|
139
|
+
const lexicon = options?.lexicon ?? anchorLexicon(anchor);
|
|
119
140
|
|
|
120
141
|
const output: StackOutput = {
|
|
121
142
|
[STACK_OUTPUT_MARKER]: true,
|