@intentius/chant 0.107.1 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +16 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +82 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +185 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +6 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +101 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +198 -0
- package/src/workspace/ci-green-workflow.ts +215 -0
- package/src/workspace/ci-green.test.ts +375 -0
- package/src/workspace/ci-green.ts +408 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema to TypeScript type text, for code a lexicon generates into a
|
|
3
|
+
* project (`chant generate`): a CRD's `openAPIV3Schema`, a Helm chart's
|
|
4
|
+
* `values.schema.json`.
|
|
5
|
+
*
|
|
6
|
+
* Unlike ./json-schema.ts, which maps the subset of JSON Schema that resource
|
|
7
|
+
* specifications use onto a lexicon's named property types, this renders a
|
|
8
|
+
* whole schema as one type expression, nested object literals inline, plus a
|
|
9
|
+
* named declaration for each `$ref` target. What it handles:
|
|
10
|
+
*
|
|
11
|
+
* - `$ref` into `$defs`, `definitions` or any other JSON pointer in the same
|
|
12
|
+
* document, including `#` itself; each target becomes one named type, so
|
|
13
|
+
* recursive schemas terminate. A reference to another document is `unknown`.
|
|
14
|
+
* - Property names that are not identifiers (`app.kubernetes.io/name`,
|
|
15
|
+
* `use-forwarded-headers`) are quoted, never renamed: a Helm values key or
|
|
16
|
+
* a CRD field is a literal the consumer reads back.
|
|
17
|
+
* - `additionalProperties` as a schema becomes an index signature, so maps of
|
|
18
|
+
* objects keep their value type; `false` closes the object. Whether an
|
|
19
|
+
* object with neither is open is the caller's choice (`openByDefault`): JSON
|
|
20
|
+
* Schema says open, a Kubernetes structural schema says closed.
|
|
21
|
+
* - `enum` and `const` become literal unions; `oneOf`/`anyOf` unions;
|
|
22
|
+
* `allOf` intersections; `type` arrays and `nullable` unions with `null`.
|
|
23
|
+
* - `x-kubernetes-int-or-string` and `x-kubernetes-preserve-unknown-fields`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Options for {@link schemaToTypeScript}. */
|
|
27
|
+
export interface SchemaToTsOptions {
|
|
28
|
+
/** Make every object member optional, whatever `required` says (a Helm values override sets any subset). */
|
|
29
|
+
allOptional?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Whether an object with no `additionalProperties` accepts keys it does not
|
|
32
|
+
* list. Default `true`, JSON Schema's rule. Pass `false` for a Kubernetes
|
|
33
|
+
* structural schema, where only `x-kubernetes-preserve-unknown-fields` (or
|
|
34
|
+
* an object with no `properties` at all) opens one.
|
|
35
|
+
*/
|
|
36
|
+
openByDefault?: boolean;
|
|
37
|
+
/** Prefix for the names of `$ref` target declarations. Default none. */
|
|
38
|
+
namePrefix?: string;
|
|
39
|
+
/** The name a `$ref: "#"` resolves to; the caller declares the root type under it. Default `${namePrefix}Root`. */
|
|
40
|
+
rootName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A named type a `$ref` target produced. */
|
|
44
|
+
export interface SchemaTsDeclaration {
|
|
45
|
+
name: string;
|
|
46
|
+
/** The type expression, at indentation zero. */
|
|
47
|
+
type: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What {@link schemaToTypeScript} returns. */
|
|
52
|
+
export interface SchemaToTsResult {
|
|
53
|
+
/** The root schema's type expression, at indentation zero. */
|
|
54
|
+
type: string;
|
|
55
|
+
/** Declarations the expression (or another declaration) references, in first-reference order. */
|
|
56
|
+
declarations: SchemaTsDeclaration[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Schema = Record<string, unknown>;
|
|
60
|
+
|
|
61
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
62
|
+
|
|
63
|
+
/** Quote an object key unless it is a plain identifier. Never renames. */
|
|
64
|
+
export function tsPropertyKey(name: string): string {
|
|
65
|
+
return IDENT.test(name) ? name : JSON.stringify(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A JSDoc block for `text` at `indent`, or nothing when there is no text. */
|
|
69
|
+
export function jsdocLines(text: unknown, indent: string): string[] {
|
|
70
|
+
if (typeof text !== "string" || text.trim() === "") return [];
|
|
71
|
+
const lines = text.trim().replace(/\*\//g, "*\\/").split(/\r?\n/);
|
|
72
|
+
if (lines.length === 1) return [`${indent}/** ${lines[0]} */`];
|
|
73
|
+
return [`${indent}/**`, ...lines.map((l) => (l.trim() === "" ? `${indent} *` : `${indent} * ${l}`)), `${indent} */`];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render a JSON Schema as a TypeScript type expression plus the declarations it references. */
|
|
77
|
+
export function schemaToTypeScript(schema: unknown, options: SchemaToTsOptions = {}): SchemaToTsResult {
|
|
78
|
+
const converter = new Converter(schema, options);
|
|
79
|
+
const type = converter.convert(schema, "");
|
|
80
|
+
return { type, declarations: converter.declarations() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class Converter {
|
|
84
|
+
private readonly names = new Map<string, string>();
|
|
85
|
+
private readonly decls = new Map<string, SchemaTsDeclaration>();
|
|
86
|
+
private readonly used = new Set<string>();
|
|
87
|
+
private readonly prefix: string;
|
|
88
|
+
private readonly rootName: string;
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly root: unknown,
|
|
92
|
+
private readonly opts: SchemaToTsOptions,
|
|
93
|
+
) {
|
|
94
|
+
this.prefix = opts.namePrefix ?? "";
|
|
95
|
+
this.rootName = opts.rootName ?? `${this.prefix}Root`;
|
|
96
|
+
this.used.add(this.rootName);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
declarations(): SchemaTsDeclaration[] {
|
|
100
|
+
return [...this.decls.values()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
convert(node: unknown, indent: string): string {
|
|
104
|
+
if (node === true || node === undefined) return "unknown";
|
|
105
|
+
if (node === false) return "never";
|
|
106
|
+
if (!isObject(node)) return "unknown";
|
|
107
|
+
|
|
108
|
+
if (typeof node.$ref === "string") return this.ref(node.$ref);
|
|
109
|
+
|
|
110
|
+
if (node.const !== undefined) return literal(node.const) ?? "unknown";
|
|
111
|
+
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
112
|
+
const literals = node.enum.map(literal);
|
|
113
|
+
if (literals.every((l): l is string => l !== undefined)) return union(literals);
|
|
114
|
+
}
|
|
115
|
+
if (node["x-kubernetes-int-or-string"] === true) return "number | string";
|
|
116
|
+
|
|
117
|
+
const parts: string[] = [];
|
|
118
|
+
const base = this.baseType(node, indent);
|
|
119
|
+
if (base) parts.push(base);
|
|
120
|
+
|
|
121
|
+
const all = shapedBranches(node.allOf);
|
|
122
|
+
for (const branch of all) parts.push(paren(this.convert(branch, indent)));
|
|
123
|
+
|
|
124
|
+
const alternatives = shapedBranches(node.oneOf ?? node.anyOf);
|
|
125
|
+
if (alternatives.length > 0) {
|
|
126
|
+
parts.push(paren(union(alternatives.map((b) => this.convert(b, indent)))));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parts.length === 0) return "unknown";
|
|
130
|
+
const type = parts.length === 1 ? unparen(parts[0]) : parts.join(" & ");
|
|
131
|
+
return node.nullable === true && type !== "unknown" ? union([paren(type), "null"]) : type;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The type `type` (or the presence of `properties`/`items`) implies, before combinators. */
|
|
135
|
+
private baseType(node: Schema, indent: string): string | undefined {
|
|
136
|
+
let types: string[];
|
|
137
|
+
if (typeof node.type === "string") types = [node.type];
|
|
138
|
+
else if (Array.isArray(node.type)) types = node.type.filter((t): t is string => typeof t === "string");
|
|
139
|
+
else if (node.properties || node.additionalProperties !== undefined || node.patternProperties) types = ["object"];
|
|
140
|
+
else if (node.items) types = ["array"];
|
|
141
|
+
else if (node["x-kubernetes-preserve-unknown-fields"] === true) types = ["object"];
|
|
142
|
+
else types = [];
|
|
143
|
+
|
|
144
|
+
const out = types.map((t) => {
|
|
145
|
+
switch (t) {
|
|
146
|
+
case "string":
|
|
147
|
+
return "string";
|
|
148
|
+
case "integer":
|
|
149
|
+
case "number":
|
|
150
|
+
return "number";
|
|
151
|
+
case "boolean":
|
|
152
|
+
return "boolean";
|
|
153
|
+
case "null":
|
|
154
|
+
return "null";
|
|
155
|
+
case "array":
|
|
156
|
+
return this.arrayType(node, indent);
|
|
157
|
+
case "object":
|
|
158
|
+
return this.objectType(node, indent);
|
|
159
|
+
default:
|
|
160
|
+
return "unknown";
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return out.length > 0 ? union(out) : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private arrayType(node: Schema, indent: string): string {
|
|
167
|
+
const items = node.items;
|
|
168
|
+
if (items === undefined || Array.isArray(items)) return "unknown[]";
|
|
169
|
+
const item = this.convert(items, indent);
|
|
170
|
+
return /^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(item) ? `${item}[]` : `Array<${item}>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private objectType(node: Schema, indent: string): string {
|
|
174
|
+
const props = isObject(node.properties) ? node.properties : {};
|
|
175
|
+
const names = Object.keys(props);
|
|
176
|
+
const required = new Set(Array.isArray(node.required) ? node.required.filter((r) => typeof r === "string") : []);
|
|
177
|
+
const inner = indent + " ";
|
|
178
|
+
|
|
179
|
+
// The index signature, if the object accepts keys it does not list.
|
|
180
|
+
let index: string | undefined;
|
|
181
|
+
const additional = node.additionalProperties;
|
|
182
|
+
if (additional === false) {
|
|
183
|
+
index = undefined;
|
|
184
|
+
} else if (isObject(additional)) {
|
|
185
|
+
index = this.convert(additional, inner);
|
|
186
|
+
} else if (additional === true) {
|
|
187
|
+
index = "unknown";
|
|
188
|
+
} else if (isObject(node.patternProperties) && Object.keys(node.patternProperties).length > 0) {
|
|
189
|
+
index = union(Object.values(node.patternProperties).map((p) => this.convert(p, inner)));
|
|
190
|
+
} else if (
|
|
191
|
+
node["x-kubernetes-preserve-unknown-fields"] === true ||
|
|
192
|
+
names.length === 0 ||
|
|
193
|
+
this.opts.openByDefault !== false
|
|
194
|
+
) {
|
|
195
|
+
index = "unknown";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (names.length === 0) {
|
|
199
|
+
return index === undefined ? "Record<string, never>" : `Record<string, ${index}>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const lines = ["{"];
|
|
203
|
+
const memberTypes: string[] = [];
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
const child = props[name];
|
|
206
|
+
const type = this.convert(child, inner);
|
|
207
|
+
const optional = this.opts.allOptional || !required.has(name);
|
|
208
|
+
memberTypes.push(type);
|
|
209
|
+
if (optional) memberTypes.push("undefined");
|
|
210
|
+
lines.push(...jsdocLines(isObject(child) ? child.description : undefined, inner));
|
|
211
|
+
lines.push(`${inner}${tsPropertyKey(name)}${optional ? "?" : ""}: ${type};`);
|
|
212
|
+
}
|
|
213
|
+
if (index !== undefined) {
|
|
214
|
+
// A listed member must be assignable to the index signature, so a typed
|
|
215
|
+
// map that also lists members widens its signature to cover them.
|
|
216
|
+
const signature = index === "unknown" ? index : union([index, ...memberTypes].map(paren));
|
|
217
|
+
lines.push(`${inner}[key: string]: ${signature};`);
|
|
218
|
+
}
|
|
219
|
+
lines.push(`${indent}}`);
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private ref(ref: string): string {
|
|
224
|
+
if (ref === "#" || ref === "#/") return this.rootName;
|
|
225
|
+
const existing = this.names.get(ref);
|
|
226
|
+
if (existing) return existing;
|
|
227
|
+
if (!ref.startsWith("#/")) return "unknown";
|
|
228
|
+
const target = resolvePointer(this.root, ref.slice(2));
|
|
229
|
+
if (target === undefined) return "unknown";
|
|
230
|
+
|
|
231
|
+
const segments = ref.slice(2).split("/");
|
|
232
|
+
const name = this.uniqueName(this.prefix + pascal(decodePointerSegment(segments[segments.length - 1])));
|
|
233
|
+
this.names.set(ref, name);
|
|
234
|
+
// Reserve the slot before converting, so a self-reference resolves to the name.
|
|
235
|
+
const decl: SchemaTsDeclaration = { name, type: "unknown" };
|
|
236
|
+
if (isObject(target) && typeof target.description === "string") decl.description = target.description;
|
|
237
|
+
this.decls.set(name, decl);
|
|
238
|
+
decl.type = this.convert(target, "");
|
|
239
|
+
return name;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private uniqueName(base: string): string {
|
|
243
|
+
let name = IDENT.test(base) ? base : `${this.prefix}Def`;
|
|
244
|
+
if (!this.used.has(name)) {
|
|
245
|
+
this.used.add(name);
|
|
246
|
+
return name;
|
|
247
|
+
}
|
|
248
|
+
let i = 2;
|
|
249
|
+
while (this.used.has(`${name}${i}`)) i++;
|
|
250
|
+
name = `${name}${i}`;
|
|
251
|
+
this.used.add(name);
|
|
252
|
+
return name;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isObject(value: unknown): value is Schema {
|
|
257
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Branches of a combinator that say something about shape; constraint-only branches (`{ required: [...] }`) say nothing a type can. */
|
|
261
|
+
function shapedBranches(value: unknown): unknown[] {
|
|
262
|
+
if (!Array.isArray(value)) return [];
|
|
263
|
+
return value.filter(
|
|
264
|
+
(b) =>
|
|
265
|
+
b === false ||
|
|
266
|
+
(isObject(b) &&
|
|
267
|
+
["type", "$ref", "properties", "items", "enum", "const", "additionalProperties", "allOf", "oneOf", "anyOf"].some(
|
|
268
|
+
(k) => k in b,
|
|
269
|
+
)),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function literal(value: unknown): string | undefined {
|
|
274
|
+
if (value === null) return "null";
|
|
275
|
+
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
276
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function union(types: string[]): string {
|
|
281
|
+
const unique = [...new Set(types)];
|
|
282
|
+
if (unique.includes("unknown")) return "unknown";
|
|
283
|
+
return unique.join(" | ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Parenthesize a union or intersection so it can be an operand. */
|
|
287
|
+
function paren(type: string): string {
|
|
288
|
+
return hasTopLevelOperator(type) ? `(${type})` : type;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Strip one pair of parentheses that wraps the whole expression. */
|
|
292
|
+
function unparen(type: string): string {
|
|
293
|
+
if (!type.startsWith("(") || closingIndex(type, 0) !== type.length - 1) return type;
|
|
294
|
+
return type.slice(1, -1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Whether `type` has a `|` or `&` outside any brackets or string literal. */
|
|
298
|
+
function hasTopLevelOperator(type: string): boolean {
|
|
299
|
+
let depth = 0;
|
|
300
|
+
for (let i = 0; i < type.length; i++) {
|
|
301
|
+
const c = type[i];
|
|
302
|
+
if (c === '"') i = stringEnd(type, i);
|
|
303
|
+
else if (c === "/" && type[i + 1] === "*") i = commentEnd(type, i);
|
|
304
|
+
else if (c === "(" || c === "{" || c === "[" || c === "<") depth++;
|
|
305
|
+
else if (c === ")" || c === "}" || c === "]" || c === ">") depth--;
|
|
306
|
+
else if (depth === 0 && (c === "|" || c === "&")) return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Index of the bracket closing the one at `open`, or -1. */
|
|
312
|
+
function closingIndex(type: string, open: number): number {
|
|
313
|
+
let depth = 0;
|
|
314
|
+
for (let i = open; i < type.length; i++) {
|
|
315
|
+
const c = type[i];
|
|
316
|
+
if (c === '"') i = stringEnd(type, i);
|
|
317
|
+
else if (c === "/" && type[i + 1] === "*") i = commentEnd(type, i);
|
|
318
|
+
else if (c === "(" || c === "{" || c === "[" || c === "<") depth++;
|
|
319
|
+
else if (c === ")" || c === "}" || c === "]" || c === ">") {
|
|
320
|
+
depth--;
|
|
321
|
+
if (depth === 0) return i;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return -1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Index of the `/` closing the block comment that opens at `start`. */
|
|
328
|
+
function commentEnd(type: string, start: number): number {
|
|
329
|
+
const end = type.indexOf("*/", start + 2);
|
|
330
|
+
return end === -1 ? type.length : end + 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Index of the quote closing the JSON string literal that opens at `start`. */
|
|
334
|
+
function stringEnd(type: string, start: number): number {
|
|
335
|
+
for (let i = start + 1; i < type.length; i++) {
|
|
336
|
+
if (type[i] === "\\") i++;
|
|
337
|
+
else if (type[i] === '"') return i;
|
|
338
|
+
}
|
|
339
|
+
return type.length;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function decodePointerSegment(segment: string): string {
|
|
343
|
+
let s = segment;
|
|
344
|
+
try {
|
|
345
|
+
s = decodeURIComponent(s);
|
|
346
|
+
} catch {
|
|
347
|
+
// Not percent-encoded; use as is.
|
|
348
|
+
}
|
|
349
|
+
return s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function resolvePointer(root: unknown, pointer: string): unknown {
|
|
353
|
+
let node: unknown = root;
|
|
354
|
+
for (const raw of pointer.split("/")) {
|
|
355
|
+
if (raw === "") continue;
|
|
356
|
+
const key = decodePointerSegment(raw);
|
|
357
|
+
if (Array.isArray(node)) node = node[Number(key)];
|
|
358
|
+
else if (isObject(node)) node = node[key];
|
|
359
|
+
else return undefined;
|
|
360
|
+
if (node === undefined) return undefined;
|
|
361
|
+
}
|
|
362
|
+
return node;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** PascalCase an arbitrary definition name into an identifier fragment. */
|
|
366
|
+
function pascal(name: string): string {
|
|
367
|
+
const out = name
|
|
368
|
+
.split(/[^A-Za-z0-9]+/)
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
371
|
+
.join("");
|
|
372
|
+
return /^[0-9]/.test(out) ? `_${out}` : out;
|
|
373
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -200,6 +200,9 @@ export const ChantConfigSchema = z.object({
|
|
|
200
200
|
knowledge: z.object({
|
|
201
201
|
dir: z.string().min(1).optional(),
|
|
202
202
|
}).optional(),
|
|
203
|
+
codegen: z.object({
|
|
204
|
+
outDir: z.string().min(1).optional(),
|
|
205
|
+
}).strict().optional(),
|
|
203
206
|
decide: decideConfigSchema.optional(),
|
|
204
207
|
exclude: z.array(z.string().min(1)).optional(),
|
|
205
208
|
include: z.array(z.string().min(1)).optional(),
|
|
@@ -478,6 +481,19 @@ export interface ChantConfig {
|
|
|
478
481
|
dir?: string;
|
|
479
482
|
};
|
|
480
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Project-local code generation (`chant generate`): where lexicons write
|
|
486
|
+
* the typed code they generate from sources this config declares, such as
|
|
487
|
+
* the k8s lexicon's `k8s.crds` and helm's `helm.charts`. Each lexicon
|
|
488
|
+
* writes to its own subdirectory, `<outDir>/<lexicon>/`. The output is
|
|
489
|
+
* meant to be committed, and `chant build` fails when it no longer matches
|
|
490
|
+
* the declared sources.
|
|
491
|
+
*/
|
|
492
|
+
codegen?: {
|
|
493
|
+
/** Output directory, relative to the project root. Defaults to `"src/generated"`. */
|
|
494
|
+
outDir?: string;
|
|
495
|
+
};
|
|
496
|
+
|
|
481
497
|
/**
|
|
482
498
|
* The backends the `decide` Op activity calls (#2828): backend name, as a
|
|
483
499
|
* decision point's model decider names it, to `{ url, key?, timeoutMs? }`.
|
package/src/lexicon.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity
|
|
|
28
28
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
29
29
|
import type { BehaviourResult, PredictBehaviourOptions } from "./behaviour";
|
|
30
30
|
import type { BehaviourKinds } from "./behaviour-kinds";
|
|
31
|
+
import type { ProjectCodegen } from "./project-codegen";
|
|
31
32
|
import type { SymbolResolver } from "./workspace/symbols";
|
|
32
33
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
33
34
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
@@ -46,6 +47,13 @@ export type {
|
|
|
46
47
|
OpRunStatus,
|
|
47
48
|
} from "./op/runtime";
|
|
48
49
|
|
|
50
|
+
export type {
|
|
51
|
+
ProjectCodegen,
|
|
52
|
+
ProjectCodegenContext,
|
|
53
|
+
ProjectCodegenGenerateContext,
|
|
54
|
+
ProjectCodegenOutput,
|
|
55
|
+
} from "./project-codegen";
|
|
56
|
+
|
|
49
57
|
// Re-exported so a lexicon can author its command group (#1078) from the
|
|
50
58
|
// same `@intentius/chant/lexicon` entry it imports the plugin contract from.
|
|
51
59
|
export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./cli/command-group";
|
|
@@ -1350,6 +1358,18 @@ export interface LexiconPlugin {
|
|
|
1350
1358
|
*/
|
|
1351
1359
|
buildRoots?(ctx: BuildRootContext): Promise<BuildRootContribution>;
|
|
1352
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* Project-local code generation: typed code this lexicon generates into
|
|
1363
|
+
* the project from sources the project declares in its own config
|
|
1364
|
+
* namespace (the k8s lexicon's `k8s.crds`, helm's `helm.charts`). `chant
|
|
1365
|
+
* generate` calls `generate` and writes the files under
|
|
1366
|
+
* `<codegen.outDir>/<lexicon>/`; every build checks the committed output
|
|
1367
|
+
* against `inputs` and fails when they disagree, then calls `load`. See
|
|
1368
|
+
* ./project-codegen.ts. Omit for lexicons with nothing to generate per
|
|
1369
|
+
* project.
|
|
1370
|
+
*/
|
|
1371
|
+
projectCodegen?(): ProjectCodegen;
|
|
1372
|
+
|
|
1353
1373
|
/**
|
|
1354
1374
|
* The units this lexicon owns that a change touched, judged from the
|
|
1355
1375
|
* changed files alone (#3183). A unit is a name a component's deploy step
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
checkProjectCodegen,
|
|
7
|
+
generateProjectCode,
|
|
8
|
+
GENERATED_TS_HEADER,
|
|
9
|
+
prepareProjectCodegen,
|
|
10
|
+
readStamp,
|
|
11
|
+
resolveCodegenRoot,
|
|
12
|
+
STAMP_FILE,
|
|
13
|
+
type ProjectCodegen,
|
|
14
|
+
type ProjectCodegenPlugin,
|
|
15
|
+
} from "./project-codegen";
|
|
16
|
+
import { collectBuildRootContributors } from "./cli/plugins";
|
|
17
|
+
import { mergeBuildRootEntities } from "./build";
|
|
18
|
+
import { GENERATED_MARKER } from "./discovery/files";
|
|
19
|
+
import { runGenerate } from "./cli/handlers/generate";
|
|
20
|
+
import type { LexiconPlugin } from "./lexicon";
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = [];
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function project(): string {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-project-codegen-"));
|
|
29
|
+
dirs.push(dir);
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A lexicon whose declared input is one local file, `widgets.txt`, named by
|
|
35
|
+
* `config.fake.source`. Each line becomes a generated file.
|
|
36
|
+
*/
|
|
37
|
+
function fakePlugin(onLoad?: () => void): ProjectCodegenPlugin {
|
|
38
|
+
const codegen: ProjectCodegen = {
|
|
39
|
+
inputs(ctx) {
|
|
40
|
+
const source = (ctx.config.fake as { source?: string } | undefined)?.source;
|
|
41
|
+
if (!source) return undefined;
|
|
42
|
+
return { source, content: readFileSync(join(ctx.projectRoot, source), "utf8") };
|
|
43
|
+
},
|
|
44
|
+
async generate(ctx) {
|
|
45
|
+
const source = (ctx.config.fake as { source: string }).source;
|
|
46
|
+
const names = readFileSync(join(ctx.projectRoot, source), "utf8").split("\n").filter(Boolean);
|
|
47
|
+
const files: Record<string, string> = { "index.ts": `${GENERATED_TS_HEADER}\nexport const names = ${JSON.stringify(names)};\n` };
|
|
48
|
+
for (const n of names) files[`parts/${n}.ts`] = `export const ${n} = 1;\n`;
|
|
49
|
+
return { files, summary: names };
|
|
50
|
+
},
|
|
51
|
+
load: onLoad,
|
|
52
|
+
};
|
|
53
|
+
return { name: "fake", projectCodegen: () => codegen };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("project codegen", () => {
|
|
57
|
+
test("the generated header carries discovery's generated-file marker", () => {
|
|
58
|
+
expect(GENERATED_TS_HEADER).toContain(GENERATED_MARKER);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("outDir defaults to src/generated and follows codegen.outDir", () => {
|
|
62
|
+
expect(resolveCodegenRoot("/p", {})).toBe("/p/src/generated");
|
|
63
|
+
expect(resolveCodegenRoot("/p", { codegen: { outDir: "gen" } })).toBe("/p/gen");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("generate writes the files and a stamp; the check is then current", async () => {
|
|
67
|
+
const root = project();
|
|
68
|
+
writeFileSync(join(root, "widgets.txt"), "a\nb\n");
|
|
69
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
70
|
+
const plugin = fakePlugin();
|
|
71
|
+
|
|
72
|
+
const result = await generateProjectCode(plugin, root, config);
|
|
73
|
+
expect(result?.status).toBe("written");
|
|
74
|
+
expect(result?.files).toEqual(["index.ts", "parts/a.ts", "parts/b.ts"]);
|
|
75
|
+
const outDir = join(root, "src/generated/fake");
|
|
76
|
+
expect(readFileSync(join(outDir, "index.ts"), "utf8")).toContain('["a","b"]');
|
|
77
|
+
expect(readStamp(outDir)?.inputs).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
78
|
+
|
|
79
|
+
expect(await checkProjectCodegen(plugin, root, config)).toEqual({ status: "current" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("editing a declared source without regenerating is drift", async () => {
|
|
83
|
+
const root = project();
|
|
84
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
85
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
86
|
+
const plugin = fakePlugin();
|
|
87
|
+
await generateProjectCode(plugin, root, config);
|
|
88
|
+
|
|
89
|
+
writeFileSync(join(root, "widgets.txt"), "a\nc\n");
|
|
90
|
+
const check = await checkProjectCodegen(plugin, root, config);
|
|
91
|
+
expect(check.status).toBe("drift");
|
|
92
|
+
expect("message" in check && check.message).toMatch(/src\/generated\/fake is out of date.*Run `chant generate`/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("declared sources with no output, or a recorded file deleted, is missing", async () => {
|
|
96
|
+
const root = project();
|
|
97
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
98
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
99
|
+
const plugin = fakePlugin();
|
|
100
|
+
expect((await checkProjectCodegen(plugin, root, config)).status).toBe("missing");
|
|
101
|
+
|
|
102
|
+
await generateProjectCode(plugin, root, config);
|
|
103
|
+
rmSync(join(root, "src/generated/fake/parts/a.ts"));
|
|
104
|
+
const check = await checkProjectCodegen(plugin, root, config);
|
|
105
|
+
expect(check.status).toBe("missing");
|
|
106
|
+
expect("message" in check && check.message).toContain("parts/a.ts");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("regenerating deletes files the previous run wrote and this one did not", async () => {
|
|
110
|
+
const root = project();
|
|
111
|
+
writeFileSync(join(root, "widgets.txt"), "a\nb\n");
|
|
112
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
113
|
+
const plugin = fakePlugin();
|
|
114
|
+
await generateProjectCode(plugin, root, config);
|
|
115
|
+
writeFileSync(join(root, "src/generated/fake/hand-written.ts"), "// mine\n");
|
|
116
|
+
|
|
117
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
118
|
+
await generateProjectCode(plugin, root, config);
|
|
119
|
+
expect(existsSync(join(root, "src/generated/fake/parts/b.ts"))).toBe(false);
|
|
120
|
+
expect(existsSync(join(root, "src/generated/fake/parts/a.ts"))).toBe(true);
|
|
121
|
+
// Only files a run recorded are ever removed.
|
|
122
|
+
expect(existsSync(join(root, "src/generated/fake/hand-written.ts"))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("with nothing declared, leftover output is an orphan and generate removes it", async () => {
|
|
126
|
+
const root = project();
|
|
127
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
128
|
+
const plugin = fakePlugin();
|
|
129
|
+
await generateProjectCode(plugin, root, { fake: { source: "widgets.txt" } });
|
|
130
|
+
|
|
131
|
+
expect((await checkProjectCodegen(plugin, root, {})).status).toBe("orphan");
|
|
132
|
+
const result = await generateProjectCode(plugin, root, {});
|
|
133
|
+
expect(result?.status).toBe("removed");
|
|
134
|
+
expect(existsSync(join(root, "src/generated/fake"))).toBe(false);
|
|
135
|
+
expect(await checkProjectCodegen(plugin, root, {})).toEqual({ status: "none" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("prepare loads current output and throws on drift", async () => {
|
|
139
|
+
const root = project();
|
|
140
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
141
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
142
|
+
const load = vi.fn();
|
|
143
|
+
const plugin = fakePlugin(load);
|
|
144
|
+
await generateProjectCode(plugin, root, config);
|
|
145
|
+
|
|
146
|
+
await prepareProjectCodegen(plugin, root, config);
|
|
147
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
148
|
+
|
|
149
|
+
writeFileSync(join(root, "widgets.txt"), "b\n");
|
|
150
|
+
await expect(prepareProjectCodegen(plugin, root, config)).rejects.toThrow(/out of date/);
|
|
151
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the build-root contributors carry the check, so a drifted build reports an error", async () => {
|
|
155
|
+
const root = project();
|
|
156
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
157
|
+
const config = { fake: { source: "widgets.txt" } };
|
|
158
|
+
const plugin = fakePlugin() as unknown as LexiconPlugin;
|
|
159
|
+
await generateProjectCode(plugin, root, config);
|
|
160
|
+
|
|
161
|
+
const current = await mergeBuildRootEntities(new Map(), collectBuildRootContributors([plugin], config, root));
|
|
162
|
+
expect(current.errors).toEqual([]);
|
|
163
|
+
|
|
164
|
+
writeFileSync(join(root, "widgets.txt"), "b\n");
|
|
165
|
+
const drifted = await mergeBuildRootEntities(new Map(), collectBuildRootContributors([plugin], config, root));
|
|
166
|
+
expect(drifted.errors).toHaveLength(1);
|
|
167
|
+
expect(drifted.errors[0]).toMatch(/out of date/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("chant generate", () => {
|
|
172
|
+
function args(path: string, extra: Record<string, unknown> = {}) {
|
|
173
|
+
return { command: "generate", path, ...extra } as never;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("writes each lexicon's code, and --check reports drift with exit 1", async () => {
|
|
177
|
+
const root = project();
|
|
178
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ fake: { source: "widgets.txt" } }));
|
|
179
|
+
writeFileSync(join(root, "widgets.txt"), "a\n");
|
|
180
|
+
const plugins = [fakePlugin() as unknown as LexiconPlugin];
|
|
181
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
182
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
183
|
+
try {
|
|
184
|
+
expect(await runGenerate({ args: args(root), plugins, serializers: [] })).toBe(0);
|
|
185
|
+
expect(existsSync(join(root, "src/generated/fake", STAMP_FILE))).toBe(true);
|
|
186
|
+
expect(await runGenerate({ args: args(root, { check: true }), plugins, serializers: [] })).toBe(0);
|
|
187
|
+
|
|
188
|
+
writeFileSync(join(root, "widgets.txt"), "z\n");
|
|
189
|
+
expect(await runGenerate({ args: args(root, { check: true }), plugins, serializers: [] })).toBe(1);
|
|
190
|
+
expect(err.mock.calls.flat().join("\n")).toMatch(/out of date/);
|
|
191
|
+
} finally {
|
|
192
|
+
log.mockRestore();
|
|
193
|
+
err.mockRestore();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("--lexicon naming a lexicon with no project codegen fails", async () => {
|
|
198
|
+
const root = project();
|
|
199
|
+
writeFileSync(join(root, "chant.config.json"), "{}");
|
|
200
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
201
|
+
try {
|
|
202
|
+
const code = await runGenerate({ args: args(root, { lexicon: "aws" }), plugins: [fakePlugin() as unknown as LexiconPlugin], serializers: [] });
|
|
203
|
+
expect(code).toBe(1);
|
|
204
|
+
} finally {
|
|
205
|
+
err.mockRestore();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|