@intentius/chant 0.34.1 → 0.37.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 (40) 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.d.ts +16 -0
  10. package/dist/codegen/docs.d.ts.map +1 -1
  11. package/dist/codegen/fetch.d.ts +1 -1
  12. package/dist/codegen/fetch.d.ts.map +1 -1
  13. package/dist/deep-observation.d.ts +0 -10
  14. package/dist/deep-observation.d.ts.map +1 -1
  15. package/dist/lifecycle/rollback.d.ts +18 -0
  16. package/dist/lifecycle/rollback.d.ts.map +1 -1
  17. package/dist/lifecycle/status.d.ts +53 -0
  18. package/dist/lifecycle/status.d.ts.map +1 -1
  19. package/dist/yaml.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
  22. package/src/cli/commands/check-lexicon-docs.ts +71 -0
  23. package/src/cli/commands/check-lexicon.ts +15 -0
  24. package/src/cli/handlers/components.ts +54 -3
  25. package/src/cli/handlers/graph.test.ts +61 -0
  26. package/src/cli/handlers/lifecycle.ts +8 -1
  27. package/src/cli/main.ts +2 -0
  28. package/src/cli/registry.ts +2 -0
  29. package/src/codegen/docs-sections.ts +1 -1
  30. package/src/codegen/docs.ts +122 -5
  31. package/src/codegen/fetch.test.ts +24 -5
  32. package/src/codegen/fetch.ts +19 -1
  33. package/src/deep-observation.test.ts +151 -0
  34. package/src/deep-observation.ts +66 -2
  35. package/src/lifecycle/rollback.test.ts +78 -1
  36. package/src/lifecycle/rollback.ts +41 -2
  37. package/src/lifecycle/status.test.ts +90 -5
  38. package/src/lifecycle/status.ts +85 -2
  39. package/src/yaml.test.ts +89 -0
  40. package/src/yaml.ts +66 -9
@@ -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) ─────────────
@@ -86,6 +86,19 @@ export interface ComponentStatusRow {
86
86
  * present-but-not-healthy amber (mid-deploy) / red (rollback/failed).
87
87
  */
88
88
  stack?: LiveStackInfo;
89
+ /**
90
+ * How this component's own resources answered (behold#98). Present whenever
91
+ * `--live` gathered evidence across a live-name mapping.
92
+ *
93
+ * `stack` above only exists where the substrate has a deploy object to read,
94
+ * which is AWS and nowhere else — floci-az and floci-gcp have none, so a
95
+ * consumer painting component status off `stack` has nothing to paint from
96
+ * there. These counts are the substrate-neutral source for the same job:
97
+ * they aggregate observations, which every lexicon produces, rather than a
98
+ * provider-specific grouping object. `stack` stays as the richer enrichment
99
+ * where it exists.
100
+ */
101
+ resources?: ComponentResourceRollup;
89
102
  }
90
103
 
91
104
  /** A component's owning deploy unit and its provider-native status. */
@@ -129,6 +142,14 @@ export interface LiveComponentEvidence {
129
142
  /** The owning deploy unit's raw status, when observed (AWS: the component's own
130
143
  * CFN stack). Surfaced onto `ComponentStatusRow.stack` for a richer palette. */
131
144
  stack?: LiveStackInfo;
145
+ /**
146
+ * How the component's own resources answered, before any merge collapsed
147
+ * them (behold#98). Always present when evidence exists — a component that
148
+ * maps to a single entity by identity gets a rollup of one, so a consumer
149
+ * never has to branch on whether a live-name mapping happened to be
150
+ * configured.
151
+ */
152
+ rollup?: ComponentResourceRollup;
132
153
  }
133
154
 
134
155
  /**
@@ -141,6 +162,16 @@ export interface LiveComponentEvidence {
141
162
  * is authoritative for **presence** (`live`) and **ownership**; the change-set's
142
163
  * `action` is kept, since drift is still assessed from the diff. A component in
143
164
  * only one map passes through unchanged.
165
+ *
166
+ * The change-set's `rollup` is kept too (behold#100). This merge rebuilds the
167
+ * evidence object field by field, so anything not named here is dropped — and
168
+ * `describeStackStatus` reports a stack, never per-resource counts, so the
169
+ * supplement has no rollup to contribute. Before this, every component on a
170
+ * lexicon that implements `describeStackStatus` lost the counts #1300 had just
171
+ * computed. That is AWS and only AWS, which made the rollup absent on exactly
172
+ * the substrate it was meant to be verified against: behold#98 shipped its
173
+ * consumer against floci-az/floci-gcp rows, where no stack observer runs and
174
+ * the field survived.
144
175
  */
145
176
  export function mergeLiveEvidence(
146
177
  base: Map<string, LiveComponentEvidence> | undefined,
@@ -158,6 +189,9 @@ export function mergeLiveEvidence(
158
189
  ownership: sup.ownership ?? b?.ownership,
159
190
  action: b?.action,
160
191
  stack: sup.stack ?? b?.stack,
192
+ // Base first: the counts come from the change set, and a stack
193
+ // observation has none to offer.
194
+ ...(b?.rollup ?? sup.rollup ? { rollup: b?.rollup ?? sup.rollup } : {}),
161
195
  });
162
196
  }
163
197
  return merged;
@@ -180,6 +214,42 @@ export function resolveLiveNames(component: string, mapping?: LiveNameMapping):
180
214
  return mapped && mapped.length > 0 ? mapped : [component];
181
215
  }
182
216
 
217
+ /**
218
+ * How a component's own resources answered, one count per tri-state verdict.
219
+ *
220
+ * The merged verdict above is deliberately lossy — it answers "is this
221
+ * component deployed" and nothing else. A consumer painting component status
222
+ * without a deploy object to read (behold#98: floci-az and floci-gcp have no
223
+ * CloudFormation stack, so `stack` below is absent and there is nothing to
224
+ * colour from) needs the shape underneath: how many of the component's
225
+ * resources were seen, how many were confirmed gone, how many nobody could
226
+ * look at. Substrate-neutral by construction — it counts observations, not
227
+ * provider objects.
228
+ */
229
+ export interface ComponentResourceRollup {
230
+ /** Resources this component owns, per the live-name mapping. */
231
+ total: number;
232
+ /** Observed present. */
233
+ present: number;
234
+ /** Looked for, reported missing. Never includes a resource nobody could read. */
235
+ absent: number;
236
+ /** NOT-OBSERVED (#1089) — a hole, never counted as absence. */
237
+ unobserved: number;
238
+ }
239
+
240
+ /** Count a component's entity verdicts without collapsing them (behold#98). */
241
+ function rollUp(entries: LiveComponentEvidence[]): ComponentResourceRollup {
242
+ let present = 0;
243
+ let absent = 0;
244
+ let unobserved = 0;
245
+ for (const e of entries) {
246
+ if (e.unobserved) unobserved += 1;
247
+ else if (e.live) present += 1;
248
+ else absent += 1;
249
+ }
250
+ return { total: entries.length, present, absent, unobserved };
251
+ }
252
+
183
253
  /**
184
254
  * Merge live evidence for several entity/resource names owned by one
185
255
  * component into a single verdict. `live` and `ownership` favor the
@@ -187,10 +257,14 @@ export function resolveLiveNames(component: string, mapping?: LiveNameMapping):
187
257
  * favors `update` so that drift on *any* owned entity surfaces as drift for
188
258
  * the component as a whole, matching `reconcileStatus`'s single check for
189
259
  * `action === "update"`.
260
+ *
261
+ * The per-entity counts survive as {@link LiveComponentEvidence.rollup}, so a
262
+ * consumer that needs the shape rather than the verdict is not forced to redo
263
+ * this join on the far side of a CLI boundary.
190
264
  */
191
265
  function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence | undefined {
192
266
  if (entries.length === 0) return undefined;
193
- if (entries.length === 1) return entries[0];
267
+ if (entries.length === 1) return { ...entries[0], rollup: rollUp(entries) };
194
268
 
195
269
  const live = entries.some((e) => e.live);
196
270
  const ownership = entries.some((e) => e.ownership === "owned")
@@ -206,7 +280,7 @@ function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence
206
280
  // actually seen live, which already answers "is this deployed".
207
281
  const unobserved = live ? undefined : entries.find((e) => e.unobserved)?.unobserved;
208
282
 
209
- return { live, ownership, action, ...(unobserved ? { unobserved } : {}) };
283
+ return { live, ownership, action, ...(unobserved ? { unobserved } : {}), rollup: rollUp(entries) };
210
284
  }
211
285
 
212
286
  /**
@@ -228,6 +302,14 @@ export function liveEvidenceFromChangeSet(
228
302
  const evidenceByName = new Map<string, LiveComponentEvidence>();
229
303
  for (const entry of cs.entries) {
230
304
  evidenceByName.set(entry.name, {
305
+ rollup: rollUp([
306
+ {
307
+ live: entry.evidence.live,
308
+ ...(entry.action === "unobserved" && entry.unobservedReason
309
+ ? { unobserved: { reason: entry.unobservedReason } }
310
+ : {}),
311
+ },
312
+ ]),
231
313
  live: entry.evidence.live,
232
314
  action: entry.action,
233
315
  ownership: entry.ownership,
@@ -352,6 +434,7 @@ export function reconcileStatus(
352
434
  ...(liveEvidence && !evidence?.unobserved ? { live: !!evidence?.live } : {}),
353
435
  ...(evidence?.unobserved ? { unobserved: evidence.unobserved } : {}),
354
436
  ...(evidence?.stack ? { stack: evidence.stack } : {}),
437
+ ...(evidence?.rollup ? { resources: evidence.rollup } : {}),
355
438
  });
356
439
  }
357
440
 
package/src/yaml.test.ts CHANGED
@@ -244,3 +244,92 @@ describe("parseYAML block scalars (#910)", () => {
244
244
  expect(parseYAML("msg: >\n a\n b\n\n c\n")).toEqual({ msg: "a b\nc\n" });
245
245
  });
246
246
  });
247
+
248
+ // #1311 — a sequence item's sibling keys survive a nested block, whichever key
249
+ // comes first. The bug was purely positional: the same keys in the other order
250
+ // parsed correctly, so nothing about the keys themselves was at fault.
251
+ describe("parseYAML — sibling keys after a nested block in a sequence item (#1311)", () => {
252
+ test("a sibling key after a nested MAPPING is not swallowed", () => {
253
+ expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n")).toEqual({
254
+ items: [{ context: { cluster: "c1" }, name: "n1" }],
255
+ });
256
+ });
257
+
258
+ test("the same keys in the other order still parse — the ordering is what mattered", () => {
259
+ expect(parseYAML("items:\n- name: n1\n context:\n cluster: c1\n")).toEqual({
260
+ items: [{ name: "n1", context: { cluster: "c1" } }],
261
+ });
262
+ });
263
+
264
+ test("several siblings after a nested mapping", () => {
265
+ expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n user: u1\n")).toEqual({
266
+ items: [{ context: { cluster: "c1" }, name: "n1", user: "u1" }],
267
+ });
268
+ });
269
+
270
+ test("back-to-back nested mappings", () => {
271
+ expect(parseYAML("items:\n- a:\n x: 1\n b:\n y: 2\n")).toEqual({ items: [{ a: { x: 1 }, b: { y: 2 } }] });
272
+ });
273
+
274
+ test("every item in a multi-item sequence keeps its siblings", () => {
275
+ expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n- context:\n cluster: c2\n name: n2\n")).toEqual({
276
+ items: [
277
+ { context: { cluster: "c1" }, name: "n1" },
278
+ { context: { cluster: "c2" }, name: "n2" },
279
+ ],
280
+ });
281
+ });
282
+
283
+ test("a sibling key after a SAME-COLUMN nested sequence — valid YAML, and what kubectl emits", () => {
284
+ expect(parseYAML("items:\n- ports:\n - 80\n - 443\n name: n1\n")).toEqual({
285
+ items: [{ ports: [80, 443], name: "n1" }],
286
+ });
287
+ });
288
+
289
+ test("a key with no value stays null when the next line is a sibling, not its content", () => {
290
+ // The reason a nested MAPPING must be indented PAST its key while a
291
+ // sequence may share its column: `other` here belongs to the item, not to
292
+ // `meta`. Reading both against the same threshold breaks one or the other.
293
+ expect(parseYAML("items:\n- name: a\n meta:\n other: b\n")).toEqual({
294
+ items: [{ name: "a", meta: null, other: "b" }],
295
+ });
296
+ });
297
+
298
+ test("a real Kubernetes container: same-column sequences between scalar keys", () => {
299
+ expect(
300
+ parseYAML('containers:\n- name: web\n ports:\n - containerPort: 80\n env:\n - name: X\n value: "1"\n image: nginx\n'),
301
+ ).toEqual({
302
+ containers: [
303
+ {
304
+ name: "web",
305
+ ports: [{ containerPort: 80 }],
306
+ env: [{ name: "X", value: "1" }],
307
+ image: "nginx",
308
+ },
309
+ ],
310
+ });
311
+ });
312
+
313
+ test("a real kubeconfig context block — the shape that surfaced this", () => {
314
+ const kubeconfig = [
315
+ "contexts:",
316
+ "- context:",
317
+ " cluster: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
318
+ " user: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
319
+ " name: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
320
+ "current-context: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
321
+ "",
322
+ ].join("\n");
323
+ const arn = "arn:aws:eks:us-east-1:000000000000:cluster/cc-eks";
324
+ expect(parseYAML(kubeconfig)).toEqual({
325
+ contexts: [{ context: { cluster: arn, user: arn }, name: arn }],
326
+ "current-context": arn,
327
+ });
328
+ });
329
+
330
+ test("a GitHub Actions step with `with:` before its sibling keys", () => {
331
+ expect(parseYAML("steps:\n- with:\n fetch-depth: 0\n name: checkout\n uses: actions/checkout@v4\n")).toEqual({
332
+ steps: [{ with: { "fetch-depth": 0 }, name: "checkout", uses: "actions/checkout@v4" }],
333
+ });
334
+ });
335
+ });
package/src/yaml.ts CHANGED
@@ -329,7 +329,7 @@ function parseArrayItemValue(
329
329
  inlineValue: string,
330
330
  lines: string[],
331
331
  currentIndex: number,
332
- childIndent: number,
332
+ keyIndent: number,
333
333
  ): unknown {
334
334
  if (inlineValue !== "" && !inlineValue.startsWith("#")) {
335
335
  const header = blockScalarHeader(inlineValue);
@@ -349,16 +349,28 @@ function parseArrayItemValue(
349
349
  }
350
350
  return parseScalar(inlineValue);
351
351
  }
352
- // Empty inline value — check for nested block
352
+ // Empty inline value — check for a nested block. `keyIndent` is the key's own
353
+ // column, and the two nested shapes do NOT share a threshold (#1311):
354
+ //
355
+ // - a SEQUENCE may sit at the key's own column (valid YAML, and what
356
+ // kubectl and Kubernetes manifests emit);
357
+ // - a MAPPING must be indented past it, otherwise the next line is a
358
+ // sibling key and this key's value is null:
359
+ //
360
+ // - name: a
361
+ // meta: <- no value
362
+ // other: b <- a sibling, NOT meta's content
363
+ //
364
+ // Testing both against `>= keyIndent` would swallow that sibling; testing
365
+ // both against `> keyIndent` loses the same-column sequence.
353
366
  const nextIdx = currentIndex + 1;
354
367
  if (nextIdx < lines.length) {
355
368
  const nextLine = lines[nextIdx];
356
369
  if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
357
370
  const ni = nextLine.search(/\S/);
358
- if (ni >= childIndent) {
359
- if (nextLine.trimStart().startsWith("- ")) {
360
- return parseYAMLArray(lines, nextIdx, ni).value;
361
- }
371
+ if (nextLine.trimStart().startsWith("- ")) {
372
+ if (ni >= keyIndent) return parseYAMLArray(lines, nextIdx, ni).value;
373
+ } else if (ni > keyIndent) {
362
374
  return parseYAMLLines(lines, nextIdx, ni).value;
363
375
  }
364
376
  }
@@ -366,6 +378,37 @@ function parseArrayItemValue(
366
378
  return null;
367
379
  }
368
380
 
381
+ /**
382
+ * Skip past the value block belonging to a key at column `keyIndent` inside a
383
+ * sequence item, returning the first line that is NOT part of it (#1311).
384
+ *
385
+ * Two shapes, and only the first is a matter of indentation:
386
+ *
387
+ * - a nested MAPPING is indented past its key, so anything further right
388
+ * belongs to it and anything at the key's own column is a sibling;
389
+ * - a nested SEQUENCE may sit at the SAME column as its key, which is valid
390
+ * YAML and what kubectl and Kubernetes manifests both emit:
391
+ *
392
+ * - name: web
393
+ * ports:
394
+ * - containerPort: 80
395
+ * env: <- a sibling, at `ports`' own column
396
+ *
397
+ * An indent rule cannot separate those, so the sequence is re-parsed to
398
+ * find where it ends. `parseYAMLArray` already reports that as `endIndex`.
399
+ */
400
+ function skipValueBlock(lines: string[], startIndex: number, keyIndent: number): number {
401
+ let k = startIndex;
402
+ while (k < lines.length && (lines[k].trim() === "" || lines[k].trim().startsWith("#"))) k++;
403
+ if (k < lines.length) {
404
+ const ni = lines[k].search(/\S/);
405
+ if (ni >= keyIndent && lines[k].trimStart().startsWith("- ")) {
406
+ return parseYAMLArray(lines, k, ni).endIndex;
407
+ }
408
+ }
409
+ return skipNestedBlock(lines, startIndex, keyIndent + 1);
410
+ }
411
+
369
412
  /**
370
413
  * Skip past a nested block (object or array) starting at startIndex with the given indent.
371
414
  * Returns the index of the first line that is NOT part of the nested block.
@@ -420,7 +463,19 @@ export function parseYAMLArray(
420
463
  const nextIndent = indent + 2;
421
464
  const firstVal = kvMatch[2].trim();
422
465
  let j = firstVal === "" || firstVal.startsWith("#")
423
- ? skipNestedBlock(lines, i + 1, nextIndent)
466
+ // The nested block belongs to THIS key and is indented past it, so
467
+ // skip lines indented more than the key's own column (#1311). Using
468
+ // the key's column itself also swallowed the item's sibling keys,
469
+ // which sit at exactly that column:
470
+ //
471
+ // - context: <- key at column 2
472
+ // cluster: c1 <- its block, column 4
473
+ // name: n1 <- a SIBLING at column 2, was skipped
474
+ //
475
+ // Only the item's first key was affected: the sibling loop below
476
+ // already skips past its own key's column, and the block-scalar
477
+ // branch immediately below has always used `+ 1` for this reason.
478
+ ? skipValueBlock(lines, i + 1, nextIndent)
424
479
  : blockScalarHeader(firstVal)
425
480
  // Block body is indented past the key (nextIndent); skip it (#910).
426
481
  ? skipNestedBlock(lines, i + 1, nextIndent + 1)
@@ -437,9 +492,11 @@ export function parseYAMLArray(
437
492
  const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
438
493
  if (nextKV) {
439
494
  const nextVal = nextKV[3].trim();
440
- obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni + 2);
495
+ obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
441
496
  if (nextVal === "" || nextVal.startsWith("#")) {
442
- j = skipNestedBlock(lines, j + 1, ni + 2);
497
+ // Same rule as the first key above: past this key's own column,
498
+ // or to the end of a same-column sequence (#1311).
499
+ j = skipValueBlock(lines, j + 1, ni);
443
500
  } else if (blockScalarHeader(nextVal)) {
444
501
  // Skip the block body (indented past this key at `ni`) (#910).
445
502
  j = skipNestedBlock(lines, j + 1, ni + 1);