@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.
Files changed (54) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/operator.d.ts +0 -18
  3. package/dist/cli/handlers/operator.d.ts.map +1 -1
  4. package/dist/codegen/json-schema.d.ts +5 -2
  5. package/dist/codegen/json-schema.d.ts.map +1 -1
  6. package/dist/graph-ir.d.ts +70 -2
  7. package/dist/graph-ir.d.ts.map +1 -1
  8. package/dist/lexicon.d.ts +51 -0
  9. package/dist/lexicon.d.ts.map +1 -1
  10. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  11. package/dist/lifecycle/git.d.ts +117 -0
  12. package/dist/lifecycle/git.d.ts.map +1 -1
  13. package/dist/lifecycle/observe.d.ts.map +1 -1
  14. package/dist/observation.d.ts +21 -1
  15. package/dist/observation.d.ts.map +1 -1
  16. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  17. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  18. package/dist/op/activities/reconcile.d.ts +225 -14
  19. package/dist/op/activities/reconcile.d.ts.map +1 -1
  20. package/dist/op/gate.d.ts.map +1 -1
  21. package/dist/op/local-executor.d.ts +17 -2
  22. package/dist/op/local-executor.d.ts.map +1 -1
  23. package/dist/op/operator.d.ts.map +1 -1
  24. package/dist/op/runtimes/local.d.ts.map +1 -1
  25. package/dist/runtime-adapter.d.ts +8 -0
  26. package/dist/runtime-adapter.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/handlers/components.ts +1 -1
  29. package/src/cli/handlers/lifecycle.ts +1 -0
  30. package/src/cli/handlers/operator.test.ts +102 -1
  31. package/src/cli/handlers/operator.ts +75 -5
  32. package/src/codegen/json-schema.test.ts +159 -0
  33. package/src/codegen/json-schema.ts +106 -8
  34. package/src/graph-ir.test.ts +63 -0
  35. package/src/graph-ir.ts +125 -8
  36. package/src/lexicon.ts +51 -0
  37. package/src/lifecycle/assert-live.ts +1 -0
  38. package/src/lifecycle/git.test.ts +49 -5
  39. package/src/lifecycle/git.ts +312 -11
  40. package/src/lifecycle/observe.ts +3 -0
  41. package/src/observation.test.ts +21 -9
  42. package/src/observation.ts +31 -2
  43. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  44. package/src/op/activities/lexicon-upgrade.ts +55 -9
  45. package/src/op/activities/reconcile.test.ts +527 -1
  46. package/src/op/activities/reconcile.ts +446 -23
  47. package/src/op/gate.test.ts +504 -0
  48. package/src/op/gate.ts +9 -1
  49. package/src/op/local-executor.test.ts +115 -0
  50. package/src/op/local-executor.ts +130 -21
  51. package/src/op/operator.test.ts +20 -0
  52. package/src/op/operator.ts +39 -1
  53. package/src/op/runtimes/local.ts +11 -0
  54. 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 pushLifecycle().catch(() => undefined);
626
- console.error(formatSuccess(`Gate "${gate}" on "${opName}" expired at ${now} — not approved`));
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 pushLifecycle().catch(() => undefined);
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
- // Handle oneOf/anyOf → any
87
- if ((prop.oneOf && prop.oneOf.length > 0) || (prop.anyOf && prop.anyOf.length > 0)) {
88
- return "any";
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
- const sorted = [...prop.enum].sort();
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
- const itemType = resolvePropertyType(prop.items, schema, resolveDefName);
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
 
@@ -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
+ });