@intentius/chant 0.34.1 → 0.37.2

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 (45) hide show
  1. package/dist/cli/commands/check-lexicon-docs.d.ts +42 -0
  2. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/handlers/components.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +2 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  10. package/dist/codegen/docs-sections.d.ts.map +1 -1
  11. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  12. package/dist/codegen/docs.d.ts +27 -0
  13. package/dist/codegen/docs.d.ts.map +1 -1
  14. package/dist/codegen/fetch.d.ts +1 -1
  15. package/dist/codegen/fetch.d.ts.map +1 -1
  16. package/dist/deep-observation.d.ts +0 -10
  17. package/dist/deep-observation.d.ts.map +1 -1
  18. package/dist/lifecycle/rollback.d.ts +18 -0
  19. package/dist/lifecycle/rollback.d.ts.map +1 -1
  20. package/dist/lifecycle/status.d.ts +53 -0
  21. package/dist/lifecycle/status.d.ts.map +1 -1
  22. package/dist/yaml.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
  25. package/src/cli/commands/check-lexicon-docs.ts +71 -0
  26. package/src/cli/commands/check-lexicon.ts +15 -0
  27. package/src/cli/handlers/components.ts +54 -3
  28. package/src/cli/handlers/graph.test.ts +61 -0
  29. package/src/cli/handlers/lifecycle.ts +8 -1
  30. package/src/cli/main.ts +2 -0
  31. package/src/cli/registry.ts +2 -0
  32. package/src/codegen/docs-rule-scanning.ts +12 -3
  33. package/src/codegen/docs-sections.ts +6 -4
  34. package/src/codegen/docs-sidebar.ts +8 -2
  35. package/src/codegen/docs.ts +141 -5
  36. package/src/codegen/fetch.test.ts +24 -5
  37. package/src/codegen/fetch.ts +19 -1
  38. package/src/deep-observation.test.ts +151 -0
  39. package/src/deep-observation.ts +66 -2
  40. package/src/lifecycle/rollback.test.ts +78 -1
  41. package/src/lifecycle/rollback.ts +41 -2
  42. package/src/lifecycle/status.test.ts +90 -5
  43. package/src/lifecycle/status.ts +85 -2
  44. package/src/yaml.test.ts +89 -0
  45. package/src/yaml.ts +66 -9
@@ -84,6 +84,54 @@ describe("normalizeDeepProperties", () => {
84
84
  expect(out).toEqual({ Name: "n" });
85
85
  });
86
86
 
87
+ // A container the rules emptied is not a container the source declared empty.
88
+ // Keeping the husk turns a suppressed default into drift-shaped noise —
89
+ // `SecurityGroupEgress[#{}]: <undeclared> → {}` was the case that found this.
90
+ test("an object whose every field was pruned is dropped, not left as {}", () => {
91
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "CidrIp" || n.key === "IpProtocol" };
92
+ const out = normalizeDeepProperties(
93
+ { Egress: [{ CidrIp: "0.0.0.0/0", IpProtocol: "-1" }], Name: "n" },
94
+ { entityType: "T", side: "live", hooks },
95
+ );
96
+ expect(out).toEqual({ Name: "n" });
97
+ });
98
+
99
+ test("an object the source declared empty survives", () => {
100
+ const hooks: DeepNormalizationHooks = { prune: () => false };
101
+ const out = normalizeDeepProperties(
102
+ { Spec: {}, Items: [], Name: "n" },
103
+ { entityType: "T", side: "live", hooks },
104
+ );
105
+ expect(out).toEqual({ Spec: {}, Items: [], Name: "n" });
106
+ });
107
+
108
+ test("a partly pruned object keeps what survived", () => {
109
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Arn" };
110
+ const out = normalizeDeepProperties(
111
+ { Role: { Arn: "arn:…", Path: "/" } },
112
+ { entityType: "T", side: "live", hooks },
113
+ );
114
+ expect(out).toEqual({ Role: { Path: "/" } });
115
+ });
116
+
117
+ test("emptiness propagates up as far as the pruning reaches", () => {
118
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Gone" };
119
+ const out = normalizeDeepProperties(
120
+ { Outer: { Inner: { Gone: 1 } }, Name: "n" },
121
+ { entityType: "T", side: "live", hooks },
122
+ );
123
+ expect(out).toEqual({ Name: "n" });
124
+ });
125
+
126
+ test("an array keeps the elements pruning did not empty", () => {
127
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Default" };
128
+ const out = normalizeDeepProperties(
129
+ { Rules: [{ Default: true }, { Port: 443 }] },
130
+ { entityType: "T", side: "live", hooks },
131
+ );
132
+ expect(out).toEqual({ Rules: [{ Port: 443 }] });
133
+ });
134
+
87
135
  test("hooks see an index-erased pattern alongside the exact path", () => {
88
136
  const seen: Array<[string, string]> = [];
89
137
  const hooks: DeepNormalizationHooks = {
@@ -232,3 +280,106 @@ describe("deepValueEqual", () => {
232
280
  expect(deepValueEqual(1, 1)).toBe(true);
233
281
  });
234
282
  });
283
+
284
+ // #1314 — a nested property authored through a lexicon's generated constructor
285
+ // is authored data, not an opaque class instance. Collapsing it to UNRESOLVED
286
+ // left the declared side empty while the live side held the real value, so
287
+ // every field of it reported `<undeclared>` on a clean apply.
288
+ describe("normalizeDeepProperties — property-kind declarables (#1314)", () => {
289
+ /** Shaped like a generated property constructor's instance. */
290
+ const propertyDeclarable = (entityType: string, props: Record<string, unknown>) => ({
291
+ entityType,
292
+ kind: "property",
293
+ props,
294
+ });
295
+
296
+ test("unwraps a property-kind declarable to its authored props", () => {
297
+ const out = normalizeDeepProperties(
298
+ {
299
+ GroupDescription: "sg",
300
+ SecurityGroupIngress: [
301
+ propertyDeclarable("AWS::EC2::SecurityGroup.Ingress", {
302
+ IpProtocol: "tcp",
303
+ FromPort: 443,
304
+ ToPort: 443,
305
+ CidrIp: "10.42.0.0/16",
306
+ }),
307
+ ],
308
+ },
309
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
310
+ );
311
+ expect(out).toEqual({
312
+ GroupDescription: "sg",
313
+ SecurityGroupIngress: [{ CidrIp: "10.42.0.0/16", FromPort: 443, IpProtocol: "tcp", ToPort: 443 }],
314
+ });
315
+ });
316
+
317
+ test("produces the same tree as the equivalent plain object — the two authoring forms must not differ", () => {
318
+ const viaConstructor = normalizeDeepProperties(
319
+ { Ingress: [propertyDeclarable("T.Ingress", { IpProtocol: "tcp", FromPort: 443 })] },
320
+ { entityType: "T", side: "declared" },
321
+ );
322
+ const viaLiteral = normalizeDeepProperties(
323
+ { Ingress: [{ IpProtocol: "tcp", FromPort: 443 }] },
324
+ { entityType: "T", side: "declared" },
325
+ );
326
+ expect(viaConstructor).toEqual(viaLiteral);
327
+ });
328
+
329
+ test("unwraps nested property declarables all the way down", () => {
330
+ const out = normalizeDeepProperties(
331
+ {
332
+ Logging: propertyDeclarable("T.Logging", {
333
+ CloudWatch: propertyDeclarable("T.CloudWatch", { Enabled: true, LogGroup: "g" }),
334
+ }),
335
+ },
336
+ { entityType: "T", side: "declared" },
337
+ );
338
+ expect(out).toEqual({ Logging: { CloudWatch: { Enabled: true, LogGroup: "g" } } });
339
+ });
340
+
341
+ test("still collapses a RESOURCE-kind declarable — it is a reference with no source-side value", () => {
342
+ // A class instance, as a lexicon actually constructs one: a resource-kind
343
+ // declarable in another resource's props is a Ref, and there is nothing on
344
+ // the source side to compare a live value against.
345
+ class VpcDeclarable {
346
+ readonly entityType = "AWS::EC2::VPC";
347
+ readonly kind = "resource";
348
+ readonly props = { CidrBlock: "10.0.0.0/16" };
349
+ }
350
+ const out = normalizeDeepProperties(
351
+ { VpcId: new VpcDeclarable() },
352
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
353
+ );
354
+ expect(out).toEqual({ VpcId: UNRESOLVED });
355
+ });
356
+
357
+ test("unwraps a property-kind declarable that is a class instance, which is how a lexicon builds one", () => {
358
+ class IngressDeclarable {
359
+ readonly entityType = "AWS::EC2::SecurityGroup.Ingress";
360
+ readonly kind = "property";
361
+ constructor(readonly props: Record<string, unknown>) {}
362
+ }
363
+ const out = normalizeDeepProperties(
364
+ { Ingress: [new IngressDeclarable({ IpProtocol: "tcp", FromPort: 443 })] },
365
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
366
+ );
367
+ expect(out).toEqual({ Ingress: [{ FromPort: 443, IpProtocol: "tcp" }] });
368
+ });
369
+
370
+ test("still collapses a genuine class instance, which is what the branch is for", () => {
371
+ class Sub {
372
+ constructor(readonly template: string) {}
373
+ }
374
+ const out = normalizeDeepProperties({ Name: new Sub("${AWS::StackName}-x") }, { entityType: "T", side: "declared" });
375
+ expect(out).toEqual({ Name: UNRESOLVED });
376
+ });
377
+
378
+ test("masks a secret inside an unwrapped property declarable, same as in a plain object", () => {
379
+ const out = normalizeDeepProperties(
380
+ { Auth: propertyDeclarable("T.Auth", { Username: "u", Password: "hunter2" }) },
381
+ { entityType: "T", side: "declared" },
382
+ );
383
+ expect(out).toEqual({ Auth: { Password: MASKED, Username: "u" } });
384
+ });
385
+ });
@@ -324,6 +324,36 @@ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
324
324
  * on every read; array order is canonicalized only where the lexicon says the
325
325
  * array is a set, because list order often *is* semantic.
326
326
  */
327
+ /**
328
+ * A container whose every member the rules pruned.
329
+ *
330
+ * The distinction it carries is between a value that was empty in the source
331
+ * and one this pass emptied: `{}` a lexicon actually declared is a fact worth
332
+ * diffing, while `{}` left behind after both of a rule's fields were subtracted
333
+ * as provider defaults is a husk. Reporting the husk turns a suppressed default
334
+ * into `SecurityGroupEgress[#{}]: <undeclared> → {}` — noise wearing the shape
335
+ * of drift, which is the one thing the noise rules exist to prevent.
336
+ *
337
+ * Module-private: it never leaves this function's recursion.
338
+ */
339
+ const EMPTIED = Symbol("emptied-by-pruning");
340
+
341
+ /**
342
+ * A property-kind Declarable — a nested property authored through a lexicon's
343
+ * generated constructor rather than as a plain object (#1314).
344
+ *
345
+ * Duck-typed on the same three facts `declarable.ts` defines it by
346
+ * (`entityType`, `kind === "property"`, `props`) rather than imported, keeping
347
+ * this module free of a dependency on the authoring types it only ever
348
+ * inspects. A resource-kind Declarable deliberately does not match.
349
+ */
350
+ function isPropertyDeclarableValue(value: unknown): boolean {
351
+ if (typeof value !== "object" || value === null) return false;
352
+ const v = value as { entityType?: unknown; kind?: unknown; props?: unknown };
353
+ return typeof v.entityType === "string" && v.kind === "property" && typeof v.props === "object" && v.props !== null;
354
+ }
355
+
356
+
327
357
  export function normalizeDeepProperties(
328
358
  tree: Record<string, unknown>,
329
359
  options: NormalizeDeepOptions,
@@ -351,27 +381,61 @@ export function normalizeDeepProperties(
351
381
  if (isSensitiveKey(key)) return MASKED;
352
382
  if (isJsonPrimitive(value)) return value;
353
383
 
384
+ // A PROPERTY-kind Declarable is authored data wearing a class, not an
385
+ // opaque instance (#1314). The generated property constructors —
386
+ // `SecurityGroup_Ingress`, `Role_Policy`, `MicrovmImage_Logging` — are the
387
+ // typed way to write a nested property, and the serializer inlines their
388
+ // `.props` (serializer-walker.ts `visitor.propertyDeclarable`). The
389
+ // declared tree has to do the same, or the two sides disagree about a
390
+ // property the author did write: without this the declared side held
391
+ // UNRESOLVED while the live side held the real rule, so every one of its
392
+ // fields reported `<undeclared> → <value>` on a clean apply, and the noise
393
+ // scaled with how strictly a project typed its properties.
394
+ //
395
+ // Checked ahead of the array/object branches rather than in the non-JSON
396
+ // fallback below, so it holds whether the declarable arrives as a class
397
+ // instance (what a lexicon constructs) or as an equivalent plain object.
398
+ //
399
+ // Deliberately property-kind only. A RESOURCE-kind Declarable in another
400
+ // resource's props is a reference, which has no source-side value to
401
+ // compare against a live one, so UNRESOLVED stays correct for it — and a
402
+ // resource-kind instance falls through to that fallback unchanged.
403
+ if (isPropertyDeclarableValue(value)) {
404
+ return normalizeValue((value as { props?: unknown }).props ?? {}, path, pattern, key);
405
+ }
406
+
354
407
  if (Array.isArray(value)) {
355
408
  const elements: unknown[] = [];
356
409
  for (let i = 0; i < value.length; i++) {
357
410
  const elPath = joinIndex(path, i);
358
411
  const elPattern = joinPattern(pattern);
359
412
  if (prune(elPath, elPattern, String(i), value[i])) continue;
360
- elements.push(normalizeValue(value[i], elPath, elPattern, String(i)));
413
+ const element = normalizeValue(value[i], elPath, elPattern, String(i));
414
+ if (element === EMPTIED) continue;
415
+ elements.push(element);
361
416
  }
417
+ // An array that had elements and has none left was emptied by pruning,
418
+ // not declared empty. Reporting `[]` for it is reporting the husk of a
419
+ // value the rules just decided was noise.
420
+ if (elements.length === 0 && value.length > 0) return EMPTIED;
362
421
  return orderElements(elements, path, pattern);
363
422
  }
364
423
 
365
424
  if (isPlainObject(value)) {
366
425
  const out: Record<string, unknown> = {};
426
+ let had = 0;
367
427
  for (const childKey of Object.keys(value).sort()) {
368
428
  const childPath = joinPath(path, childKey);
369
429
  const childPattern = joinPath(pattern, childKey);
370
430
  const childValue = value[childKey];
371
431
  if (childValue === undefined) continue;
432
+ had += 1;
372
433
  if (prune(childPath, childPattern, childKey, childValue)) continue;
373
- out[childKey] = normalizeValue(childValue, childPath, childPattern, childKey);
434
+ const child = normalizeValue(childValue, childPath, childPattern, childKey);
435
+ if (child === EMPTIED) continue;
436
+ out[childKey] = child;
374
437
  }
438
+ if (Object.keys(out).length === 0 && had > 0) return EMPTIED;
375
439
  return out;
376
440
  }
377
441
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { rollbackBranchName, rollbackTitle, rollbackBody } from "./rollback";
2
+ import { rollbackBranchName, rollbackTitle, rollbackBody, rollbackToRevision } from "./rollback";
3
3
 
4
4
  describe("rollback helpers (#873)", () => {
5
5
  test("branch name includes env and short ref", () => {
@@ -23,3 +23,80 @@ describe("rollback helpers (#873)", () => {
23
23
  expect(body).toMatch(/approval gate|Sync|apply/i);
24
24
  });
25
25
  });
26
+
27
+ // A dry run has to work where the PR path cannot: no remote, no `gh`, and the
28
+ // repository left exactly as it was found. That is what makes chant#1208's
29
+ // round-trip demonstrable offline instead of only asserting the noop case.
30
+ describe("rollbackToRevision --dry-run", () => {
31
+ const git = async (args: string[], cwd: string) => {
32
+ const { promisify } = await import("node:util");
33
+ const { execFile } = await import("node:child_process");
34
+ return promisify(execFile)("git", args, { cwd });
35
+ };
36
+
37
+ /** A throwaway repo with two commits and NO remote configured. */
38
+ async function repoWithTwoCommits(): Promise<{ dir: string; base: string }> {
39
+ const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
40
+ const { tmpdir } = await import("node:os");
41
+ const { join } = await import("node:path");
42
+ const dir = mkdtempSync(join(tmpdir(), "chant-rollback-test-"));
43
+ mkdirSync(join(dir, "src"));
44
+ await git(["init", "-q"], dir);
45
+ await git(["config", "user.email", "t@example.com"], dir);
46
+ await git(["config", "user.name", "t"], dir);
47
+ writeFileSync(join(dir, "src", "main.ts"), "export const a = 1;\n");
48
+ await git(["add", "-A"], dir);
49
+ await git(["commit", "-qm", "v1"], dir);
50
+ const base = (await git(["rev-parse", "HEAD"], dir)).stdout.trim();
51
+ writeFileSync(join(dir, "src", "main.ts"), "export const a = 2;\n");
52
+ await git(["add", "-A"], dir);
53
+ await git(["commit", "-qm", "v2"], dir);
54
+ return { dir, base };
55
+ }
56
+
57
+ test("returns the delta without a remote, and leaves no branch behind", async () => {
58
+ const { dir, base } = await repoWithTwoCommits();
59
+ const before = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
60
+
61
+ const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
62
+
63
+ expect(result.noop).toBe(false);
64
+ expect(result.prUrl).toBeUndefined();
65
+ expect(result.diff).toContain("-export const a = 2;");
66
+ expect(result.diff).toContain("+export const a = 1;");
67
+
68
+ // Nothing persisted: same branches as before, and the working tree still
69
+ // holds the NEW content — a dry run reports, it does not roll back.
70
+ const after = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
71
+ expect(after).toBe(before);
72
+ const { readFileSync } = await import("node:fs");
73
+ const { join } = await import("node:path");
74
+ expect(readFileSync(join(dir, "src", "main.ts"), "utf8")).toContain("a = 2");
75
+ });
76
+
77
+ test("removes a file added since the ref — a per-path checkout leaves it, and that read as noop (#1327)", async () => {
78
+ // The shape a reconcile produces: `chant import --from <env>` writes NEW
79
+ // files rather than editing the authored ones, so the whole difference from
80
+ // the pre-reconcile revision is additions. `git checkout <ref> -- <dir>`
81
+ // never touches those, so rollback used to report "nothing to roll back".
82
+ const { dir, base } = await repoWithTwoCommits();
83
+ const { writeFileSync } = await import("node:fs");
84
+ const { join } = await import("node:path");
85
+ writeFileSync(join(dir, "src", "generated.ts"), "export const added = true;\n");
86
+ await git(["add", "-A"], dir);
87
+ await git(["commit", "-qm", "reconciled: a new file"], dir);
88
+
89
+ const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
90
+
91
+ expect(result.noop).toBe(false);
92
+ expect(result.diff).toContain("src/generated.ts");
93
+ expect(result.diff).toContain("deleted file");
94
+ });
95
+
96
+ test("still reports noop when the source already matches the ref", async () => {
97
+ const { dir } = await repoWithTwoCommits();
98
+ const result = await rollbackToRevision({ ref: "HEAD", env: "local", sourceDir: "src", cwd: dir, dryRun: true });
99
+ expect(result.noop).toBe(true);
100
+ expect(result.diff).toBeUndefined();
101
+ });
102
+ });
@@ -40,19 +40,37 @@ export interface RollbackResult {
40
40
  noop: boolean;
41
41
  branch?: string;
42
42
  prUrl?: string;
43
+ /**
44
+ * The rollback delta as a unified diff — present only for a `dryRun`, where
45
+ * it is the whole point: the PR body's diff is what a reviewer would act on,
46
+ * so producing it without a PR is what makes the delta inspectable offline.
47
+ */
48
+ diff?: string;
43
49
  }
44
50
 
45
51
  /**
46
52
  * Open a rollback PR restoring `sourceDir` to `ref`. Isolated worktree; the
47
53
  * caller's branch is untouched. Throws on an unknown ref or a git/gh failure.
54
+ *
55
+ * `dryRun` computes the same delta and returns it as a diff without pushing a
56
+ * branch or opening a PR, leaving the repository exactly as it found it.
57
+ *
58
+ * That mode exists because the PR path needs a GitHub remote and an
59
+ * authenticated `gh` — reasonable for the operator flow this was built for
60
+ * (#873), and impossible for a hermetic acceptance run. chant#1208's CC
61
+ * round-trip has to demonstrate rollback offline, on an emulator, with no
62
+ * remote in the picture; without this it could only assert the `noop` case,
63
+ * which exercises none of the interesting work.
48
64
  */
49
65
  export async function rollbackToRevision(opts: {
50
66
  ref: string;
51
67
  env: string | undefined;
52
68
  sourceDir: string;
53
69
  cwd: string;
70
+ /** Compute and return the delta; open no PR, push nothing, leave no branch. */
71
+ dryRun?: boolean;
54
72
  }): Promise<RollbackResult> {
55
- const { ref, env, sourceDir, cwd } = opts;
73
+ const { ref, env, sourceDir, cwd, dryRun } = opts;
56
74
  const git = (args: string[], wd: string): Promise<{ stdout: string }> => execFileAsync("git", args, { cwd: wd });
57
75
 
58
76
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)).stdout.trim();
@@ -64,11 +82,28 @@ export async function rollbackToRevision(opts: {
64
82
 
65
83
  await git(["worktree", "add", wt, "-b", branch, "HEAD"], repoRoot);
66
84
  try {
67
- // Restore just the source tree to the target revision (stages the changes).
85
+ // Restore the source tree to the target revision (stages the changes).
86
+ //
87
+ // `git checkout <ref> -- <dir>` alone is a PER-PATH checkout, not a tree
88
+ // replacement: it restores the paths that exist at `ref` and leaves
89
+ // everything else untouched, so a file added since `ref` survives. That
90
+ // made rollback report "nothing to roll back" whenever the only difference
91
+ // was added files (#1327) — which is exactly what a reconcile produces,
92
+ // since `chant import --from <env>` writes NEW files rather than editing
93
+ // the authored ones. Clearing the directory first makes this a real
94
+ // restore, so the deletions show up in the delta.
95
+ await git(["rm", "-rq", "--ignore-unmatch", "--", sourceDir], wt);
68
96
  await git(["checkout", ref, "--", sourceDir], wt);
69
97
  const staged = (await git(["status", "--porcelain", "--", sourceDir], wt)).stdout.trim();
70
98
  if (!staged) return { noop: true };
71
99
 
100
+ // The delta, before committing: `git diff` against the index shows exactly
101
+ // what restoring the source to `ref` changes. A dry run stops here.
102
+ if (dryRun) {
103
+ const { stdout } = await git(["diff", "--cached", "--", sourceDir], wt);
104
+ return { noop: false, branch, diff: stdout };
105
+ }
106
+
72
107
  await git(["commit", "-m", rollbackTitle(env, ref)], wt);
73
108
  await git(["push", "-u", "origin", branch], wt);
74
109
  const { stdout } = await execFileAsync(
@@ -79,5 +114,9 @@ export async function rollbackToRevision(opts: {
79
114
  return { noop: false, branch, prUrl: stdout.trim() };
80
115
  } finally {
81
116
  await git(["worktree", "remove", "--force", wt], repoRoot).catch(() => {});
117
+ // Removing the worktree leaves its branch behind. For the PR path that is
118
+ // correct — the branch is the PR. A dry run must leave nothing, or a repo
119
+ // accumulates a `chant/rollback-*` branch per inspection.
120
+ if (dryRun) await git(["branch", "-D", branch], repoRoot).catch(() => {});
82
121
  }
83
122
  }
@@ -37,8 +37,18 @@ describe("status", () => {
37
37
  ],
38
38
  };
39
39
  const evidence = liveEvidenceFromChangeSet(cs);
40
- expect(evidence.get("search-service")).toEqual({ live: true, action: "noop", ownership: "owned" });
41
- expect(evidence.get("orphan-thing")).toEqual({ live: true, action: "adopt", ownership: "foreign" });
40
+ expect(evidence.get("search-service")).toEqual({
41
+ live: true,
42
+ action: "noop",
43
+ ownership: "owned",
44
+ rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
45
+ });
46
+ expect(evidence.get("orphan-thing")).toEqual({
47
+ live: true,
48
+ action: "adopt",
49
+ ownership: "foreign",
50
+ rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
51
+ });
42
52
  });
43
53
 
44
54
  // #598: a component's name need not equal the live entity/resource name
@@ -54,7 +64,15 @@ describe("status", () => {
54
64
  };
55
65
  const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
56
66
  const evidence = liveEvidenceFromChangeSet(cs, mapping);
57
- expect(evidence.get("search-svc")).toEqual({ live: true, action: "noop", ownership: "owned" });
67
+ // The merged verdict, plus the per-resource counts it collapsed
68
+ // (behold#98) — a consumer with no deploy object to read paints from
69
+ // those rather than from a CloudFormation stack.
70
+ expect(evidence.get("search-svc")).toEqual({
71
+ live: true,
72
+ action: "noop",
73
+ ownership: "owned",
74
+ rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
75
+ });
58
76
  // The live entity's own name is no longer a separate top-level key once
59
77
  // it's claimed by an explicit mapping's component.
60
78
  });
@@ -68,7 +86,14 @@ describe("status", () => {
68
86
  };
69
87
  const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
70
88
  const evidence = liveEvidenceFromChangeSet(cs, mapping);
71
- expect(evidence.get("search-service")).toEqual({ live: true, action: "noop", ownership: "owned" });
89
+ // A rollup of one: the identity join still reports the shape, so a
90
+ // consumer never branches on whether a mapping was configured.
91
+ expect(evidence.get("search-service")).toEqual({
92
+ live: true,
93
+ action: "noop",
94
+ ownership: "owned",
95
+ rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
96
+ });
72
97
  });
73
98
 
74
99
  test("aggregates evidence across several live names owned by one component", () => {
@@ -82,7 +107,12 @@ describe("status", () => {
82
107
  const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
83
108
  const evidence = liveEvidenceFromChangeSet(cs, mapping);
84
109
  // Drift on any owned entity surfaces as drift for the component.
85
- expect(evidence.get("neo4j-cluster")).toEqual({ live: true, action: "update", ownership: "owned" });
110
+ expect(evidence.get("neo4j-cluster")).toEqual({
111
+ live: true,
112
+ action: "update",
113
+ ownership: "owned",
114
+ rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
115
+ });
86
116
  });
87
117
 
88
118
  test("a mapped component with none of its live names observed has no evidence entry", () => {
@@ -135,6 +165,39 @@ describe("status", () => {
135
165
  expect(rows[0].reconciliation).toBe("drifted");
136
166
  });
137
167
 
168
+ // behold#98 — floci-az and floci-gcp have no deploy object, so `stack` is
169
+ // absent and a renderer has nothing provider-native to colour from. The
170
+ // rollup is the substrate-neutral source for the same job.
171
+ test("surfaces a resource rollup for a component with no deploy object", () => {
172
+ const liveEvidence = new Map<string, LiveComponentEvidence>([
173
+ [
174
+ "search-service",
175
+ { live: true, ownership: "owned", rollup: { total: 4, present: 3, absent: 0, unobserved: 1 } },
176
+ ],
177
+ ]);
178
+ const rows = reconcileStatus("prod", [record()], { liveEvidence });
179
+ expect(rows[0].resources).toEqual({ total: 4, present: 3, absent: 0, unobserved: 1 });
180
+ // No CloudFormation stack to enrich from, and the row is still paintable.
181
+ expect(rows[0].stack).toBeUndefined();
182
+ });
183
+
184
+ test("a rollup and a stack coexist — the stack stays the richer enrichment where it exists", () => {
185
+ const liveEvidence = new Map<string, LiveComponentEvidence>([
186
+ [
187
+ "search-service",
188
+ {
189
+ live: true,
190
+ ownership: "owned",
191
+ stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true },
192
+ rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
193
+ },
194
+ ],
195
+ ]);
196
+ const rows = reconcileStatus("prod", [record()], { liveEvidence });
197
+ expect(rows[0].resources).toEqual({ total: 2, present: 2, absent: 0, unobserved: 0 });
198
+ expect(rows[0].stack?.healthy).toBe(true);
199
+ });
200
+
138
201
  test("surfaces machine-readable live + stack status when observed (#57 hardening)", () => {
139
202
  const liveEvidence = new Map<string, LiveComponentEvidence>([
140
203
  ["search-service", { live: true, ownership: "owned", stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true } }],
@@ -395,6 +458,28 @@ describe("status", () => {
395
458
  expect(merged.get("a")!.unobserved).toBeUndefined();
396
459
  expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
397
460
  });
461
+
462
+ test("the change-set rollup survives the stack overlay (behold#100)", () => {
463
+ // The merge rebuilds the evidence object field by field, so a field it
464
+ // does not name is dropped. `describeStackStatus` reports a stack, not
465
+ // per-resource counts, so the supplement never carries a rollup — and
466
+ // dropping the base's meant AWS, the only substrate with a stack
467
+ // observer, was the one substrate whose rows lost the #1300 counts.
468
+ const rollup = { total: 10, present: 10, absent: 0, unobserved: 0 };
469
+ const base = new Map<string, LiveComponentEvidence>([["cc-canonical", { live: true, ownership: "owned", rollup }]]);
470
+ const supplement = new Map<string, LiveComponentEvidence>([
471
+ ["cc-canonical", { live: true, ownership: "owned", stack: { name: "cc-canonical", status: "CREATE_COMPLETE", healthy: true } }],
472
+ ]);
473
+ const merged = mergeLiveEvidence(base, supplement);
474
+ expect(merged.get("cc-canonical")!.rollup).toEqual(rollup);
475
+ expect(merged.get("cc-canonical")!.stack?.status).toBe("CREATE_COMPLETE");
476
+ });
477
+
478
+ test("no rollup on either side leaves the field absent rather than undefined", () => {
479
+ const base = new Map<string, LiveComponentEvidence>([["c", { live: true }]]);
480
+ const supplement = new Map<string, LiveComponentEvidence>([["c", { live: true, ownership: "owned" }]]);
481
+ expect(mergeLiveEvidence(base, supplement).get("c")).not.toHaveProperty("rollup");
482
+ });
398
483
  });
399
484
 
400
485
  // ── The observation tri-state reaches the status join (#1089) ─────────────