@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.
- 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/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/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/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/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
|
@@ -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
|
/**
|