@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
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
- outputs.set(
557
- lexiconName,
558
- serializer.serialize(lexiconEntities, lexiconLexiconOutputs, { ownership: options?.ownership }),
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, { ownership });
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 interfere
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(plugin.name);
149
+ const pluginOutput = result.outputs.get(outputKey);
111
150
  if (pluginOutput !== undefined) {
112
- scopedOutputs.set(plugin.name, pluginOutput);
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
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
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
- const buildFormat = (args.format || "json") as "json" | "yaml";
19
- if (buildFormat !== "json" && buildFormat !== "yaml") {
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
  }
@@ -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
@@ -6,6 +6,7 @@
6
6
 
7
7
  export * from "./declarable";
8
8
  export * from "./composite";
9
+ export * from "./provenance";
9
10
  export * from "./intrinsic";
10
11
  export * from "./types";
11
12
  export * from "./errors";
@@ -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
  /**