@intentius/chant-lexicon-helm 0.45.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,259 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { canonicalizeRender } from "./render-digest";
8
+ import { persistHelmRender } from "./render-store";
9
+ import { routeRender, routeStoredRender, type RoutedRender } from "./render-wrapper";
10
+ import { splitDocuments } from "./pinnability/render-stream";
11
+
12
+ const FIXTURES = join(import.meta.dirname, "..", "test", "survey", "fixtures");
13
+
14
+ function helmAvailable(): boolean {
15
+ try {
16
+ execFileSync("helm", ["version"], { stdio: "ignore" });
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ const hasHelm = helmAvailable();
24
+
25
+ function renderFixture(chartDir: string): string {
26
+ return execFileSync(
27
+ "helm",
28
+ ["template", "rel", chartDir, "--include-crds", "--kube-version", "1.33.6"],
29
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 },
30
+ );
31
+ }
32
+
33
+ /** Every routed document must be byte-identical to a document of the input stream. */
34
+ function expectByteIdentity(routed: RoutedRender, input: string): void {
35
+ const inputDocs = splitDocuments(input);
36
+ for (const doc of [...routed.crds, ...routed.hooks, ...routed.main]) {
37
+ expect(inputDocs).toContain(doc.text);
38
+ }
39
+ }
40
+
41
+ function allRoutedTexts(routed: RoutedRender): string[] {
42
+ return [...routed.crds, ...routed.hooks, ...routed.main].map((d) => d.text);
43
+ }
44
+
45
+ // ── pure routing over literal streams ─────────────────────────────────────
46
+
47
+ const crdDoc = (source: string, name: string, kind: string) =>
48
+ [
49
+ `# Source: umb/${source}`,
50
+ "apiVersion: apiextensions.k8s.io/v1",
51
+ "kind: CustomResourceDefinition",
52
+ "metadata:",
53
+ ` name: ${name}`,
54
+ "spec:",
55
+ " group: example.com",
56
+ " names:",
57
+ ` kind: ${kind}`,
58
+ ].join("\n");
59
+
60
+ describe("routeRender", () => {
61
+ test("routes by crds/ segment, hook annotation, and default main; inherits chart identity", () => {
62
+ const stream = [
63
+ "---",
64
+ crdDoc("crds/parentcrd.yaml", "parentthings.example.com", "ParentThing"),
65
+ "---",
66
+ crdDoc("charts/kid/crds/kidcrd.yaml", "kidthings.example.com", "KidThing"),
67
+ "---",
68
+ "# Source: umb/templates/hook-job.yaml",
69
+ "apiVersion: batch/v1",
70
+ "kind: Job",
71
+ "metadata:",
72
+ " name: setup",
73
+ " annotations:",
74
+ ' "helm.sh/hook": pre-install',
75
+ "---",
76
+ "# Source: umb/templates/cm.yaml",
77
+ "apiVersion: v1",
78
+ "kind: ConfigMap",
79
+ "metadata:",
80
+ " name: cm",
81
+ ].join("\n");
82
+ const routed = routeRender(stream, { chart: "umb", chartVersion: "0.1.0" });
83
+
84
+ expect(routed.chart).toBe("umb");
85
+ expect(routed.chartVersion).toBe("0.1.0");
86
+ expect(routed.crds.map((d) => d.source)).toEqual([
87
+ "crds/parentcrd.yaml",
88
+ "charts/kid/crds/kidcrd.yaml", // segment rule: a prefix match would miss this one
89
+ ]);
90
+ expect(routed.hooks.map((d) => d.kind)).toEqual(["Job"]);
91
+ expect(routed.main.map((d) => d.kind)).toEqual(["ConfigMap"]);
92
+ expect(routed.warnings).toEqual([]);
93
+ expectByteIdentity(routed, stream);
94
+ });
95
+
96
+ test("deduplicates aliased-dependency CRDs by (group, kind): first wins, warning names both sources", () => {
97
+ const stream = [
98
+ "---",
99
+ crdDoc("charts/kid/crds/kidcrd.yaml", "kidthings.example.com", "KidThing"),
100
+ "---",
101
+ crdDoc("charts/kidtwo/crds/kidcrd.yaml", "kidthings.example.com", "KidThing"),
102
+ ].join("\n");
103
+ const routed = routeRender(stream, { chart: "umb" });
104
+
105
+ expect(routed.crds).toHaveLength(1);
106
+ expect(routed.crds[0].source).toBe("charts/kid/crds/kidcrd.yaml");
107
+ expect(routed.warnings).toHaveLength(1);
108
+ expect(routed.warnings[0].code).toBe("duplicate-crd");
109
+ expect(routed.warnings[0].message).toContain("charts/kid/crds/kidcrd.yaml");
110
+ expect(routed.warnings[0].message).toContain("charts/kidtwo/crds/kidcrd.yaml");
111
+ expect(routed.warnings[0].message).toContain("example.com/KidThing");
112
+ });
113
+
114
+ test("distinct (group, kind) CRDs are never deduplicated", () => {
115
+ const stream = [
116
+ "---",
117
+ crdDoc("crds/a.yaml", "parentthings.example.com", "ParentThing"),
118
+ "---",
119
+ crdDoc("charts/kid/crds/b.yaml", "kidthings.example.com", "KidThing"),
120
+ ].join("\n");
121
+ const routed = routeRender(stream, { chart: "umb" });
122
+ expect(routed.crds).toHaveLength(2);
123
+ expect(routed.warnings).toEqual([]);
124
+ });
125
+
126
+ test("a crds/ document routing cannot identify is kept, never deduplicated", () => {
127
+ // Missing spec.names.kind — no (group, kind) key, so both survive.
128
+ const doc = [
129
+ "# Source: umb/crds/odd.yaml",
130
+ "apiVersion: apiextensions.k8s.io/v1",
131
+ "kind: CustomResourceDefinition",
132
+ "metadata:",
133
+ " name: oddthings.example.com",
134
+ ].join("\n");
135
+ const routed = routeRender(["---", doc, "---", doc].join("\n"), { chart: "umb" });
136
+ expect(routed.crds).toHaveLength(2);
137
+ expect(routed.warnings).toEqual([]);
138
+ });
139
+
140
+ test("template-rendered CRD stays in main per the segment rule, with a note", () => {
141
+ const stream = [
142
+ "---",
143
+ "# Source: umb/templates/crd.yaml",
144
+ "apiVersion: apiextensions.k8s.io/v1",
145
+ "kind: CustomResourceDefinition",
146
+ "metadata:",
147
+ " name: templated.example.com",
148
+ "spec:",
149
+ " group: example.com",
150
+ " names:",
151
+ " kind: Templated",
152
+ ].join("\n");
153
+ const routed = routeRender(stream, { chart: "umb" });
154
+
155
+ expect(routed.crds).toEqual([]);
156
+ expect(routed.main).toHaveLength(1);
157
+ expect(routed.main[0].kind).toBe("CustomResourceDefinition");
158
+ expect(routed.warnings).toHaveLength(1);
159
+ expect(routed.warnings[0].code).toBe("template-crd");
160
+ expect(routed.warnings[0].message).toContain("templated.example.com");
161
+ expect(routed.warnings[0].message).toContain("templates/crd.yaml");
162
+ });
163
+
164
+ test("unparseable and source-less documents route to main verbatim", () => {
165
+ const stream = [
166
+ "---",
167
+ "not: [valid: yaml",
168
+ "---",
169
+ "apiVersion: v1",
170
+ "kind: ConfigMap",
171
+ "metadata:",
172
+ " name: bare",
173
+ ].join("\n");
174
+ const routed = routeRender(stream, { chart: "umb" });
175
+ expect(routed.main).toHaveLength(2);
176
+ expect(routed.main[0]).toMatchObject({ source: null, kind: null, name: null });
177
+ expect(routed.main[1]).toMatchObject({ source: null, kind: "ConfigMap", name: "bare" });
178
+ expectByteIdentity(routed, stream);
179
+ });
180
+ });
181
+
182
+ // ── fixture routing (helm on PATH, offline) ───────────────────────────────
183
+
184
+ describe.skipIf(!hasHelm)("routeRender over rendered fixtures", () => {
185
+ test("umbrella fixture: groups correct, aliased duplicate deduped with both sources named, bytes identical", () => {
186
+ const canonical = canonicalizeRender(renderFixture(join(FIXTURES, "umbrella-fixture")));
187
+ const routed = routeRender(canonical, { chart: "umbrella-fixture", chartVersion: "0.1.0" });
188
+
189
+ // 3 CRD documents rendered (parent + kid + kidtwo), 2 distinct CRDs kept.
190
+ expect(routed.crds.map((d) => d.name).sort()).toEqual([
191
+ "kidthings.example.com",
192
+ "parentthings.example.com",
193
+ ]);
194
+ expect(routed.crds.map((d) => d.source)).toContain("crds/parentcrd.yaml");
195
+ expect(routed.hooks).toEqual([]);
196
+ // Parent cm + kid cm + kidtwo cm (opt is disabled by default).
197
+ expect(routed.main.map((d) => d.kind)).toEqual(["ConfigMap", "ConfigMap", "ConfigMap"]);
198
+
199
+ expect(routed.warnings).toHaveLength(1);
200
+ expect(routed.warnings[0].code).toBe("duplicate-crd");
201
+ expect(routed.warnings[0].message).toContain("charts/kid/crds/kidcrd.yaml");
202
+ expect(routed.warnings[0].message).toContain("charts/kidtwo/crds/kidcrd.yaml");
203
+
204
+ // Structure preservation: every routed document is byte-identical to a
205
+ // canonical input document, and the groups partition the input minus
206
+ // exactly the one deduplicated duplicate.
207
+ expectByteIdentity(routed, canonical);
208
+ const inputDocs = splitDocuments(canonical);
209
+ expect(allRoutedTexts(routed)).toHaveLength(inputDocs.length - 1);
210
+
211
+ // Wrapper inherits the source chart's identity (epic Decisions).
212
+ expect(routed.chart).toBe("umbrella-fixture");
213
+ expect(routed.chartVersion).toBe("0.1.0");
214
+ });
215
+
216
+ test("hook fixture: hook-annotated Job routes to its own group, annotations intact", () => {
217
+ const canonical = canonicalizeRender(renderFixture(join(FIXTURES, "hook-fixture")));
218
+ const routed = routeRender(canonical, { chart: "hook-fixture", chartVersion: "0.1.0" });
219
+
220
+ expect(routed.hooks).toHaveLength(1);
221
+ expect(routed.hooks[0].kind).toBe("Job");
222
+ expect(routed.hooks[0].text).toContain("helm.sh/hook");
223
+ expect(routed.hooks[0].text).toContain("pre-install");
224
+ expect(routed.hooks[0].text).toContain("helm.sh/hook-delete-policy");
225
+ expect(routed.crds).toEqual([]);
226
+ expect(routed.main.map((d) => d.kind)).toEqual(["ConfigMap"]);
227
+ expect(routed.warnings).toEqual([]);
228
+ expectByteIdentity(routed, canonical);
229
+ });
230
+
231
+ test("routeStoredRender routes a stored render by contentDigest, inheriting the manifest's chart identity", () => {
232
+ const root = mkdtempSync(join(tmpdir(), "chant-render-wrapper-"));
233
+ const rendered = renderFixture(join(FIXTURES, "umbrella-fixture"));
234
+ const { manifest } = persistHelmRender({
235
+ rendered,
236
+ releaseName: "rel",
237
+ chart: "umbrella-fixture",
238
+ chartVersion: "0.1.0",
239
+ capabilityProfile: { name: "test", kubeVersion: "1.33.6", apiVersions: [] },
240
+ root,
241
+ });
242
+
243
+ const routed = routeStoredRender(manifest.contentDigest, { root });
244
+ expect(routed).toBeDefined();
245
+ expect(routed?.chart).toBe("umbrella-fixture");
246
+ expect(routed?.chartVersion).toBe("0.1.0");
247
+ expect(routed?.crds.map((d) => d.name).sort()).toEqual([
248
+ "kidthings.example.com",
249
+ "parentthings.example.com",
250
+ ]);
251
+ // The routed bytes are the stored bytes: identical to routing the
252
+ // canonical stream directly.
253
+ expect(routed).toEqual(
254
+ routeRender(canonicalizeRender(rendered), { chart: "umbrella-fixture", chartVersion: "0.1.0" }),
255
+ );
256
+
257
+ expect(routeStoredRender(`sha256:${"0".repeat(64)}`, { root })).toBeUndefined();
258
+ });
259
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Structure-preserving render wrapper (#1239, epic #1228 Phase 3).
3
+ *
4
+ * Turns a pinned render's document stream into chant-shaped routing groups
5
+ * WITHOUT restructuring the documents. Structure-preserving means the bytes
6
+ * that deploy are the bytes the render store recorded — this module only
7
+ * routes and labels; it never re-serializes, reorders lines, or edits a
8
+ * document. Each `RoutedDocument.text` is the exact document text from the
9
+ * input stream.
10
+ *
11
+ * Routing is by origin, not by kind. `helm template` stamps every document
12
+ * with `# Source: <chart>/<path>`, and the discriminator is whether that
13
+ * path contains a `crds/` **segment** (`(^|/)crds/` — `isCrdSource` from
14
+ * the #1234 classifier seeds):
15
+ *
16
+ * - A `crds/`-segment origin routes to the CRD group — the wrapper's
17
+ * `crds/`. Segment matching is load-bearing: a subchart's CRDs arrive as
18
+ * `charts/<child>/crds/<file>`, so a prefix rule catches none of them and
19
+ * would route every subchart CRD into `templates/`, making it deletable
20
+ * on uninstall (epic findings 4, 10 — the A/B test showed a flat wrap
21
+ * converts `helm uninstall` from safe to data-destroying).
22
+ * - A hook-annotated template document (`helm.sh/hook` in
23
+ * `metadata.annotations`) routes to its own group, annotations intact.
24
+ * Hooks survive pinning (epic finding 5); they are surfaced separately so
25
+ * the install path can account for them, never silently dropped.
26
+ * - Everything else routes to the main group — the wrapper's `templates/`.
27
+ * A template-rendered `CustomResourceDefinition` stays here, because the
28
+ * `crds/`-segment is the discriminator, not the kind: a chart that
29
+ * templates its CRDs opted into template lifecycle semantics (upgrades
30
+ * and deletes apply to them), and moving them to `crds/` would change
31
+ * that. The routing notes it with a warning so the choice is visible.
32
+ *
33
+ * Aliased dependencies emit duplicate CRDs (epic finding 11): the same
34
+ * dependency included twice under an alias renders its `crds/` once per
35
+ * instance — two documents, one distinct CRD. The stored artifact keeps
36
+ * both (canonicalization is order- and duplicate-preserving), but a wrapper
37
+ * chart's `crds/` holds each CRD once, so the routed CRD group deduplicates
38
+ * by `(group, kind)`: the first occurrence wins and a warning names both
39
+ * sources. A CRD document that cannot be identified (unparseable, or
40
+ * missing `spec.group` / `spec.names.kind`) is never deduplicated — the
41
+ * routing must not silently drop what it cannot identify.
42
+ *
43
+ * The wrapper inherits the source chart's name and version (epic
44
+ * Decisions), keeping `helm history` continuous across the pinned
45
+ * migration; `RoutedRender.chart` / `chartVersion` carry them.
46
+ *
47
+ * This is the seam the pinned-install path (#1242) consumes:
48
+ * `routeStoredRender(contentDigest)` loads a stored render's canonical
49
+ * bytes and manifest and routes them.
50
+ */
51
+
52
+ import yaml from "js-yaml";
53
+
54
+ import { isCrdSource, sourcePath, splitDocuments } from "./pinnability/render-stream";
55
+ import { loadRenderContent, loadRenderManifest } from "./render-store";
56
+
57
+ // ── routed shapes ─────────────────────────────────────────────────────────
58
+
59
+ /** One routed document: exact bytes plus the labels routing derived from them. */
60
+ export interface RoutedDocument {
61
+ /**
62
+ * The document's exact text from the input stream (from just past the
63
+ * `---` separator, trimmed of surrounding blank lines — the same split
64
+ * `splitDocuments` performs). Never re-serialized: what deploys is what
65
+ * was recorded.
66
+ */
67
+ text: string;
68
+ /**
69
+ * Chart-relative `# Source:` origin path (e.g. `crds/a.yaml`,
70
+ * `charts/kid/crds/kidcrd.yaml`), or `null` when the document carries no
71
+ * source header.
72
+ */
73
+ source: string | null;
74
+ /** Kubernetes kind, or `null` when the document does not parse to a kinded object. */
75
+ kind: string | null;
76
+ /** apiVersion as rendered, or `null`. */
77
+ apiVersion: string | null;
78
+ /** `metadata.name`, or `null`. */
79
+ name: string | null;
80
+ }
81
+
82
+ /** Machine-readable warning categories the routing emits. */
83
+ export type RoutedRenderWarningCode = "duplicate-crd" | "template-crd";
84
+
85
+ export interface RoutedRenderWarning {
86
+ /**
87
+ * - `duplicate-crd` — an aliased dependency emitted the same CRD twice;
88
+ * the first occurrence was kept, the duplicate dropped. The message
89
+ * names both sources.
90
+ * - `template-crd` — a `CustomResourceDefinition` was rendered from
91
+ * `templates/` (no `crds/` segment in its origin). It stays in the main
92
+ * group per the segment rule; this note makes the lifecycle choice
93
+ * visible.
94
+ */
95
+ code: RoutedRenderWarningCode;
96
+ message: string;
97
+ }
98
+
99
+ /**
100
+ * A pinned render routed into the structure the wrapper chart preserves.
101
+ * The document groups partition the input stream (minus deduplicated CRD
102
+ * duplicates, each named in `warnings`); no document is ever silently
103
+ * dropped and no document text is ever altered.
104
+ */
105
+ export interface RoutedRender {
106
+ /**
107
+ * Wrapper chart name — inherited from the source chart, so `helm history`
108
+ * stays continuous across the pinned migration (epic Decisions).
109
+ */
110
+ chart: string;
111
+ /** Wrapper chart version — inherited from the source chart; `null` for a local chart rendered without one. */
112
+ chartVersion: string | null;
113
+ /**
114
+ * Documents whose origin has a `crds/` segment — the wrapper's `crds/`,
115
+ * which is what keeps `helm uninstall` from deleting them (epic finding
116
+ * 4). Deduplicated by `(group, kind)`, first occurrence winning.
117
+ */
118
+ crds: RoutedDocument[];
119
+ /**
120
+ * Hook-annotated template documents (`helm.sh/hook`), annotations intact.
121
+ * Routed as their own group so the install path can account for them —
122
+ * never silently dropped (epic finding 5).
123
+ */
124
+ hooks: RoutedDocument[];
125
+ /** Everything else — the wrapper's `templates/`. */
126
+ main: RoutedDocument[];
127
+ /** What routing had to decide beyond the mechanical rule: dropped duplicates, template-rendered CRDs. */
128
+ warnings: RoutedRenderWarning[];
129
+ }
130
+
131
+ // ── routing ───────────────────────────────────────────────────────────────
132
+
133
+ interface ParsedDoc {
134
+ kind: string | null;
135
+ apiVersion: string | null;
136
+ name: string | null;
137
+ hook: boolean;
138
+ crdKey: string | null;
139
+ }
140
+
141
+ const CRD_KIND = "CustomResourceDefinition";
142
+
143
+ /**
144
+ * Parse a document just far enough to label it. Parsing is for labels only —
145
+ * the routed text is always the original bytes. An unparseable document
146
+ * labels as `null`s and routes to the main group.
147
+ */
148
+ function parseForLabels(doc: string): ParsedDoc {
149
+ let parsed: unknown;
150
+ try {
151
+ parsed = yaml.load(doc);
152
+ } catch {
153
+ return { kind: null, apiVersion: null, name: null, hook: false, crdKey: null };
154
+ }
155
+ if (parsed === null || typeof parsed !== "object") {
156
+ return { kind: null, apiVersion: null, name: null, hook: false, crdKey: null };
157
+ }
158
+ const obj = parsed as {
159
+ kind?: unknown;
160
+ apiVersion?: unknown;
161
+ metadata?: { name?: unknown; annotations?: Record<string, unknown> };
162
+ spec?: { group?: unknown; names?: { kind?: unknown } };
163
+ };
164
+ const kind = typeof obj.kind === "string" ? obj.kind : null;
165
+ const annotations = obj.metadata?.annotations;
166
+ const hook =
167
+ annotations !== null &&
168
+ typeof annotations === "object" &&
169
+ Object.prototype.hasOwnProperty.call(annotations, "helm.sh/hook");
170
+ let crdKey: string | null = null;
171
+ if (kind === CRD_KIND) {
172
+ const group = obj.spec?.group;
173
+ const namesKind = obj.spec?.names?.kind;
174
+ if (typeof group === "string" && typeof namesKind === "string") {
175
+ crdKey = `${group}/${namesKind}`;
176
+ }
177
+ }
178
+ return {
179
+ kind,
180
+ apiVersion: typeof obj.apiVersion === "string" ? obj.apiVersion : null,
181
+ name: typeof obj.metadata?.name === "string" ? obj.metadata.name : null,
182
+ hook,
183
+ crdKey,
184
+ };
185
+ }
186
+
187
+ export interface RouteRenderOptions {
188
+ /** Source chart name — becomes the wrapper's name. */
189
+ chart: string;
190
+ /** Source chart version — becomes the wrapper's version. */
191
+ chartVersion?: string | null;
192
+ }
193
+
194
+ /**
195
+ * Route a rendered stream (canonical or raw `helm template` output) into
196
+ * `RoutedRender` groups. Pure over the stream: no store access, no helm.
197
+ */
198
+ export function routeRender(rendered: string, opts: RouteRenderOptions): RoutedRender {
199
+ const routed: RoutedRender = {
200
+ chart: opts.chart,
201
+ chartVersion: opts.chartVersion ?? null,
202
+ crds: [],
203
+ hooks: [],
204
+ main: [],
205
+ warnings: [],
206
+ };
207
+ const firstCrdByKey = new Map<string, RoutedDocument>();
208
+
209
+ for (const text of splitDocuments(rendered)) {
210
+ const source = sourcePath(text) ?? null;
211
+ const labels = parseForLabels(text);
212
+ const doc: RoutedDocument = {
213
+ text,
214
+ source,
215
+ kind: labels.kind,
216
+ apiVersion: labels.apiVersion,
217
+ name: labels.name,
218
+ };
219
+
220
+ if (source !== null && isCrdSource(source)) {
221
+ if (labels.crdKey !== null) {
222
+ const first = firstCrdByKey.get(labels.crdKey);
223
+ if (first) {
224
+ routed.warnings.push({
225
+ code: "duplicate-crd",
226
+ message:
227
+ `duplicate CRD ${labels.crdKey}: keeping ${first.source ?? "<no source>"}, ` +
228
+ `dropping ${source} (an aliased dependency emits the same CRD once per instance; ` +
229
+ `a wrapper chart's crds/ holds it once, matching what helm installs)`,
230
+ });
231
+ continue;
232
+ }
233
+ firstCrdByKey.set(labels.crdKey, doc);
234
+ }
235
+ routed.crds.push(doc);
236
+ continue;
237
+ }
238
+
239
+ if (labels.kind === CRD_KIND) {
240
+ // Template-rendered CRD: the crds/-segment is the discriminator, not
241
+ // the kind. It stays in its rendered group with template lifecycle
242
+ // semantics — noted, never moved.
243
+ routed.warnings.push({
244
+ code: "template-crd",
245
+ message:
246
+ `CustomResourceDefinition ${labels.name ?? "<unnamed>"} is template-rendered ` +
247
+ `(source: ${source ?? "<none>"}), not shipped in crds/ — it stays in the main set ` +
248
+ `with template lifecycle semantics: helm upgrades and deletes will apply to it`,
249
+ });
250
+ }
251
+
252
+ (labels.hook ? routed.hooks : routed.main).push(doc);
253
+ }
254
+
255
+ return routed;
256
+ }
257
+
258
+ /**
259
+ * Route a stored pinned render by its `contentDigest`: loads the canonical
260
+ * bytes and the `RenderManifest` from the render store (#1238) and routes
261
+ * them, inheriting the manifest's chart name and version. This is the seam
262
+ * the pinned-install path (#1242) consumes. `undefined` when the store has
263
+ * no such entry.
264
+ */
265
+ export function routeStoredRender(
266
+ contentDigest: string,
267
+ opts?: { root?: string },
268
+ ): RoutedRender | undefined {
269
+ const manifest = loadRenderManifest(contentDigest, opts);
270
+ if (!manifest) return undefined;
271
+ const content = loadRenderContent(contentDigest, opts);
272
+ if (content === undefined) return undefined;
273
+ return routeRender(content, { chart: manifest.chart, chartVersion: manifest.chartVersion });
274
+ }