@intentius/chant 0.60.0 → 0.62.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/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +0 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/codegen/json-schema.d.ts +5 -2
- package/dist/codegen/json-schema.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +70 -2
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/observation.d.ts +21 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +225 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/lifecycle.ts +1 -0
- package/src/cli/handlers/operator.test.ts +102 -1
- package/src/cli/handlers/operator.ts +75 -5
- package/src/codegen/json-schema.test.ts +159 -0
- package/src/codegen/json-schema.ts +106 -8
- package/src/graph-ir.test.ts +63 -0
- package/src/graph-ir.ts +125 -8
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.ts +1 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/observe.ts +3 -0
- package/src/observation.test.ts +21 -9
- package/src/observation.ts +31 -2
- package/src/op/activities/lexicon-upgrade.test.ts +122 -39
- package/src/op/activities/lexicon-upgrade.ts +55 -9
- package/src/op/activities/reconcile.test.ts +527 -1
- package/src/op/activities/reconcile.ts +446 -23
- package/src/op/gate.test.ts +504 -0
- package/src/op/gate.ts +9 -1
- package/src/op/local-executor.test.ts +115 -0
- package/src/op/local-executor.ts +130 -21
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtimes/local.ts +11 -0
- package/src/runtime-adapter.ts +17 -3
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
35
35
|
resolveApprovalUrl, isApprovalUrl,
|
|
36
36
|
} from "../../lifecycle/gate-ledger";
|
|
37
|
-
import { pushLifecycle } from "../../lifecycle/git";
|
|
37
|
+
import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
|
|
38
38
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
39
39
|
import type { CommandContext } from "../registry";
|
|
40
40
|
|
|
@@ -598,6 +598,39 @@ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
|
|
|
598
598
|
* is already expired, which supersedes the standing one on an append-only
|
|
599
599
|
* ledger, so the next run decides the gate from scratch.
|
|
600
600
|
*/
|
|
601
|
+
/** The ledger branch, named in the warnings below so a reader can go look at it. */
|
|
602
|
+
const LIFECYCLE_BRANCH = "chant/lifecycle";
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Push the ledger and say so when it does not land (#2309 review, refs #2310).
|
|
606
|
+
*
|
|
607
|
+
* Both write paths here used `pushLifecycle().catch(() => undefined)` and then
|
|
608
|
+
* printed unconditional success and exited 0. A rejected push — a stale lease,
|
|
609
|
+
* no credentials, no network — therefore read as a completed approval, which
|
|
610
|
+
* is the one thing an approval must never do: the operator walks away
|
|
611
|
+
* believing the gate is answered for everybody, while the resolution exists
|
|
612
|
+
* only in their own checkout.
|
|
613
|
+
*
|
|
614
|
+
* The append is still a correct *local* fact, so this is a warning and not a
|
|
615
|
+
* failure; the exit code is unchanged.
|
|
616
|
+
*/
|
|
617
|
+
async function reportedPush(consequence: string): Promise<boolean> {
|
|
618
|
+
try {
|
|
619
|
+
const pushed = await pushLifecycle();
|
|
620
|
+
if (pushed) return true;
|
|
621
|
+
console.error(formatWarning({
|
|
622
|
+
message: `No remote is configured, so nothing was pushed. ${consequence}`,
|
|
623
|
+
}));
|
|
624
|
+
return false;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error(formatWarning({
|
|
627
|
+
message: `The push to the remote was rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
628
|
+
hint: consequence,
|
|
629
|
+
}));
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
601
634
|
export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
602
635
|
const opName = ctx.args.path;
|
|
603
636
|
const gate = ctx.args.extraPositional;
|
|
@@ -607,6 +640,18 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
607
640
|
}
|
|
608
641
|
|
|
609
642
|
if (ctx.args.expire) {
|
|
643
|
+
// Same guard as the approve path below (#2303): `--expire` reads the
|
|
644
|
+
// standing fact and appends beside it, so a clone that never fetched the
|
|
645
|
+
// branch would both fail to see the fact it is expiring and replace the
|
|
646
|
+
// branch with the expiry alone.
|
|
647
|
+
try {
|
|
648
|
+
await requireLifecycleLedger();
|
|
649
|
+
} catch (err) {
|
|
650
|
+
console.error(formatError({
|
|
651
|
+
message: `Cannot expire the gate: ${err instanceof Error ? err.message : String(err)}`,
|
|
652
|
+
}));
|
|
653
|
+
return 1;
|
|
654
|
+
}
|
|
610
655
|
const now = new Date().toISOString();
|
|
611
656
|
const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
|
|
612
657
|
if (!standing || isPendingGateExpired(standing, now)) {
|
|
@@ -622,8 +667,14 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
622
667
|
expiresAt: now,
|
|
623
668
|
...(standing.description ? { description: standing.description } : {}),
|
|
624
669
|
});
|
|
625
|
-
await
|
|
626
|
-
|
|
670
|
+
const pushed = await reportedPush(
|
|
671
|
+
`The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
672
|
+
`a run in another checkout still sees the old pending fact.`,
|
|
673
|
+
);
|
|
674
|
+
console.error(formatSuccess(
|
|
675
|
+
`Gate "${gate}" on "${opName}" expired at ${now} — not approved` +
|
|
676
|
+
(pushed ? "" : " (local only — the push did not land)"),
|
|
677
|
+
));
|
|
627
678
|
console.error(formatInfo(
|
|
628
679
|
`The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
|
|
629
680
|
));
|
|
@@ -682,6 +733,21 @@ export async function recordGateApproval(
|
|
|
682
733
|
}));
|
|
683
734
|
}
|
|
684
735
|
|
|
736
|
+
// Read the branch before appending to it (#2303 finding 2). Without this,
|
|
737
|
+
// an approve in a clone that never fetched `chant/lifecycle` builds its
|
|
738
|
+
// commit from an empty tree with no parent and replaces the branch with a
|
|
739
|
+
// single commit holding only this resolution — the pending fact it is
|
|
740
|
+
// answering is discarded rather than appended to.
|
|
741
|
+
try {
|
|
742
|
+
await requireLifecycleLedger();
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error(formatError({
|
|
745
|
+
message: `Cannot record the approval: ${err instanceof Error ? err.message : String(err)}`,
|
|
746
|
+
hint: "The pending fact this answers lives on that branch; appending without it would drop it.",
|
|
747
|
+
}));
|
|
748
|
+
return { ok: false };
|
|
749
|
+
}
|
|
750
|
+
|
|
685
751
|
const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
|
|
686
752
|
|
|
687
753
|
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
@@ -704,11 +770,15 @@ export async function recordGateApproval(
|
|
|
704
770
|
...(opts.note ? { note: opts.note } : {}),
|
|
705
771
|
...(url ? { url } : {}),
|
|
706
772
|
});
|
|
707
|
-
await
|
|
773
|
+
const pushed = await reportedPush(
|
|
774
|
+
`The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
775
|
+
`a run in another checkout will not see it.`,
|
|
776
|
+
);
|
|
708
777
|
|
|
709
778
|
console.error(formatSuccess(
|
|
710
779
|
`Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
|
|
711
|
-
(record.url ? ` (${record.url})` : "")
|
|
780
|
+
(record.url ? ` (${record.url})` : "") +
|
|
781
|
+
(pushed ? "" : " (local only — the push did not land)"),
|
|
712
782
|
));
|
|
713
783
|
return { ok: true, record };
|
|
714
784
|
}
|
|
@@ -110,6 +110,91 @@ describe("resolvePropertyType", () => {
|
|
|
110
110
|
};
|
|
111
111
|
expect(resolvePropertyType({ $ref: "#/definitions/Foo" }, schema, null)).toBe("any");
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// chant #2205 — a branch list beside a real `type` relaxes that type.
|
|
115
|
+
test("reads the sibling type through a oneOf", () => {
|
|
116
|
+
const prop: JsonSchemaProperty = {
|
|
117
|
+
type: "string",
|
|
118
|
+
oneOf: [{ pattern: "^a" }, { pattern: "^b" }],
|
|
119
|
+
};
|
|
120
|
+
expect(resolvePropertyType(prop, emptySchema, defName)).toBe("string");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reads the sibling $ref through an anyOf", () => {
|
|
124
|
+
const schema: JsonSchemaDocument = {
|
|
125
|
+
definitions: { Foo: { properties: { bar: { type: "string" } } } },
|
|
126
|
+
};
|
|
127
|
+
const prop: JsonSchemaProperty = {
|
|
128
|
+
$ref: "#/definitions/Foo",
|
|
129
|
+
anyOf: [{ required: ["bar"] }],
|
|
130
|
+
};
|
|
131
|
+
expect(resolvePropertyType(prop, schema, defName)).toBe("Test_Foo");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an anyOf branch carrying an enum narrows a string property to that union", () => {
|
|
135
|
+
// AWS::AmazonMQ::Broker.EngineType: the enum branch beside case-insensitive
|
|
136
|
+
// patterns for the same two values.
|
|
137
|
+
const prop: JsonSchemaProperty = {
|
|
138
|
+
type: "string",
|
|
139
|
+
anyOf: [
|
|
140
|
+
{ type: "string", enum: ["ACTIVEMQ", "RABBITMQ"] },
|
|
141
|
+
{ pattern: "^[Aa][Cc][Tt][Ii][Vv][Ee][Mm][Qq]$" },
|
|
142
|
+
{ pattern: "^[Rr][Aa][Bb][Bb][Ii][Tt][Mm][Qq]$" },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
expect(resolvePropertyType(prop, emptySchema, defName)).toBe('"ACTIVEMQ" | "RABBITMQ"');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a non-string enum branch is left alone", () => {
|
|
149
|
+
const prop: JsonSchemaProperty = {
|
|
150
|
+
type: "object",
|
|
151
|
+
anyOf: [{ enum: ["a", "b"] }],
|
|
152
|
+
};
|
|
153
|
+
expect(resolvePropertyType(prop, emptySchema, defName)).toBe("Record<string, any>");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a branch list with nothing beside it stays 'any'", () => {
|
|
157
|
+
const prop: JsonSchemaProperty = {
|
|
158
|
+
oneOf: [
|
|
159
|
+
{ type: "object", properties: { Fixed: { type: "string" } } },
|
|
160
|
+
{ type: "object", properties: { Below: { type: "string" } } },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
expect(resolvePropertyType(prop, emptySchema, defName)).toBe("any");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// chant #2205 — allOf of one $ref and an annotation types as that $ref.
|
|
167
|
+
test("resolves an allOf of a single $ref plus an annotation", () => {
|
|
168
|
+
const schema: JsonSchemaDocument = {
|
|
169
|
+
definitions: { ProtocolType: { type: "string", enum: ["MCP"] } },
|
|
170
|
+
};
|
|
171
|
+
const prop: JsonSchemaProperty = {
|
|
172
|
+
allOf: [{ $ref: "#/definitions/ProtocolType" }, { default: "MCP" }],
|
|
173
|
+
};
|
|
174
|
+
expect(resolvePropertyType(prop, schema, defName)).toBe("Test_ProtocolType");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("leaves an allOf that intersects two real shapes as 'any'", () => {
|
|
178
|
+
const schema: JsonSchemaDocument = {
|
|
179
|
+
definitions: {
|
|
180
|
+
A: { properties: { a: { type: "string" } } },
|
|
181
|
+
B: { properties: { b: { type: "string" } } },
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const prop: JsonSchemaProperty = {
|
|
185
|
+
allOf: [{ $ref: "#/definitions/A" }, { $ref: "#/definitions/B" }],
|
|
186
|
+
};
|
|
187
|
+
expect(resolvePropertyType(prop, schema, defName)).toBe("any");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// chant #2205 — `[]` binds tighter than `|`.
|
|
191
|
+
test("parenthesizes a union inside an array", () => {
|
|
192
|
+
const prop: JsonSchemaProperty = {
|
|
193
|
+
type: "array",
|
|
194
|
+
items: { type: "string", enum: ["arm64", "x86_64"] },
|
|
195
|
+
};
|
|
196
|
+
expect(resolvePropertyType(prop, emptySchema, defName)).toBe('("arm64" | "x86_64")[]');
|
|
197
|
+
});
|
|
113
198
|
});
|
|
114
199
|
|
|
115
200
|
describe("resolveRef", () => {
|
|
@@ -129,6 +214,80 @@ describe("resolveRef", () => {
|
|
|
129
214
|
};
|
|
130
215
|
expect(resolveRef("#/definitions/Count", schema, defName)).toBe("number");
|
|
131
216
|
});
|
|
217
|
+
|
|
218
|
+
// chant #2205 — CloudFormation names its list shapes, and a `$ref` to one
|
|
219
|
+
// used to fall through to "any".
|
|
220
|
+
test("resolves an array definition through its items", () => {
|
|
221
|
+
const schema: JsonSchemaDocument = {
|
|
222
|
+
definitions: {
|
|
223
|
+
TagList: { type: "array", items: { $ref: "#/definitions/Tag" } },
|
|
224
|
+
Tag: { properties: { Key: { type: "string" }, Value: { type: "string" } } },
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
expect(resolveRef("#/definitions/TagList", schema, defName)).toBe("Test_Tag[]");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("resolves an array definition of scalars", () => {
|
|
231
|
+
const schema: JsonSchemaDocument = {
|
|
232
|
+
definitions: { Names: { type: "array", items: { type: "string" } } },
|
|
233
|
+
};
|
|
234
|
+
expect(resolveRef("#/definitions/Names", schema, defName)).toBe("string[]");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("resolves an array definition with no items to 'any[]'", () => {
|
|
238
|
+
const schema: JsonSchemaDocument = {
|
|
239
|
+
definitions: { Loose: { type: "array" } },
|
|
240
|
+
};
|
|
241
|
+
expect(resolveRef("#/definitions/Loose", schema, defName)).toBe("any[]");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("parenthesizes a union inside an array definition", () => {
|
|
245
|
+
const schema: JsonSchemaDocument = {
|
|
246
|
+
definitions: { Modes: { type: "array", items: { type: "string", enum: ["b", "a"] } } },
|
|
247
|
+
};
|
|
248
|
+
expect(resolveRef("#/definitions/Modes", schema, defName)).toBe('("a" | "b")[]');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a list definition that reaches itself terminates", () => {
|
|
252
|
+
const schema: JsonSchemaDocument = {
|
|
253
|
+
definitions: { Tree: { type: "array", items: { $ref: "#/definitions/Tree" } } },
|
|
254
|
+
};
|
|
255
|
+
expect(resolveRef("#/definitions/Tree", schema, defName)).toBe("any[]");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a definition that is an allOf of one $ref resolves as that $ref", () => {
|
|
259
|
+
const schema: JsonSchemaDocument = {
|
|
260
|
+
definitions: {
|
|
261
|
+
Wrapped: { allOf: [{ $ref: "#/definitions/Inner" }, { default: "x" }] },
|
|
262
|
+
Inner: { properties: { a: { type: "string" } } },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
expect(resolveRef("#/definitions/Wrapped", schema, defName)).toBe("Test_Inner");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a definition that is a sum of object branches stays 'any'", () => {
|
|
269
|
+
const schema: JsonSchemaDocument = {
|
|
270
|
+
definitions: {
|
|
271
|
+
FieldPosition: {
|
|
272
|
+
oneOf: [
|
|
273
|
+
{ type: "object", properties: { Fixed: { type: "string" } } },
|
|
274
|
+
{ type: "object", properties: { Below: { type: "string" } } },
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
expect(resolveRef("#/definitions/FieldPosition", schema, defName)).toBe("any");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a nested list definition resolves through both hops", () => {
|
|
283
|
+
const schema: JsonSchemaDocument = {
|
|
284
|
+
definitions: {
|
|
285
|
+
Matrix: { type: "array", items: { $ref: "#/definitions/Row" } },
|
|
286
|
+
Row: { type: "array", items: { type: "number" } },
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
expect(resolveRef("#/definitions/Matrix", schema, defName)).toBe("number[][]");
|
|
290
|
+
});
|
|
132
291
|
});
|
|
133
292
|
|
|
134
293
|
describe("extractConstraints", () => {
|
|
@@ -20,6 +20,7 @@ export interface JsonSchemaProperty {
|
|
|
20
20
|
items?: JsonSchemaProperty;
|
|
21
21
|
oneOf?: JsonSchemaProperty[];
|
|
22
22
|
anyOf?: JsonSchemaProperty[];
|
|
23
|
+
allOf?: JsonSchemaProperty[];
|
|
23
24
|
properties?: Record<string, JsonSchemaProperty>;
|
|
24
25
|
required?: string[];
|
|
25
26
|
enum?: string[];
|
|
@@ -54,6 +55,9 @@ export interface PropertyConstraints {
|
|
|
54
55
|
|
|
55
56
|
// --- Functions ---
|
|
56
57
|
|
|
58
|
+
/** Shared empty cycle guard, so the common call allocates nothing. */
|
|
59
|
+
const EMPTY_SEEN: ReadonlySet<string> = new Set<string>();
|
|
60
|
+
|
|
57
61
|
/**
|
|
58
62
|
* Get the primary type from a type field that can be string or string[].
|
|
59
63
|
* Returns first non-"null" type, or "any" if empty.
|
|
@@ -67,6 +71,66 @@ export function primaryType(type: string | string[] | undefined): string {
|
|
|
67
71
|
return type.length > 0 ? type[0] : "any";
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/** The branch list a property carries under `oneOf` or `anyOf`, or undefined. */
|
|
75
|
+
function branchesOf(prop: JsonSchemaProperty): JsonSchemaProperty[] | undefined {
|
|
76
|
+
if (prop.oneOf && prop.oneOf.length > 0) return prop.oneOf;
|
|
77
|
+
if (prop.anyOf && prop.anyOf.length > 0) return prop.anyOf;
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A union of string literals, sorted, from a list of enum values. */
|
|
82
|
+
function enumUnion(values: string[]): string {
|
|
83
|
+
return [...values].sort().map((v) => JSON.stringify(v)).join(" | ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `T[]`, parenthesized when `T` is a union.
|
|
88
|
+
*
|
|
89
|
+
* `[]` binds tighter than `|`, so an unparenthesized `"a" | "b"[]` reads as
|
|
90
|
+
* `"a" | ("b"[])`: it accepts the bare string `"a"` and rejects `["a"]`.
|
|
91
|
+
*/
|
|
92
|
+
function arrayOf(itemType: string): string {
|
|
93
|
+
return itemType.includes(" | ") ? `(${itemType})[]` : `${itemType}[]`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The branch of a `oneOf`/`anyOf` that narrows a string property to an enum.
|
|
98
|
+
*
|
|
99
|
+
* CloudFormation writes several string properties as a `type: "string"` beside
|
|
100
|
+
* a branch list whose first branch is the real enum and whose other branches
|
|
101
|
+
* are case-insensitive `pattern`s for the same values (`AWS::AmazonMQ::Broker`'s
|
|
102
|
+
* `EngineType`). The branch list relaxes the enum rather than summing shapes, so
|
|
103
|
+
* the enum is the useful type. Only strings qualify: anything else is a real sum.
|
|
104
|
+
*/
|
|
105
|
+
function relaxedEnumBranch(
|
|
106
|
+
prop: JsonSchemaProperty,
|
|
107
|
+
branches: JsonSchemaProperty[],
|
|
108
|
+
): string[] | undefined {
|
|
109
|
+
if (prop.type !== undefined && primaryType(prop.type) !== "string") return undefined;
|
|
110
|
+
for (const b of branches) {
|
|
111
|
+
if (!b.enum || b.enum.length === 0) continue;
|
|
112
|
+
if (b.type !== undefined && primaryType(b.type) !== "string") return undefined;
|
|
113
|
+
if (!b.enum.every((v) => typeof v === "string")) return undefined;
|
|
114
|
+
return b.enum;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The single content-bearing branch of an `allOf`, when that is the whole shape.
|
|
121
|
+
*
|
|
122
|
+
* Every `allOf` in the CloudFormation Registry is one `$ref` beside an
|
|
123
|
+
* annotation object (`{ "default": "MCP" }`), which types exactly as the `$ref`
|
|
124
|
+
* alone. An `allOf` that intersects two real shapes has no single answer and is
|
|
125
|
+
* left to the caller.
|
|
126
|
+
*/
|
|
127
|
+
function soleTypedBranch(branches: JsonSchemaProperty[]): JsonSchemaProperty | undefined {
|
|
128
|
+
const typed = branches.filter(
|
|
129
|
+
(b) => b.$ref || b.type || b.properties || b.items || (b.enum && b.enum.length > 0) || branchesOf(b),
|
|
130
|
+
);
|
|
131
|
+
return typed.length === 1 ? typed[0] : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
70
134
|
/**
|
|
71
135
|
* Resolve a schema property to its TypeScript type string.
|
|
72
136
|
*
|
|
@@ -75,28 +139,43 @@ export function primaryType(type: string | string[] | undefined): string {
|
|
|
75
139
|
* @param resolveDefName - Callback to produce a TypeScript name from a definition.
|
|
76
140
|
* Receives (defName: string) and should return the TS type name for that definition.
|
|
77
141
|
* When null, $ref to object definitions resolves to "any".
|
|
142
|
+
* @param seen - Definition names already on the current resolution path, so a
|
|
143
|
+
* list definition that reaches itself terminates instead of recursing forever.
|
|
78
144
|
*/
|
|
79
145
|
export function resolvePropertyType(
|
|
80
146
|
prop: JsonSchemaProperty | undefined,
|
|
81
147
|
schema: JsonSchemaDocument,
|
|
82
148
|
resolveDefName: ((defName: string) => string) | null,
|
|
149
|
+
seen: ReadonlySet<string> = EMPTY_SEEN,
|
|
83
150
|
): string {
|
|
84
151
|
if (!prop) return "any";
|
|
85
152
|
|
|
86
|
-
//
|
|
87
|
-
if (
|
|
88
|
-
|
|
153
|
+
// `allOf` of one real branch and some annotations types as that branch.
|
|
154
|
+
if (prop.allOf && prop.allOf.length > 0 && !prop.$ref && !prop.type && !prop.enum) {
|
|
155
|
+
const sole = soleTypedBranch(prop.allOf);
|
|
156
|
+
if (sole) return resolvePropertyType(sole, schema, resolveDefName, seen);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const branches = branchesOf(prop);
|
|
160
|
+
if (branches) {
|
|
161
|
+
// A branch list beside a `type` relaxes that type; the enum branch, when
|
|
162
|
+
// there is one, is the narrowest reading of it.
|
|
163
|
+
const relaxed = relaxedEnumBranch(prop, branches);
|
|
164
|
+
if (relaxed) return enumUnion(relaxed);
|
|
165
|
+
// With nothing beside it the branch list is a sum of shapes, which this
|
|
166
|
+
// emitter does not express yet (chant #2278).
|
|
167
|
+
if (!prop.$ref && !prop.type && !(prop.enum && prop.enum.length > 0)) return "any";
|
|
168
|
+
// Otherwise fall through and read the sibling keywords.
|
|
89
169
|
}
|
|
90
170
|
|
|
91
171
|
// Handle $ref
|
|
92
172
|
if (prop.$ref) {
|
|
93
|
-
return resolveRef(prop.$ref, schema, resolveDefName);
|
|
173
|
+
return resolveRef(prop.$ref, schema, resolveDefName, seen);
|
|
94
174
|
}
|
|
95
175
|
|
|
96
176
|
// Inline enum → union of string literals
|
|
97
177
|
if (prop.enum && prop.enum.length > 0) {
|
|
98
|
-
|
|
99
|
-
return sorted.map((v) => JSON.stringify(v)).join(" | ");
|
|
178
|
+
return enumUnion(prop.enum);
|
|
100
179
|
}
|
|
101
180
|
|
|
102
181
|
const pt = primaryType(prop.type);
|
|
@@ -111,8 +190,7 @@ export function resolvePropertyType(
|
|
|
111
190
|
return "boolean";
|
|
112
191
|
case "array":
|
|
113
192
|
if (prop.items) {
|
|
114
|
-
|
|
115
|
-
return `${itemType}[]`;
|
|
193
|
+
return arrayOf(resolvePropertyType(prop.items, schema, resolveDefName, seen));
|
|
116
194
|
}
|
|
117
195
|
return "any[]";
|
|
118
196
|
case "object":
|
|
@@ -134,6 +212,7 @@ export function resolveRef(
|
|
|
134
212
|
ref: string,
|
|
135
213
|
schema: JsonSchemaDocument,
|
|
136
214
|
resolveDefName: ((defName: string) => string) | null,
|
|
215
|
+
seen: ReadonlySet<string> = EMPTY_SEEN,
|
|
137
216
|
): string {
|
|
138
217
|
const prefix = "#/definitions/";
|
|
139
218
|
if (!ref.startsWith(prefix)) return "any";
|
|
@@ -141,6 +220,8 @@ export function resolveRef(
|
|
|
141
220
|
const defName = ref.slice(prefix.length);
|
|
142
221
|
const def = schema.definitions?.[defName];
|
|
143
222
|
if (!def) return "any";
|
|
223
|
+
// A list definition whose items reach it again would recurse without end.
|
|
224
|
+
if (seen.has(defName)) return "any";
|
|
144
225
|
|
|
145
226
|
// String enum → named type (via resolveDefName) or string
|
|
146
227
|
if (isEnumDefinition(def)) {
|
|
@@ -161,9 +242,26 @@ export function resolveRef(
|
|
|
161
242
|
case "number": return "number";
|
|
162
243
|
case "boolean": return "boolean";
|
|
163
244
|
case "object": return "Record<string, any>";
|
|
245
|
+
// CloudFormation names its list shapes: `Tags` is a `$ref` to a `TagList`
|
|
246
|
+
// definition whose items are the `Tag` definition one hop away. Resolve
|
|
247
|
+
// through `items` the way the inline `array` case does. (chant #2205)
|
|
248
|
+
case "array": {
|
|
249
|
+
if (!def.items) return "any[]";
|
|
250
|
+
const nested = new Set(seen);
|
|
251
|
+
nested.add(defName);
|
|
252
|
+
return arrayOf(resolvePropertyType(def.items, schema, resolveDefName, nested));
|
|
253
|
+
}
|
|
164
254
|
}
|
|
165
255
|
}
|
|
166
256
|
|
|
257
|
+
// An `allOf`/`oneOf`/`anyOf` definition types as its property form would.
|
|
258
|
+
if (def.allOf || def.oneOf || def.anyOf) {
|
|
259
|
+
const nested = new Set(seen);
|
|
260
|
+
nested.add(defName);
|
|
261
|
+
const viaBranches = resolvePropertyType(def, schema, resolveDefName, nested);
|
|
262
|
+
if (viaBranches !== "any") return viaBranches;
|
|
263
|
+
}
|
|
264
|
+
|
|
167
265
|
return "any";
|
|
168
266
|
}
|
|
169
267
|
|
package/src/graph-ir.test.ts
CHANGED
|
@@ -224,3 +224,66 @@ describe("buildGraphIr", () => {
|
|
|
224
224
|
expect(JSON.stringify(buildGraphIr(reordered))).toEqual(JSON.stringify(ir));
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The two duck-typed channels an entity may carry beside its `props` (#2265,
|
|
230
|
+
* #2266), for a lexicon whose references are strings in someone else's syntax
|
|
231
|
+
* rather than `AttrRef` objects. Asserted here with a made-up lexicon rather
|
|
232
|
+
* than terraform's, because what is being checked is that this module reads
|
|
233
|
+
* the channels without knowing anything about who filled them.
|
|
234
|
+
*/
|
|
235
|
+
describe("entity-declared references and stacks", () => {
|
|
236
|
+
test("turns a declared reference into an edge, with both attribute names", () => {
|
|
237
|
+
const vpc = decl({ lexicon: "hcl", entityType: "Vpc" });
|
|
238
|
+
const subnet = decl({
|
|
239
|
+
lexicon: "hcl",
|
|
240
|
+
entityType: "Subnet",
|
|
241
|
+
props: { vpc_id: "${aws_vpc.main.id}" },
|
|
242
|
+
references: [{ to: "vpc", viaAttr: "vpc_id", toAttr: "id" }],
|
|
243
|
+
});
|
|
244
|
+
const entities = new Map<string, Declarable>([
|
|
245
|
+
["vpc", vpc],
|
|
246
|
+
["subnet", subnet],
|
|
247
|
+
]);
|
|
248
|
+
resolveAttrRefs(entities);
|
|
249
|
+
|
|
250
|
+
const ir = buildGraphIr(entities);
|
|
251
|
+
expect(ir.edges).toEqual([{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "vpc_id", toAttr: "id" }]);
|
|
252
|
+
// The reference channel is metadata about the graph, not declared config,
|
|
253
|
+
// so it never lands in attrs; the string it was resolved from still does.
|
|
254
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ vpc_id: "${aws_vpc.main.id}" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("drops a declared reference to a self or to something that is not a node", () => {
|
|
258
|
+
const a = decl({
|
|
259
|
+
lexicon: "hcl",
|
|
260
|
+
entityType: "Vpc",
|
|
261
|
+
references: [
|
|
262
|
+
{ to: "a", viaAttr: "self" },
|
|
263
|
+
{ to: "nowhere", viaAttr: "gone" },
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const entities = new Map<string, Declarable>([["a", a]]);
|
|
267
|
+
resolveAttrRefs(entities);
|
|
268
|
+
expect(buildGraphIr(entities).edges).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("keys byStack by the unit an entity names, and leaves byLexicon alone", () => {
|
|
272
|
+
const app = decl({ lexicon: "hcl", entityType: "Vpc", stack: "app" });
|
|
273
|
+
const net = decl({ lexicon: "hcl", entityType: "Vpc", stack: "network" });
|
|
274
|
+
// An entity that names no unit still falls back to the lexicon partition,
|
|
275
|
+
// which is every other lexicon's behaviour and stays unchanged.
|
|
276
|
+
const plain = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
277
|
+
const entities = new Map<string, Declarable>([
|
|
278
|
+
["app/vpc", app],
|
|
279
|
+
["network/vpc", net],
|
|
280
|
+
["plain", plain],
|
|
281
|
+
]);
|
|
282
|
+
resolveAttrRefs(entities);
|
|
283
|
+
|
|
284
|
+
const ir = buildGraphIr(entities);
|
|
285
|
+
expect(ir.groups.byStack).toEqual({ app: ["app/vpc"], gcp: ["plain"], network: ["network/vpc"] });
|
|
286
|
+
expect(ir.groups.byLexicon).toEqual({ gcp: ["plain"], hcl: ["app/vpc", "network/vpc"] });
|
|
287
|
+
expect(ir.nodes.find((n) => n.id === "app/vpc")!.attrs).not.toHaveProperty("stack");
|
|
288
|
+
});
|
|
289
|
+
});
|