@intentius/chant-lexicon-helm 0.46.0 → 0.49.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 (118) hide show
  1. package/dist/archive-render.d.ts +94 -0
  2. package/dist/archive-render.d.ts.map +1 -0
  3. package/dist/commands.d.ts +54 -0
  4. package/dist/commands.d.ts.map +1 -0
  5. package/dist/config.d.ts +135 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +2 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +54 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +17 -0
  12. package/dist/describe-resources.d.ts.map +1 -0
  13. package/dist/index.d.ts +18 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/integrity.json +5 -3
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/index.d.ts.map +1 -1
  18. package/dist/lint/post-synth/whm503.d.ts +34 -0
  19. package/dist/lint/post-synth/whm503.d.ts.map +1 -0
  20. package/dist/lint/post-synth/whm504.d.ts +20 -0
  21. package/dist/lint/post-synth/whm504.d.ts.map +1 -0
  22. package/dist/manifest.json +1 -1
  23. package/dist/okf/index.md +2 -0
  24. package/dist/okf/rules/WHM503.md +15 -0
  25. package/dist/okf/rules/WHM504.md +11 -0
  26. package/dist/okf/types/Values.md +1 -0
  27. package/dist/op/activities/cluster-probe.d.ts +44 -0
  28. package/dist/op/activities/cluster-probe.d.ts.map +1 -0
  29. package/dist/op/activities/helm.d.ts +179 -5
  30. package/dist/op/activities/helm.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +4 -2
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/pinnability/actions.d.ts +76 -0
  34. package/dist/pinnability/actions.d.ts.map +1 -0
  35. package/dist/pinnability/classify.d.ts +129 -0
  36. package/dist/pinnability/classify.d.ts.map +1 -0
  37. package/dist/pinnability/conditions.d.ts +109 -0
  38. package/dist/pinnability/conditions.d.ts.map +1 -0
  39. package/dist/pinnability/index.d.ts +17 -0
  40. package/dist/pinnability/index.d.ts.map +1 -0
  41. package/dist/pinnability/localize.d.ts +124 -0
  42. package/dist/pinnability/localize.d.ts.map +1 -0
  43. package/dist/pinnability/render-stream.d.ts +27 -0
  44. package/dist/pinnability/render-stream.d.ts.map +1 -0
  45. package/dist/pinnability/values.d.ts +69 -0
  46. package/dist/pinnability/values.d.ts.map +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/release-observe.d.ts +109 -0
  49. package/dist/release-observe.d.ts.map +1 -0
  50. package/dist/render-diff.d.ts +192 -0
  51. package/dist/render-diff.d.ts.map +1 -0
  52. package/dist/render-digest.d.ts +135 -0
  53. package/dist/render-digest.d.ts.map +1 -0
  54. package/dist/render-store.d.ts +310 -0
  55. package/dist/render-store.d.ts.map +1 -0
  56. package/dist/render-wrapper.d.ts +140 -0
  57. package/dist/render-wrapper.d.ts.map +1 -0
  58. package/dist/render.d.ts +81 -0
  59. package/dist/render.d.ts.map +1 -1
  60. package/dist/rules/whm503.ts +101 -0
  61. package/dist/rules/whm504.ts +52 -0
  62. package/dist/skills/chant-helm.md +7 -0
  63. package/dist/values-probe.d.ts +177 -0
  64. package/dist/values-probe.d.ts.map +1 -0
  65. package/dist/wrapper-chart.d.ts +61 -0
  66. package/dist/wrapper-chart.d.ts.map +1 -0
  67. package/package.json +10 -4
  68. package/src/archive-render.test.ts +201 -0
  69. package/src/archive-render.ts +119 -0
  70. package/src/commands.test.ts +487 -0
  71. package/src/commands.ts +445 -0
  72. package/src/config.test.ts +130 -0
  73. package/src/config.ts +220 -0
  74. package/src/deep-observe-hooks.ts +20 -0
  75. package/src/deep-observe.test.ts +165 -0
  76. package/src/deep-observe.ts +121 -0
  77. package/src/describe-resources.test.ts +255 -0
  78. package/src/describe-resources.ts +198 -0
  79. package/src/index.ts +98 -2
  80. package/src/lifecycle-integration.test.ts +289 -0
  81. package/src/lint/audit-catalog.ts +2 -0
  82. package/src/lint/post-synth/index.ts +4 -0
  83. package/src/lint/post-synth/whm503.test.ts +134 -0
  84. package/src/lint/post-synth/whm503.ts +101 -0
  85. package/src/lint/post-synth/whm504.test.ts +105 -0
  86. package/src/lint/post-synth/whm504.ts +52 -0
  87. package/src/op/activities/cluster-probe.test.ts +66 -0
  88. package/src/op/activities/cluster-probe.ts +148 -0
  89. package/src/op/activities/helm.test.ts +824 -0
  90. package/src/op/activities/helm.ts +628 -5
  91. package/src/op/activities/index.ts +17 -2
  92. package/src/pinnability/actions.ts +263 -0
  93. package/src/pinnability/classify.ts +579 -0
  94. package/src/pinnability/conditions.ts +0 -0
  95. package/src/pinnability/index.ts +77 -0
  96. package/src/pinnability/localize.test.ts +234 -0
  97. package/src/pinnability/localize.ts +644 -0
  98. package/src/pinnability/pinnability.test.ts +560 -0
  99. package/src/pinnability/render-stream.ts +59 -0
  100. package/src/pinnability/values.ts +205 -0
  101. package/src/plugin.test.ts +3 -1
  102. package/src/plugin.ts +47 -0
  103. package/src/release-observe.ts +392 -0
  104. package/src/render-diff.test.ts +366 -0
  105. package/src/render-diff.ts +407 -0
  106. package/src/render-digest.test.ts +186 -0
  107. package/src/render-digest.ts +207 -0
  108. package/src/render-store.test.ts +412 -0
  109. package/src/render-store.ts +621 -0
  110. package/src/render-wrapper.test.ts +259 -0
  111. package/src/render-wrapper.ts +274 -0
  112. package/src/render.test.ts +383 -4
  113. package/src/render.ts +319 -8
  114. package/src/skills/chant-helm.md +7 -0
  115. package/src/values-probe.test.ts +400 -0
  116. package/src/values-probe.ts +627 -0
  117. package/src/wrapper-chart.test.ts +110 -0
  118. package/src/wrapper-chart.ts +124 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Render canonicalization and the contentDigest/inputDigest split
3
+ * (#1237, epic #1228 Phase 2).
4
+ *
5
+ * A pinned render's output is normalized to a canonical byte form before
6
+ * digesting, and two digests are recorded with different jobs:
7
+ *
8
+ * - `inputDigest` answers "same inputs?" cheaply — a `sha256:` over the
9
+ * canonical JSON of { chart reference, chart version, resolved values,
10
+ * capability profile }. It is the same digest `helmInstall` records in the
11
+ * release ledger (#1243), computed by the same helper, so a render and a
12
+ * deploy of the same inputs share an identity.
13
+ * - `contentDigest` answers "same bytes on the cluster?" — a `sha256:` over
14
+ * the canonical rendered bytes. It is the artifact identity.
15
+ *
16
+ * The two diverge exactly when rendering is unpinned or the chart is
17
+ * unstable: two renders with the same inputDigest but different
18
+ * contentDigests mean the render function is not a function of its declared
19
+ * inputs. That divergence is itself a signal — `renderStability` names it.
20
+ *
21
+ * Canonicalization scope (what the epic measured, not more):
22
+ * - Mapping key order is normalized (sorted) — YAML mappings are unordered
23
+ * in Kubernetes semantics, so key order is render noise. Sequence order is
24
+ * never touched; list order is meaningful to Kubernetes.
25
+ * - Document order is preserved, not sorted. The epic verified document
26
+ * ordering is already stable across renders (three renders, again with
27
+ * subcharts), so reordering would only destroy information.
28
+ * - Duplicate documents are preserved. An aliased dependency emits the same
29
+ * CRD twice (epic finding 11); helm dedupes on install, the artifact keeps
30
+ * both, and the digest stays stable because canonicalization is
31
+ * per-document and order-preserving.
32
+ * - The helm-inserted `# Source:` header is kept (it is the origin-routing
33
+ * key the archive wrapper builds on) and normalized to a single leading
34
+ * line. All other comments, trailing whitespace, CRLF line endings, and
35
+ * document-separator styling are render noise and are dropped or
36
+ * normalized.
37
+ */
38
+
39
+ import { createHash } from "node:crypto";
40
+
41
+ import { canonicalJson } from "@intentius/chant/effect-receipt";
42
+ import yaml from "js-yaml";
43
+
44
+ import { splitDocuments } from "./pinnability/render-stream";
45
+ import type { HelmRenderRecord } from "./render";
46
+
47
+ /**
48
+ * The declared inputs of a helm render or deploy, in the exact shape #1243
49
+ * digests for the release ledger. `helmInstallInputDigest`
50
+ * (op/activities/helm.ts) and `HelmRender`'s recorded `inputDigest` both
51
+ * delegate here, so the two cannot drift: the same chart reference, version,
52
+ * resolved values, and capability facts produce the same digest whether they
53
+ * reach helm through a render or a deploy.
54
+ */
55
+ export interface HelmInputDigestSource {
56
+ /** Chart reference — a local path, or `<repo-url>/<chart>` for repo-fetched charts. */
57
+ chart: string;
58
+ /** Pinned chart version. Digested as `null` when absent. */
59
+ chartVersion?: string;
60
+ /** Resolved values (file merged with overrides). Digested as `{}` when absent. */
61
+ values?: Record<string, unknown>;
62
+ /**
63
+ * Capability facts the render is pinned against. Only the facts join the
64
+ * digest — the profile's *name* is a label, not an input, so two profiles
65
+ * declaring the same cluster digest identically. `apiVersions` is sorted:
66
+ * it declares a set, not an order.
67
+ */
68
+ capabilityProfile?: { kubeVersion?: string; apiVersions?: string[] };
69
+ }
70
+
71
+ /**
72
+ * The input-side identity of a helm render or deploy: `sha256:` over the
73
+ * canonical JSON (core's `canonicalJson`, RFC 8785 shape) of the declared
74
+ * inputs. Shared with #1243's release-ledger digest — see
75
+ * `HelmInputDigestSource`. Deliberately excludes the release name and
76
+ * namespace: those are baked into the rendered bytes but the ledger uses
77
+ * this digest as a cross-environment join key, and two environments
78
+ * deploying the same chart, version, and values must share it.
79
+ */
80
+ export function helmInputDigest(source: HelmInputDigestSource): string {
81
+ const input: Record<string, unknown> = {
82
+ chart: source.chart,
83
+ chartVersion: source.chartVersion ?? null,
84
+ values: source.values ?? {},
85
+ };
86
+ if (source.capabilityProfile) {
87
+ input.capabilityProfile = {
88
+ kubeVersion: source.capabilityProfile.kubeVersion ?? null,
89
+ apiVersions: [...(source.capabilityProfile.apiVersions ?? [])].sort(),
90
+ };
91
+ }
92
+ return `sha256:${createHash("sha256").update(canonicalJson(input), "utf8").digest("hex")}`;
93
+ }
94
+
95
+ /** The helm-inserted origin header, e.g. `# Source: chart/templates/deploy.yaml`. */
96
+ const SOURCE_HEADER = /^# Source: \S+$/m;
97
+
98
+ /**
99
+ * Normalize a `helm template` stream to its canonical byte form.
100
+ *
101
+ * Per document: the `# Source:` header (if any) becomes the single leading
102
+ * comment line, the YAML body is re-serialized with sorted mapping keys
103
+ * (sequences untouched), no line-width folding, and a trailing newline.
104
+ * Documents are emitted in render order, each introduced by a `---` line.
105
+ * CRLF endings, trailing whitespace, non-Source comments, and empty or
106
+ * comment-only documents are render noise and do not survive.
107
+ *
108
+ * A document that fails to parse as YAML is kept verbatim (trimmed) rather
109
+ * than dropped — the canonical form must never silently lose content, and a
110
+ * verbatim document still digests deterministically.
111
+ */
112
+ export function canonicalizeRender(rendered: string): string {
113
+ const out: string[] = [];
114
+ for (const doc of splitDocuments(rendered.replace(/\r\n/g, "\n"))) {
115
+ const header = doc.match(SOURCE_HEADER)?.[0];
116
+ let parsed: unknown;
117
+ let parseFailed = false;
118
+ try {
119
+ parsed = yaml.load(doc);
120
+ } catch {
121
+ parseFailed = true;
122
+ }
123
+ if (parseFailed) {
124
+ out.push(`---\n${doc}\n`);
125
+ continue;
126
+ }
127
+ if (parsed === null || parsed === undefined) continue; // comment-only or empty document
128
+ const body = yaml.dump(parsed, { sortKeys: true, lineWidth: -1, noRefs: true });
129
+ out.push(`---\n${header ? header + "\n" : ""}${body}`);
130
+ }
131
+ return out.join("");
132
+ }
133
+
134
+ /**
135
+ * The content-side identity of a render: `sha256:` over the canonical bytes
136
+ * of the rendered stream (see `canonicalizeRender`). Unlike `inputDigest`,
137
+ * this changes whenever the bytes that would reach the cluster change — a
138
+ * chart upgrade with identical values, a helm version that renders
139
+ * differently, an unstable template.
140
+ */
141
+ export function helmContentDigest(rendered: string): string {
142
+ return `sha256:${createHash("sha256").update(canonicalizeRender(rendered), "utf8").digest("hex")}`;
143
+ }
144
+
145
+ /** One inputDigest's renders and the distinct content identities they produced. */
146
+ export interface RenderStabilityGroup {
147
+ inputDigest: string;
148
+ /** Render names in this group, in record order. */
149
+ names: string[];
150
+ /** Distinct content digests observed, in first-seen order. */
151
+ contentDigests: string[];
152
+ }
153
+
154
+ export interface RenderStabilityReport {
155
+ /** Groups whose every render produced the same bytes. */
156
+ stable: RenderStabilityGroup[];
157
+ /**
158
+ * Groups where the same inputs produced different bytes: the render is
159
+ * not pinned down by its declared inputs — an unstable chart, or an input
160
+ * that escaped declaration. Every group here is a defect signal.
161
+ */
162
+ unstable: RenderStabilityGroup[];
163
+ /** Names of records carrying no digests (unpinned renders) — unassessable. */
164
+ unassessed: string[];
165
+ }
166
+
167
+ /**
168
+ * Group recorded renders by input identity and report whether the same
169
+ * inputs always produced the same bytes.
170
+ *
171
+ * Two renders with equal `inputDigest` and different `contentDigest` mean
172
+ * the render is not a function of its declared inputs — the chart is
173
+ * unstable (generated values, timestamps) or an input escaped declaration.
174
+ * That divergence is the signal this helper exists to name; a single render
175
+ * per input proves nothing and lands in `stable` by default.
176
+ *
177
+ * Grouping keys on `(inputDigest, name)` because the release name is a real
178
+ * render input baked into the bytes but deliberately absent from
179
+ * `inputDigest` (see `helmInputDigest`) — two differently named renders of
180
+ * the same chart legitimately differ in content and must not be read as
181
+ * instability.
182
+ */
183
+ export function renderStability(records: readonly HelmRenderRecord[]): RenderStabilityReport {
184
+ const groups = new Map<string, RenderStabilityGroup>();
185
+ const unassessed: string[] = [];
186
+ for (const record of records) {
187
+ if (!record.inputDigest || !record.contentDigest) {
188
+ unassessed.push(record.name);
189
+ continue;
190
+ }
191
+ const key = `${record.inputDigest}${record.name}`;
192
+ let group = groups.get(key);
193
+ if (!group) {
194
+ group = { inputDigest: record.inputDigest, names: [], contentDigests: [] };
195
+ groups.set(key, group);
196
+ }
197
+ group.names.push(record.name);
198
+ if (!group.contentDigests.includes(record.contentDigest)) {
199
+ group.contentDigests.push(record.contentDigest);
200
+ }
201
+ }
202
+ const report: RenderStabilityReport = { stable: [], unstable: [], unassessed };
203
+ for (const group of groups.values()) {
204
+ (group.contentDigests.length > 1 ? report.unstable : report.stable).push(group);
205
+ }
206
+ return report;
207
+ }
@@ -0,0 +1,412 @@
1
+ import { describe, test, expect, beforeAll, beforeEach, afterEach } from "vitest";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ import yaml from "js-yaml";
7
+
8
+ import { HelmRender, clearHelmRenderRecords, getHelmRenderRecords } from "./render";
9
+ import { canonicalizeRender, helmContentDigest, helmInputDigest } from "./render-digest";
10
+ import {
11
+ findRenderByCacheKey,
12
+ helmValuesDigest,
13
+ indexRenderDocuments,
14
+ listRenderManifests,
15
+ loadRenderContent,
16
+ loadRenderManifest,
17
+ persistHelmRender,
18
+ readRenderDocument,
19
+ renderCacheKey,
20
+ } from "./render-store";
21
+ import type { HelmCapabilityProfile } from "./config";
22
+
23
+ /**
24
+ * A raw `helm template`-shaped stream with deliberate render noise: CRLF
25
+ * endings, unsorted mapping keys, a comment-only document, and a duplicate
26
+ * document (epic finding 11 — an aliased dependency emits the same CRD
27
+ * twice).
28
+ */
29
+ const RENDERED = [
30
+ "---",
31
+ "# Source: tiny/templates/serviceaccount.yaml",
32
+ "kind: ServiceAccount",
33
+ "apiVersion: v1",
34
+ "metadata:",
35
+ " namespace: web",
36
+ " name: tiny-sa",
37
+ "---",
38
+ "# comment-only document",
39
+ "---",
40
+ "# Source: tiny/templates/configmap.yaml\r",
41
+ "apiVersion: v1\r",
42
+ "kind: ConfigMap",
43
+ "metadata:",
44
+ " name: tiny-config",
45
+ " namespace: web",
46
+ "data:",
47
+ " zeta: last",
48
+ " alpha: first",
49
+ "---",
50
+ "# Source: tiny/crds/widgets.yaml",
51
+ "apiVersion: apiextensions.k8s.io/v1",
52
+ "kind: CustomResourceDefinition",
53
+ "metadata:",
54
+ " name: widgets.example.com",
55
+ "spec:",
56
+ " group: example.com",
57
+ "---",
58
+ "# Source: tiny/crds/widgets.yaml",
59
+ "apiVersion: apiextensions.k8s.io/v1",
60
+ "kind: CustomResourceDefinition",
61
+ "metadata:",
62
+ " name: widgets.example.com",
63
+ "spec:",
64
+ " group: example.com",
65
+ "",
66
+ ].join("\n");
67
+
68
+ const PROFILE: HelmCapabilityProfile = {
69
+ name: "prod",
70
+ kubeVersion: "1.33.6",
71
+ apiVersions: ["batch/v1"],
72
+ };
73
+
74
+ const VALUES = { replicaCount: 3 };
75
+
76
+ function freshRoot(): string {
77
+ return mkdtempSync(join(tmpdir(), "chant-helm-render-store-"));
78
+ }
79
+
80
+ function persistFixture(root: string, overrides?: Partial<Parameters<typeof persistHelmRender>[0]>) {
81
+ return persistHelmRender({
82
+ rendered: RENDERED,
83
+ releaseName: "rel",
84
+ chart: "tiny",
85
+ repo: "https://charts.example.com",
86
+ chartVersion: "0.1.0",
87
+ namespace: "web",
88
+ values: VALUES,
89
+ capabilityProfile: PROFILE,
90
+ helmVersion: "v4.1.1",
91
+ sourceRef: "deadbeef",
92
+ root,
93
+ ...overrides,
94
+ });
95
+ }
96
+
97
+ describe("persistHelmRender / render store", () => {
98
+ test("round-trip: persisted bytes load back byte-identical, and canonicalization is idempotent over them", () => {
99
+ const root = freshRoot();
100
+ const { manifest } = persistFixture(root);
101
+
102
+ const stored = loadRenderContent(manifest.contentDigest, { root });
103
+ expect(stored).toBe(canonicalizeRender(RENDERED));
104
+
105
+ // The store serves canonical bytes back as the cache — re-canonicalizing
106
+ // and re-digesting them must be a fixed point, or a cache hit would
107
+ // change the render's recorded identity.
108
+ expect(canonicalizeRender(stored!)).toBe(stored);
109
+ expect(helmContentDigest(stored!)).toBe(manifest.contentDigest);
110
+ });
111
+
112
+ test("the manifest records every field: identity, inputs, profile, index, provenance", () => {
113
+ const root = freshRoot();
114
+ const now = () => new Date("2026-08-24T12:00:00.000Z");
115
+ const { manifest, dir } = persistFixture(root, { now });
116
+
117
+ expect(manifest.version).toBe(1);
118
+ expect(manifest.chart).toBe("tiny");
119
+ expect(manifest.chartVersion).toBe("0.1.0");
120
+ expect(manifest.repo).toBe("https://charts.example.com");
121
+ expect(manifest.releaseName).toBe("rel");
122
+ expect(manifest.namespace).toBe("web");
123
+ expect(manifest.valuesDigest).toBe(helmValuesDigest(VALUES));
124
+ expect(manifest.inputDigest).toBe(
125
+ helmInputDigest({
126
+ chart: "https://charts.example.com/tiny",
127
+ chartVersion: "0.1.0",
128
+ values: VALUES,
129
+ capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["batch/v1"] },
130
+ }),
131
+ );
132
+ expect(manifest.capabilityProfile).toEqual({
133
+ cluster: "prod",
134
+ kubeVersion: "1.33.6",
135
+ apiVersions: ["batch/v1"],
136
+ });
137
+ expect(manifest.contentDigest).toBe(helmContentDigest(RENDERED));
138
+ // 4 canonical documents: the comment-only one is render noise and does
139
+ // not survive canonicalization; the duplicate CRD does (finding 11).
140
+ expect(manifest.docCount).toBe(4);
141
+ expect(manifest.documents.length).toBe(4);
142
+ expect(manifest.renderedAt).toBe("2026-08-24T12:00:00.000Z");
143
+ expect(manifest.helmVersion).toBe("v4.1.1");
144
+ expect(manifest.chantVersion).toMatch(/^\d+\.\d+\.\d+/);
145
+ expect(manifest.sourceRef).toBe("deadbeef");
146
+
147
+ // What landed on disk is the manifest itself, next to the bytes.
148
+ expect(dir).toBe(join(root, manifest.contentDigest.replace(":", "-")));
149
+ const onDisk = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
150
+ expect(onDisk).toEqual(manifest);
151
+ });
152
+
153
+ test("an unpinned render is refused with the specific reason", () => {
154
+ const root = freshRoot();
155
+ expect(() => persistFixture(root, { capabilityProfile: undefined })).toThrow(
156
+ /no capability profile is declared.*no stable content identity/s,
157
+ );
158
+ // Nothing was written.
159
+ expect(readdirSync(root)).toEqual([]);
160
+ });
161
+
162
+ test("two renders with the same contentDigest share one store entry", () => {
163
+ const root = freshRoot();
164
+ const first = persistFixture(root, { now: () => new Date("2026-08-24T12:00:00.000Z") });
165
+ expect(first.deduplicated).toBe(false);
166
+
167
+ // Same bytes, later clock, different declared release inputs that do
168
+ // not change the bytes' identity: the content entry is reused untouched.
169
+ const second = persistFixture(root, { now: () => new Date("2026-08-25T09:00:00.000Z") });
170
+ expect(second.deduplicated).toBe(true);
171
+ expect(second.dir).toBe(first.dir);
172
+ // First writer wins — the stored manifest is immutable.
173
+ expect(second.manifest.renderedAt).toBe("2026-08-24T12:00:00.000Z");
174
+
175
+ const entries = readdirSync(root).filter((e) => e.startsWith("sha256-"));
176
+ expect(entries.length).toBe(1);
177
+ expect(listRenderManifests({ root }).length).toBe(1);
178
+ });
179
+
180
+ test("the document index resolves kind/namespace/name to that document's exact bytes", () => {
181
+ const root = freshRoot();
182
+ const { manifest } = persistFixture(root);
183
+
184
+ const hit = readRenderDocument(
185
+ manifest.contentDigest,
186
+ { kind: "ConfigMap", namespace: "web", name: "tiny-config" },
187
+ { root },
188
+ );
189
+ expect(hit).toBeDefined();
190
+ expect(hit!.entry.apiVersion).toBe("v1");
191
+ expect(hit!.entry.source).toBe("tiny/templates/configmap.yaml");
192
+ const doc = yaml.load(hit!.text) as { kind: string; data: Record<string, string> };
193
+ expect(doc.kind).toBe("ConfigMap");
194
+ expect(doc.data).toEqual({ zeta: "last", alpha: "first" });
195
+ // The bytes are the manifest-indexed slice of the stored content.
196
+ const content = loadRenderContent(manifest.contentDigest, { root })!;
197
+ expect(hit!.text).toBe(
198
+ Buffer.from(content, "utf8")
199
+ .subarray(hit!.entry.start, hit!.entry.start + hit!.entry.length)
200
+ .toString("utf8"),
201
+ );
202
+
203
+ // Cluster-scoped documents resolve with no namespace.
204
+ const crd = readRenderDocument(
205
+ manifest.contentDigest,
206
+ { kind: "CustomResourceDefinition", name: "widgets.example.com" },
207
+ { root },
208
+ );
209
+ expect(crd).toBeDefined();
210
+ expect(crd!.entry.namespace).toBeNull();
211
+
212
+ // A document the render never contained resolves to nothing.
213
+ expect(
214
+ readRenderDocument(manifest.contentDigest, { kind: "Secret", name: "nope" }, { root }),
215
+ ).toBeUndefined();
216
+ });
217
+
218
+ test("the inputs index resolves the full-inputs key to the stored digests", () => {
219
+ const root = freshRoot();
220
+ const { manifest } = persistFixture(root);
221
+
222
+ const key = renderCacheKey({
223
+ chart: "https://charts.example.com/tiny",
224
+ chartVersion: "0.1.0",
225
+ releaseName: "rel",
226
+ namespace: "web",
227
+ values: VALUES,
228
+ capabilityProfile: PROFILE,
229
+ });
230
+ const entry = findRenderByCacheKey(key, { root });
231
+ expect(entry).toEqual({
232
+ version: 1,
233
+ inputDigest: manifest.inputDigest,
234
+ contentDigest: manifest.contentDigest,
235
+ });
236
+
237
+ // The release name is part of the cache key even though inputDigest
238
+ // excludes it — it is baked into the bytes via .Release.Name.
239
+ const otherName = renderCacheKey({
240
+ chart: "https://charts.example.com/tiny",
241
+ chartVersion: "0.1.0",
242
+ releaseName: "other",
243
+ namespace: "web",
244
+ values: VALUES,
245
+ capabilityProfile: PROFILE,
246
+ });
247
+ expect(otherName).not.toBe(key);
248
+ expect(findRenderByCacheKey(otherName, { root })).toBeUndefined();
249
+ });
250
+
251
+ test("listRenderManifests lists every stored render and skips corrupt entries", () => {
252
+ const root = freshRoot();
253
+ const a = persistFixture(root);
254
+ const b = persistFixture(root, { rendered: RENDERED + "---\nkind: Secret\napiVersion: v1\nmetadata:\n name: extra\n" });
255
+ expect(a.manifest.contentDigest).not.toBe(b.manifest.contentDigest);
256
+
257
+ // A corrupt entry must not fail the listing.
258
+ const corrupt = join(root, `sha256-${"f".repeat(64)}`);
259
+ mkdirSync(corrupt, { recursive: true });
260
+ writeFileSync(join(corrupt, "manifest.json"), "{not json");
261
+
262
+ const listed = listRenderManifests({ root });
263
+ expect(listed.map((m) => m.contentDigest).sort()).toEqual(
264
+ [a.manifest.contentDigest, b.manifest.contentDigest].sort(),
265
+ );
266
+ });
267
+
268
+ test("indexRenderDocuments counts every canonical document, indexed or not", () => {
269
+ const canonical = canonicalizeRender(RENDERED);
270
+ const { documents, docCount } = indexRenderDocuments(canonical);
271
+ expect(docCount).toBe(4);
272
+ // The duplicate CRD appears twice in the index, at different offsets.
273
+ const crds = documents.filter((d) => d.kind === "CustomResourceDefinition");
274
+ expect(crds.length).toBe(2);
275
+ expect(crds[0].start).not.toBe(crds[1].start);
276
+ expect(crds[0].digest).toBe(crds[1].digest);
277
+ });
278
+ });
279
+
280
+ /**
281
+ * HelmRender integration — asserted against a scripted `helm` double (the
282
+ * same pattern render.test.ts uses) so these tests need no real helm, no
283
+ * network, and no chart. The double answers `helm version` and answers any
284
+ * `helm template` with two small manifests, counting its template
285
+ * invocations so cache hits are observable.
286
+ */
287
+ describe("HelmRender persistence (#1238)", () => {
288
+ const FAKE_BIN = join(tmpdir(), "chant-helm-render-store-fake-bin");
289
+ let storeRoot: string;
290
+ let countFile: string;
291
+ let origPath: string | undefined;
292
+ let origRoot: string | undefined;
293
+
294
+ beforeAll(() => {
295
+ mkdirSync(FAKE_BIN, { recursive: true });
296
+ writeFileSync(
297
+ join(FAKE_BIN, "helm"),
298
+ `#!/bin/sh
299
+ if [ "$1" = "version" ]; then
300
+ echo "v4.1.1-fake"
301
+ exit 0
302
+ fi
303
+ echo x >> "$CHANT_TEST_HELM_TEMPLATE_COUNT"
304
+ cat <<'EOF'
305
+ ---
306
+ # Source: tiny/templates/configmap.yaml
307
+ apiVersion: v1
308
+ kind: ConfigMap
309
+ metadata:
310
+ name: fake-render
311
+ namespace: web
312
+ data:
313
+ b: two
314
+ a: one
315
+ ---
316
+ # Source: tiny/templates/serviceaccount.yaml
317
+ apiVersion: v1
318
+ kind: ServiceAccount
319
+ metadata:
320
+ name: fake-sa
321
+ namespace: web
322
+ EOF
323
+ `,
324
+ { mode: 0o755 },
325
+ );
326
+ });
327
+
328
+ beforeEach(() => {
329
+ storeRoot = freshRoot();
330
+ countFile = join(mkdtempSync(join(tmpdir(), "chant-helm-count-")), "count.txt");
331
+ process.env.CHANT_TEST_HELM_TEMPLATE_COUNT = countFile;
332
+ origRoot = process.env.CHANT_HELM_RENDER_ROOT;
333
+ process.env.CHANT_HELM_RENDER_ROOT = storeRoot;
334
+ origPath = process.env.PATH;
335
+ process.env.PATH = FAKE_BIN + delimiter + (origPath ?? "");
336
+ clearHelmRenderRecords();
337
+ });
338
+
339
+ afterEach(() => {
340
+ process.env.PATH = origPath;
341
+ if (origRoot === undefined) delete process.env.CHANT_HELM_RENDER_ROOT;
342
+ else process.env.CHANT_HELM_RENDER_ROOT = origRoot;
343
+ delete process.env.CHANT_TEST_HELM_TEMPLATE_COUNT;
344
+ });
345
+
346
+ function templateInvocations(): number {
347
+ if (!existsSync(countFile)) return 0;
348
+ return readFileSync(countFile, "utf8").split("\n").filter((l) => l.length > 0).length;
349
+ }
350
+
351
+ const pinnedProps = () =>
352
+ ({
353
+ name: "rel",
354
+ chart: "/dev/null/some-chart",
355
+ namespace: "web",
356
+ values: { replicaCount: 2 },
357
+ capabilityProfile: { ...PROFILE },
358
+ }) as unknown as Parameters<typeof HelmRender>[0];
359
+
360
+ test("a pinned render persists by default, and an identical render is a store cache hit", () => {
361
+ HelmRender(pinnedProps());
362
+ expect(templateInvocations()).toBe(1);
363
+
364
+ const [record] = getHelmRenderRecords();
365
+ const manifest = loadRenderManifest(record.contentDigest!);
366
+ expect(manifest).toBeDefined();
367
+ expect(manifest!.inputDigest).toBe(record.inputDigest);
368
+ expect(manifest!.helmVersion).toBe("v4.1.1-fake");
369
+ expect(manifest!.releaseName).toBe("rel");
370
+ expect(loadRenderContent(record.contentDigest!)).toBeDefined();
371
+
372
+ // Second identical render: served from the store through the inputs
373
+ // index — helm template does not run again, and the recorded digests
374
+ // are unchanged.
375
+ clearHelmRenderRecords();
376
+ const result = HelmRender(pinnedProps());
377
+ expect(templateInvocations()).toBe(1);
378
+ const [second] = getHelmRenderRecords();
379
+ expect(second.contentDigest).toBe(record.contentDigest);
380
+ expect(second.inputDigest).toBe(record.inputDigest);
381
+ const keys = Object.keys(result.members as Record<string, unknown>);
382
+ expect(keys).toContain("ConfigMap_fake_render");
383
+ expect(keys).toContain("ServiceAccount_fake_sa");
384
+ });
385
+
386
+ test("persist: true on an unpinned render is a synth error naming the reason", () => {
387
+ expect(() =>
388
+ HelmRender({
389
+ name: "rel",
390
+ chart: "/dev/null/some-chart",
391
+ persist: true,
392
+ } as Parameters<typeof HelmRender>[0]),
393
+ ).toThrow(/unpinned.*no capabilityProfile declared/s);
394
+ // Refused before helm ran.
395
+ expect(templateInvocations()).toBe(0);
396
+ });
397
+
398
+ test("noCache renders fresh and skips the store; persist: true forces the write anyway", () => {
399
+ HelmRender({ ...pinnedProps(), noCache: true } as Parameters<typeof HelmRender>[0]);
400
+ expect(templateInvocations()).toBe(1);
401
+ expect(listRenderManifests({ root: storeRoot }).length).toBe(0);
402
+
403
+ HelmRender({ ...pinnedProps(), noCache: true, persist: true } as Parameters<typeof HelmRender>[0]);
404
+ expect(templateInvocations()).toBe(2);
405
+ expect(listRenderManifests({ root: storeRoot }).length).toBe(1);
406
+
407
+ // persist: false turns the store off even without noCache.
408
+ HelmRender({ ...pinnedProps(), name: "rel2", persist: false } as Parameters<typeof HelmRender>[0]);
409
+ expect(templateInvocations()).toBe(3);
410
+ expect(listRenderManifests({ root: storeRoot }).length).toBe(1);
411
+ });
412
+ });