@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,621 @@
1
+ /**
2
+ * RenderManifest + content-addressed render storage (#1238, epic #1228
3
+ * Phase 2).
4
+ *
5
+ * #1237 gave a pinned render its identities — `inputDigest` over the
6
+ * declared inputs, `contentDigest` over the canonical rendered bytes. This
7
+ * module makes the render durable: the canonical bytes land in a
8
+ * content-addressed store keyed by `contentDigest`, and a `RenderManifest`
9
+ * — modeled on core's `BuildArchiveManifest`
10
+ * (packages/core/src/components/verbs/build-archive.ts) — records what the
11
+ * bytes are, what produced them, and how to find each document inside them.
12
+ *
13
+ * Storage layout, under `~/.chant/helm-renders/` (override with
14
+ * `CHANT_HELM_RENDER_ROOT`):
15
+ *
16
+ * ```
17
+ * sha256-<64 hex>/ one per distinct contentDigest
18
+ * content.yaml the canonical rendered bytes (canonicalizeRender)
19
+ * manifest.json the RenderManifest
20
+ * inputs/<64 hex>.json inputs index: full-render-inputs key -> digests
21
+ * ```
22
+ *
23
+ * The `sha256-<hex>` entries are the new root the issue asks for: the
24
+ * pre-#1238 cache keyed entries by a *truncated, input-derived* hash
25
+ * (16 hex characters, no algorithm prefix), which is a cache key, not an
26
+ * artifact identity. Those legacy entries are left exactly where they are —
27
+ * unpinned renders keep using them — and pinned renders write here instead.
28
+ * No migration: a truncated key cannot be turned back into a digest.
29
+ *
30
+ * Two renders that produce the same bytes share one `sha256-<hex>` entry —
31
+ * the content file and manifest are written once and never rewritten
32
+ * (first writer wins; the store is immutable, like a build archive entry).
33
+ * Dedup falls out of content addressing rather than being a feature.
34
+ *
35
+ * The inputs index preserves cache-hit behavior: before shelling out to
36
+ * helm, `HelmRender` computes the full-inputs key and reads
37
+ * `inputs/<key>.json` to find the bytes a previous identical render stored.
38
+ * The index key deliberately differs from `inputDigest`: the release name
39
+ * and namespace are baked into the rendered bytes (`.Release.Name`,
40
+ * `--namespace`) but are excluded from `inputDigest` because the release
41
+ * ledger uses that digest as a cross-environment join key (see
42
+ * `helmInputDigest` in ./render-digest.ts). A cache must key on everything
43
+ * that changes the bytes, so the index key includes both.
44
+ *
45
+ * Retention: none, deliberately. The build archive this is modeled on has
46
+ * no garbage collection — an archive entry lives until someone deletes it —
47
+ * and the pre-#1238 render cache behaved the same way. This store follows
48
+ * suit: entries accumulate until removed by hand. If the archive ever grows
49
+ * a retention story, this store should adopt the same one.
50
+ */
51
+
52
+ import { createHash } from "node:crypto";
53
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
54
+ import { createRequire } from "node:module";
55
+ import { homedir } from "node:os";
56
+ import { dirname, join } from "node:path";
57
+ import { fileURLToPath } from "node:url";
58
+
59
+ import { canonicalJson } from "@intentius/chant/effect-receipt";
60
+ import yaml from "js-yaml";
61
+
62
+ import type { HelmCapabilityProfile } from "./config";
63
+ import { canonicalizeRender, helmContentDigest, helmInputDigest } from "./render-digest";
64
+ import type { ValueOrigin } from "./values-probe";
65
+
66
+ // ── manifest shape ────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * One document's entry in a render's document index: enough identity to ask
70
+ * "give me the ServiceAccount named X in namespace Y" and enough addressing
71
+ * to answer it without parsing the whole stream.
72
+ */
73
+ export interface RenderDocumentEntry {
74
+ /** Kubernetes kind, e.g. `Deployment`. */
75
+ kind: string;
76
+ /** apiVersion as rendered, e.g. `apps/v1`. */
77
+ apiVersion: string;
78
+ /** `metadata.name`. */
79
+ name: string;
80
+ /** `metadata.namespace`, or `null` for cluster-scoped / namespace-less documents. */
81
+ namespace: string | null;
82
+ /** The helm-inserted `# Source:` origin path, when the document carries one. */
83
+ source: string | null;
84
+ /**
85
+ * Byte offset of this document's first byte inside `content.yaml`
86
+ * (pointing just past the `---` separator line, at the `# Source:` header
87
+ * when there is one).
88
+ */
89
+ start: number;
90
+ /** Byte length of the document, up to the next separator (or end of file). */
91
+ length: number;
92
+ /** `sha256:` over exactly those bytes — lets a reader verify a slice without trusting offsets. */
93
+ digest: string;
94
+ }
95
+
96
+ /**
97
+ * The durable record of one pinned render — what `manifest.json` holds.
98
+ * Modeled on `BuildArchiveManifest`: versioned, content-addressed, and
99
+ * carrying both identity sides the epic's Decisions require — the
100
+ * input-side join key (`inputDigest`, with `valuesDigest` as its
101
+ * values-only component) for cross-environment questions, and
102
+ * `contentDigest` proving what this cluster's profile actually received.
103
+ */
104
+ export interface RenderManifest {
105
+ /** Manifest schema version, so an incompatible future shape is detected before being misread. */
106
+ version: 1;
107
+ /** Chart name or local chart path, as the render declared it. */
108
+ chart: string;
109
+ /** Pinned chart version, or `null` for a local chart rendered without one. */
110
+ chartVersion: string | null;
111
+ /** Chart repo URL, or `null` for a local chart. */
112
+ repo: string | null;
113
+ /** Helm release name baked into the bytes (`.Release.Name`). */
114
+ releaseName: string;
115
+ /** Namespace the render targeted (`--namespace`), or `null`. */
116
+ namespace: string | null;
117
+ /**
118
+ * `sha256:` over the canonical JSON of the resolved values alone — the
119
+ * input-side join key the epic's ledger queries use together with
120
+ * `(chart, chartVersion)`.
121
+ */
122
+ valuesDigest: string;
123
+ /**
124
+ * `sha256:` over the full declared-input set (chart reference, version,
125
+ * values, capability facts) — `helmInputDigest`, the same digest the
126
+ * release ledger records on deploy (#1243).
127
+ */
128
+ inputDigest: string;
129
+ /** The capability profile the render was pinned against. `cluster` is the profile's declared name — profiles are per cluster. */
130
+ capabilityProfile: { cluster: string; kubeVersion: string; apiVersions: string[] };
131
+ /** `sha256:` over the canonical rendered bytes — the artifact identity, and this entry's storage key. */
132
+ contentDigest: string;
133
+ /** Number of documents in the canonical stream (including any the index could not identify). */
134
+ docCount: number;
135
+ /** Index of identifiable documents: kind/namespace/name -> byte span + per-document digest. */
136
+ documents: RenderDocumentEntry[];
137
+ /** ISO-8601 timestamp this manifest was written. Not part of any digest. */
138
+ renderedAt: string;
139
+ /**
140
+ * Version of the helm binary that produced the bytes. Load-bearing: the
141
+ * default kube version is a property of the binary (epic finding 1), so
142
+ * this field is what explains a digest mismatch between two machines.
143
+ */
144
+ helmVersion: string;
145
+ /** chant version that wrote this manifest. */
146
+ chantVersion: string;
147
+ /** Source ref/commit the render was produced from, when the caller supplied one. */
148
+ sourceRef: string | null;
149
+ /**
150
+ * `sha256:` over the canonical JSON of the coalesced values the
151
+ * build-time probe (#1251) observed for every chart instance, keyed by
152
+ * scope path — `coalescedValuesDigest` from `./values-probe.ts`. `null`
153
+ * when the probe did not run for this render (a repo-fetched chart, whose
154
+ * source the probe needs on disk, or a probe failure — never fabricated).
155
+ */
156
+ coalescedValuesDigest: string | null;
157
+ /**
158
+ * Each coalesced value's winning layer (#1252), dot-joined root-coordinate
159
+ * path to `ValueOrigin` — `computeValueSources` from `./values-probe.ts`.
160
+ * `null` under the same conditions as `coalescedValuesDigest`.
161
+ */
162
+ valueSources: Record<string, ValueOrigin> | null;
163
+ }
164
+
165
+ /** What `inputs/<key>.json` holds: the digests a full-inputs cache key resolves to. */
166
+ export interface RenderInputsIndexEntry {
167
+ version: 1;
168
+ inputDigest: string;
169
+ contentDigest: string;
170
+ }
171
+
172
+ // ── store location ────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Root of the render store. `CHANT_HELM_RENDER_ROOT` overrides the default
176
+ * `~/.chant/helm-renders` — the same directory the legacy input-keyed cache
177
+ * lives in; the two coexist because legacy entries are bare 16-hex names
178
+ * and store entries are `sha256-` prefixed.
179
+ */
180
+ export function renderStoreRoot(): string {
181
+ return process.env.CHANT_HELM_RENDER_ROOT ?? join(homedir(), ".chant", "helm-renders");
182
+ }
183
+
184
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
185
+
186
+ /** `sha256:<hex>` -> the store directory name `sha256-<hex>` (colons are not filesystem-safe everywhere). */
187
+ function digestDirName(contentDigest: string): string {
188
+ if (!DIGEST_RE.test(contentDigest)) {
189
+ throw new Error(`not a content digest: ${JSON.stringify(contentDigest)} (expected "sha256:<64 hex>")`);
190
+ }
191
+ return contentDigest.replace(":", "-");
192
+ }
193
+
194
+ function contentPath(root: string, contentDigest: string): string {
195
+ return join(root, digestDirName(contentDigest), "content.yaml");
196
+ }
197
+
198
+ function manifestPath(root: string, contentDigest: string): string {
199
+ return join(root, digestDirName(contentDigest), "manifest.json");
200
+ }
201
+
202
+ // ── inputs index key ──────────────────────────────────────────────────────
203
+
204
+ /** The full set of inputs that determine a pinned render's bytes. */
205
+ export interface RenderCacheKeySource {
206
+ /** Chart reference — a local path, or `<repo-url>/<chart>` for repo-fetched charts (same convention `helmInputDigest` uses). */
207
+ chart: string;
208
+ chartVersion?: string;
209
+ /** Release name — baked into the bytes via `.Release.Name`, so a real cache input even though `inputDigest` excludes it. */
210
+ releaseName: string;
211
+ /** Namespace — baked in via `--namespace`, likewise excluded from `inputDigest`. */
212
+ namespace?: string;
213
+ values?: Record<string, unknown>;
214
+ capabilityProfile: HelmCapabilityProfile;
215
+ }
216
+
217
+ /**
218
+ * Cache key over *everything* that changes a pinned render's bytes — a
219
+ * strict superset of `helmInputDigest`'s inputs (adds release name,
220
+ * namespace, and the profile's declared name, mirroring the legacy cache
221
+ * key's fields). 64 hex characters, no prefix: this is a lookup key, not an
222
+ * artifact identity.
223
+ */
224
+ export function renderCacheKey(source: RenderCacheKeySource): string {
225
+ const input = {
226
+ chart: source.chart,
227
+ chartVersion: source.chartVersion ?? null,
228
+ releaseName: source.releaseName,
229
+ namespace: source.namespace ?? null,
230
+ values: source.values ?? {},
231
+ capabilityProfile: {
232
+ name: source.capabilityProfile.name,
233
+ kubeVersion: source.capabilityProfile.kubeVersion,
234
+ apiVersions: [...(source.capabilityProfile.apiVersions ?? [])].sort(),
235
+ },
236
+ };
237
+ return createHash("sha256").update(canonicalJson(input), "utf8").digest("hex");
238
+ }
239
+
240
+ const CACHE_KEY_RE = /^[0-9a-f]{64}$/;
241
+
242
+ function inputsIndexPath(root: string, cacheKey: string): string {
243
+ if (!CACHE_KEY_RE.test(cacheKey)) {
244
+ throw new Error(`not a render cache key: ${JSON.stringify(cacheKey)} (expected 64 hex characters)`);
245
+ }
246
+ return join(root, "inputs", `${cacheKey}.json`);
247
+ }
248
+
249
+ // ── values digest ─────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * `sha256:` over the canonical JSON of the resolved values alone — the
253
+ * `valuesDigest` half of the ledger's input-side join key
254
+ * `(chart, chartVersion, valuesDigest)`. Absent values digest as `{}`,
255
+ * matching `helmInputDigest`'s treatment.
256
+ */
257
+ export function helmValuesDigest(values?: Record<string, unknown>): string {
258
+ return `sha256:${createHash("sha256").update(canonicalJson(values ?? {}), "utf8").digest("hex")}`;
259
+ }
260
+
261
+ // ── chant version ─────────────────────────────────────────────────────────
262
+
263
+ function readVersionAbove(startDir: string): string | undefined {
264
+ let dir = startDir;
265
+ for (let i = 0; i < 6; i++) {
266
+ const candidate = join(dir, "package.json");
267
+ if (existsSync(candidate)) {
268
+ try {
269
+ const version = (JSON.parse(readFileSync(candidate, "utf8")) as { version?: string }).version;
270
+ if (version) return version;
271
+ } catch {
272
+ // unreadable package.json — fall through to the caller's fallback
273
+ }
274
+ return undefined;
275
+ }
276
+ const parent = dirname(dir);
277
+ if (parent === dir) break;
278
+ dir = parent;
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ /**
284
+ * The core package's version, read from its `package.json` next to wherever
285
+ * `@intentius/chant` resolves from here. Falls back to this lexicon's own
286
+ * version (the two release in lockstep), then `"unknown"` — a manifest is
287
+ * never blocked on version discovery.
288
+ */
289
+ function chantVersion(): string {
290
+ try {
291
+ const req = createRequire(import.meta.url);
292
+ const fromCore = readVersionAbove(dirname(req.resolve("@intentius/chant")));
293
+ if (fromCore) return fromCore;
294
+ } catch {
295
+ // resolution failed (unusual layout) — use the lexicon's own version
296
+ }
297
+ return readVersionAbove(dirname(fileURLToPath(import.meta.url))) ?? "unknown";
298
+ }
299
+
300
+ // ── document index ────────────────────────────────────────────────────────
301
+
302
+ interface CanonicalSegment {
303
+ /** Segment text (from just past the `---` line to the next separator). */
304
+ text: string;
305
+ /** Byte offset of the segment inside the canonical stream. */
306
+ byteStart: number;
307
+ /** Byte length of the segment. */
308
+ byteLength: number;
309
+ }
310
+
311
+ /**
312
+ * Split a canonical stream (canonicalizeRender output) into its documents
313
+ * with byte-accurate offsets. Boundaries are column-0 `---` lines — the
314
+ * canonical form introduces every document with one, and no canonical
315
+ * document body can contain one (block-scalar content is always indented).
316
+ */
317
+ function canonicalSegments(canonical: string): CanonicalSegment[] {
318
+ const marks: number[] = [];
319
+ const re = /^---$/gm;
320
+ let m: RegExpExecArray | null;
321
+ while ((m = re.exec(canonical)) !== null) marks.push(m.index);
322
+
323
+ const segments: CanonicalSegment[] = [];
324
+ let lastCharEnd = 0;
325
+ let lastByteEnd = 0;
326
+ for (let k = 0; k < marks.length; k++) {
327
+ const charStart = canonical.indexOf("\n", marks[k]) + 1; // just past "---\n"
328
+ const charEnd = k + 1 < marks.length ? marks[k + 1] : canonical.length;
329
+ const byteStart = lastByteEnd + Buffer.byteLength(canonical.slice(lastCharEnd, charStart), "utf8");
330
+ const text = canonical.slice(charStart, charEnd);
331
+ const byteLength = Buffer.byteLength(text, "utf8");
332
+ segments.push({ text, byteStart, byteLength });
333
+ lastCharEnd = charEnd;
334
+ lastByteEnd = byteStart + byteLength;
335
+ }
336
+ return segments;
337
+ }
338
+
339
+ const SOURCE_LINE = /^# Source: (\S+)$/m;
340
+
341
+ /**
342
+ * Build the document index over a canonical stream: for every identifiable
343
+ * document (it parses and carries `kind` + `metadata.name`), its
344
+ * kind/namespace/name identity, byte span, and per-document digest.
345
+ * Returns the index and the total document count — the count includes
346
+ * documents the index could not identify, so `docCount` is always the
347
+ * number of documents the artifact carries.
348
+ */
349
+ export function indexRenderDocuments(canonical: string): { documents: RenderDocumentEntry[]; docCount: number } {
350
+ const segments = canonicalSegments(canonical);
351
+ const documents: RenderDocumentEntry[] = [];
352
+ for (const segment of segments) {
353
+ let parsed: unknown;
354
+ try {
355
+ parsed = yaml.load(segment.text);
356
+ } catch {
357
+ continue; // verbatim (unparseable) document — counted, not indexed
358
+ }
359
+ if (parsed === null || typeof parsed !== "object") continue;
360
+ const doc = parsed as { kind?: unknown; apiVersion?: unknown; metadata?: { name?: unknown; namespace?: unknown } };
361
+ if (typeof doc.kind !== "string" || typeof doc.metadata?.name !== "string") continue;
362
+ documents.push({
363
+ kind: doc.kind,
364
+ apiVersion: typeof doc.apiVersion === "string" ? doc.apiVersion : "",
365
+ name: doc.metadata.name,
366
+ namespace: typeof doc.metadata.namespace === "string" ? doc.metadata.namespace : null,
367
+ source: segment.text.match(SOURCE_LINE)?.[1] ?? null,
368
+ start: segment.byteStart,
369
+ length: segment.byteLength,
370
+ digest: `sha256:${createHash("sha256").update(segment.text, "utf8").digest("hex")}`,
371
+ });
372
+ }
373
+ return { documents, docCount: segments.length };
374
+ }
375
+
376
+ // ── write path ────────────────────────────────────────────────────────────
377
+
378
+ export interface PersistHelmRenderInput {
379
+ /** The `helm template` output — raw or already canonical; it is canonicalized before storage either way. */
380
+ rendered: string;
381
+ /** Release name the render was produced under (`HelmRenderProps.name`). */
382
+ releaseName: string;
383
+ /** Chart name or local chart path, as declared. */
384
+ chart: string;
385
+ /** Chart repo URL for repo-fetched charts. */
386
+ repo?: string;
387
+ chartVersion?: string;
388
+ namespace?: string;
389
+ values?: Record<string, unknown>;
390
+ /**
391
+ * The capability profile the render was pinned against. Absent means the
392
+ * render was unpinned — persistence is refused, because an unpinned
393
+ * render's bytes are a function of the local helm binary's defaults and
394
+ * have no identity worth storing.
395
+ */
396
+ capabilityProfile?: HelmCapabilityProfile;
397
+ /** Version of the helm binary that produced the bytes. Recorded as `"unknown"` when not supplied. */
398
+ helmVersion?: string;
399
+ /** Source ref/commit, when the caller has one. Never fabricated. */
400
+ sourceRef?: string;
401
+ /**
402
+ * The build-time coalesced-values probe's digest (#1251), when the caller
403
+ * ran one for this render. Absent (not merely `undefined`-passed) records
404
+ * `null` in the manifest — the probe needs the chart source on disk, so a
405
+ * repo-fetched render legitimately has none.
406
+ */
407
+ coalescedValuesDigest?: string;
408
+ /** The probe's value-source attribution (#1252), alongside `coalescedValuesDigest`. */
409
+ valueSources?: Record<string, ValueOrigin>;
410
+ /** Clock override for tests. */
411
+ now?: () => Date;
412
+ /** Store root override; defaults to `renderStoreRoot()`. */
413
+ root?: string;
414
+ }
415
+
416
+ export interface PersistedHelmRender {
417
+ /** The manifest now on disk — the existing one when the content was already stored. */
418
+ manifest: RenderManifest;
419
+ /** Directory the render lives in: `<root>/sha256-<hex>`. */
420
+ dir: string;
421
+ /**
422
+ * True when this contentDigest was already in the store — the bytes and
423
+ * manifest were left untouched (only the inputs index was updated).
424
+ */
425
+ deduplicated: boolean;
426
+ }
427
+
428
+ /**
429
+ * Persist one pinned render: canonical bytes under their `contentDigest`,
430
+ * the `RenderManifest` beside them, and an inputs-index entry so an
431
+ * identical future render is a cache hit.
432
+ *
433
+ * Content-addressed entries are immutable: when the digest directory
434
+ * already holds a manifest, both files are left as they are and the
435
+ * existing manifest is returned (`deduplicated: true`). The inputs index is
436
+ * still written — a new input combination can legitimately map to bytes the
437
+ * store already holds.
438
+ *
439
+ * Refuses an unpinned render (no capability profile) with the specific
440
+ * reason: no profile means the bytes depend on the local helm binary's
441
+ * defaulted capabilities, so they have no stable content identity to store
442
+ * under.
443
+ */
444
+ export function persistHelmRender(input: PersistHelmRenderInput): PersistedHelmRender {
445
+ if (!input.capabilityProfile) {
446
+ throw new Error(
447
+ `refusing to persist unpinned render "${input.releaseName}" (${input.chart}): ` +
448
+ `no capability profile is declared, so the rendered bytes are a function of the local ` +
449
+ `helm binary's defaulted capabilities and have no stable content identity to store under. ` +
450
+ `Declare capabilityProfile on the HelmRender to pin it (#1235).`,
451
+ );
452
+ }
453
+ const profile = input.capabilityProfile;
454
+ const root = input.root ?? renderStoreRoot();
455
+
456
+ const canonical = canonicalizeRender(input.rendered);
457
+ const contentDigest = helmContentDigest(input.rendered);
458
+ const chartRef = input.repo ? `${input.repo}/${input.chart}` : input.chart;
459
+ const inputDigest = helmInputDigest({
460
+ chart: chartRef,
461
+ chartVersion: input.chartVersion,
462
+ values: input.values ?? {},
463
+ capabilityProfile: { kubeVersion: profile.kubeVersion, apiVersions: profile.apiVersions },
464
+ });
465
+
466
+ const dir = join(root, digestDirName(contentDigest));
467
+ const existing = loadRenderManifest(contentDigest, { root });
468
+ let manifest: RenderManifest;
469
+ let deduplicated = false;
470
+ if (existing) {
471
+ // First writer wins — the entry is immutable, like a build archive's.
472
+ manifest = existing;
473
+ deduplicated = true;
474
+ } else {
475
+ const { documents, docCount } = indexRenderDocuments(canonical);
476
+ manifest = {
477
+ version: 1,
478
+ chart: input.chart,
479
+ chartVersion: input.chartVersion ?? null,
480
+ repo: input.repo ?? null,
481
+ releaseName: input.releaseName,
482
+ namespace: input.namespace ?? null,
483
+ valuesDigest: helmValuesDigest(input.values),
484
+ inputDigest,
485
+ capabilityProfile: {
486
+ cluster: profile.name,
487
+ kubeVersion: profile.kubeVersion,
488
+ apiVersions: profile.apiVersions ?? [],
489
+ },
490
+ contentDigest,
491
+ docCount,
492
+ documents,
493
+ renderedAt: (input.now ?? (() => new Date()))().toISOString(),
494
+ helmVersion: input.helmVersion ?? "unknown",
495
+ chantVersion: chantVersion(),
496
+ sourceRef: input.sourceRef ?? null,
497
+ coalescedValuesDigest: input.coalescedValuesDigest ?? null,
498
+ valueSources: input.valueSources ?? null,
499
+ };
500
+ mkdirSync(dir, { recursive: true });
501
+ writeFileSync(contentPath(root, contentDigest), canonical);
502
+ writeFileSync(manifestPath(root, contentDigest), JSON.stringify(manifest, null, 2) + "\n");
503
+ }
504
+
505
+ const key = renderCacheKey({
506
+ chart: chartRef,
507
+ chartVersion: input.chartVersion,
508
+ releaseName: input.releaseName,
509
+ namespace: input.namespace,
510
+ values: input.values,
511
+ capabilityProfile: profile,
512
+ });
513
+ const indexEntry: RenderInputsIndexEntry = { version: 1, inputDigest, contentDigest };
514
+ mkdirSync(join(root, "inputs"), { recursive: true });
515
+ writeFileSync(inputsIndexPath(root, key), JSON.stringify(indexEntry, null, 2) + "\n");
516
+
517
+ return { manifest, dir, deduplicated };
518
+ }
519
+
520
+ // ── read path ─────────────────────────────────────────────────────────────
521
+
522
+ /** Load the `RenderManifest` stored under a content digest, or `undefined` when the store has no such entry. */
523
+ export function loadRenderManifest(contentDigest: string, opts?: { root?: string }): RenderManifest | undefined {
524
+ const root = opts?.root ?? renderStoreRoot();
525
+ const path = manifestPath(root, contentDigest);
526
+ if (!existsSync(path)) return undefined;
527
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as RenderManifest;
528
+ if (parsed.version !== 1) {
529
+ throw new Error(`render manifest ${path} has unsupported version ${String(parsed.version)}`);
530
+ }
531
+ return parsed;
532
+ }
533
+
534
+ /** Load the canonical rendered bytes stored under a content digest, or `undefined` when absent. */
535
+ export function loadRenderContent(contentDigest: string, opts?: { root?: string }): string | undefined {
536
+ const root = opts?.root ?? renderStoreRoot();
537
+ const path = contentPath(root, contentDigest);
538
+ if (!existsSync(path)) return undefined;
539
+ return readFileSync(path, "utf8");
540
+ }
541
+
542
+ /** Resolve a full-inputs cache key through the inputs index, or `undefined` on a miss. */
543
+ export function findRenderByCacheKey(
544
+ cacheKey: string,
545
+ opts?: { root?: string },
546
+ ): RenderInputsIndexEntry | undefined {
547
+ const root = opts?.root ?? renderStoreRoot();
548
+ const path = inputsIndexPath(root, cacheKey);
549
+ if (!existsSync(path)) return undefined;
550
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as RenderInputsIndexEntry;
551
+ if (parsed.version !== 1) return undefined;
552
+ return parsed;
553
+ }
554
+
555
+ /**
556
+ * Every manifest in the store, sorted by `renderedAt` then `contentDigest`
557
+ * for a stable listing. Entries whose manifest is missing or unreadable are
558
+ * skipped — a listing never fails on one corrupt entry. This is the query
559
+ * surface the diff issues (#1249/#1250) resolve digests against.
560
+ */
561
+ export function listRenderManifests(opts?: { root?: string }): RenderManifest[] {
562
+ const root = opts?.root ?? renderStoreRoot();
563
+ if (!existsSync(root)) return [];
564
+ const manifests: RenderManifest[] = [];
565
+ for (const entry of readdirSync(root)) {
566
+ if (!/^sha256-[0-9a-f]{64}$/.test(entry)) continue;
567
+ try {
568
+ const manifest = loadRenderManifest(entry.replace("-", ":"), { root });
569
+ if (manifest) manifests.push(manifest);
570
+ } catch {
571
+ // corrupt entry — skip it, never fail the listing
572
+ }
573
+ }
574
+ manifests.sort((a, b) =>
575
+ a.renderedAt === b.renderedAt
576
+ ? a.contentDigest.localeCompare(b.contentDigest)
577
+ : a.renderedAt.localeCompare(b.renderedAt),
578
+ );
579
+ return manifests;
580
+ }
581
+
582
+ /** How a caller names one document inside a stored render. */
583
+ export interface RenderDocumentRef {
584
+ kind: string;
585
+ name: string;
586
+ /** Omit (or pass `null`) to match a cluster-scoped / namespace-less document. */
587
+ namespace?: string | null;
588
+ }
589
+
590
+ /**
591
+ * Resolve one document by kind/namespace/name to its exact bytes inside a
592
+ * stored render, via the manifest's document index. The slice is verified
593
+ * against the entry's per-document digest before being returned — corrupt
594
+ * content is an error, never silently wrong bytes. `undefined` means the
595
+ * store has no such render or the index has no such document.
596
+ */
597
+ export function readRenderDocument(
598
+ contentDigest: string,
599
+ ref: RenderDocumentRef,
600
+ opts?: { root?: string },
601
+ ): { entry: RenderDocumentEntry; text: string } | undefined {
602
+ const root = opts?.root ?? renderStoreRoot();
603
+ const manifest = loadRenderManifest(contentDigest, { root });
604
+ if (!manifest) return undefined;
605
+ const wantNs = ref.namespace ?? null;
606
+ const entry = manifest.documents.find(
607
+ (d) => d.kind === ref.kind && d.name === ref.name && d.namespace === wantNs,
608
+ );
609
+ if (!entry) return undefined;
610
+ const content = loadRenderContent(contentDigest, { root });
611
+ if (content === undefined) return undefined;
612
+ const bytes = Buffer.from(content, "utf8").subarray(entry.start, entry.start + entry.length);
613
+ const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
614
+ if (digest !== entry.digest) {
615
+ throw new Error(
616
+ `stored render ${contentDigest} is corrupt: document ${entry.kind}/${entry.name} ` +
617
+ `digests to ${digest}, manifest says ${entry.digest}`,
618
+ );
619
+ }
620
+ return { entry, text: bytes.toString("utf8") };
621
+ }