@intentius/chant 0.1.23 → 0.3.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/cli/commands/build.test.ts +78 -1
- package/src/cli/commands/build.ts +38 -1
- package/src/cli/commands/migrate.ts +8 -1
- 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/lexicon.ts +5 -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/package.json
CHANGED
|
@@ -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
|
|
@@ -326,7 +358,12 @@ function jsonToYaml(obj: unknown, indent = 0): string {
|
|
|
326
358
|
return entries
|
|
327
359
|
.map(([key, value]) => {
|
|
328
360
|
const yamlValue = jsonToYaml(value, indent + 1);
|
|
329
|
-
|
|
361
|
+
// A non-empty container (object OR array) renders as a block: the key on
|
|
362
|
+
// its own line, the value indented beneath it. Arrays were previously
|
|
363
|
+
// excluded here, which inlined `Tags: - Key: t` as invalid YAML.
|
|
364
|
+
const isContainer = typeof value === "object" && value !== null;
|
|
365
|
+
const isEmpty = isContainer && (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
|
366
|
+
if (isContainer && !isEmpty) {
|
|
330
367
|
return `${spaces}${key}:\n${yamlValue}`;
|
|
331
368
|
}
|
|
332
369
|
return `${spaces}${key}: ${yamlValue.trimStart()}`;
|
|
@@ -96,6 +96,9 @@ export async function migrateCommand(opts: MigrateCliOpts): Promise<MigrateCliRe
|
|
|
96
96
|
useComposites: opts.useComposites,
|
|
97
97
|
sourceFile: opts.sourceFile,
|
|
98
98
|
strict: opts.strict,
|
|
99
|
+
// --validate also runs the target lexicon's security checks against the
|
|
100
|
+
// migrated output and classifies security-property fates (#306).
|
|
101
|
+
security: opts.validate,
|
|
99
102
|
});
|
|
100
103
|
} catch (err) {
|
|
101
104
|
return {
|
|
@@ -168,7 +171,11 @@ export async function migrateCommand(opts: MigrateCliOpts): Promise<MigrateCliRe
|
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
// Markdown summary (always to stderr — leaves stdout clean for piping)
|
|
171
|
-
|
|
174
|
+
let markdownSummary = formatMarkdownSummary(result.provenance, result.diagnostics, content);
|
|
175
|
+
// Append the security posture section when security analysis ran (#306).
|
|
176
|
+
if (result.securityPosture) {
|
|
177
|
+
markdownSummary += `\n\n${result.securityPosture}`;
|
|
178
|
+
}
|
|
172
179
|
|
|
173
180
|
// Determine exit code: any error-severity diagnostic fails when --strict.
|
|
174
181
|
// The transformer already escalates needs-review → error when opts.strict
|
|
@@ -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
package/src/lexicon.ts
CHANGED
|
@@ -109,6 +109,9 @@ export interface MigrateOptions {
|
|
|
109
109
|
sourceFile?: string;
|
|
110
110
|
/** Escalate needs-review diagnostics to errors. */
|
|
111
111
|
strict?: boolean;
|
|
112
|
+
/** Run security-aware migration analysis (classify property fates + run
|
|
113
|
+
* target security checks). Enabled by `chant migrate --validate`. */
|
|
114
|
+
security?: boolean;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
/**
|
|
@@ -125,6 +128,8 @@ export interface MigrationResult {
|
|
|
125
128
|
provenance: Array<Record<string, unknown>>;
|
|
126
129
|
/** SARIF-shaped diagnostics. */
|
|
127
130
|
diagnostics: Array<Record<string, unknown>>;
|
|
131
|
+
/** Markdown "Security posture" section, when security analysis ran (#306). */
|
|
132
|
+
securityPosture?: string;
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
/**
|
|
@@ -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
|
+
}
|