@intentius/chant-lexicon-helm 0.0.24 → 0.1.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * WHM004: HelmTpl Expression Has No Effect in Values Constructor
3
+ *
4
+ * Detects Values constructor props that use `v.xxx` (the `values` proxy)
5
+ * or any HelmTpl-like expression. values.yaml is static YAML — it is NOT
6
+ * processed as a Go template by Helm. These expressions silently become ''.
7
+ *
8
+ * Bad: new Values({ host: v.pgHost })
9
+ * Good: new Values({ host: runtimeSlot("Cloud SQL private IP") })
10
+ */
11
+
12
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
13
+ import * as ts from "typescript";
14
+
15
+ export const valuesNoHelmTplRule: LintRule = {
16
+ id: "WHM004",
17
+ severity: "warning",
18
+ category: "correctness",
19
+ description:
20
+ "HelmTpl expression has no effect in values.yaml — use runtimeSlot() for deploy-time values",
21
+
22
+ check(context: LintContext): LintDiagnostic[] {
23
+ const { sourceFile } = context;
24
+ const diagnostics: LintDiagnostic[] = [];
25
+
26
+ function visit(node: ts.Node): void {
27
+ if (
28
+ ts.isNewExpression(node) &&
29
+ ts.isIdentifier(node.expression) &&
30
+ node.expression.text === "Values" &&
31
+ node.arguments &&
32
+ node.arguments.length > 0
33
+ ) {
34
+ const arg = node.arguments[0];
35
+ if (ts.isObjectLiteralExpression(arg)) {
36
+ checkObjectLiteral(arg, [], sourceFile, diagnostics);
37
+ }
38
+ }
39
+ ts.forEachChild(node, visit);
40
+ }
41
+
42
+ visit(sourceFile);
43
+ return diagnostics;
44
+ },
45
+ };
46
+
47
+ /**
48
+ * Get the root identifier name of a property access / call chain.
49
+ * v.foo → "v"; values.x.pipe("fn") → "values"; runtimeSlot() → "runtimeSlot"
50
+ */
51
+ function getRootIdentifier(node: ts.Node): string | null {
52
+ if (ts.isIdentifier(node)) return node.text;
53
+ if (ts.isPropertyAccessExpression(node)) return getRootIdentifier(node.expression);
54
+ if (ts.isCallExpression(node)) return getRootIdentifier(node.expression);
55
+ return null;
56
+ }
57
+
58
+ function isHelmTplExpr(node: ts.Node): boolean {
59
+ const root = getRootIdentifier(node);
60
+ return root === "v" || root === "values";
61
+ }
62
+
63
+ function checkObjectLiteral(
64
+ obj: ts.ObjectLiteralExpression,
65
+ path: string[],
66
+ sourceFile: ts.SourceFile,
67
+ diagnostics: LintDiagnostic[],
68
+ ): void {
69
+ for (const prop of obj.properties) {
70
+ if (!ts.isPropertyAssignment(prop)) continue;
71
+
72
+ const keyName = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
73
+ ? prop.name.text
74
+ : undefined;
75
+ const propPath = keyName ? [...path, keyName] : path;
76
+
77
+ if (isHelmTplExpr(prop.initializer)) {
78
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(prop.getStart());
79
+ const pathStr = propPath.join(".");
80
+ diagnostics.push({
81
+ file: sourceFile.fileName,
82
+ line: line + 1,
83
+ column: character + 1,
84
+ ruleId: "WHM004",
85
+ severity: "warning",
86
+ message: `HelmTpl expression has no effect in values.yaml (values.yaml is not a Go template). Use runtimeSlot() for deploy-time values or a static default.${pathStr ? ` (path: ${pathStr})` : ""}`,
87
+ });
88
+ } else if (ts.isObjectLiteralExpression(prop.initializer)) {
89
+ checkObjectLiteral(prop.initializer, propPath, sourceFile, diagnostics);
90
+ }
91
+ }
92
+ }
@@ -28,17 +28,19 @@ describe("helmPlugin", () => {
28
28
 
29
29
  test("provides lint rules", () => {
30
30
  const rules = helmPlugin.lintRules!();
31
- expect(rules.length).toBe(3);
31
+ expect(rules.length).toBe(4);
32
32
  const ids = rules.map((r) => r.id);
33
33
  expect(ids).toContain("WHM001");
34
34
  expect(ids).toContain("WHM002");
35
35
  expect(ids).toContain("WHM003");
36
+ expect(ids).toContain("WHM004");
36
37
  });
37
38
 
38
39
  test("provides post-synth checks", () => {
39
40
  const checks = helmPlugin.postSynthChecks!();
40
- expect(checks.length).toBe(20);
41
+ expect(checks.length).toBe(21);
41
42
  const ids = checks.map((c) => c.id);
43
+ expect(ids).toContain("WHM005");
42
44
  expect(ids).toContain("WHM101");
43
45
  expect(ids).toContain("WHM105");
44
46
  expect(ids).toContain("WHM301");
package/src/resources.ts CHANGED
@@ -67,6 +67,26 @@ export const HelmDependency = createProperty("Helm::Dependency", LEXICON);
67
67
  */
68
68
  export const HelmMaintainer = createProperty("Helm::Maintainer", LEXICON);
69
69
 
70
+ /**
71
+ * Helm::ValuesOverride — Generate a named YAML override file.
72
+ *
73
+ * Emits a standalone YAML file inside the chart directory, intended to be
74
+ * passed as `helm upgrade -f chart-dir/values-base.yaml`. Use this for
75
+ * static configuration shared across all deployments (disabled bundled
76
+ * services, shared secret references, etc.).
77
+ *
78
+ * Props: { filename: string, values: Record<string, unknown> }
79
+ *
80
+ * ```ts
81
+ * new ValuesOverride({
82
+ * filename: "values-base",
83
+ * values: { postgresql: { install: false }, redis: { install: false } },
84
+ * })
85
+ * // → generates chart-dir/values-base.yaml
86
+ * ```
87
+ */
88
+ export const ValuesOverride = createResource("Helm::ValuesOverride", LEXICON, {});
89
+
70
90
  // ── CRD ──────────────────────────────────────────────────
71
91
 
72
92
  /**
@@ -3,8 +3,8 @@ import { createResource, createProperty } from "@intentius/chant/runtime";
3
3
  import type { Declarable } from "@intentius/chant/declarable";
4
4
  import type { SerializerResult } from "@intentius/chant/serializer";
5
5
  import { helmSerializer } from "./serializer";
6
- import { Chart, Values, HelmNotes, HelmTest, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources";
7
- import { values, include, printf, toYaml, quote, helmDefault, required, If, ElseIf, Range, With, Release, ChartRef, Capabilities } from "./intrinsics";
6
+ import { Chart, Values, ValuesOverride, HelmNotes, HelmTest, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources";
7
+ import { values, include, printf, toYaml, quote, helmDefault, required, If, ElseIf, Range, With, Release, ChartRef, Capabilities, runtimeSlot } from "./intrinsics";
8
8
 
9
9
  function makeEntities(...pairs: [string, Record<string, unknown>][]): Map<string, Declarable> {
10
10
  const entities = new Map<string, Declarable>();
@@ -792,6 +792,117 @@ describe("Capabilities in template", () => {
792
792
  });
793
793
  });
794
794
 
795
+ describe("runtimeSlot in Values", () => {
796
+ test("emits '' for runtimeSlot in values.yaml", () => {
797
+ const chart = new Chart({ name: "test", version: "0.1.0" });
798
+ const vals = new Values({
799
+ global: {
800
+ psql: { host: runtimeSlot("Cloud SQL private IP") },
801
+ },
802
+ replicaCount: 1,
803
+ });
804
+ const entities = makeEntities(["chart", chart], ["values", vals]);
805
+ const result = helmSerializer.serialize(entities) as SerializerResult;
806
+ const valuesYaml = result.files!["values.yaml"];
807
+
808
+ expect(valuesYaml).toContain("host: ''");
809
+ expect(valuesYaml).toContain("replicaCount: 1");
810
+ });
811
+
812
+ test("emits values-runtime-slots.yaml when RuntimeSlot present", () => {
813
+ const chart = new Chart({ name: "test", version: "0.1.0" });
814
+ const vals = new Values({
815
+ global: {
816
+ psql: { host: runtimeSlot("Cloud SQL private IP") },
817
+ redis: { host: runtimeSlot("Memorystore host") },
818
+ },
819
+ });
820
+ const entities = makeEntities(["chart", chart], ["values", vals]);
821
+ const result = helmSerializer.serialize(entities) as SerializerResult;
822
+ const slotsYaml = result.files!["values-runtime-slots.yaml"];
823
+
824
+ expect(slotsYaml).toBeDefined();
825
+ expect(slotsYaml).toContain("# Generated by chant");
826
+ expect(slotsYaml).toContain("# Cloud SQL private IP");
827
+ expect(slotsYaml).toContain("host: ''");
828
+ expect(slotsYaml).toContain("# Memorystore host");
829
+ // Only RuntimeSlot fields should appear
830
+ expect(slotsYaml).not.toContain("replicaCount");
831
+ });
832
+
833
+ test("does not emit values-runtime-slots.yaml when no RuntimeSlot", () => {
834
+ const chart = new Chart({ name: "test", version: "0.1.0" });
835
+ const vals = new Values({ replicaCount: 1, image: { repository: "nginx" } });
836
+ const entities = makeEntities(["chart", chart], ["values", vals]);
837
+ const result = helmSerializer.serialize(entities) as SerializerResult;
838
+
839
+ expect(result.files!["values-runtime-slots.yaml"]).toBeUndefined();
840
+ });
841
+
842
+ test("runtimeSlot with empty description emits no comment", () => {
843
+ const chart = new Chart({ name: "test", version: "0.1.0" });
844
+ const vals = new Values({ host: runtimeSlot() });
845
+ const entities = makeEntities(["chart", chart], ["values", vals]);
846
+ const result = helmSerializer.serialize(entities) as SerializerResult;
847
+ const slotsYaml = result.files!["values-runtime-slots.yaml"];
848
+
849
+ expect(slotsYaml).toBeDefined();
850
+ expect(slotsYaml).toContain("host: ''");
851
+ // No comment line for empty description
852
+ const lines = slotsYaml!.split("\n");
853
+ const hostLine = lines.findIndex((l) => l.includes("host:"));
854
+ const prevLine = lines[hostLine - 1] ?? "";
855
+ expect(prevLine.trim().startsWith("#") && !prevLine.includes("Generated")).toBe(false);
856
+ });
857
+ });
858
+
859
+ describe("ValuesOverride serialization", () => {
860
+ test("emits ValuesOverride as a named yaml file", () => {
861
+ const chart = new Chart({ name: "test", version: "0.1.0" });
862
+ const override = new ValuesOverride({
863
+ filename: "values-base",
864
+ values: {
865
+ postgresql: { install: false },
866
+ redis: { install: false },
867
+ },
868
+ });
869
+ const entities = makeEntities(["chart", chart], ["baseOverride", override]);
870
+ const result = helmSerializer.serialize(entities) as SerializerResult;
871
+
872
+ expect(result.files!["values-base.yaml"]).toBeDefined();
873
+ expect(result.files!["values-base.yaml"]).toContain("postgresql:");
874
+ expect(result.files!["values-base.yaml"]).toContain("install: false");
875
+ expect(result.files!["values-base.yaml"]).toContain("redis:");
876
+ });
877
+
878
+ test("ValuesOverride content does not affect values.yaml", () => {
879
+ const chart = new Chart({ name: "test", version: "0.1.0" });
880
+ const vals = new Values({ replicaCount: 1 });
881
+ const override = new ValuesOverride({
882
+ filename: "values-base",
883
+ values: { postgresql: { install: false } },
884
+ });
885
+ const entities = makeEntities(["chart", chart], ["values", vals], ["override", override]);
886
+ const result = helmSerializer.serialize(entities) as SerializerResult;
887
+
888
+ expect(result.files!["values.yaml"]).toContain("replicaCount: 1");
889
+ expect(result.files!["values.yaml"]).not.toContain("postgresql");
890
+ expect(result.files!["values-base.yaml"]).toContain("postgresql:");
891
+ expect(result.files!["values-base.yaml"]).not.toContain("replicaCount");
892
+ });
893
+
894
+ test("multiple ValuesOverride entities emit separate files", () => {
895
+ const chart = new Chart({ name: "test", version: "0.1.0" });
896
+ const ov1 = new ValuesOverride({ filename: "values-dev", values: { env: "dev" } });
897
+ const ov2 = new ValuesOverride({ filename: "values-prod", values: { env: "prod" } });
898
+ const entities = makeEntities(["chart", chart], ["dev", ov1], ["prod", ov2]);
899
+ const result = helmSerializer.serialize(entities) as SerializerResult;
900
+
901
+ expect(result.files!["values-dev.yaml"]).toContain("env: dev");
902
+ expect(result.files!["values-prod.yaml"]).toContain("env: prod");
903
+ });
904
+ });
905
+
795
906
  describe("helpers", () => {
796
907
  test("generateHelpers includes all standard templates", () => {
797
908
  const { generateHelpers } = require("./helpers");
package/src/serializer.ts CHANGED
@@ -19,7 +19,7 @@ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
19
19
  import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
20
20
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
21
21
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
22
- import { HELM_TPL_KEY, HELM_IF_KEY, HELM_RANGE_KEY, HELM_WITH_KEY, type HelmConditional } from "./intrinsics";
22
+ import { HELM_TPL_KEY, HELM_IF_KEY, HELM_RANGE_KEY, HELM_WITH_KEY, RUNTIME_SLOT_KEY, type HelmConditional } from "./intrinsics";
23
23
  import { generateHelpers } from "./helpers";
24
24
 
25
25
  // ── GVK resolution for K8s resources ──────────────────────
@@ -96,14 +96,33 @@ function emitElseChain(elseBody: unknown, indent: number): string {
96
96
  /**
97
97
  * Emit a YAML value, detecting __helm_tpl markers and emitting them
98
98
  * as raw Go template expressions.
99
+ *
100
+ * @param valuesContext - When true, HelmTpl intrinsics are emitted as empty
101
+ * string placeholders instead of raw Go template expressions. values.yaml
102
+ * is not processed as a Go template by Helm, so {{ .Values.x }} is invalid
103
+ * there. Actual values are provided via -f override files at deploy time.
99
104
  */
100
- function emitHelmYAML(value: unknown, indent: number): string {
105
+ function emitHelmYAML(value: unknown, indent: number, valuesContext: boolean = false): string {
101
106
  const prefix = " ".repeat(indent);
102
107
 
103
108
  if (value === null || value === undefined) return "null";
104
109
  if (typeof value === "boolean") return value ? "true" : "false";
105
110
  if (typeof value === "number") return String(value);
106
111
 
112
+ // Detect HelmTpl / Intrinsic objects via INTRINSIC_MARKER before string check
113
+ if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
114
+ const tplObj = value as { toJSON(): unknown };
115
+ if (valuesContext) {
116
+ // In values.yaml context: emit empty placeholder — actual value comes from override files
117
+ return "''";
118
+ }
119
+ const json = tplObj.toJSON() as Record<string, unknown>;
120
+ if (typeof json === "object" && json !== null && HELM_TPL_KEY in json) {
121
+ return json[HELM_TPL_KEY] as string;
122
+ }
123
+ return "''";
124
+ }
125
+
107
126
  if (typeof value === "string") {
108
127
  // Check if this is a raw template expression that was already inlined
109
128
  if (value.startsWith("{{") && value.endsWith("}}")) {
@@ -131,7 +150,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
131
150
  const entries = Object.entries(item as Record<string, unknown>);
132
151
  if (entries.length > 0) {
133
152
  const [firstKey, firstVal] = entries[0];
134
- const firstEmitted = emitHelmYAML(firstVal, indent + 2);
153
+ const firstEmitted = emitHelmYAML(firstVal, indent + 2, valuesContext);
135
154
  if (firstEmitted.startsWith("\n")) {
136
155
  lines.push(`${prefix}- ${firstKey}:${firstEmitted}`);
137
156
  } else {
@@ -139,7 +158,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
139
158
  }
140
159
  for (let i = 1; i < entries.length; i++) {
141
160
  const [key, val] = entries[i];
142
- const emitted = emitHelmYAML(val, indent + 2);
161
+ const emitted = emitHelmYAML(val, indent + 2, valuesContext);
143
162
  if (emitted.startsWith("\n")) {
144
163
  lines.push(`${prefix} ${key}:${emitted}`);
145
164
  } else {
@@ -148,7 +167,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
148
167
  }
149
168
  }
150
169
  } else {
151
- lines.push(`${prefix}- ${emitHelmYAML(item, indent + 1).trimStart()}`);
170
+ lines.push(`${prefix}- ${emitHelmYAML(item, indent + 1, valuesContext).trimStart()}`);
152
171
  }
153
172
  }
154
173
  return "\n" + lines.join("\n");
@@ -157,8 +176,9 @@ function emitHelmYAML(value: unknown, indent: number): string {
157
176
  if (typeof value === "object") {
158
177
  const obj = value as Record<string, unknown>;
159
178
 
160
- // Detect __helm_tpl marker → emit raw template expression
179
+ // Detect __helm_tpl marker → emit raw template expression (not valid in valuesContext)
161
180
  if (HELM_TPL_KEY in obj && typeof obj[HELM_TPL_KEY] === "string") {
181
+ if (valuesContext) return "''";
162
182
  return obj[HELM_TPL_KEY] as string;
163
183
  }
164
184
 
@@ -167,7 +187,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
167
187
  const condition = obj[HELM_IF_KEY] as string;
168
188
  const body = obj.body;
169
189
  const elseBody = obj.else;
170
- let result = `{{- if ${condition} }}\n${emitHelmYAML(body, indent)}`;
190
+ let result = `{{- if ${condition} }}\n${emitHelmYAML(body, indent, valuesContext)}`;
171
191
  if (elseBody !== undefined) {
172
192
  result += emitElseChain(elseBody, indent);
173
193
  }
@@ -179,21 +199,21 @@ function emitHelmYAML(value: unknown, indent: number): string {
179
199
  if (HELM_RANGE_KEY in obj) {
180
200
  const list = obj[HELM_RANGE_KEY] as string;
181
201
  const body = obj.body;
182
- return `{{- range ${list} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
202
+ return `{{- range ${list} }}\n${emitHelmYAML(body, indent, valuesContext)}\n{{- end }}`;
183
203
  }
184
204
 
185
205
  // Detect __helm_with marker → emit with scope
186
206
  if (HELM_WITH_KEY in obj) {
187
207
  const scope = obj[HELM_WITH_KEY] as string;
188
208
  const body = obj.body;
189
- return `{{- with ${scope} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
209
+ return `{{- with ${scope} }}\n${emitHelmYAML(body, indent, valuesContext)}\n{{- end }}`;
190
210
  }
191
211
 
192
212
  const entries = Object.entries(obj);
193
213
  if (entries.length === 0) return "{}";
194
214
  const lines: string[] = [];
195
215
  for (const [key, val] of entries) {
196
- const emitted = emitHelmYAML(val, indent + 1);
216
+ const emitted = emitHelmYAML(val, indent + 1, valuesContext);
197
217
  if (emitted.startsWith("\n")) {
198
218
  lines.push(`${prefix}${key}:${emitted}`);
199
219
  } else {
@@ -209,8 +229,8 @@ function emitHelmYAML(value: unknown, indent: number): string {
209
229
  /**
210
230
  * Emit a top-level key-value pair in Helm YAML.
211
231
  */
212
- function emitKeyValue(key: string, value: unknown): string {
213
- const yamlStr = emitHelmYAML(value, 1);
232
+ function emitKeyValue(key: string, value: unknown, valuesContext: boolean = false): string {
233
+ const yamlStr = emitHelmYAML(value, 1, valuesContext);
214
234
  if (yamlStr.startsWith("\n")) {
215
235
  return `${key}:${yamlStr}`;
216
236
  }
@@ -264,16 +284,105 @@ function emitChartYaml(props: Record<string, unknown>): string {
264
284
 
265
285
  /**
266
286
  * Generate values.yaml content from Helm::Values entity props.
287
+ * HelmTpl intrinsics are emitted as empty placeholders since values.yaml
288
+ * is not processed as a Go template by Helm.
267
289
  */
268
290
  function emitValuesYaml(props: Record<string, unknown>): string {
269
291
  if (Object.keys(props).length === 0) return "{}\n";
270
292
  const lines: string[] = [];
271
293
  for (const [key, val] of Object.entries(props)) {
272
- lines.push(emitKeyValue(key, val));
294
+ lines.push(emitKeyValue(key, val, true));
273
295
  }
274
296
  return lines.join("\n") + "\n";
275
297
  }
276
298
 
299
+ // ── RuntimeSlot helpers ───────────────────────────────────
300
+
301
+ /**
302
+ * Represents the tree structure built from runtime slot paths.
303
+ * Leaves are description strings; branches are nested SlotTree objects.
304
+ */
305
+ type SlotTree = { [key: string]: SlotTree | string };
306
+
307
+ /**
308
+ * Collect all RuntimeSlot instances from a props tree.
309
+ * Returns the path (as array of keys) and description for each slot.
310
+ */
311
+ function collectRuntimeSlots(
312
+ props: unknown,
313
+ path: string[],
314
+ ): { path: string[]; description: string }[] {
315
+ const result: { path: string[]; description: string }[] = [];
316
+ if (props === null || props === undefined) return result;
317
+
318
+ if (typeof props === "object" && INTRINSIC_MARKER in (props as object)) {
319
+ const json = (props as { toJSON(): unknown }).toJSON() as Record<string, unknown>;
320
+ if (typeof json === "object" && json !== null && RUNTIME_SLOT_KEY in json) {
321
+ result.push({ path, description: json[RUNTIME_SLOT_KEY] as string });
322
+ }
323
+ return result;
324
+ }
325
+
326
+ if (typeof props === "object" && !Array.isArray(props)) {
327
+ for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
328
+ result.push(...collectRuntimeSlots(value, [...path, key]));
329
+ }
330
+ }
331
+
332
+ return result;
333
+ }
334
+
335
+ /**
336
+ * Build a nested tree from flat path+description pairs.
337
+ */
338
+ function buildSlotTree(slots: { path: string[]; description: string }[]): SlotTree {
339
+ const tree: SlotTree = {};
340
+ for (const { path, description } of slots) {
341
+ let node = tree;
342
+ for (let i = 0; i < path.length - 1; i++) {
343
+ const key = path[i];
344
+ if (typeof node[key] !== "object") {
345
+ node[key] = {};
346
+ }
347
+ node = node[key] as SlotTree;
348
+ }
349
+ node[path[path.length - 1]] = description;
350
+ }
351
+ return tree;
352
+ }
353
+
354
+ /**
355
+ * Emit a slot tree as YAML lines with description comments before leaves.
356
+ */
357
+ function emitSlotTreeYaml(tree: SlotTree, indent: number): string {
358
+ const prefix = " ".repeat(indent);
359
+ const lines: string[] = [];
360
+ for (const [key, value] of Object.entries(tree)) {
361
+ if (typeof value === "string") {
362
+ if (value) {
363
+ lines.push(`${prefix}# ${value}`);
364
+ }
365
+ lines.push(`${prefix}${key}: ''`);
366
+ } else {
367
+ lines.push(`${prefix}${key}:`);
368
+ const nested = emitSlotTreeYaml(value, indent + 1);
369
+ if (nested) lines.push(nested);
370
+ }
371
+ }
372
+ return lines.join("\n");
373
+ }
374
+
375
+ /**
376
+ * Generate values-runtime-slots.yaml from collected RuntimeSlot instances.
377
+ * Returns empty string if no slots found.
378
+ */
379
+ function emitRuntimeSlotsYaml(slots: { path: string[]; description: string }[]): string {
380
+ if (slots.length === 0) return "";
381
+ const tree = buildSlotTree(slots);
382
+ return "# Generated by chant — fill these in before running helm upgrade\n" +
383
+ emitSlotTreeYaml(tree, 0) + "\n";
384
+ }
385
+
277
386
  /**
278
387
  * Description inference — static map from common Helm value key names.
279
388
  */
@@ -397,6 +506,11 @@ function generateValuesSchema(props: Record<string, unknown>): string {
397
506
  return schema;
398
507
  }
399
508
 
509
+ if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
510
+ // HelmTpl / Intrinsic — actual value provided via -f override; accept any type
511
+ return {};
512
+ }
513
+
400
514
  if (typeof value === "object") {
401
515
  const obj = value as Record<string, unknown>;
402
516
  const properties: Record<string, unknown> = {};
@@ -645,6 +759,7 @@ export const helmSerializer: Serializer = {
645
759
  let notesContent: string | undefined;
646
760
  const dependencies: Record<string, unknown>[] = [];
647
761
  const maintainers: Record<string, unknown>[] = [];
762
+ const valuesOverrides: { filename: string; values: Record<string, unknown> }[] = [];
648
763
 
649
764
  // First pass: extract Helm-specific resources and collect metadata
650
765
  for (const [_name, entity] of entities) {
@@ -674,6 +789,14 @@ export const helmSerializer: Serializer = {
674
789
  const filename = (props.filename as string) ?? `${toFileName(_name)}.yaml`;
675
790
  files[`crds/${filename}`] = props.content as string;
676
791
  }
792
+ } else if (entityType === "Helm::ValuesOverride") {
793
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
794
+ if (props?.filename && props?.values) {
795
+ valuesOverrides.push({
796
+ filename: props.filename as string,
797
+ values: props.values as Record<string, unknown>,
798
+ });
799
+ }
677
800
  }
678
801
  }
679
802
 
@@ -696,6 +819,19 @@ export const helmSerializer: Serializer = {
696
819
  // Emit values.yaml
697
820
  files["values.yaml"] = emitValuesYaml(valuesProps);
698
821
 
822
+ // Emit values-runtime-slots.yaml if any RuntimeSlot instances found
823
+ if (hasValues) {
824
+ const slots = collectRuntimeSlots(valuesProps, []);
825
+ if (slots.length > 0) {
826
+ files["values-runtime-slots.yaml"] = emitRuntimeSlotsYaml(slots);
827
+ }
828
+ }
829
+
830
+ // Emit ValuesOverride files
831
+ for (const override of valuesOverrides) {
832
+ files[`${override.filename}.yaml`] = emitValuesYaml(override.values);
833
+ }
834
+
699
835
  // Emit values.schema.json if we have values
700
836
  if (hasValues && Object.keys(valuesProps).length > 0) {
701
837
  files["values.schema.json"] = generateValuesSchema(valuesProps);
@@ -215,6 +215,58 @@ ChartRef.Name // {{ .Chart.Name }}
215
215
  ChartRef.Version // {{ .Chart.Version }}
216
216
  ```
217
217
 
218
+ ## Deploying Upstream Charts with Value Overrides
219
+
220
+ When you need to deploy an upstream chart (like `gitlab/gitlab`) with custom values, avoid wrapper charts with no templates. Instead:
221
+
222
+ 1. Use `runtimeSlot()` in `new Values({...})` for deploy-time values (DB IPs, bucket names, replicas)
223
+ 2. Use `ValuesOverride` for static values shared across all environments (disabled bundled services, shared secret refs)
224
+ 3. Deploy the upstream chart directly with `-f` flags
225
+
226
+ ```typescript
227
+ import { Values, ValuesOverride, runtimeSlot } from "@intentius/chant-lexicon-helm";
228
+
229
+ // Runtime slots → values-runtime-slots.yaml (deploy-time checklist)
230
+ export const vals = new Values({
231
+ global: {
232
+ psql: { host: runtimeSlot("Cloud SQL private IP") },
233
+ redis: { host: runtimeSlot("Memorystore host") },
234
+ },
235
+ });
236
+
237
+ // Static overrides → values-base.yaml (shared across all deployments)
238
+ export const baseOverride = new ValuesOverride({
239
+ filename: "values-base",
240
+ values: {
241
+ postgresql: { install: false },
242
+ redis: { install: false },
243
+ certmanager: { install: false },
244
+ "nginx-ingress": { enabled: false },
245
+ },
246
+ });
247
+ ```
248
+
249
+ Outputs:
250
+ - `chart-dir/values.yaml` — defaults; runtime slots appear as `''`
251
+ - `chart-dir/values-base.yaml` — generated static overrides
252
+ - `chart-dir/values-runtime-slots.yaml` — deploy-time slots with descriptions as comments
253
+
254
+ Deploy:
255
+ ```bash
256
+ # chant build generates chart-dir/ including values-base.yaml
257
+ chant build
258
+
259
+ # Fill in runtime-slot values (one per environment)
260
+ # values-prod.yaml contains: global.psql.host, global.redis.host, etc.
261
+
262
+ helm upgrade --install my-release upstream/chart \
263
+ -f chart-dir/values-base.yaml \
264
+ -f values-prod.yaml \
265
+ --wait
266
+ ```
267
+
268
+ **WHM005** warns when a chart has `HelmDependency` entries but generates no templates — this is the "empty wrapper" anti-pattern that requires `helm dependency build` as a non-obvious prerequisite.
269
+
218
270
  ## Template Functions
219
271
 
220
272
  ```typescript