@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
@@ -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);