@intentius/chant 0.105.1 → 0.106.3

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.
Files changed (111) hide show
  1. package/dist/change-set.d.ts +30 -8
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts +11 -0
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/lint.d.ts.map +1 -1
  6. package/dist/cli/handlers/build.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.d.ts +16 -2
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.d.ts.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  15. package/dist/cli/registry.d.ts +7 -0
  16. package/dist/cli/registry.d.ts.map +1 -1
  17. package/dist/components/changed-files.d.ts +8 -2
  18. package/dist/components/changed-files.d.ts.map +1 -1
  19. package/dist/components/fan-out-record.d.ts +24 -0
  20. package/dist/components/fan-out-record.d.ts.map +1 -1
  21. package/dist/components/pr-pipeline.d.ts +67 -4
  22. package/dist/components/pr-pipeline.d.ts.map +1 -1
  23. package/dist/components/pr-run.d.ts +94 -3
  24. package/dist/components/pr-run.d.ts.map +1 -1
  25. package/dist/gated-waves.d.ts +4 -1
  26. package/dist/gated-waves.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +13 -2
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  30. package/dist/op/gate-name.d.ts +7 -0
  31. package/dist/op/gate-name.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts +2 -0
  33. package/dist/op/gate-summary.d.ts.map +1 -1
  34. package/dist/op/gate.d.ts +10 -2
  35. package/dist/op/gate.d.ts.map +1 -1
  36. package/dist/op/index.d.ts +2 -2
  37. package/dist/op/index.d.ts.map +1 -1
  38. package/dist/op/local-executor.d.ts.map +1 -1
  39. package/dist/op/local-output.d.ts +2 -2
  40. package/dist/op/local-output.d.ts.map +1 -1
  41. package/dist/op/runtime.d.ts +29 -4
  42. package/dist/op/runtime.d.ts.map +1 -1
  43. package/dist/op/runtimes/local.d.ts.map +1 -1
  44. package/dist/pr-loop.d.ts +32 -6
  45. package/dist/pr-loop.d.ts.map +1 -1
  46. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  47. package/dist/workspace/member-pipeline.d.ts +15 -1
  48. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
  51. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  52. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  53. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  54. package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
  55. package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
  56. package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
  57. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
  58. package/src/build.test.ts +1 -1
  59. package/src/build.ts +1 -1
  60. package/src/change-set.test.ts +42 -12
  61. package/src/change-set.ts +50 -11
  62. package/src/cli/commands/build.ts +33 -14
  63. package/src/cli/commands/lint.ts +34 -1
  64. package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
  65. package/src/cli/handlers/build.ts +68 -0
  66. package/src/cli/handlers/fan-out.test.ts +12 -1
  67. package/src/cli/handlers/fan-out.ts +7 -3
  68. package/src/cli/handlers/operator.ts +1 -2
  69. package/src/cli/handlers/pr.ts +223 -35
  70. package/src/cli/handlers/promote.test.ts +15 -1
  71. package/src/cli/handlers/promote.ts +5 -3
  72. package/src/cli/handlers/run.test.ts +215 -1
  73. package/src/cli/handlers/run.ts +36 -15
  74. package/src/cli/main.ts +13 -1
  75. package/src/cli/mcp/never-over-mcp.test.ts +120 -0
  76. package/src/cli/mcp/op-approve-origin.test.ts +1 -1
  77. package/src/cli/mcp/op-tools.ts +14 -2
  78. package/src/cli/mcp/server.test.ts +2 -1
  79. package/src/cli/registry.ts +7 -0
  80. package/src/components/changed-files.test.ts +58 -0
  81. package/src/components/changed-files.ts +19 -4
  82. package/src/components/fan-out-record.test.ts +47 -2
  83. package/src/components/fan-out-record.ts +53 -0
  84. package/src/components/pr-pipeline.ts +96 -6
  85. package/src/components/pr-run.test.ts +311 -4
  86. package/src/components/pr-run.ts +198 -5
  87. package/src/gated-waves.test.ts +11 -0
  88. package/src/gated-waves.ts +7 -2
  89. package/src/lexicon.ts +13 -1
  90. package/src/lifecycle/gate-ledger.ts +4 -3
  91. package/src/op/gate-name.ts +17 -0
  92. package/src/op/gate-summary.test.ts +12 -0
  93. package/src/op/gate-summary.ts +3 -1
  94. package/src/op/gate.test.ts +15 -1
  95. package/src/op/gate.ts +21 -4
  96. package/src/op/index.ts +2 -2
  97. package/src/op/local-executor.test.ts +35 -0
  98. package/src/op/local-executor.ts +2 -2
  99. package/src/op/local-output.test.ts +20 -0
  100. package/src/op/local-output.ts +4 -4
  101. package/src/op/runtime.ts +31 -4
  102. package/src/op/runtimes/local.test.ts +15 -0
  103. package/src/op/runtimes/local.ts +3 -1
  104. package/src/pr-loop.test.ts +41 -1
  105. package/src/pr-loop.ts +41 -10
  106. package/src/workspace/change-set.schema.json +1 -1
  107. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  108. package/src/workspace/lineage-upgrade.test.ts +40 -0
  109. package/src/workspace/member-pipeline.test.ts +28 -0
  110. package/src/workspace/member-pipeline.ts +21 -3
  111. package/src/workspace/pr-report.schema.json +14 -1
package/src/change-set.ts CHANGED
@@ -19,12 +19,19 @@
19
19
  * Every member carries the plan digest a gate on that member alone binds
20
20
  * (#2300): `terraformPlanDigest` for a terraform-family root, and here
21
21
  * `computePlanDigest("lifecycle-plan", …)` and `computePlanDigest(
22
- * "reconcile-plan", …)`. The document's digest is {@link changeSetDigest}
23
- * over the sorted `{ member, planDigest }` pairs, the set digest #3049
24
- * describes, so a wave's digest is the same function over the wave's
25
- * members. The entries and the summary are derived from the plans those
26
- * digests cover and are not hashed again: a change to how chant projects a
27
- * plan into entries does not expire an approval.
22
+ * "reconcile-plan", …)`. {@link changeSetDigest} over the sorted `{ member,
23
+ * planDigest }` pairs is the set digest #3049 describes, and a wave's digest
24
+ * is that function over the wave's members.
25
+ *
26
+ * The document's digest is {@link changeSetDocumentDigest}: the set digest,
27
+ * each member's status and holes, the entries and the side effects. An
28
+ * approval of the document binds what it says as well as the plans it came
29
+ * from, so a pull request's apply that resumes from a record (#3464) can
30
+ * check a re-planned member against the record's entries without trusting
31
+ * whoever wrote the record. The summary is computed from the entries and is
32
+ * not hashed. A change to how chant projects a plan into entries moves the
33
+ * document's digest, so an approval given under one chant does not carry to
34
+ * an apply under a chant that projects the same plan differently.
28
35
  *
29
36
  * ## The import graph
30
37
  *
@@ -192,6 +199,9 @@ function entryKey(e: ChangeSetEntry): string {
192
199
  return `${e.member}\u0000${e.address}\u0000${e.deposed ?? ""}\u0000${e.action}`;
193
200
  }
194
201
 
202
+ const bySideEffect = (a: ChangeSetSideEffect, b: ChangeSetSideEffect): number =>
203
+ byString(a.member, b.member) || byString(a.address, b.address) || byString(a.trigger ?? "", b.trigger ?? "");
204
+
195
205
  /**
196
206
  * The set digest over members' plan digests: `computePlanDigest("change-set",
197
207
  * …)` over `{ member, planDigest }` sorted by member. Order-independent,
@@ -212,6 +222,27 @@ export function changeSetDigest(members: ReadonlyArray<Pick<ChangeSetMember, "me
212
222
  return computePlanDigest("change-set", pairs);
213
223
  }
214
224
 
225
+ /**
226
+ * The document's digest: `computePlanDigest("change-set-document", …)` over
227
+ * the set digest ({@link changeSetDigest}), each member's name, status and
228
+ * holes, the entries and the side effects. Members are sorted by name and
229
+ * entries and side effects by the keys {@link composeChangeSet} sorts them by,
230
+ * so order makes no difference.
231
+ *
232
+ * A provisional member is left out, with its entries and side effects, as
233
+ * the set digest leaves it out: no approval of the document covers it.
234
+ */
235
+ export function changeSetDocumentDigest(doc: Pick<ChangeSetDocument, "members" | "entries" | "sideEffects">): string {
236
+ const provisional = new Set(doc.members.filter((m) => m.provisional === true).map((m) => m.member));
237
+ const members = doc.members
238
+ .filter((m) => !provisional.has(m.member))
239
+ .map((m) => ({ member: m.member, status: m.status, holes: m.holes }))
240
+ .sort((a, b) => byString(a.member, b.member));
241
+ const entries = doc.entries.filter((e) => !provisional.has(e.member)).sort((a, b) => byString(entryKey(a), entryKey(b)));
242
+ const sideEffects = (doc.sideEffects ?? []).filter((s) => !provisional.has(s.member)).sort(bySideEffect);
243
+ return computePlanDigest("change-set-document", { set: changeSetDigest(doc.members), members, entries, sideEffects });
244
+ }
245
+
215
246
  /** The summary of a set of members and entries. */
216
247
  export function summarizeChangeSet(members: ChangeSetMember[], entries: ChangeSetEntry[]): ChangeSetSummary {
217
248
  const actions = Object.fromEntries(CHANGE_SET_ACTIONS.map((a) => [a, 0])) as Record<ChangeSetAction, number>;
@@ -275,8 +306,8 @@ export function composeChangeSet(parts: ReadonlyArray<ChangeSetPart>, options: C
275
306
  entries.sort((a, b) => byString(entryKey(a), entryKey(b)));
276
307
  const sideEffects: ChangeSetSideEffect[] = parts
277
308
  .flatMap((p) => (p.sideEffects ?? []).map((s) => ({ member: p.member.member, ...s })))
278
- .sort((a, b) => byString(a.member, b.member) || byString(a.address, b.address) || byString(a.trigger ?? "", b.trigger ?? ""));
279
- const digest = changeSetDigest(members);
309
+ .sort(bySideEffect);
310
+ const digest = changeSetDocumentDigest({ members, entries, sideEffects });
280
311
  return {
281
312
  $schema: CHANGE_SET_SCHEMA_ID,
282
313
  contract: CHANGE_SET_CONTRACT,
@@ -289,9 +320,17 @@ export function composeChangeSet(parts: ReadonlyArray<ChangeSetPart>, options: C
289
320
  };
290
321
  }
291
322
 
292
- /** Whether a document's digest is the one its members' plan digests give. */
293
- export function verifyChangeSetDigest(doc: Pick<ChangeSetDocument, "digest" | "members">): boolean {
294
- return changeSetDigest(doc.members) === doc.digest;
323
+ /**
324
+ * Whether a document's digest is the one its members, entries and side
325
+ * effects give ({@link changeSetDocumentDigest}). A document that names a
326
+ * member twice has no digest and is not verified.
327
+ */
328
+ export function verifyChangeSetDigest(doc: Pick<ChangeSetDocument, "digest" | "members" | "entries" | "sideEffects">): boolean {
329
+ try {
330
+ return changeSetDocumentDigest(doc) === doc.digest;
331
+ } catch {
332
+ return false;
333
+ }
295
334
  }
296
335
 
297
336
  // ── chant lifecycle plan ───────────────────────────────────────────────────────
@@ -109,6 +109,14 @@ export interface BuildOptions {
109
109
  * any flame/icicle viewer with no chant-specific tooling.
110
110
  */
111
111
  foldRankCollapsedFile?: string;
112
+
113
+ /**
114
+ * `--lexicon-output` runs `buildCommand` once per output file. The first call
115
+ * runs the build with `serializers` (every serializer the project lists) and
116
+ * keeps the promise here; the later calls reuse it and each takes only the
117
+ * outputs of its own `serializers`, so the sources are evaluated once.
118
+ */
119
+ sharedBuild?: { serializers: Serializer[]; result?: ReturnType<typeof build> };
112
120
  }
113
121
 
114
122
  /**
@@ -311,20 +319,31 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
311
319
  // (../build-options.ts), which `evaluateProjectPolicies` calls too, so the
312
320
  // `policyGate` step decides on the same project this command builds.
313
321
  const telemetry = await resolveTelemetryAttribution(configDir, config as unknown as Record<string, unknown>, env);
314
- const result = await build(
315
- infraPath,
316
- options.serializers,
317
- undefined,
318
- resolveProjectBuildOptions({
319
- config,
320
- configDir,
321
- plugins: options.plugins,
322
- modes,
323
- ownership,
324
- buildParams: paramsResolution.provenance,
325
- telemetry,
326
- }),
327
- );
322
+ const runBuild = (serializers: Serializer[]) =>
323
+ build(
324
+ infraPath,
325
+ serializers,
326
+ undefined,
327
+ resolveProjectBuildOptions({
328
+ config,
329
+ configDir,
330
+ plugins: options.plugins,
331
+ modes,
332
+ ownership,
333
+ buildParams: paramsResolution.provenance,
334
+ telemetry,
335
+ }),
336
+ );
337
+ let result;
338
+ if (options.sharedBuild) {
339
+ const shared = options.sharedBuild;
340
+ shared.result ??= runBuild(shared.serializers);
341
+ const full = await shared.result;
342
+ const own = new Set(options.serializers.map((x) => x.name));
343
+ result = { ...full, outputs: new Map([...full.outputs].filter(([name]) => own.has(name))) };
344
+ } else {
345
+ result = await runBuild(options.serializers);
346
+ }
328
347
 
329
348
  // #1022 — report per-file fold vs run so it's visible what still runs.
330
349
  // #1424 — one line by default; the per-file lines and their reasons are
@@ -4,7 +4,8 @@ import { readFileSync, writeFileSync, readdirSync } from "fs";
4
4
  import { runLint, parseDisableComments } from "../../lint/engine";
5
5
  import type { LintRule, LintDiagnostic, LintFix } from "../../lint/rule";
6
6
  import type { IntrinsicDef, LexiconPlugin } from "../../lexicon";
7
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
7
+ import { loadPlugin, loadPlugins, resolveProjectLexicons } from "../plugins";
8
+ import { findInfraFiles, detectLexicons } from "../../index";
8
9
  import { formatStylish, formatJson, formatSarif } from "../reporters/stylish";
9
10
  import { loadLocalRules } from "../../lint/rule-loader";
10
11
  import { loadCoreRules } from "../../lint/rules/index";
@@ -129,6 +130,29 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
129
130
  };
130
131
  }
131
132
 
133
+ /**
134
+ * The plugins of lexicons that the project's source imports from and that are not in `configured`, for their
135
+ * property-kind class names (#3184). A package that is not installed, or that fails to load, is skipped.
136
+ */
137
+ async function loadImportedPropertyPlugins(projectPath: string, configured: readonly string[]): Promise<LexiconPlugin[]> {
138
+ let imported: string[];
139
+ try {
140
+ imported = await detectLexicons(await findInfraFiles(projectPath));
141
+ } catch {
142
+ return [];
143
+ }
144
+ const plugins: LexiconPlugin[] = [];
145
+ for (const name of imported) {
146
+ if (configured.includes(name)) continue;
147
+ try {
148
+ plugins.push(await loadPlugin(name));
149
+ } catch {
150
+ // Not installed here; the import is the build's problem to report, not lint's.
151
+ }
152
+ }
153
+ return plugins;
154
+ }
155
+
132
156
  /**
133
157
  * Load all lint rules: core COR/EVL rules, then lexicon plugin rules.
134
158
  *
@@ -210,6 +234,15 @@ async function loadAllPluginRules(
210
234
  // them as resources.
211
235
  const propertyClasses = new Set(plugins.flatMap((plugin) => plugin.propertyClassNames?.() ?? []));
212
236
 
237
+ // chant #3184: a lexicon the source imports from but the config does not list (grafana in a k8s project that
238
+ // holds a dashboard ConfigMap, after `chant import`) still declares which of its classes are property-kind. Only
239
+ // those names are taken from it; its rules and intrinsics stay out, since the project did not ask for them.
240
+ if (!lexiconError) {
241
+ for (const plugin of await loadImportedPropertyPlugins(projectPath, lexiconNames)) {
242
+ for (const name of plugin.propertyClassNames?.() ?? []) propertyClasses.add(name);
243
+ }
244
+ }
245
+
213
246
  for (const plugin of plugins) {
214
247
  if (plugin.lintRules) {
215
248
  for (const r of plugin.lintRules()) {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `chant build --lexicon-output <lexicon>=<path>`: each named lexicon is built
3
+ * into its own file, so one project can declare otel and prometheus. The build
4
+ * itself is mocked; this checks how the handler splits the serializers and
5
+ * the output paths.
6
+ */
7
+ import { describe, test, expect, vi, beforeEach } from "vitest";
8
+ import type { ParsedArgs, CommandContext } from "../registry";
9
+ import type { Serializer } from "../../serializer";
10
+
11
+ const buildCommandMock = vi.fn();
12
+
13
+ vi.mock("../commands/build", async () => {
14
+ const actual = await vi.importActual<typeof import("../commands/build")>("../commands/build");
15
+ return { ...actual, buildCommand: (...args: unknown[]) => buildCommandMock(...args) };
16
+ });
17
+
18
+ const { runBuild } = await import("./build");
19
+
20
+ const ser = (name: string) => ({ name }) as unknown as Serializer;
21
+
22
+ function ctx(overrides: Partial<ParsedArgs>, names = ["otel", "prometheus", "grafana"]): CommandContext {
23
+ const args = {
24
+ command: "build",
25
+ path: ".",
26
+ format: "",
27
+ fix: false,
28
+ watch: false,
29
+ verbose: false,
30
+ help: false,
31
+ live: false,
32
+ ...overrides,
33
+ } as ParsedArgs;
34
+ return { args, plugins: [], serializers: names.map(ser) } as unknown as CommandContext;
35
+ }
36
+
37
+ describe("runBuild --lexicon-output", () => {
38
+ beforeEach(() => {
39
+ buildCommandMock.mockReset().mockResolvedValue({ success: true, errors: [], warnings: [], resourceCount: 1, fileCount: 1 });
40
+ vi.spyOn(console, "error").mockImplementation(() => {});
41
+ });
42
+
43
+ test("builds each named lexicon into its own file", async () => {
44
+ const code = await runBuild(ctx({ lexiconOutput: ["otel=out/collector.yml", "prometheus=out/prometheus.yml"] }));
45
+ expect(code).toBe(0);
46
+ expect(buildCommandMock).toHaveBeenCalledTimes(2);
47
+ const calls = buildCommandMock.mock.calls.map(([o]) => [o.serializers.map((s: Serializer) => s.name), o.output, o.format]);
48
+ expect(calls).toEqual([
49
+ [["otel"], "out/collector.yml", "yaml"],
50
+ [["prometheus"], "out/prometheus.yml", "yaml"],
51
+ ]);
52
+ });
53
+
54
+ test("every file shares one build over all the serializers", async () => {
55
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml", "prometheus=b.yml"] }));
56
+ const shared = buildCommandMock.mock.calls.map(([o]) => o.sharedBuild);
57
+ expect(shared).toHaveLength(2);
58
+ expect(shared[0]).toBe(shared[1]);
59
+ expect(shared[0].serializers.map((s: Serializer) => s.name)).toEqual(["otel", "prometheus", "grafana"]);
60
+ });
61
+
62
+ test("lexicons not named go to --output when given, and are skipped otherwise", async () => {
63
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml"], output: "rest.json" }));
64
+ expect(buildCommandMock.mock.calls.map(([o]) => [o.serializers.map((s: Serializer) => s.name), o.output])).toEqual([
65
+ [["otel"], "a.yml"],
66
+ [["prometheus", "grafana"], "rest.json"],
67
+ ]);
68
+ buildCommandMock.mockClear();
69
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml"] }));
70
+ expect(buildCommandMock).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ test("drops the 'No serializer found' warning for lexicons another file carries, and repeats nothing", async () => {
74
+ const warn = "No serializer found for lexicon \"grafana\"";
75
+ buildCommandMock.mockResolvedValue({ success: true, errors: [], warnings: [warn, "something else"], resourceCount: 1, fileCount: 1 });
76
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
77
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml", "prometheus=b.yml"] }));
78
+ const printed = err.mock.calls.map((c) => String(c[0])).join("\n");
79
+ expect(printed).not.toContain("No serializer found");
80
+ expect(printed.match(/something else/g)).toHaveLength(1);
81
+ });
82
+
83
+ test("a failed build fails the command, and the other files are still written", async () => {
84
+ buildCommandMock.mockResolvedValueOnce({ success: false, errors: ["boom"], warnings: [], resourceCount: 0, fileCount: 0 });
85
+ const code = await runBuild(ctx({ lexiconOutput: ["otel=a.yml", "prometheus=b.yml"] }));
86
+ expect(code).toBe(1);
87
+ expect(buildCommandMock).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ test("refuses a malformed spec, an unknown lexicon, a repeat, and --lexicon", async () => {
91
+ for (const a of [
92
+ { lexiconOutput: ["otel"] },
93
+ { lexiconOutput: ["otel="] },
94
+ { lexiconOutput: ["nope=a.yml"] },
95
+ { lexiconOutput: ["otel=a.yml", "otel=b.yml"] },
96
+ { lexiconOutput: ["otel=a.yml"], lexicon: "otel" },
97
+ ]) {
98
+ expect(await runBuild(ctx(a))).toBe(1);
99
+ }
100
+ expect(buildCommandMock).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -65,6 +65,8 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
65
65
  output: args.output,
66
66
  params: args.param,
67
67
  paramsFile: args.paramsFile,
68
+ ...(args.waveGate ? { waveGate: { gate: args.waveGate, ...(args.canary?.length ? { canary: args.canary } : {}), ...(args.base ? { base: args.base } : {}) } } : {}),
69
+ ...(args.prLoop ? { prLoop: { ...(args.gate ? { gate: args.gate } : {}), ...(args.branch ? { branch: args.branch } : {}) } } : {}),
68
70
  });
69
71
  } catch (err) {
70
72
  console.error(formatError({ message: memberPipeline.describeError(err) }));
@@ -177,6 +179,72 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
177
179
  // #1064 — `--param name=value`, repeated, into a flat { name: value } record.
178
180
  const params = parseParamFlags(args.param);
179
181
 
182
+ // `--lexicon-output <lexicon>=<path>`: each named lexicon builds into its own
183
+ // file. One build per file, over the same project, so every write, check and
184
+ // diagnostic path is the one a single-lexicon `--lexicon` build already takes.
185
+ if (args.lexiconOutput?.length) {
186
+ if (args.watch || args.lexicon) {
187
+ console.error(formatError({ message: "--lexicon-output cannot be combined with --watch or --lexicon" }));
188
+ return 1;
189
+ }
190
+ const targets = new Map<string, string>();
191
+ for (const spec of args.lexiconOutput) {
192
+ const eq = (spec ?? "").indexOf("=");
193
+ if (eq <= 0 || eq === spec.length - 1) {
194
+ console.error(formatError({ message: `Invalid --lexicon-output "${spec}". Expected <lexicon>=<path>.` }));
195
+ return 1;
196
+ }
197
+ const name = spec.slice(0, eq);
198
+ if (targets.has(name)) {
199
+ console.error(formatError({ message: `--lexicon-output names lexicon "${name}" twice.` }));
200
+ return 1;
201
+ }
202
+ if (!serializers.some((s) => s.name === name)) {
203
+ console.error(formatError({ message: `No serializer found for lexicon "${name}". Available: ${serializers.map((s) => s.name).join(", ")}. List the lexicon in chant.config.ts.` }));
204
+ return 1;
205
+ }
206
+ targets.set(name, spec.slice(eq + 1));
207
+ }
208
+ const groups: { serializers: typeof serializers; output: string }[] = [...targets].map(([name, output]) => ({
209
+ serializers: serializers.filter((s) => s.name === name),
210
+ output,
211
+ }));
212
+ const rest = serializers.filter((s) => !targets.has(s.name));
213
+ if (args.output && rest.length > 0) groups.push({ serializers: rest, output: args.output });
214
+
215
+ const seenWarnings = new Set<string>();
216
+ let failed = false;
217
+ // One build for every file: each group takes its own lexicons' outputs.
218
+ const sharedBuild = { serializers };
219
+ for (const group of groups) {
220
+ const { format, warning } = resolveBuildFormat(args.format, group.output);
221
+ if (warning) console.error(formatInfo(warning));
222
+ const result = await buildCommand({
223
+ path: args.path,
224
+ output: group.output,
225
+ format,
226
+ serializers: group.serializers,
227
+ sharedBuild,
228
+ plugins,
229
+ verbose: args.verbose,
230
+ env: args.env,
231
+ fold: args.fold,
232
+ sandbox: args.sandbox,
233
+ foldRank: args.foldRank,
234
+ foldRankCollapsedFile: args.foldRankCollapsedFile,
235
+ params: parseParamFlags(args.param),
236
+ paramsFile: args.paramsFile,
237
+ });
238
+ // The lexicons built by another group are not missing serializers.
239
+ const own = result.warnings.filter((w) => !w.includes("No serializer found for lexicon") && !seenWarnings.has(w));
240
+ for (const w of own) seenWarnings.add(w);
241
+ printWarnings(own);
242
+ printErrors(result.errors);
243
+ if (!result.success) failed = true;
244
+ }
245
+ return failed ? 1 : 0;
246
+ }
247
+
180
248
  if (args.watch) {
181
249
  const cleanup = buildCommandWatch({
182
250
  path: args.path,
@@ -45,8 +45,9 @@ vi.mock("../../components/capability-plugin-loader", () => ({
45
45
  */
46
46
  vi.mock("../../op/gate", async () => {
47
47
  const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
48
- return { ...actual, gitGateLedgerPort: () => gateLedger };
48
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
49
49
  });
50
+ const sealedGates = new Set<string>();
50
51
 
51
52
  const { CapabilityRegistry } = await import("../../components/capability");
52
53
  const { memoryGateLedgerPort } = await import("../../op/gate");
@@ -105,6 +106,7 @@ let errSpy: ReturnType<typeof vi.spyOn>;
105
106
  let outSpy: ReturnType<typeof vi.spyOn>;
106
107
 
107
108
  beforeEach(() => {
109
+ sealedGates.clear();
108
110
  vi.clearAllMocks();
109
111
  ran = [];
110
112
  failing = [];
@@ -409,6 +411,14 @@ describe("proof: killed mid-fan-out, a re-run finishes without hand repair", ()
409
411
  });
410
412
 
411
413
  describe("proof: one approval covers the set, and the printed digest is what approves it", () => {
414
+ test("a sealed gate's approve line carries --sign after --plan (#3521)", async () => {
415
+ sealedGates.add("release");
416
+ const exit = await runComponentsFanOut(ctx({ base: "main", env: "test", gate: "release" }));
417
+
418
+ expect(exit).toBe(3);
419
+ expect(stderr()).toMatch(/approve : chant approve fan-out release --plan jcs1-sha256:[0-9a-f]{64} --sign/);
420
+ });
421
+
412
422
  test("an unapproved gate stops the whole fan-out before anything runs", async () => {
413
423
  const exit = await runComponentsFanOut(ctx({ base: "main", env: "test", gate: "release" }));
414
424
 
@@ -417,6 +427,7 @@ describe("proof: one approval covers the set, and the printed digest is what app
417
427
  const out = stderr();
418
428
  expect(out).toContain("gated: nothing ran.");
419
429
  expect(out).toMatch(/approve : chant approve fan-out release --plan jcs1-sha256:[0-9a-f]{64}/);
430
+ expect(out).not.toContain("--sign");
420
431
  // One pending fact for the whole set, not one per component.
421
432
  expect(gateLedger.appended).toHaveLength(1);
422
433
  expect(gateLedger.appended[0].planDigest).toBe(/plan: (jcs1-sha256:[0-9a-f]{64})/.exec(out)?.[1]);
@@ -53,7 +53,7 @@ import { describeChangedWave, withWaveRecord, type WaveRecord } from "../../gate
53
53
  import { renderFanOutHuman, renderFanOutJson, renderFanOutPlan } from "../../components/fan-out-output";
54
54
  import { ndjsonProgressSink } from "../../components/run-progress";
55
55
  import { summaryLedgerPrefix, writeGatedRunSummary } from "../../op/gate-summary";
56
- import { approveCommand } from "../../op/gate";
56
+ import { approveCommand, gateIsSealed } from "../../op/gate";
57
57
  import { getHeadCommit } from "../../lifecycle/git";
58
58
  import { remainingFanOut, type ChangedUnits, type FanOutProgress } from "../../components/fan-out";
59
59
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
@@ -405,7 +405,9 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
405
405
  // (#3049), the #2300 refusal on a set. Printed on stderr even under --json,
406
406
  // since it is the line a CI log reader is looking for.
407
407
  for (const wave of result.waves ?? []) {
408
- if (wave.status === "gated" && wave.approved) console.error(formatWarning({ message: describeChangedWave(wave) }));
408
+ if (wave.status === "gated" && wave.approved) {
409
+ console.error(formatWarning({ message: describeChangedWave(wave, await gateIsSealed(wave.gate)) }));
410
+ }
409
411
  }
410
412
 
411
413
  // The same durable trace `chant run --components` leaves (#597), for the
@@ -429,10 +431,12 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
429
431
  const pending = result.gate;
430
432
  // The pending fact's own plan: the fan-out's digest for the gate over the
431
433
  // set, or a component's plan and environment for a gate inside one (#2574).
434
+ const sealed = await gateIsSealed(pending.gate);
432
435
  console.error(formatInfo(
433
- `approve : ${approveCommand(pending.op, pending.gate, pending.environment)} --plan ${pending.planDigest ?? result.plan.digest}`,
436
+ `approve : ${approveCommand(pending.op, pending.gate, pending.environment, pending.planDigest ?? result.plan.digest, sealed)}`,
434
437
  ));
435
438
  writeGatedRunSummary({
439
+ sealed,
436
440
  op: pending.op,
437
441
  gate: pending.gate,
438
442
  ...(pending.description ? { description: pending.description } : {}),
@@ -49,6 +49,7 @@ import {
49
49
  } from "../../op/gate-approval";
50
50
  import { approverOf, tallyGateApprovals } from "../../op/gate";
51
51
  import { FAN_OUT_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
52
+ import { PR_GATE_OP } from "../../pr-loop";
52
53
  import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
53
54
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
54
55
  import type { CommandContext } from "../registry";
@@ -134,8 +135,6 @@ async function collectOperatorSubscribers(
134
135
  }
135
136
  }
136
137
 
137
- /** A pull request's gate op, `pr-<number>` (#3183, `prOp` in ../../pr-loop.ts). */
138
- const PR_GATE_OP = /^pr-\d+$/;
139
138
 
140
139
  // ── chant operator ──────────────────────────────────────────────────────────
141
140