@intentius/chant 0.29.0 → 0.31.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/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts +16 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +23 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +44 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +18 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +86 -0
- package/src/cli/handlers/graph.ts +64 -3
- package/src/cli/handlers/lifecycle.test.ts +126 -1
- package/src/cli/handlers/lifecycle.ts +184 -3
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/generate.ts +25 -0
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +235 -79
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +88 -18
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir.ts +47 -0
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +22 -2
- package/src/lexicon.ts +59 -0
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/snapshot.ts +6 -11
|
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
4
|
import { partitionByLexicon, computeStackGraph, build } from "../../build";
|
|
5
|
-
import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type LiveObservation } from "../../graph-ir";
|
|
5
|
+
import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
|
|
6
6
|
import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
|
|
7
7
|
import { observeResources } from "../../lifecycle/observe";
|
|
8
8
|
import { loadChantConfig, environmentNames } from "../../config";
|
|
@@ -17,7 +17,7 @@ import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
18
|
import { formatError, formatWarning, formatBold } from "../format";
|
|
19
19
|
import type { CommandContext } from "../registry";
|
|
20
|
-
import { computeComponentGraph } from "../../components/cli-support";
|
|
20
|
+
import { computeComponentGraph, generateComponentsPipeline } from "../../components/cli-support";
|
|
21
21
|
import { discoverComponents } from "../../components/discover";
|
|
22
22
|
import { cfnDeployStacks } from "./components";
|
|
23
23
|
|
|
@@ -34,6 +34,18 @@ import { cfnDeployStacks } from "./components";
|
|
|
34
34
|
export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
35
35
|
const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
|
|
36
36
|
const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
|
|
37
|
+
// `--projection <lexicon>` (#989) only means anything for the component
|
|
38
|
+
// graph's IR — it adds the CI/pipeline shape to `GraphIR.pipeline`, a field
|
|
39
|
+
// the other view formats' emitters (mermaid/dot/layout) don't read, and the
|
|
40
|
+
// plain (non-`--format`) `--components` text/`--json` modes don't build an
|
|
41
|
+
// IR at all. Reject every other combination up front rather than silently
|
|
42
|
+
// ignoring the flag.
|
|
43
|
+
if (ctx.args.projection && !(ctx.args.components && ctx.args.format === "ir")) {
|
|
44
|
+
console.error(formatError({
|
|
45
|
+
message: "--projection needs --components --format ir — the CI/pipeline projection extends the component-graph IR, the only mode that carries it.",
|
|
46
|
+
}));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
37
49
|
// `--live` graphs the provisioned (observed) infrastructure, not the declared
|
|
38
50
|
// source (epic #776). It only makes sense as a view format; default to `ir`.
|
|
39
51
|
if (ctx.args.live) {
|
|
@@ -281,6 +293,15 @@ async function runComponentGraph(ctx: CommandContext): Promise<number> {
|
|
|
281
293
|
* the CI pipeline. Distinct from `runGraphView`, which emits the AWS *entity*
|
|
282
294
|
* graph — the component projection has one node per component, not per resource.
|
|
283
295
|
*
|
|
296
|
+
* `--projection <lexicon>` (#989, `--format ir` only — validated in `runGraph`)
|
|
297
|
+
* adds the **CI/pipeline projection** alongside this component graph:
|
|
298
|
+
* `ir.pipeline` carries the stages/jobs/`needs` `<lexicon>`'s
|
|
299
|
+
* `generateComponentPipeline` synthesizes for `chant build --components
|
|
300
|
+
* --generate <lexicon>` (`generateComponentsPipeline`, ../../components/cli-support.ts)
|
|
301
|
+
* — reused wholesale, not re-derived, so a consumer (e.g. behold, epic #492/
|
|
302
|
+
* INTENTIUS/behold#54) gets the pipeline shape as first-class IR nodes/edges
|
|
303
|
+
* instead of re-deriving it from `dependsOn` or parsing generated CI YAML.
|
|
304
|
+
*
|
|
284
305
|
* Lint-gated like the entity view: the DAG stands for deployable source, so we
|
|
285
306
|
* refuse to emit it for source that does not pass lint.
|
|
286
307
|
*/
|
|
@@ -312,7 +333,7 @@ async function runComponentGraphView(
|
|
|
312
333
|
const waveOf = new Map<string, number>();
|
|
313
334
|
graph.waves.forEach((wave, i) => wave.forEach((name) => waveOf.set(name, i + 1)));
|
|
314
335
|
|
|
315
|
-
|
|
336
|
+
let ir: GraphIR = {
|
|
316
337
|
nodes: graph.order.map((name) => ({
|
|
317
338
|
id: name,
|
|
318
339
|
kind: "Component",
|
|
@@ -327,9 +348,49 @@ async function runComponentGraphView(
|
|
|
327
348
|
},
|
|
328
349
|
};
|
|
329
350
|
|
|
351
|
+
if (ctx.args.projection) {
|
|
352
|
+
const pipeline = await buildPipelineProjection(projectPath, ctx.args.projection, ctx.args.sandbox);
|
|
353
|
+
if (!pipeline.success) {
|
|
354
|
+
console.error(formatError({ message: pipeline.error ?? `Failed to generate ${ctx.args.projection} pipeline projection` }));
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
ir = { ...ir, pipeline: pipeline.pipeline };
|
|
358
|
+
}
|
|
359
|
+
|
|
330
360
|
return emitIr(ir, ctx, format);
|
|
331
361
|
}
|
|
332
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Reshape `generateComponentsPipeline`'s result (../../components/cli-support.ts
|
|
365
|
+
* — the exact function `chant build --components --generate <lexicon>` calls)
|
|
366
|
+
* into the IR's `IRPipeline` vocabulary (#989): one `IRPipelineNode` per
|
|
367
|
+
* generated CI job, one `IRPipelineEdge` per `needs:` dependency (consumer job
|
|
368
|
+
* → producer job, mirroring the component edges' consumer → producer
|
|
369
|
+
* direction). Every shape decision — job naming, one stage per wave,
|
|
370
|
+
* dependency resolution — stays owned by `lexicon`'s `generateComponentPipeline`;
|
|
371
|
+
* this only relabels its `{ stages, jobs }` output as IR nodes/edges, it never
|
|
372
|
+
* re-derives the graph.
|
|
373
|
+
*/
|
|
374
|
+
async function buildPipelineProjection(
|
|
375
|
+
projectPath: string,
|
|
376
|
+
lexicon: string,
|
|
377
|
+
sandbox?: boolean,
|
|
378
|
+
): Promise<{ success: true; pipeline: IRPipeline } | { success: false; error?: string }> {
|
|
379
|
+
const result = await generateComponentsPipeline(projectPath, lexicon, undefined, sandbox);
|
|
380
|
+
if (!result.success) return { success: false, error: result.error };
|
|
381
|
+
|
|
382
|
+
const jobs = result.jobs ?? [];
|
|
383
|
+
return {
|
|
384
|
+
success: true,
|
|
385
|
+
pipeline: {
|
|
386
|
+
provider: lexicon,
|
|
387
|
+
stages: result.stages ?? [],
|
|
388
|
+
nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
|
|
389
|
+
edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
333
394
|
/**
|
|
334
395
|
* `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
|
|
335
396
|
* `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { sep } from "node:path";
|
|
3
|
-
import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
3
|
+
import { createMockPlugin, staticDescribeResources, staticObservation, staticDeepObservation, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
4
4
|
import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
|
|
5
5
|
import type { BuildResult } from "../../build";
|
|
6
6
|
import type { ParsedArgs } from "../registry";
|
|
@@ -12,14 +12,22 @@ const readEnvironmentSnapshotsMock = vi.fn();
|
|
|
12
12
|
const listSnapshotsMock = vi.fn();
|
|
13
13
|
const takeSnapshotMock = vi.fn();
|
|
14
14
|
const loadChantConfigMock = vi.fn();
|
|
15
|
+
const pushLifecycleMock = vi.fn();
|
|
16
|
+
const readBlobFromPathMock = vi.fn();
|
|
17
|
+
const writeBlobToPathMock = vi.fn();
|
|
15
18
|
|
|
16
19
|
vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
|
|
17
20
|
vi.mock("../../lifecycle/git", () => ({
|
|
18
21
|
fetchLifecycle: () => fetchLifecycleMock(),
|
|
22
|
+
pushLifecycle: () => pushLifecycleMock(),
|
|
19
23
|
readSnapshot: (...args: unknown[]) => readSnapshotMock(...args),
|
|
20
24
|
readEnvironmentSnapshots: (...args: unknown[]) => readEnvironmentSnapshotsMock(...args),
|
|
21
25
|
listSnapshots: (...args: unknown[]) => listSnapshotsMock(...args),
|
|
22
26
|
snapshotStorageKey: (lexicon: string, stack?: string) => (stack ? `${stack}__${lexicon}` : lexicon),
|
|
27
|
+
// The accepted-observation baseline (#1014) rides the same orphan-branch
|
|
28
|
+
// plumbing as the snapshots, so it is mocked at the same seam.
|
|
29
|
+
readBlobFromPath: (...args: unknown[]) => readBlobFromPathMock(...args),
|
|
30
|
+
writeBlobToPath: (...args: unknown[]) => writeBlobToPathMock(...args),
|
|
23
31
|
}));
|
|
24
32
|
vi.mock("../../lifecycle/snapshot", () => ({
|
|
25
33
|
takeSnapshot: (...args: unknown[]) => takeSnapshotMock(...args),
|
|
@@ -93,6 +101,12 @@ describe("runLifecycleDiff --live", () => {
|
|
|
93
101
|
readSnapshotMock.mockReset();
|
|
94
102
|
loadChantConfigMock.mockReset();
|
|
95
103
|
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
104
|
+
readBlobFromPathMock.mockReset();
|
|
105
|
+
readBlobFromPathMock.mockResolvedValue(null); // no accepted baseline recorded
|
|
106
|
+
writeBlobToPathMock.mockReset();
|
|
107
|
+
writeBlobToPathMock.mockResolvedValue("sha");
|
|
108
|
+
pushLifecycleMock.mockReset();
|
|
109
|
+
pushLifecycleMock.mockResolvedValue(true);
|
|
96
110
|
});
|
|
97
111
|
|
|
98
112
|
test("surfaces drift between previous snapshot and live state", async () => {
|
|
@@ -352,6 +366,117 @@ describe("runLifecycleDiff --live", () => {
|
|
|
352
366
|
expect(output).toContain("added");
|
|
353
367
|
});
|
|
354
368
|
|
|
369
|
+
// #1014 — property-level drift, gated purely on the deep capability.
|
|
370
|
+
describe("deep observation (#1014)", () => {
|
|
371
|
+
const withDeep = (over: Parameters<typeof createMockPlugin>[0] = {}) =>
|
|
372
|
+
createMockPlugin({
|
|
373
|
+
name: "aws",
|
|
374
|
+
describeResources: staticObservation({ bucket: meta() }),
|
|
375
|
+
observeResourcesDeep: staticDeepObservation({
|
|
376
|
+
bucket: {
|
|
377
|
+
type: "AWS::S3::Bucket",
|
|
378
|
+
properties: { Versioning: "Suspended", Logging: { Target: "audit" } },
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
...over,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const runDiff = async (plugins: LexiconPlugin[], args: Partial<ParsedArgs> = {}) => {
|
|
385
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
386
|
+
// Declared: versioning on, nothing about logging.
|
|
387
|
+
const build = makeBuildResult({ aws: ["bucket"] });
|
|
388
|
+
build.entities.set("bucket", {
|
|
389
|
+
lexicon: "aws",
|
|
390
|
+
entityType: "AWS::S3::Bucket",
|
|
391
|
+
props: { Versioning: "Enabled" },
|
|
392
|
+
} as never);
|
|
393
|
+
buildMock.mockResolvedValue(build);
|
|
394
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
395
|
+
readSnapshotMock.mockResolvedValue(null);
|
|
396
|
+
return runLifecycleDiff({
|
|
397
|
+
args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: true, ...args }),
|
|
398
|
+
plugins,
|
|
399
|
+
serializers: plugins.map((p) => p.serializer),
|
|
400
|
+
} as never);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
test("reports the changed property and the undeclared one", async () => {
|
|
404
|
+
await runDiff([withDeep()]);
|
|
405
|
+
const output = stdoutBuf.join("\n");
|
|
406
|
+
expect(output).toContain("aws (properties)");
|
|
407
|
+
expect(output).toContain("Versioning: Enabled → Suspended");
|
|
408
|
+
expect(output).toContain("Logging.Target: <undeclared> → audit");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a lexicon with no deep reader prints nothing extra", async () => {
|
|
412
|
+
await runDiff([createMockPlugin({ name: "aws", describeResources: staticObservation({ bucket: meta() }) })]);
|
|
413
|
+
expect(stdoutBuf.join("\n")).not.toContain("(properties)");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("an accepted deviation in the baseline stops re-alerting", async () => {
|
|
417
|
+
readBlobFromPathMock.mockResolvedValue(
|
|
418
|
+
JSON.stringify({
|
|
419
|
+
baseline: "v1",
|
|
420
|
+
environment: "prod",
|
|
421
|
+
lexicons: { aws: { bucket: { accepted: [{ path: "Logging.Target", value: "audit" }] } } },
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
await runDiff([withDeep()]);
|
|
425
|
+
const output = stdoutBuf.join("\n");
|
|
426
|
+
expect(output).toContain("Versioning: Enabled → Suspended");
|
|
427
|
+
expect(output).not.toContain("Logging.Target: <undeclared>");
|
|
428
|
+
expect(output).toContain("ACCEPTED (in the baseline; not drift)");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("--json carries the property drift under the lexicon's `deep` key", async () => {
|
|
432
|
+
await runDiff([withDeep()], { json: true });
|
|
433
|
+
const payload = JSON.parse(stdoutBuf.join("\n")) as {
|
|
434
|
+
lexicons: { aws: { deep: { drifted: Array<{ changes: Array<{ path: string }> }> } } };
|
|
435
|
+
};
|
|
436
|
+
expect(payload.lexicons.aws.deep.drifted[0].changes.map((c) => c.path).sort()).toEqual([
|
|
437
|
+
"Logging.Target",
|
|
438
|
+
"Versioning",
|
|
439
|
+
]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("a deep read that could not look is a hole, not drift", async () => {
|
|
443
|
+
await runDiff([
|
|
444
|
+
withDeep({
|
|
445
|
+
observeResourcesDeep: staticDeepObservation(
|
|
446
|
+
{},
|
|
447
|
+
{ bucket: { type: "AWS::S3::Bucket", reason: "no-credentials", detail: "token expired" } },
|
|
448
|
+
),
|
|
449
|
+
}),
|
|
450
|
+
]);
|
|
451
|
+
const output = `${stdoutBuf.join("\n")}\n${stderrBuf.join("\n")}`;
|
|
452
|
+
expect(output).toContain("PROPERTIES UNOBSERVED");
|
|
453
|
+
expect(output).toContain("no credentials");
|
|
454
|
+
expect(output).toContain("could not be observed — that part of the estate is unknown, not clean");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("--update-baseline writes what was reported and pushes it", async () => {
|
|
458
|
+
await runDiff([withDeep()], { updateBaseline: true });
|
|
459
|
+
expect(writeBlobToPathMock).toHaveBeenCalledTimes(1);
|
|
460
|
+
const [environment, filename, content] = writeBlobToPathMock.mock.calls[0] as [string, string, string];
|
|
461
|
+
expect(environment).toBe("prod");
|
|
462
|
+
expect(filename).toBe("observation-baseline.json");
|
|
463
|
+
const written = JSON.parse(content) as {
|
|
464
|
+
lexicons: { aws: { bucket: { accepted: Array<{ path: string; value: unknown }> } } };
|
|
465
|
+
};
|
|
466
|
+
expect(written.lexicons.aws.bucket.accepted.map((a) => a.path)).toEqual(["Logging.Target", "Versioning"]);
|
|
467
|
+
expect(pushLifecycleMock).toHaveBeenCalled();
|
|
468
|
+
expect(stderrBuf.join("\n")).toContain("accepted 2 deviation(s)");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("--update-baseline with nothing reported writes nothing", async () => {
|
|
472
|
+
await runDiff([
|
|
473
|
+
withDeep({ observeResourcesDeep: staticDeepObservation({}) }),
|
|
474
|
+
], { updateBaseline: true });
|
|
475
|
+
expect(writeBlobToPathMock).not.toHaveBeenCalled();
|
|
476
|
+
expect(stderrBuf.join("\n")).toContain("nothing to accept");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
355
480
|
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
356
481
|
// Floci), applied to the ambient var of every observing lexicon that has one
|
|
357
482
|
// unless the ambient shell already set it.
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { build } from "../../build";
|
|
3
3
|
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
4
|
-
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
4
|
+
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
|
+
import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
|
|
6
|
+
import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
|
|
7
|
+
import {
|
|
8
|
+
acceptDeviations,
|
|
9
|
+
baselineForLexicon,
|
|
10
|
+
emptyBaseline,
|
|
11
|
+
readObservationBaseline,
|
|
12
|
+
writeObservationBaseline,
|
|
13
|
+
OBSERVATION_BASELINE_FILE,
|
|
14
|
+
type DeviationToAccept,
|
|
15
|
+
type ObservationBaseline,
|
|
16
|
+
} from "../../lifecycle/observation-baseline";
|
|
5
17
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
18
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
7
19
|
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
@@ -287,6 +299,12 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
287
299
|
let totalChecked = 0;
|
|
288
300
|
let anyBuildError = false;
|
|
289
301
|
|
|
302
|
+
// Accepted-deviation baseline (#1014). Read once for the whole run — it is
|
|
303
|
+
// env-keyed, not stack-keyed, and every deep pass subtracts from the same
|
|
304
|
+
// committed set. Absent is the normal state (nothing accepted yet).
|
|
305
|
+
const baseline = args.live ? await readObservationBaseline(environment) : null;
|
|
306
|
+
const accepted: Record<string, DeviationToAccept[]> = {};
|
|
307
|
+
|
|
290
308
|
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
291
309
|
// Floci), so `--live` is self-sufficient even when the ambient shell never
|
|
292
310
|
// exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
|
|
@@ -324,10 +342,23 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
324
342
|
// no components / discovery failed → single-stack observe path
|
|
325
343
|
}
|
|
326
344
|
}
|
|
327
|
-
const r = await runLifecycleDiffLive({
|
|
345
|
+
const r = await runLifecycleDiffLive({
|
|
346
|
+
environment,
|
|
347
|
+
lexicons,
|
|
348
|
+
plugins,
|
|
349
|
+
buildResult,
|
|
350
|
+
json,
|
|
351
|
+
stack: target.stack,
|
|
352
|
+
componentStacks,
|
|
353
|
+
baseline,
|
|
354
|
+
updateBaseline: args.updateBaseline,
|
|
355
|
+
});
|
|
328
356
|
totalDrift += r.totalDrift;
|
|
329
357
|
totalUnobserved += r.totalUnobserved;
|
|
330
358
|
totalChecked += r.totalLexiconsChecked;
|
|
359
|
+
for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
|
|
360
|
+
(accepted[lexicon] ??= []).push(...deviations);
|
|
361
|
+
}
|
|
331
362
|
if (json) {
|
|
332
363
|
if (target.stack) perStackJson[target.stack] = r.byLexicon;
|
|
333
364
|
else combinedLexiconsJson = r.byLexicon;
|
|
@@ -337,6 +368,13 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
337
368
|
}
|
|
338
369
|
}
|
|
339
370
|
|
|
371
|
+
// `--update-baseline` (#1014): record what the deep pass just reported as
|
|
372
|
+
// accepted, so it stops re-alerting. Runs before the summary lines so the
|
|
373
|
+
// "no drift" verdict below still describes the run that produced it.
|
|
374
|
+
if (args.live && args.updateBaseline) {
|
|
375
|
+
await recordAcceptedBaseline(environment, baseline, accepted, json);
|
|
376
|
+
}
|
|
377
|
+
|
|
340
378
|
if (args.live) {
|
|
341
379
|
if (json) {
|
|
342
380
|
// Single-stack keeps the original `{ environment, lexicons }` shape
|
|
@@ -372,6 +410,50 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
372
410
|
}
|
|
373
411
|
}
|
|
374
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Write the accepted-deviation baseline (#1014) for everything the deep pass
|
|
415
|
+
* reported this run, and push it on the same orphan branch the snapshots use.
|
|
416
|
+
*
|
|
417
|
+
* Acceptance is a deliberate, committed act — that is the whole difference
|
|
418
|
+
* between this and a suppression flag — so the write is loud: it names the
|
|
419
|
+
* count and the storage path, and a failed push says so rather than leaving
|
|
420
|
+
* the operator believing the team's baseline moved.
|
|
421
|
+
*/
|
|
422
|
+
async function recordAcceptedBaseline(
|
|
423
|
+
environment: string,
|
|
424
|
+
existing: ObservationBaseline | null,
|
|
425
|
+
accepted: Record<string, DeviationToAccept[]>,
|
|
426
|
+
json: boolean,
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
|
|
429
|
+
if (total === 0) {
|
|
430
|
+
if (!json) {
|
|
431
|
+
console.error(formatWarning({
|
|
432
|
+
message: "--update-baseline: nothing to accept — no property-level deviations were reported",
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
let next = existing ?? emptyBaseline(environment);
|
|
438
|
+
for (const [lexicon, deviations] of Object.entries(accepted)) {
|
|
439
|
+
next = acceptDeviations(next, lexicon, deviations);
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
await writeObservationBaseline(next);
|
|
443
|
+
const pushed = await pushLifecycle();
|
|
444
|
+
if (!json) {
|
|
445
|
+
console.error(formatSuccess(
|
|
446
|
+
`--update-baseline: accepted ${total} deviation(s) into ${environment}/${OBSERVATION_BASELINE_FILE} on chant/lifecycle` +
|
|
447
|
+
(pushed ? " (pushed)" : " (local only — no remote configured or push refused)"),
|
|
448
|
+
));
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.error(formatError({
|
|
452
|
+
message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
375
457
|
interface BetweenDiffArgs {
|
|
376
458
|
environment: string;
|
|
377
459
|
lexiconFilter?: string;
|
|
@@ -494,6 +576,10 @@ interface LiveDiffArgs {
|
|
|
494
576
|
* union (the same fix graph/plan use), else every deployed resource reads as
|
|
495
577
|
* "missing". Empty → the single-stack observe path. */
|
|
496
578
|
componentStacks?: string[];
|
|
579
|
+
/** Accepted-deviation baseline for this environment (#1014), or null when none is recorded. */
|
|
580
|
+
baseline: ObservationBaseline | null;
|
|
581
|
+
/** `--update-baseline`: accept everything the deep pass reports this run. */
|
|
582
|
+
updateBaseline?: boolean;
|
|
497
583
|
}
|
|
498
584
|
|
|
499
585
|
interface LiveDiffOutcome {
|
|
@@ -504,6 +590,8 @@ interface LiveDiffOutcome {
|
|
|
504
590
|
observed?: Record<string, ResourceMetadata>;
|
|
505
591
|
/** Declared entities the lexicon could not read (#1089), keyed by name. */
|
|
506
592
|
unobserved?: Record<string, UnobservedEntity>;
|
|
593
|
+
/** Property-level drift (#1014), present only for lexicons with a deep reader. */
|
|
594
|
+
deep?: DeepDiffResult;
|
|
507
595
|
artifacts?: LiveArtifactDiffResult;
|
|
508
596
|
}
|
|
509
597
|
>;
|
|
@@ -511,6 +599,8 @@ interface LiveDiffOutcome {
|
|
|
511
599
|
/** Declared entities nobody could read. Not drift — a hole in the report. */
|
|
512
600
|
totalUnobserved: number;
|
|
513
601
|
totalLexiconsChecked: number;
|
|
602
|
+
/** Deviations `--update-baseline` should record, per lexicon. */
|
|
603
|
+
toAccept: Record<string, DeviationToAccept[]>;
|
|
514
604
|
}
|
|
515
605
|
|
|
516
606
|
/**
|
|
@@ -576,6 +666,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
576
666
|
let totalUnobserved = 0;
|
|
577
667
|
let totalLexiconsChecked = 0;
|
|
578
668
|
const byLexicon: LiveDiffOutcome["byLexicon"] = {};
|
|
669
|
+
const toAccept: Record<string, DeviationToAccept[]> = {};
|
|
579
670
|
if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
|
|
580
671
|
|
|
581
672
|
for (const lexiconName of args.lexicons) {
|
|
@@ -644,6 +735,27 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
644
735
|
if (Object.keys(observed.unobserved).length > 0) entry.unobserved = observed.unobserved;
|
|
645
736
|
} else renderLiveDiff(lexiconName, args.environment, diff);
|
|
646
737
|
lexiconChecked = true;
|
|
738
|
+
|
|
739
|
+
// ── Deep path (property-level, #1014) ───────────────────────────────
|
|
740
|
+
// Gated purely on the capability: a lexicon without a deep reader is
|
|
741
|
+
// completely unaffected, including its output.
|
|
742
|
+
if (plugin.observeResourcesDeep) {
|
|
743
|
+
const deep = await deepDiffForLexicon(plugin, {
|
|
744
|
+
environment: args.environment,
|
|
745
|
+
buildOutput,
|
|
746
|
+
entities,
|
|
747
|
+
stack: args.stack,
|
|
748
|
+
componentStacks: args.componentStacks,
|
|
749
|
+
baseline: baselineForLexicon(args.baseline, lexiconName),
|
|
750
|
+
});
|
|
751
|
+
totalDrift += countPropertyDrift(deep);
|
|
752
|
+
// Only count a deep hole for an entity the thin read *did* resolve —
|
|
753
|
+
// otherwise one unreadable entity is counted twice.
|
|
754
|
+
totalUnobserved += deep.unobserved.filter((u) => !observed.unobserved[u.name]).length;
|
|
755
|
+
if (args.updateBaseline) toAccept[lexiconName] = deviationsToAccept(deep);
|
|
756
|
+
if (args.json) (byLexicon[lexiconName] ??= {}).deep = deep;
|
|
757
|
+
else renderDeepDiff(lexiconName, deep);
|
|
758
|
+
}
|
|
647
759
|
}
|
|
648
760
|
|
|
649
761
|
// ── Artifacts path (context-keyed) ─────────────────────────────────────
|
|
@@ -668,7 +780,76 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
668
780
|
if (lexiconChecked) totalLexiconsChecked++;
|
|
669
781
|
}
|
|
670
782
|
|
|
671
|
-
return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked };
|
|
783
|
+
return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked, toAccept };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Everything a deep diff reported this run, as deviations to record accepted.
|
|
788
|
+
* `--update-baseline` accepts what was *reported*, never what was already
|
|
789
|
+
* suppressed — re-accepting an unchanged suppression would rewrite its
|
|
790
|
+
* `recordedAt` on every run and turn the baseline into a churn file.
|
|
791
|
+
*/
|
|
792
|
+
function deviationsToAccept(deep: DeepDiffResult): DeviationToAccept[] {
|
|
793
|
+
const out: DeviationToAccept[] = [];
|
|
794
|
+
for (const entity of deep.drifted) {
|
|
795
|
+
for (const change of entity.changes) {
|
|
796
|
+
// Only a value that is actually live can be accepted: `absent` means the
|
|
797
|
+
// cloud does not carry the declared property, which is a finding to fix
|
|
798
|
+
// in source or in the cloud, not a value to bless.
|
|
799
|
+
if (!("live" in change)) continue;
|
|
800
|
+
out.push({ entity: entity.name, type: entity.type, path: change.path, value: change.live });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Property-level drift report (#1014). Silent when a lexicon's deep read found nothing to say. */
|
|
807
|
+
function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
|
|
808
|
+
const drift = countPropertyDrift(deep);
|
|
809
|
+
if (
|
|
810
|
+
drift === 0 &&
|
|
811
|
+
deep.accepted.length === 0 &&
|
|
812
|
+
deep.unobserved.length === 0 &&
|
|
813
|
+
deep.undeclaredEntities.length === 0
|
|
814
|
+
) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const acceptedCount = deep.accepted.reduce((n, e) => n + e.changes.length, 0);
|
|
819
|
+
console.log(`\n${formatBold(`${lexiconName} (properties)`)}`);
|
|
820
|
+
console.log(
|
|
821
|
+
`${drift} property drift across ${deep.drifted.length} resource(s), ` +
|
|
822
|
+
`${acceptedCount} accepted, ${deep.unchanged.length} unchanged` +
|
|
823
|
+
(deep.unobserved.length > 0 ? `, ${deep.unobserved.length} unobserved` : ""),
|
|
824
|
+
);
|
|
825
|
+
console.log("-".repeat(80));
|
|
826
|
+
|
|
827
|
+
if (deep.unobserved.length > 0) {
|
|
828
|
+
console.log(formatBold("\nPROPERTIES UNOBSERVED (declared; the deep read could not look):"));
|
|
829
|
+
for (const u of deep.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
830
|
+
}
|
|
831
|
+
if (deep.drifted.length > 0) {
|
|
832
|
+
console.log(formatBold("\nPROPERTY DRIFT (declared vs live; baseline shown where one exists):"));
|
|
833
|
+
for (const entity of deep.drifted) {
|
|
834
|
+
console.log(` - ${entity.name} (${entity.type})`);
|
|
835
|
+
for (const change of entity.changes) {
|
|
836
|
+
const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
|
|
837
|
+
const live = "live" in change ? formatValue(change.live) : "<absent>";
|
|
838
|
+
const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
|
|
839
|
+
console.log(` ${change.path}: ${declared} → ${live}${baseline}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (deep.undeclaredEntities.length > 0) {
|
|
844
|
+
console.log(formatBold("\nUNDECLARED (read deeply, never declared in source):"));
|
|
845
|
+
for (const name of deep.undeclaredEntities) console.log(` - ${name}`);
|
|
846
|
+
}
|
|
847
|
+
if (acceptedCount > 0) {
|
|
848
|
+
console.log(formatBold("\nACCEPTED (in the baseline; not drift):"));
|
|
849
|
+
for (const entity of deep.accepted) {
|
|
850
|
+
console.log(` - ${entity.name}: ${entity.changes.map((c) => c.path).join(", ")}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
672
853
|
}
|
|
673
854
|
|
|
674
855
|
function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
|
package/src/cli/main.test.ts
CHANGED
|
@@ -95,6 +95,12 @@ describe("parseArgs", () => {
|
|
|
95
95
|
expect(result.down).toBe(true);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("parses graph --components --format ir --projection <lexicon>", () => {
|
|
99
|
+
const result = parseArgs(["graph", "--components", "--format", "ir", "--projection", "gitlab"]);
|
|
100
|
+
expect(result.components).toBe(true);
|
|
101
|
+
expect(result.projection).toBe("gitlab");
|
|
102
|
+
});
|
|
103
|
+
|
|
98
104
|
test("combines multiple options", () => {
|
|
99
105
|
const result = parseArgs([
|
|
100
106
|
"build",
|
package/src/cli/main.ts
CHANGED
|
@@ -64,6 +64,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
64
64
|
"--json",
|
|
65
65
|
"--progress-json",
|
|
66
66
|
"--update-snapshot",
|
|
67
|
+
"--update-baseline",
|
|
67
68
|
"--run-examples",
|
|
68
69
|
"--check",
|
|
69
70
|
"--bump",
|
|
@@ -269,6 +270,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
269
270
|
result.progressJson = true;
|
|
270
271
|
} else if (arg === "--update-snapshot") {
|
|
271
272
|
result.updateSnapshot = true;
|
|
273
|
+
} else if (arg === "--update-baseline") {
|
|
274
|
+
result.updateBaseline = true;
|
|
272
275
|
} else if (arg === "--run-examples") {
|
|
273
276
|
result.runExamples = true;
|
|
274
277
|
} else if (arg === "--pinned-digest") {
|
|
@@ -312,6 +315,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
312
315
|
(result.param ??= []).push(args[++i]);
|
|
313
316
|
} else if (arg === "--params-file") {
|
|
314
317
|
result.paramsFile = args[++i];
|
|
318
|
+
} else if (arg === "--projection") {
|
|
319
|
+
result.projection = args[++i];
|
|
315
320
|
} else if (arg.startsWith("--")) {
|
|
316
321
|
// chant #1127 — every recognized flag is matched above; anything left
|
|
317
322
|
// starting with `--` is unrecognized, whether it arrived bare
|
|
@@ -421,12 +426,19 @@ Ops:
|
|
|
421
426
|
--layout-engine graphviz to use dot instead;
|
|
422
427
|
--detail 0..3: stacks|composites|declarables|attributes;
|
|
423
428
|
--lens lexicon:<n>|stack:<n>|blast:<node> (--up/--down))
|
|
429
|
+
--components --format ir --projection gitlab|github|forgejo:
|
|
430
|
+
add the CI/pipeline projection (stages/jobs/needs) to
|
|
431
|
+
the component-graph IR, from the same generator
|
|
432
|
+
'build --components --generate' uses (#989)
|
|
424
433
|
|
|
425
434
|
Lifecycle (alias: lc):
|
|
426
435
|
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
|
427
436
|
lifecycle show <env> Show latest lifecycle snapshot
|
|
428
437
|
lifecycle diff <env> Compare current build against last snapshot
|
|
429
438
|
--live: query cloud now and detect drift
|
|
439
|
+
(lexicons with a deep reader also report
|
|
440
|
+
property-level drift; --update-baseline records
|
|
441
|
+
what it reports as accepted so it stops alerting)
|
|
430
442
|
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
431
443
|
lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
|
|
432
444
|
--json: emit the ChangeSet as JSON
|
package/src/cli/registry.ts
CHANGED
|
@@ -118,6 +118,14 @@ export interface ParsedArgs {
|
|
|
118
118
|
theme?: string;
|
|
119
119
|
/** `chant dev surface-diff --update-snapshot` — write the fresh snapshot as the new baseline */
|
|
120
120
|
updateSnapshot?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* `chant lifecycle diff <env> --live --update-baseline` (#1014) — record every
|
|
123
|
+
* property-level deviation this run reports as *accepted*, so it stops
|
|
124
|
+
* re-alerting. Value-bound: a later change to the accepted value is drift
|
|
125
|
+
* again. Writes `<env>/observation-baseline.json` on the chant/lifecycle
|
|
126
|
+
* orphan branch; never touches the cloud.
|
|
127
|
+
*/
|
|
128
|
+
updateBaseline?: boolean;
|
|
121
129
|
/** `chant dev surface-diff --run-examples` — also run the example build harness */
|
|
122
130
|
runExamples?: boolean;
|
|
123
131
|
/** `chant dev surface-diff --pinned-digest <file>` — path to SHA-256 digest file for supply-chain verification */
|
|
@@ -154,6 +162,12 @@ export interface ParsedArgs {
|
|
|
154
162
|
param?: string[];
|
|
155
163
|
/** `chant build --params-file <path>` (#1064) — a JSON file of `{ "name": value }` build-time parameter values. Second precedence, after `--param`. */
|
|
156
164
|
paramsFile?: string;
|
|
165
|
+
/** `chant graph --components --format ir --projection <lexicon>` (#989) — add
|
|
166
|
+
* the CI/pipeline projection (stages/jobs/`needs`) to the component-graph IR,
|
|
167
|
+
* synthesized by `<lexicon>`'s `generateComponentPipeline` (gitlab, github,
|
|
168
|
+
* forgejo today) — the same generator `chant build --components --generate
|
|
169
|
+
* <lexicon>` uses, reused rather than re-derived. */
|
|
170
|
+
projection?: string;
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
/**
|
package/src/codegen/generate.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface GenerateResult {
|
|
|
27
27
|
properties: number;
|
|
28
28
|
enums: number;
|
|
29
29
|
warnings: Array<{ file: string; error: string }>;
|
|
30
|
+
/**
|
|
31
|
+
* Additional generated files, keyed by filename, produced by the optional
|
|
32
|
+
* {@link GeneratePipelineConfig.generateExtraArtifacts} hook. They come out
|
|
33
|
+
* of the same parse a lexicon's types and registry come out of, which is the
|
|
34
|
+
* point: an artifact derived here cannot drift from the types, the way a
|
|
35
|
+
* hand-maintained table beside them can (chant #1074's operation surface is
|
|
36
|
+
* the first of these).
|
|
37
|
+
*/
|
|
38
|
+
extraArtifacts?: Record<string, string>;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -68,6 +77,14 @@ export interface GeneratePipelineConfig<T extends ParsedResult> {
|
|
|
68
77
|
/** Generate runtime index with factory exports. */
|
|
69
78
|
generateRuntimeIndex: (results: T[], naming: NamingStrategy) => string;
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Optional extra artifacts from the same parsed results — filename → content.
|
|
82
|
+
* Used when a lexicon needs a second derived table alongside the registry and
|
|
83
|
+
* the types, and needs it to come from the same pass so the three cannot
|
|
84
|
+
* skew.
|
|
85
|
+
*/
|
|
86
|
+
generateExtraArtifacts?: (results: T[], naming: NamingStrategy) => Record<string, string>;
|
|
87
|
+
|
|
71
88
|
/** Optional pre-parse hook (patches, overlays, extra resources, etc.). */
|
|
72
89
|
augmentSchemas?: (
|
|
73
90
|
schemas: Map<string, Buffer>,
|
|
@@ -161,6 +178,13 @@ export async function generatePipeline<T extends ParsedResult>(
|
|
|
161
178
|
log("Generating runtime index...");
|
|
162
179
|
const indexTS = config.generateRuntimeIndex(results, naming);
|
|
163
180
|
|
|
181
|
+
let extraArtifacts: Record<string, string> | undefined;
|
|
182
|
+
if (config.generateExtraArtifacts) {
|
|
183
|
+
log("Generating extra artifacts...");
|
|
184
|
+
extraArtifacts = config.generateExtraArtifacts(results, naming);
|
|
185
|
+
log(`Generated ${Object.keys(extraArtifacts).length} extra artifact(s)`);
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
// Count stats
|
|
165
189
|
let resourceCount = 0;
|
|
166
190
|
let propertyCount = 0;
|
|
@@ -179,6 +203,7 @@ export async function generatePipeline<T extends ParsedResult>(
|
|
|
179
203
|
properties: propertyCount,
|
|
180
204
|
enums: enumCount,
|
|
181
205
|
warnings,
|
|
206
|
+
...(extraArtifacts ? { extraArtifacts } : {}),
|
|
182
207
|
};
|
|
183
208
|
}
|
|
184
209
|
|