@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.
Files changed (60) hide show
  1. package/dist/cli/handlers/graph.d.ts.map +1 -1
  2. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +14 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/codegen/generate.d.ts +16 -0
  7. package/dist/codegen/generate.d.ts.map +1 -1
  8. package/dist/deep-observation.d.ts +257 -0
  9. package/dist/deep-observation.d.ts.map +1 -0
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/fold/fold.d.ts +23 -3
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +9 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/graph-ir.d.ts +44 -0
  16. package/dist/graph-ir.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/kubectl-context.d.ts +18 -1
  20. package/dist/kubectl-context.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +47 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/deep-diff.d.ts +103 -0
  24. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  25. package/dist/lifecycle/deep-observe.d.ts +62 -0
  26. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  27. package/dist/lifecycle/index.d.ts +3 -0
  28. package/dist/lifecycle/index.d.ts.map +1 -1
  29. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  30. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  31. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/handlers/graph.test.ts +86 -0
  34. package/src/cli/handlers/graph.ts +64 -3
  35. package/src/cli/handlers/lifecycle.test.ts +126 -1
  36. package/src/cli/handlers/lifecycle.ts +184 -3
  37. package/src/cli/main.test.ts +6 -0
  38. package/src/cli/main.ts +12 -0
  39. package/src/cli/registry.ts +14 -0
  40. package/src/codegen/generate.ts +25 -0
  41. package/src/deep-observation.test.ts +234 -0
  42. package/src/deep-observation.ts +489 -0
  43. package/src/discovery/fold-import.test.ts +372 -1
  44. package/src/discovery/fold-import.ts +235 -79
  45. package/src/fold/fold.test.ts +105 -0
  46. package/src/fold/fold.ts +88 -18
  47. package/src/fold/subset.test.ts +38 -7
  48. package/src/fold/subset.ts +9 -0
  49. package/src/graph-ir.ts +47 -0
  50. package/src/index.ts +1 -0
  51. package/src/kubectl-context.ts +22 -2
  52. package/src/lexicon.ts +59 -0
  53. package/src/lifecycle/deep-diff.test.ts +157 -0
  54. package/src/lifecycle/deep-diff.ts +213 -0
  55. package/src/lifecycle/deep-observe.test.ts +174 -0
  56. package/src/lifecycle/deep-observe.ts +173 -0
  57. package/src/lifecycle/index.ts +3 -0
  58. package/src/lifecycle/observation-baseline.test.ts +99 -0
  59. package/src/lifecycle/observation-baseline.ts +217 -0
  60. 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
- const ir: GraphIR = {
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({ environment, lexicons, plugins, buildResult, json, stack: target.stack, componentStacks });
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 {
@@ -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
@@ -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
  /**
@@ -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