@intentius/chant 0.59.0 → 0.61.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 (60) hide show
  1. package/dist/build-params.d.ts +2 -2
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/lint.d.ts.map +1 -1
  5. package/dist/codegen/json-schema.d.ts +5 -2
  6. package/dist/codegen/json-schema.d.ts.map +1 -1
  7. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  8. package/dist/config.d.ts +4 -4
  9. package/dist/graph-ir.d.ts +70 -2
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +48 -5
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  14. package/dist/lifecycle/observe.d.ts +4 -4
  15. package/dist/lifecycle/observe.d.ts.map +1 -1
  16. package/dist/observation.d.ts +21 -1
  17. package/dist/observation.d.ts.map +1 -1
  18. package/dist/op/activities/index.d.ts +1 -1
  19. package/dist/op/activities/index.d.ts.map +1 -1
  20. package/dist/op/activities/reconcile.d.ts +79 -7
  21. package/dist/op/activities/reconcile.d.ts.map +1 -1
  22. package/dist/op/gate-summary.d.ts +16 -4
  23. package/dist/op/gate-summary.d.ts.map +1 -1
  24. package/dist/params.d.ts +1 -1
  25. package/dist/project-root.d.ts +2 -2
  26. package/package.json +1 -1
  27. package/src/build-params.ts +2 -2
  28. package/src/cli/commands/build.ts +8 -8
  29. package/src/cli/commands/lint.test.ts +151 -0
  30. package/src/cli/commands/lint.ts +37 -4
  31. package/src/cli/handlers/components.ts +1 -1
  32. package/src/cli/handlers/graph.test.ts +4 -4
  33. package/src/cli/handlers/graph.ts +11 -11
  34. package/src/cli/handlers/lifecycle.ts +1 -0
  35. package/src/cli/handlers/lint.test.ts +107 -0
  36. package/src/cli/handlers/lint.ts +30 -0
  37. package/src/codegen/json-schema.test.ts +159 -0
  38. package/src/codegen/json-schema.ts +106 -8
  39. package/src/components/SPRAWL-VALIDATION.md +5 -5
  40. package/src/components/pilots/README.md +1 -1
  41. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  42. package/src/config.ts +4 -4
  43. package/src/discovery/fold-import.test.ts +1 -1
  44. package/src/discovery/fold-import.ts +3 -3
  45. package/src/graph-ir.test.ts +63 -0
  46. package/src/graph-ir.ts +125 -8
  47. package/src/lexicon.ts +49 -5
  48. package/src/lifecycle/assert-live.ts +1 -0
  49. package/src/lifecycle/observe.test.ts +2 -2
  50. package/src/lifecycle/observe.ts +11 -8
  51. package/src/lifecycle/release-ledger.test.ts +2 -2
  52. package/src/observation.test.ts +21 -9
  53. package/src/observation.ts +31 -2
  54. package/src/op/activities/index.ts +8 -1
  55. package/src/op/activities/reconcile.test.ts +238 -0
  56. package/src/op/activities/reconcile.ts +267 -13
  57. package/src/op/gate-summary.test.ts +62 -0
  58. package/src/op/gate-summary.ts +17 -5
  59. package/src/params.ts +1 -1
  60. package/src/project-root.ts +2 -2
@@ -0,0 +1,107 @@
1
+ /**
2
+ * chant #2251 — `chant lint` resolves this invocation's declared build-time
3
+ * parameters before it lints.
4
+ *
5
+ * The gap this covers: `runLint` built its `LintOptions` without ever
6
+ * touching `chant.config.ts`'s `buildParams`, so the OPS* checks imported
7
+ * every `*.op.ts` file with `params` (`@intentius/chant/params`) still empty.
8
+ * An Op taking a step argument from `params.<name>` therefore read
9
+ * `undefined` and OPS012 reported the activity contract violated on source
10
+ * that builds and runs — reproducible on any project with an Op that reads a
11
+ * build parameter, `--param` and the declared `env` mapping alike (the
12
+ * resolution never ran at all, so no input could reach it).
13
+ *
14
+ * Mocks `lintCommand` and `loadChantConfigUpward` and drives the exported
15
+ * `runLint` dispatcher, the same shape ./build.test.ts uses for the matching
16
+ * #1108 gap in generate mode.
17
+ */
18
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
19
+ import type { ParsedArgs } from "../registry";
20
+
21
+ const lintCommandMock = vi.fn();
22
+ const loadChantConfigUpwardMock = vi.fn();
23
+
24
+ vi.mock("../commands/lint", async () => {
25
+ const actual = await vi.importActual<typeof import("../commands/lint")>("../commands/lint");
26
+ return {
27
+ ...actual,
28
+ lintCommand: (...args: unknown[]) => lintCommandMock(...args),
29
+ printLintResult: () => {},
30
+ };
31
+ });
32
+ vi.mock("../../config", async () => {
33
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
34
+ return { ...actual, loadChantConfigUpward: (...args: unknown[]) => loadChantConfigUpwardMock(...args) };
35
+ });
36
+
37
+ const { runLint } = await import("./lint");
38
+
39
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
40
+ return {
41
+ command: "lint",
42
+ path: ".",
43
+ format: "",
44
+ fix: false,
45
+ watch: false,
46
+ verbose: false,
47
+ help: false,
48
+ live: false,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("runLint build-time parameters (#2251)", () => {
54
+ beforeEach(() => {
55
+ lintCommandMock.mockReset().mockResolvedValue({ success: true, errorCount: 0, warningCount: 0, diagnostics: [] });
56
+ loadChantConfigUpwardMock.mockReset().mockResolvedValue({ config: {} });
57
+ vi.spyOn(console, "error").mockImplementation(() => {});
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ test("a project declaring no buildParams lints with an empty provenance array", async () => {
65
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
66
+
67
+ expect(exit).toBe(0);
68
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([]);
69
+ });
70
+
71
+ test("declared buildParams resolve from their defaults and reach lintCommand", async () => {
72
+ loadChantConfigUpwardMock.mockResolvedValue({
73
+ config: { buildParams: { env: { type: "string", default: "local" } } },
74
+ });
75
+
76
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
77
+
78
+ expect(exit).toBe(0);
79
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
80
+ { name: "env", value: "local", source: "default" },
81
+ ]);
82
+ });
83
+
84
+ test("--param overrides the declared default, the same precedence chant build applies", async () => {
85
+ loadChantConfigUpwardMock.mockResolvedValue({
86
+ config: { buildParams: { env: { type: "string", default: "local" } } },
87
+ });
88
+
89
+ const exit = await runLint({ args: makeArgs({ param: ["env=pr-42"] }), plugins: [], serializers: [] });
90
+
91
+ expect(exit).toBe(0);
92
+ expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
93
+ { name: "env", value: "pr-42", source: "cli" },
94
+ ]);
95
+ });
96
+
97
+ test("an unresolvable parameter exits non-zero without linting", async () => {
98
+ loadChantConfigUpwardMock.mockResolvedValue({
99
+ config: { buildParams: { env: { type: "string", required: true } } },
100
+ });
101
+
102
+ const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
103
+
104
+ expect(exit).toBe(1);
105
+ expect(lintCommandMock).not.toHaveBeenCalled();
106
+ });
107
+ });
@@ -1,6 +1,31 @@
1
+ import { resolve } from "node:path";
1
2
  import { lintCommand, lintCommandWatch, printLintResult } from "../commands/lint";
2
3
  import { formatError, formatInfo } from "../format";
3
4
  import type { CommandContext } from "../registry";
5
+ import { commandBuildParams } from "../build-params-cli";
6
+ import { loadChantConfigUpward, type ChantConfig } from "../../config";
7
+
8
+ /**
9
+ * chant #2251 — `chant lint` resolves this invocation's declared build-time
10
+ * parameters (`chant.config.ts`'s `buildParams`) before it lints, the same
11
+ * way `chant build` and the lifecycle family do (`commandBuildParams`,
12
+ * ../build-params-cli.ts).
13
+ *
14
+ * The OPS* checks import every `*.op.ts` file to read the Op it declares
15
+ * (../commands/lint.ts's `runOpCheckDiagnostics`), and an Op that takes a
16
+ * step argument from `params.<name>` (`@intentius/chant/params`) evaluates
17
+ * that read at module load. With no parameters resolved, `params` is the
18
+ * empty object every such argument reads `undefined` out of, and OPS012
19
+ * reports the activity contract violated — `args.env: expected string,
20
+ * received undefined` — for source that builds and runs correctly. The
21
+ * config is loaded by walking up from the lint path, so linting a
22
+ * subdirectory still sees the project root's declarations.
23
+ */
24
+ async function lintBuildParams(args: { path: string; param?: string[]; paramsFile?: string }) {
25
+ const { config } = await loadChantConfigUpward(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
26
+ if (!config.buildParams) return [];
27
+ return commandBuildParams(config.buildParams, args);
28
+ }
4
29
 
5
30
  export async function runLint(ctx: CommandContext): Promise<number> {
6
31
  const { args } = ctx;
@@ -11,12 +36,16 @@ export async function runLint(ctx: CommandContext): Promise<number> {
11
36
  return 1;
12
37
  }
13
38
 
39
+ const buildParams = await lintBuildParams(args);
40
+ if (buildParams === undefined) return 1;
41
+
14
42
  if (args.watch) {
15
43
  const cleanup = lintCommandWatch({
16
44
  path: args.path,
17
45
  fix: args.fix,
18
46
  format: lintFormat,
19
47
  sandbox: args.sandbox,
48
+ buildParams,
20
49
  });
21
50
  process.on("SIGINT", () => {
22
51
  cleanup();
@@ -31,6 +60,7 @@ export async function runLint(ctx: CommandContext): Promise<number> {
31
60
  fix: args.fix,
32
61
  format: lintFormat,
33
62
  sandbox: args.sandbox,
63
+ buildParams,
34
64
  });
35
65
 
36
66
  printLintResult(result);
@@ -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
 
@@ -16,16 +16,16 @@ generic `runInterpretDriver` (#556, [`../driver.ts`](./driver.ts)), unchanged.
16
16
  ## Before / after: the ALB/ECS pipeline glue this replaces
17
17
 
18
18
  The component model's whole reason for existing is visible in one concrete
19
- diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
20
- hand-rolls a `deployService` job that shells out to CloudFormation and greps
19
+ diff. [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-services/src/pipeline.ts)
20
+ hand-rolls a `deployServices` job that shells out to CloudFormation and greps
21
21
  its own infra stack's outputs before it can deploy:
22
22
 
23
23
  ```ts
24
- // before — examples/gitlab-aws-alb-api/src/pipeline.ts (deployService job)
24
+ // before — examples/gitlab-aws-alb-services/src/pipeline.ts (deployServices job)
25
25
  `OUTPUTS=$(aws cloudformation describe-stacks --stack-name ${INFRA_STACK} --query 'Stacks[0].Outputs' --output json)`,
26
26
  `PARAMS=$(echo "$OUTPUTS" | jq -r '[(.[] | select(.OutputKey == "ClusterArn") | "clusterArn=" + .OutputValue), (.[] | select(.OutputKey == "ListenerArn") | "listenerArn=" + .OutputValue), ...] | join(" ")')`,
27
- `IMAGE_URI=$(echo "$OUTPUTS" | jq -r '.[] | select(.OutputKey == "ApiRepoUri") | .OutputValue'):${CI_COMMIT_REF_SLUG}`,
28
- `aws cloudformation deploy --template-file templates/template.json --stack-name ${STACK_NAME} ... --parameter-overrides $PARAMS image=$IMAGE_URI`,
27
+ `API_IMAGE_URI=$(echo "$OUTPUTS" | jq -r '.[] | select(.OutputKey == "ApiRepoUri") | .OutputValue'):${CI_COMMIT_REF_SLUG}`,
28
+ `aws cloudformation deploy --template-file templates/template.json --stack-name ${STACK_NAME} ... --parameter-overrides $PARAMS apiImage=$API_IMAGE_URI uiImage=$UI_IMAGE_URI`,
29
29
  ```
30
30
 
31
31
  That `describe-stacks | jq` line is bespoke per pipeline: every component that
@@ -13,7 +13,7 @@ The epic ([#551](https://github.com/INTENTIUS/chant/issues/551)) picked these th
13
13
  |---|---|---|---|---|---|---|
14
14
  | **Neo4j per-instance fan-out** | [`neo4j-fanout.pilot.ts`](./neo4j-fanout.pilot.ts) / [`neo4j-fanout.json`](../__fixtures__/neo4j-fanout.json) | no-build (`infra`, applies pre-built templates) | **fan-out** — one component composes 3 per-instance mini-compositions (`cfn-deploy` + `code-deploy` + `wait-cluster-healthy`), seed-first then rolling, gated at node 1 | simple apply (no `onReplace`/`stageGsi` — no sticky CFN concerns here) | none (self-contained cluster, no shared-stack imports) | **auto** — `code-deploy` (AWS CodeDeploy) rollback is native/automatic on failure, declared once inside the capability, never scripted per node |
15
15
  | **DynamoDB table** | [`dynamodb.pilot.ts`](./dynamodb.pilot.ts) / [`dynamodb-infra.json`](../__fixtures__/dynamodb-infra.json) | no-build (`infra`, applies an existing table template) | single (one `cfn-deploy`, no fan-out) | **sticky** — `onReplace: "block"` refuses a replacing changeset (data loss guard); `stageGsi: true` stages the GSI add→backfill→remove instead of an in-place replace | none | no rollback declared — a blocked replacement is not something to compensate, it is a stop |
16
- | **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-api/src/pipeline.ts) | **no** (component-declared) — `ecs-update-service`/`cfn-deploy` have no native automatic rollback for an already-running service swap, so the component supplies an explicit `rollback` phase (`rollback-previous`) rather than relying on capability compensation |
16
+ | **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-services/src/pipeline.ts) | **no** (component-declared) — `ecs-update-service`/`cfn-deploy` have no native automatic rollback for an already-running service swap, so the component supplies an explicit `rollback` phase (`rollback-previous`) rather than relying on capability compensation |
17
17
 
18
18
  Read together, the three cover every cell at least once: build only shows up for ALB/ECS, fan-out only for Neo4j, sticky-apply only for DynamoDB, cross-stack only for ALB/ECS, and both rollback styles (capability-native vs component-declared) appear once each.
19
19
 
@@ -9,8 +9,8 @@
9
9
  * `ecs-update-service` → `wait-steady-state` + `health-gate`.
10
10
  *
11
11
  * This is the direct component-native replacement for the hand-rolled GitLab
12
- * pipeline in `examples/gitlab-aws-alb-api/src/pipeline.ts`: the
13
- * `describe-stacks`/`jq` glue in that pipeline's `deployService` job is
12
+ * pipeline in `examples/gitlab-aws-alb-services/src/pipeline.ts`: the
13
+ * `describe-stacks`/`jq` glue in that pipeline's `deployServices` job is
14
14
  * exactly the cross-stack `stackOutput()` wiring below, and its
15
15
  * `docker build`/`docker push` steps are the `docker-build` + `publish-image`
16
16
  * capabilities. `service` archetype: build → publish → apply → verify, the
package/src/config.ts CHANGED
@@ -481,10 +481,10 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
481
481
  * lives at the project root, one or more levels up. Before this, callers
482
482
  * either read `startDir` alone or bolted on a single `dirname()` fallback —
483
483
  * fine for a one-level-deep stack, silently blind to anything deeper
484
- * (loomster's `src/<stack>` layout is exactly one level too deep: `buildParams`'
485
- * declared `env:` mappings never resolved, so `LOOM_TIER`/`LOOM_ENV` were inert
486
- * under every `npm run synth:*` for two releases — loomster#162). Uses the
487
- * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
484
+ * (a `src/<stack>` layout is exactly one level too deep: `buildParams`' declared
485
+ * `env:` mappings never resolved, so the env vars they named were inert under
486
+ * every `npm run synth:*` for two releases). Uses the same walk `chant
487
+ * lint`/`chant graph` already used ({@link findProjectConfig},
488
488
  * shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
489
489
  * contract for the whole CLI.
490
490
  *
@@ -1009,7 +1009,7 @@ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
1009
1009
  expect((entity as unknown as { props: { name: unknown } }).props.name).toBe("staging");
1010
1010
  });
1011
1011
 
1012
- test("a nullish-coalesced default still folds to a literal (loomster's `params.x ?? \"default\"` pattern)", async () => {
1012
+ test("a nullish-coalesced default still folds to a literal (the `params.x ?? \"default\"` pattern)", async () => {
1013
1013
  const file = join(testDir, "main.ts");
1014
1014
  await writeFile(
1015
1015
  file,
@@ -1697,9 +1697,9 @@ async function resolveCallArguments(
1697
1697
  // 4. **Its body is a single expression, or a block of `const` declarations
1698
1698
  // followed by one `return`** — and nothing else. No `if`, no `throw`, no
1699
1699
  // loop, no `let`/`var`, no nested function declaration, no bare expression
1700
- // statement. This is the line loomster's `composites/*.ts` fall outside
1701
- // (module-level `buildXxx()` helpers with `if`/`throw` and `.map()`), and
1702
- // they are meant to: they keep invoking, exactly as before.
1700
+ // statement. This is the line a project's hand-rolled composite modules
1701
+ // fall outside (module-level `buildXxx()` helpers with `if`/`throw` and
1702
+ // `.map()`), and they are meant to: they keep invoking, exactly as before.
1703
1703
  // 5. **Every expression in it is inside the fold subset, extended with the
1704
1704
  // two things a factory body exists to do**: `new Type(...)` in ANY value
1705
1705
  // position (a member, a nested property object, an array element), and a