@intentius/chant 0.2.0 → 0.4.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/package.json +1 -1
- package/src/build.ts +15 -4
- package/src/cli/commands/build.test.ts +78 -1
- package/src/cli/commands/build.ts +49 -5
- package/src/cli/handlers/build.ts +9 -4
- package/src/cli/handlers/lifecycle.ts +9 -1
- package/src/cli/main.ts +2 -0
- package/src/composite.ts +6 -0
- package/src/discovery/collect.ts +5 -0
- package/src/index.ts +1 -0
- package/src/lifecycle/change-set.test.ts +39 -1
- package/src/lifecycle/change-set.ts +24 -0
- package/src/provenance.test.ts +79 -0
- package/src/provenance.ts +46 -0
- package/src/serializer.ts +14 -0
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -143,6 +143,12 @@ export interface BuildOptions {
|
|
|
143
143
|
* native metadata channel. Resolved from project config by the caller.
|
|
144
144
|
*/
|
|
145
145
|
ownership?: OwnershipMarker;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The resolved project configuration, passed through to each serializer's
|
|
149
|
+
* {@link SerializeContext} so a dialect can read its lexicon-scoped settings.
|
|
150
|
+
*/
|
|
151
|
+
config?: Record<string, unknown>;
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
export interface BuildResult {
|
|
@@ -553,10 +559,15 @@ export async function build(
|
|
|
553
559
|
...(outputsByLexicon.get(lexiconName) ?? []),
|
|
554
560
|
...unassignedOutputs,
|
|
555
561
|
];
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
);
|
|
562
|
+
const serialized = serializer.serialize(lexiconEntities, lexiconLexiconOutputs, {
|
|
563
|
+
ownership: options?.ownership,
|
|
564
|
+
config: options?.config,
|
|
565
|
+
});
|
|
566
|
+
// Collect any non-fatal serializer diagnostics into the build warnings.
|
|
567
|
+
if (typeof serialized !== "string" && serialized.warnings) {
|
|
568
|
+
for (const w of serialized.warnings) warnings.push(w);
|
|
569
|
+
}
|
|
570
|
+
outputs.set(lexiconName, serialized);
|
|
560
571
|
} else {
|
|
561
572
|
warnings.push(`No serializer found for lexicon "${lexiconName}"`);
|
|
562
573
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { buildCommand, type BuildOptions } from "./build";
|
|
2
|
+
import { buildCommand, resolveBuildFormat, type BuildOptions } from "./build";
|
|
3
3
|
import type { Serializer } from "../../serializer";
|
|
4
|
+
import { parseYAML } from "../../yaml";
|
|
4
5
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -199,4 +200,80 @@ export const testEntity = {
|
|
|
199
200
|
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "activities.ts"))).toBe(true);
|
|
200
201
|
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
|
|
201
202
|
});
|
|
203
|
+
|
|
204
|
+
// ── #284 bug 2: array-of-objects must serialize to valid YAML ──────────
|
|
205
|
+
test("yaml format serializes an array of objects to valid, round-trippable YAML", async () => {
|
|
206
|
+
// A serializer that emits a CloudFormation-shaped template with a tag list.
|
|
207
|
+
const cfnSerializer: Serializer = {
|
|
208
|
+
name: "test",
|
|
209
|
+
rulePrefix: "TEST",
|
|
210
|
+
serialize: () =>
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
Resources: {
|
|
213
|
+
Bucket: {
|
|
214
|
+
Type: "AWS::S3::Bucket",
|
|
215
|
+
Properties: {
|
|
216
|
+
BucketName: "x",
|
|
217
|
+
Tags: [
|
|
218
|
+
{ Key: "team", Value: "infra" },
|
|
219
|
+
{ Key: "env", Value: "prod" },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
await writeFile(
|
|
227
|
+
join(testDir, "test.infra.ts"),
|
|
228
|
+
`export const e = { lexicon: "test", entityType: "TestEntity", [Symbol.for("chant.declarable")]: true };`,
|
|
229
|
+
);
|
|
230
|
+
const yamlOut = join(testDir, "template.yaml");
|
|
231
|
+
|
|
232
|
+
const result = await buildCommand({
|
|
233
|
+
path: testDir,
|
|
234
|
+
output: yamlOut,
|
|
235
|
+
format: "yaml",
|
|
236
|
+
serializers: [cfnSerializer],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.success).toBe(true);
|
|
240
|
+
const content = readFileSync(yamlOut, "utf-8");
|
|
241
|
+
// The list item must not be inlined onto the `Tags:` line (same-line dash).
|
|
242
|
+
expect(content).not.toMatch(/Tags:[ \t]+-/);
|
|
243
|
+
// And it must round-trip through a real YAML parser to the intended shape.
|
|
244
|
+
const parsed = parseYAML(content) as {
|
|
245
|
+
Resources: { Bucket: { Properties: { Tags: Array<{ Key: string; Value: string }> } } };
|
|
246
|
+
};
|
|
247
|
+
expect(parsed.Resources.Bucket.Properties.Tags).toEqual([
|
|
248
|
+
{ Key: "team", Value: "infra" },
|
|
249
|
+
{ Key: "env", Value: "prod" },
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── #284 bug 1: -o extension drives format when --format is absent ────────
|
|
255
|
+
describe("resolveBuildFormat", () => {
|
|
256
|
+
test("infers yaml from .yaml / .yml extension", () => {
|
|
257
|
+
expect(resolveBuildFormat("", "template.yaml")).toEqual({ format: "yaml" });
|
|
258
|
+
expect(resolveBuildFormat("", "template.yml")).toEqual({ format: "yaml" });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("infers json from .json extension", () => {
|
|
262
|
+
expect(resolveBuildFormat("", "template.json")).toEqual({ format: "json" });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("defaults to json when there is no extension to infer from", () => {
|
|
266
|
+
expect(resolveBuildFormat("", undefined)).toEqual({ format: "json" });
|
|
267
|
+
expect(resolveBuildFormat("", "outdir")).toEqual({ format: "json" });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("explicit --format wins, and a mismatch with the extension warns", () => {
|
|
271
|
+
const r = resolveBuildFormat("json", "template.yaml");
|
|
272
|
+
expect(r.format).toBe("json");
|
|
273
|
+
expect(r.warning).toMatch(/yaml/i);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("explicit --format matching the extension does not warn", () => {
|
|
277
|
+
expect(resolveBuildFormat("yaml", "template.yaml")).toEqual({ format: "yaml" });
|
|
278
|
+
});
|
|
202
279
|
});
|
|
@@ -34,6 +34,38 @@ export interface BuildOptions {
|
|
|
34
34
|
env?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the output format for `chant build`.
|
|
39
|
+
*
|
|
40
|
+
* When `--format` is not given, infer it from the `-o` file extension
|
|
41
|
+
* (`.yaml`/`.yml` → yaml, `.json` → json); fall back to json when there is no
|
|
42
|
+
* extension to infer from. An explicit `--format` always wins, but a mismatch
|
|
43
|
+
* with the output extension is surfaced as a warning.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveBuildFormat(
|
|
46
|
+
explicit: string | undefined,
|
|
47
|
+
output: string | undefined,
|
|
48
|
+
): { format: "json" | "yaml"; warning?: string } {
|
|
49
|
+
const inferred = output
|
|
50
|
+
? /\.ya?ml$/i.test(output)
|
|
51
|
+
? "yaml"
|
|
52
|
+
: /\.json$/i.test(output)
|
|
53
|
+
? "json"
|
|
54
|
+
: undefined
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
if (explicit === "json" || explicit === "yaml") {
|
|
58
|
+
if (inferred && inferred !== explicit) {
|
|
59
|
+
return {
|
|
60
|
+
format: explicit,
|
|
61
|
+
warning: `Output file "${output}" looks like ${inferred} but --format ${explicit} was given; writing ${explicit}.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { format: explicit };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { format: inferred ?? "json" };
|
|
68
|
+
}
|
|
37
69
|
|
|
38
70
|
/**
|
|
39
71
|
* Build command result
|
|
@@ -79,7 +111,10 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
79
111
|
: [];
|
|
80
112
|
|
|
81
113
|
// Run the build
|
|
82
|
-
const result = await build(infraPath, options.serializers, undefined, {
|
|
114
|
+
const result = await build(infraPath, options.serializers, undefined, {
|
|
115
|
+
ownership,
|
|
116
|
+
config: config as unknown as Record<string, unknown>,
|
|
117
|
+
});
|
|
83
118
|
|
|
84
119
|
// Format errors
|
|
85
120
|
for (const error of result.errors) {
|
|
@@ -105,11 +140,15 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
105
140
|
const checks = plugin.postSynthChecks();
|
|
106
141
|
if (checks.length === 0) continue;
|
|
107
142
|
|
|
108
|
-
// Scope outputs to this plugin's lexicon so cross-lexicon outputs don't
|
|
143
|
+
// Scope outputs to this plugin's lexicon so cross-lexicon outputs don't
|
|
144
|
+
// interfere. Outputs are keyed by the serializer's lexicon name (the
|
|
145
|
+
// build partition key), which differs from plugin.name for a dialect like
|
|
146
|
+
// forgejo (plugin "forgejo", serializer "github").
|
|
147
|
+
const outputKey = plugin.serializer.name;
|
|
109
148
|
const scopedOutputs = new Map<string, string | SerializerResult>();
|
|
110
|
-
const pluginOutput = result.outputs.get(
|
|
149
|
+
const pluginOutput = result.outputs.get(outputKey);
|
|
111
150
|
if (pluginOutput !== undefined) {
|
|
112
|
-
scopedOutputs.set(
|
|
151
|
+
scopedOutputs.set(outputKey, pluginOutput);
|
|
113
152
|
}
|
|
114
153
|
|
|
115
154
|
const scopedResult = { ...result, outputs: scopedOutputs };
|
|
@@ -326,7 +365,12 @@ function jsonToYaml(obj: unknown, indent = 0): string {
|
|
|
326
365
|
return entries
|
|
327
366
|
.map(([key, value]) => {
|
|
328
367
|
const yamlValue = jsonToYaml(value, indent + 1);
|
|
329
|
-
|
|
368
|
+
// A non-empty container (object OR array) renders as a block: the key on
|
|
369
|
+
// its own line, the value indented beneath it. Arrays were previously
|
|
370
|
+
// excluded here, which inlined `Tags: - Key: t` as invalid YAML.
|
|
371
|
+
const isContainer = typeof value === "object" && value !== null;
|
|
372
|
+
const isEmpty = isContainer && (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
|
373
|
+
if (isContainer && !isEmpty) {
|
|
330
374
|
return `${spaces}${key}:\n${yamlValue}`;
|
|
331
375
|
}
|
|
332
376
|
return `${spaces}${key}: ${yamlValue.trimStart()}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCommand, buildCommandWatch, printErrors, printWarnings } from "../commands/build";
|
|
1
|
+
import { buildCommand, buildCommandWatch, printErrors, printWarnings, resolveBuildFormat } from "../commands/build";
|
|
2
2
|
import { formatError, formatInfo } from "../format";
|
|
3
3
|
import type { CommandContext } from "../registry";
|
|
4
4
|
|
|
@@ -15,11 +15,16 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
console.error(formatError({ message: `Invalid format for build: ${buildFormat}. Expected 'json' or 'yaml'.` }));
|
|
18
|
+
if (args.format && args.format !== "json" && args.format !== "yaml") {
|
|
19
|
+
console.error(formatError({ message: `Invalid format for build: ${args.format}. Expected 'json' or 'yaml'.` }));
|
|
21
20
|
return 1;
|
|
22
21
|
}
|
|
22
|
+
// Infer format from the -o extension when --format is not given; an explicit
|
|
23
|
+
// --format wins but a mismatch warns (#284 bug 1).
|
|
24
|
+
const { format: buildFormat, warning: formatWarningMsg } = resolveBuildFormat(args.format, args.output);
|
|
25
|
+
if (formatWarningMsg) {
|
|
26
|
+
console.error(formatInfo(formatWarningMsg));
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
if (args.watch) {
|
|
25
30
|
const cleanup = buildCommandWatch({
|
|
@@ -4,7 +4,7 @@ import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
|
4
4
|
import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
5
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
6
|
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
|
|
7
|
-
import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
|
|
7
|
+
import { buildChangeSet, renderChangeSet, gitlabMrReport, type ChangeSet } from "../../lifecycle/change-set";
|
|
8
8
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
9
9
|
import { loadChantConfig } from "../../config";
|
|
10
10
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
@@ -520,6 +520,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
520
520
|
|
|
521
521
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
522
522
|
|
|
523
|
+
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
524
|
+
// human render. Write it to a file (`tfplan.json`) in CI and declare it as
|
|
525
|
+
// `artifacts:reports:terraform` to light up the merge-request widget.
|
|
526
|
+
if (args.reportFile === "gitlab-mr") {
|
|
527
|
+
console.log(JSON.stringify(gitlabMrReport(merged)));
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
523
531
|
if (args.json) {
|
|
524
532
|
console.log(JSON.stringify(merged, null, 2));
|
|
525
533
|
} else {
|
package/src/cli/main.ts
CHANGED
|
@@ -227,6 +227,8 @@ Options:
|
|
|
227
227
|
--json Emit the structured run result as JSON (run command)
|
|
228
228
|
--report Print deployment report instead of running (run command)
|
|
229
229
|
OR with a path arg: SARIF report destination (migrate)
|
|
230
|
+
OR '--report gitlab-mr': emit the GitLab MR plan-widget
|
|
231
|
+
JSON (lifecycle plan)
|
|
230
232
|
--from <name> Source lexicon for migrate (default: github)
|
|
231
233
|
--to <name> Target lexicon for migrate (default: gitlab)
|
|
232
234
|
--emit <fmt> Migration output format: yaml (default) or ts
|
package/src/composite.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isDeclarable, type Declarable } from "./declarable";
|
|
2
|
+
import { setProvenance } from "./provenance";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Marker symbol for Composite type identification.
|
|
@@ -128,15 +129,20 @@ export function expandComposite(
|
|
|
128
129
|
const result = new Map<string, Declarable>();
|
|
129
130
|
const shared = (instance as unknown as Record<symbol, unknown>)[SHARED_PROPS] as Record<string, unknown> | undefined;
|
|
130
131
|
|
|
132
|
+
const compositeName = instance._definition?.compositeName;
|
|
133
|
+
|
|
131
134
|
for (const [memberName, member] of Object.entries(instance.members)) {
|
|
132
135
|
const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
|
|
133
136
|
|
|
134
137
|
if (isCompositeInstance(member)) {
|
|
135
138
|
const nested = expandComposite(fullName, member);
|
|
136
139
|
for (const [nestedName, nestedEntity] of nested) {
|
|
140
|
+
// Inner composite already stamped; `??=` keeps the most-specific one.
|
|
141
|
+
if (compositeName) setProvenance(nestedEntity, { composite: compositeName });
|
|
137
142
|
result.set(nestedName, nestedEntity);
|
|
138
143
|
}
|
|
139
144
|
} else {
|
|
145
|
+
if (compositeName) setProvenance(member as Declarable, { composite: compositeName });
|
|
140
146
|
result.set(fullName, member as Declarable);
|
|
141
147
|
}
|
|
142
148
|
}
|
package/src/discovery/collect.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isDeclarable, type Declarable } from "../declarable";
|
|
|
2
2
|
import { isCompositeInstance, expandComposite } from "../composite";
|
|
3
3
|
import { isLexiconOutput } from "../lexicon-output";
|
|
4
4
|
import { DiscoveryError } from "../errors";
|
|
5
|
+
import { setProvenance } from "../provenance";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Collects all declarable entities from imported modules.
|
|
@@ -32,6 +33,7 @@ export function collectEntities(
|
|
|
32
33
|
);
|
|
33
34
|
}
|
|
34
35
|
} else {
|
|
36
|
+
setProvenance(value, { sourceFile: file });
|
|
35
37
|
entities.set(name, value);
|
|
36
38
|
}
|
|
37
39
|
} else if (Array.isArray(value)) {
|
|
@@ -43,6 +45,7 @@ export function collectEntities(
|
|
|
43
45
|
if (entities.has(indexedName) && entities.get(indexedName) !== item) {
|
|
44
46
|
throw new DiscoveryError(file, `Duplicate entity name "${indexedName}"`, "resolution");
|
|
45
47
|
}
|
|
48
|
+
setProvenance(item, { sourceFile: file });
|
|
46
49
|
entities.set(indexedName, item);
|
|
47
50
|
} else if (isCompositeInstance(item)) {
|
|
48
51
|
const indexedName = `${name}_${i}`;
|
|
@@ -55,6 +58,7 @@ export function collectEntities(
|
|
|
55
58
|
"resolution",
|
|
56
59
|
);
|
|
57
60
|
}
|
|
61
|
+
setProvenance(entity, { sourceFile: file });
|
|
58
62
|
entities.set(expandedName, entity);
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -69,6 +73,7 @@ export function collectEntities(
|
|
|
69
73
|
"resolution",
|
|
70
74
|
);
|
|
71
75
|
}
|
|
76
|
+
setProvenance(entity, { sourceFile: file });
|
|
72
77
|
entities.set(expandedName, entity);
|
|
73
78
|
}
|
|
74
79
|
} else if (isLexiconOutput(value)) {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { buildChangeSet, renderChangeSet, summarize } from "./change-set";
|
|
2
|
+
import { buildChangeSet, renderChangeSet, summarize, gitlabMrReport } from "./change-set";
|
|
3
3
|
import type { ResourceMetadata } from "../lexicon";
|
|
4
4
|
|
|
5
5
|
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
@@ -149,3 +149,41 @@ describe("summarize / renderChangeSet", () => {
|
|
|
149
149
|
expect(out).toContain("orphan");
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe("gitlabMrReport (#329)", () => {
|
|
154
|
+
test("counts only create/update/delete; adopt and noop are excluded", () => {
|
|
155
|
+
const cs = buildChangeSet("prod", {
|
|
156
|
+
declared: new Set(["new-bucket", "drifted-queue", "stable-topic"]),
|
|
157
|
+
observedNow: {
|
|
158
|
+
"drifted-queue": meta({ status: "ACTIVE" }), // declared+live+drift → update
|
|
159
|
+
"stable-topic": meta({ status: "OK" }), // declared+live, no drift → noop
|
|
160
|
+
"owned-orphan": meta({ ownership: "owned" }), // owned orphan → delete
|
|
161
|
+
"foreign-orphan": meta({ ownership: "foreign" }), // foreign orphan → adopt
|
|
162
|
+
// new-bucket: declared, not live → create
|
|
163
|
+
},
|
|
164
|
+
observedThen: {
|
|
165
|
+
"drifted-queue": meta({ status: "CREATING" }),
|
|
166
|
+
"stable-topic": meta({ status: "OK" }),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 1, update: 1, delete: 1 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("empty plan reports all zeros", () => {
|
|
173
|
+
const cs = buildChangeSet("prod", {
|
|
174
|
+
declared: new Set(),
|
|
175
|
+
observedNow: {},
|
|
176
|
+
observedThen: undefined,
|
|
177
|
+
});
|
|
178
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("adopt-only plan reports zeros — the widget never shows undeclared resources", () => {
|
|
182
|
+
const cs = buildChangeSet("prod", {
|
|
183
|
+
declared: new Set(),
|
|
184
|
+
observedNow: { orphan: meta() }, // unknown ownership → adopt
|
|
185
|
+
observedThen: undefined,
|
|
186
|
+
});
|
|
187
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -142,6 +142,30 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
142
142
|
return counts;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* GitLab MR plan widget report.
|
|
147
|
+
*
|
|
148
|
+
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
149
|
+
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
150
|
+
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
151
|
+
* directly. Only the mutating actions count: `adopt` and `noop` are excluded,
|
|
152
|
+
* since the widget has no column for "live but undeclared" or "no change".
|
|
153
|
+
*
|
|
154
|
+
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
155
|
+
* fixed string, not a claim chant makes.
|
|
156
|
+
*/
|
|
157
|
+
export interface GitlabMrReport {
|
|
158
|
+
create: number;
|
|
159
|
+
update: number;
|
|
160
|
+
delete: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Project a change set onto the GitLab MR plan widget shape. Pure. */
|
|
164
|
+
export function gitlabMrReport(cs: ChangeSet): GitlabMrReport {
|
|
165
|
+
const counts = summarize(cs);
|
|
166
|
+
return { create: counts.create, update: counts.update, delete: counts.delete };
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
/** Human-readable render of a change set. Pure — returns a string. */
|
|
146
170
|
export function renderChangeSet(cs: ChangeSet): string {
|
|
147
171
|
const counts = summarize(cs);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { setProvenance, getProvenance } from "./provenance";
|
|
3
|
+
import { Composite, expandComposite } from "./composite";
|
|
4
|
+
import { collectEntities } from "./discovery/collect";
|
|
5
|
+
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
6
|
+
|
|
7
|
+
const decl = (entityType: string): Declarable =>
|
|
8
|
+
({ [DECLARABLE_MARKER]: true, lexicon: "test", entityType, kind: "resource", props: {} }) as unknown as Declarable;
|
|
9
|
+
|
|
10
|
+
const Pair = Composite<{ n: string }>((props) => ({
|
|
11
|
+
first: decl(`Test::First:${props.n}`),
|
|
12
|
+
second: decl(`Test::Second:${props.n}`),
|
|
13
|
+
}), "Pair");
|
|
14
|
+
|
|
15
|
+
describe("provenance side channel", () => {
|
|
16
|
+
test("set then get returns the stamped fields", () => {
|
|
17
|
+
const e = decl("Test::Thing");
|
|
18
|
+
setProvenance(e, { sourceFile: "/proj/src/a.ts" });
|
|
19
|
+
setProvenance(e, { composite: "MyComposite" });
|
|
20
|
+
expect(getProvenance(e)).toEqual({ sourceFile: "/proj/src/a.ts", composite: "MyComposite" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("first writer wins (??= merge keeps the most specific)", () => {
|
|
24
|
+
const e = decl("Test::Thing");
|
|
25
|
+
setProvenance(e, { composite: "Inner" });
|
|
26
|
+
setProvenance(e, { composite: "Outer" });
|
|
27
|
+
expect(getProvenance(e)?.composite).toBe("Inner");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("provenance is non-enumerable — invisible to spreads and JSON", () => {
|
|
31
|
+
const e = decl("Test::Thing");
|
|
32
|
+
setProvenance(e, { sourceFile: "/x.ts" });
|
|
33
|
+
expect(Object.keys(e)).not.toContain(Symbol.for("chant.provenance").toString());
|
|
34
|
+
expect(JSON.stringify(e)).not.toContain("provenance");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("getProvenance is undefined when nothing was stamped", () => {
|
|
38
|
+
expect(getProvenance(decl("Test::Thing"))).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("composite expansion stamps composite provenance", () => {
|
|
43
|
+
test("expandComposite stamps each member with the composite name", () => {
|
|
44
|
+
const expanded = expandComposite("p", Pair({ n: "x" }));
|
|
45
|
+
for (const [, entity] of expanded) {
|
|
46
|
+
expect(getProvenance(entity)?.composite).toBe("Pair");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("nested composites keep the innermost composite name", () => {
|
|
51
|
+
// expandComposite recurses on CompositeInstance members at runtime; the
|
|
52
|
+
// CompositeMembers type only models Declarable leaves, so cast the nested
|
|
53
|
+
// composite to satisfy the factory's static type.
|
|
54
|
+
const Wrapper = Composite<{ n: string }>((props) => ({
|
|
55
|
+
inner: Pair({ n: props.n }) as unknown as Declarable,
|
|
56
|
+
}), "Wrapper");
|
|
57
|
+
const expanded = expandComposite("w", Wrapper({ n: "y" }));
|
|
58
|
+
// Pair's members are the leaves; the inner (Pair) name must win over Wrapper.
|
|
59
|
+
for (const [, entity] of expanded) {
|
|
60
|
+
expect(getProvenance(entity)?.composite).toBe("Pair");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("collectEntities stamps the source file", () => {
|
|
66
|
+
test("direct exports get their declaring file", () => {
|
|
67
|
+
const a = decl("Test::A");
|
|
68
|
+
const entities = collectEntities([{ file: "/proj/src/infra.ts", exports: { a } }]);
|
|
69
|
+
expect(getProvenance(entities.get("a")!)?.sourceFile).toBe("/proj/src/infra.ts");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("composite-expanded entities get both file and composite", () => {
|
|
73
|
+
const entities = collectEntities([{ file: "/proj/src/pipe.ts", exports: { p: Pair({ n: "z" }) } }]);
|
|
74
|
+
const member = entities.get("pFirst")!;
|
|
75
|
+
const prov = getProvenance(member);
|
|
76
|
+
expect(prov?.sourceFile).toBe("/proj/src/pipe.ts");
|
|
77
|
+
expect(prov?.composite).toBe("Pair");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build provenance: where a declared entity came from in the TypeScript source.
|
|
3
|
+
*
|
|
4
|
+
* Stamped during discovery as a non-enumerable, symbol-keyed side channel on the
|
|
5
|
+
* entity object, so it never serializes into the emitted YAML/JSON — it is build
|
|
6
|
+
* metadata, not declared configuration. Read it back with {@link getProvenance}.
|
|
7
|
+
*
|
|
8
|
+
* This is entity-level provenance (which file declared it, and which composite
|
|
9
|
+
* expanded it), not a YAML-line source map. It answers "where did this resource
|
|
10
|
+
* come from?", which is the question an agent asks before changing it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PROVENANCE = Symbol.for("chant.provenance");
|
|
14
|
+
|
|
15
|
+
export interface EntityProvenance {
|
|
16
|
+
/** Absolute path of the source file that declared (or exported) the entity. */
|
|
17
|
+
sourceFile?: string;
|
|
18
|
+
/** The composite that expanded this entity, when it came from one. */
|
|
19
|
+
composite?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Merge provenance onto an entity. Non-enumerable so it is invisible to
|
|
24
|
+
* serializers and spreads. Existing fields win (`??=`), so the first/most
|
|
25
|
+
* specific writer — the innermost composite, the declaring file — is kept.
|
|
26
|
+
*/
|
|
27
|
+
export function setProvenance(entity: object, prov: EntityProvenance): void {
|
|
28
|
+
if (!Object.isExtensible(entity)) return;
|
|
29
|
+
const existing = (entity as Record<symbol, unknown>)[PROVENANCE] as EntityProvenance | undefined;
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.sourceFile ??= prov.sourceFile;
|
|
32
|
+
existing.composite ??= prov.composite;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
Object.defineProperty(entity, PROVENANCE, {
|
|
36
|
+
value: { ...prov },
|
|
37
|
+
enumerable: false,
|
|
38
|
+
writable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read an entity's build provenance, if any was stamped. */
|
|
44
|
+
export function getProvenance(entity: object): EntityProvenance | undefined {
|
|
45
|
+
return (entity as Record<symbol, unknown>)[PROVENANCE] as EntityProvenance | undefined;
|
|
46
|
+
}
|
package/src/serializer.ts
CHANGED
|
@@ -12,6 +12,14 @@ export interface SerializeContext {
|
|
|
12
12
|
* native metadata channel (AWS/Azure tags, K8s/GCP labels).
|
|
13
13
|
*/
|
|
14
14
|
ownership?: OwnershipMarker;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The resolved project configuration, when a build was driven from a project
|
|
18
|
+
* with a `chant.config.*`. Lets a serializer read its own lexicon-scoped
|
|
19
|
+
* settings (e.g. the forgejo dialect's `forgejo.runnerLabels`). Undefined for
|
|
20
|
+
* ad-hoc builds (e.g. context tools) that pass no config.
|
|
21
|
+
*/
|
|
22
|
+
config?: Record<string, unknown>;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
@@ -22,6 +30,12 @@ export interface SerializerResult {
|
|
|
22
30
|
primary: string;
|
|
23
31
|
/** Additional files keyed by filename (e.g. "network.template.json" → content) */
|
|
24
32
|
files?: Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Non-fatal diagnostics produced during serialization (e.g. a dialect
|
|
35
|
+
* dropping keys the target platform ignores). The build pipeline collects
|
|
36
|
+
* these into its `warnings` array.
|
|
37
|
+
*/
|
|
38
|
+
warnings?: string[];
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
/**
|