@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.
- package/dist/build-params.d.ts +2 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lint.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/components/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/config.d.ts +4 -4
- package/dist/graph-ir.d.ts +70 -2
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +48 -5
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -4
- 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/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -7
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -4
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- package/package.json +1 -1
- package/src/build-params.ts +2 -2
- package/src/cli/commands/build.ts +8 -8
- package/src/cli/commands/lint.test.ts +151 -0
- package/src/cli/commands/lint.ts +37 -4
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lifecycle.ts +1 -0
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- package/src/codegen/json-schema.test.ts +159 -0
- package/src/codegen/json-schema.ts +106 -8
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- package/src/config.ts +4 -4
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/discovery/fold-import.ts +3 -3
- package/src/graph-ir.test.ts +63 -0
- package/src/graph-ir.ts +125 -8
- package/src/lexicon.ts +49 -5
- package/src/lifecycle/assert-live.ts +1 -0
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +11 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- package/src/observation.test.ts +21 -9
- package/src/observation.ts +31 -2
- package/src/op/activities/index.ts +8 -1
- package/src/op/activities/reconcile.test.ts +238 -0
- package/src/op/activities/reconcile.ts +267 -13
- package/src/op/gate-summary.test.ts +62 -0
- package/src/op/gate-summary.ts +17 -5
- package/src/params.ts +1 -1
- 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
|
+
});
|
package/src/cli/handlers/lint.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
|
@@ -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-
|
|
20
|
-
hand-rolls a `
|
|
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-
|
|
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
|
-
`
|
|
28
|
-
`aws cloudformation deploy --template-file templates/template.json --stack-name ${STACK_NAME} ... --parameter-overrides $PARAMS
|
|
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-
|
|
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-
|
|
13
|
-
* `describe-stacks`/`jq` glue in that pipeline's `
|
|
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
|
-
* (
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
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 (
|
|
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
|
|
1701
|
-
// (module-level `buildXxx()` helpers with `if`/`throw` 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
|