@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.
- package/dist/change-set.d.ts +30 -8
- package/dist/change-set.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +11 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +67 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +94 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +29 -4
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +32 -6
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +15 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/change-set.test.ts +42 -12
- package/src/change-set.ts +50 -11
- package/src/cli/commands/build.ts +33 -14
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
- package/src/cli/handlers/build.ts +68 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +223 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +215 -1
- package/src/cli/handlers/run.ts +36 -15
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +120 -0
- package/src/cli/mcp/op-approve-origin.test.ts +1 -1
- package/src/cli/mcp/op-tools.ts +14 -2
- package/src/cli/mcp/server.test.ts +2 -1
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +96 -6
- package/src/components/pr-run.test.ts +311 -4
- package/src/components/pr-run.ts +198 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +31 -4
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +41 -10
- package/src/workspace/change-set.schema.json +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +21 -3
- 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", …)`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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(
|
|
279
|
-
const digest =
|
|
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
|
-
/**
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
package/src/cli/commands/lint.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|