@intentius/chant 0.29.0 → 0.30.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 (54) 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/deep-observation.d.ts +257 -0
  7. package/dist/deep-observation.d.ts.map +1 -0
  8. package/dist/discovery/fold-import.d.ts.map +1 -1
  9. package/dist/fold/fold.d.ts +23 -3
  10. package/dist/fold/fold.d.ts.map +1 -1
  11. package/dist/fold/subset.d.ts +9 -0
  12. package/dist/fold/subset.d.ts.map +1 -1
  13. package/dist/graph-ir.d.ts +44 -0
  14. package/dist/graph-ir.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +47 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/deep-diff.d.ts +103 -0
  20. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  21. package/dist/lifecycle/deep-observe.d.ts +62 -0
  22. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  23. package/dist/lifecycle/index.d.ts +3 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  26. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  27. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/cli/handlers/graph.test.ts +86 -0
  30. package/src/cli/handlers/graph.ts +64 -3
  31. package/src/cli/handlers/lifecycle.test.ts +126 -1
  32. package/src/cli/handlers/lifecycle.ts +184 -3
  33. package/src/cli/main.test.ts +6 -0
  34. package/src/cli/main.ts +12 -0
  35. package/src/cli/registry.ts +14 -0
  36. package/src/deep-observation.test.ts +234 -0
  37. package/src/deep-observation.ts +489 -0
  38. package/src/discovery/fold-import.test.ts +372 -1
  39. package/src/discovery/fold-import.ts +235 -79
  40. package/src/fold/fold.test.ts +105 -0
  41. package/src/fold/fold.ts +88 -18
  42. package/src/fold/subset.test.ts +38 -7
  43. package/src/fold/subset.ts +9 -0
  44. package/src/graph-ir.ts +47 -0
  45. package/src/index.ts +1 -0
  46. package/src/lexicon.ts +59 -0
  47. package/src/lifecycle/deep-diff.test.ts +157 -0
  48. package/src/lifecycle/deep-diff.ts +213 -0
  49. package/src/lifecycle/deep-observe.test.ts +174 -0
  50. package/src/lifecycle/deep-observe.ts +173 -0
  51. package/src/lifecycle/index.ts +3 -0
  52. package/src/lifecycle/observation-baseline.test.ts +99 -0
  53. package/src/lifecycle/observation-baseline.ts +217 -0
  54. package/src/lifecycle/snapshot.ts +6 -11
@@ -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
  /**