@intentius/chant-lexicon-helm 0.124.31 → 0.124.33

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 (111) hide show
  1. package/dist/archive-render.js +76 -0
  2. package/dist/codegen/docs-cli.js +3 -0
  3. package/dist/codegen/docs.js +113 -0
  4. package/dist/codegen/generate-cli.js +19 -0
  5. package/dist/codegen/generate.js +210 -0
  6. package/dist/codegen/naming.js +29 -0
  7. package/dist/codegen/package.js +44 -0
  8. package/dist/codegen/values-type.js +87 -0
  9. package/dist/commands.js +413 -0
  10. package/dist/components/builders.js +9 -0
  11. package/dist/components/capability-plugin.js +17 -0
  12. package/dist/components/helm-upgrade.js +109 -0
  13. package/dist/components/index.js +11 -0
  14. package/dist/composites/catalog.js +1119 -0
  15. package/dist/composites/helm-batch-job.js +148 -0
  16. package/dist/composites/helm-crd-lifecycle.js +145 -0
  17. package/dist/composites/helm-cron-job.js +130 -0
  18. package/dist/composites/helm-daemon-set.js +125 -0
  19. package/dist/composites/helm-external-secret.js +63 -0
  20. package/dist/composites/helm-library.js +24 -0
  21. package/dist/composites/helm-microservice.js +260 -0
  22. package/dist/composites/helm-monitored-service.js +188 -0
  23. package/dist/composites/helm-namespace-env.js +116 -0
  24. package/dist/composites/helm-secure-ingress.js +86 -0
  25. package/dist/composites/helm-stateful-service.js +160 -0
  26. package/dist/composites/helm-web-app.js +201 -0
  27. package/dist/composites/helm-worker.js +164 -0
  28. package/dist/composites/index.js +13 -0
  29. package/dist/config.js +161 -0
  30. package/dist/coverage.js +37 -0
  31. package/dist/deep-observe-hooks.js +19 -0
  32. package/dist/deep-observe.js +55 -0
  33. package/dist/describe-resources.js +119 -0
  34. package/dist/describe-stack-status.js +66 -0
  35. package/dist/detect.js +11 -0
  36. package/dist/generated/index.js +11 -0
  37. package/dist/helpers.js +84 -0
  38. package/dist/import/generator.js +292 -0
  39. package/dist/import/parser.js +141 -0
  40. package/dist/import/template-stripper.js +86 -0
  41. package/dist/index.js +55 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/intrinsics.js +482 -0
  44. package/dist/lint/audit-catalog.js +33 -0
  45. package/dist/lint/audit-lineage.js +53 -0
  46. package/dist/lint/post-synth/helm-helpers.js +60 -0
  47. package/dist/lint/post-synth/index.js +48 -0
  48. package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
  49. package/dist/lint/post-synth/whm101.js +38 -0
  50. package/dist/lint/post-synth/whm102.js +26 -0
  51. package/dist/lint/post-synth/whm103.js +51 -0
  52. package/dist/lint/post-synth/whm104.js +29 -0
  53. package/dist/lint/post-synth/whm105.js +23 -0
  54. package/dist/lint/post-synth/whm201.js +29 -0
  55. package/dist/lint/post-synth/whm202.js +42 -0
  56. package/dist/lint/post-synth/whm203.js +32 -0
  57. package/dist/lint/post-synth/whm204.js +51 -0
  58. package/dist/lint/post-synth/whm301.js +31 -0
  59. package/dist/lint/post-synth/whm302.js +33 -0
  60. package/dist/lint/post-synth/whm401.js +52 -0
  61. package/dist/lint/post-synth/whm402.js +38 -0
  62. package/dist/lint/post-synth/whm403.js +38 -0
  63. package/dist/lint/post-synth/whm404.js +30 -0
  64. package/dist/lint/post-synth/whm405.js +47 -0
  65. package/dist/lint/post-synth/whm406.js +27 -0
  66. package/dist/lint/post-synth/whm407.js +72 -0
  67. package/dist/lint/post-synth/whm501.js +89 -0
  68. package/dist/lint/post-synth/whm502.js +85 -0
  69. package/dist/lint/post-synth/whm503.js +82 -0
  70. package/dist/lint/post-synth/whm504.js +47 -0
  71. package/dist/lint/rules/chart-metadata.js +53 -0
  72. package/dist/lint/rules/no-hardcoded-image.js +50 -0
  73. package/dist/lint/rules/values-no-helm-tpl.js +78 -0
  74. package/dist/lint/rules/values-no-secrets.js +65 -0
  75. package/dist/list-artifacts.js +134 -0
  76. package/dist/lsp/completions.d.ts.map +1 -1
  77. package/dist/lsp/completions.js +18 -0
  78. package/dist/lsp/hover.d.ts.map +1 -1
  79. package/dist/lsp/hover.js +40 -0
  80. package/dist/manifest.json +1 -1
  81. package/dist/op/activities/cluster-probe.js +116 -0
  82. package/dist/op/activities/helm.js +449 -0
  83. package/dist/op/activities/index.js +10 -0
  84. package/dist/op/builders.js +45 -0
  85. package/dist/package-cli.js +21 -0
  86. package/dist/pinnability/actions.js +207 -0
  87. package/dist/pinnability/classify.js +416 -0
  88. package/dist/pinnability/conditions.js +0 -0
  89. package/dist/pinnability/index.js +16 -0
  90. package/dist/pinnability/localize.js +518 -0
  91. package/dist/pinnability/render-stream.js +53 -0
  92. package/dist/pinnability/values.js +151 -0
  93. package/dist/plugin.js +458 -0
  94. package/dist/project-codegen.js +215 -0
  95. package/dist/release-observe.js +287 -0
  96. package/dist/render-diff.js +279 -0
  97. package/dist/render-digest.js +0 -0
  98. package/dist/render-records.js +29 -0
  99. package/dist/render-store.js +401 -0
  100. package/dist/render-wrapper.js +164 -0
  101. package/dist/render.js +394 -0
  102. package/dist/resources.js +109 -0
  103. package/dist/serializer.js +903 -0
  104. package/dist/validate-cli.js +17 -0
  105. package/dist/validate.js +30 -0
  106. package/dist/values-probe.js +504 -0
  107. package/dist/wrapper-chart.js +95 -0
  108. package/package.json +11 -11
  109. package/src/codegen/package.ts +2 -2
  110. package/src/lsp/completions.ts +2 -1
  111. package/src/lsp/hover.ts +2 -1
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CLI entry point for Helm lexicon validation.
4
+ */
5
+ import { validate } from "./validate.js";
6
+ import { printValidationResult } from "@intentius/chant/codegen/validate";
7
+ async function main() {
8
+ const result = await validate();
9
+ printValidationResult(result);
10
+ if (!result.success) {
11
+ process.exit(1);
12
+ }
13
+ }
14
+ main().catch((err) => {
15
+ console.error("Validation failed:", err);
16
+ process.exit(1);
17
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Validate generated lexicon-helm artifacts.
3
+ *
4
+ * Thin wrapper around the core validation framework
5
+ * with Helm-specific configuration.
6
+ */
7
+ import { dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
10
+ const REQUIRED_NAMES = [
11
+ "Chart",
12
+ "Values",
13
+ "HelmTest",
14
+ "HelmNotes",
15
+ "HelmHook",
16
+ "HelmDependency",
17
+ "HelmMaintainer",
18
+ "HelmCRD",
19
+ ];
20
+ /**
21
+ * Validate the generated lexicon-helm artifacts.
22
+ */
23
+ export async function validate(opts) {
24
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
25
+ return validateLexiconArtifacts({
26
+ lexiconJsonFilename: "lexicon-helm.json",
27
+ requiredNames: REQUIRED_NAMES,
28
+ basePath,
29
+ });
30
+ }
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Build-time coalesced-values probe (#1251, epic #1228 Phase 7) and the
3
+ * provenance derived from it (#1252).
4
+ *
5
+ * `helm template --debug` does not print coalesced values on either major
6
+ * version, and `helm get values --all` needs an installed release — the
7
+ * wrong side of the build/deploy line. So the probe extracts them from helm
8
+ * itself: copy the chart, replace every (sub)chart's `templates/` with a
9
+ * single `chant-values-probe.yaml` template containing `{{ toYaml .Values }}`,
10
+ * render, lift the probe documents out, discard the copy. Coalescing —
11
+ * parent-overrides-child, `global` propagation, alias scoping,
12
+ * `import-values`, tags — is delegated to helm rather than reimplemented
13
+ * (the approximation in `pinnability/values.ts` remains only as the
14
+ * classifier's offline fallback).
15
+ *
16
+ * The probe filename must NOT begin with `_`: helm treats underscore-prefixed
17
+ * template files as partials and skips them without error.
18
+ *
19
+ * The probe runs against a private copy in a temp directory, so the probe
20
+ * document can never appear in a real render of the chart — the pinned
21
+ * artifact path (#1237/#1242) never sees the injected template.
22
+ *
23
+ * A dependency disabled by a `condition:` (or tags) is pruned by helm before
24
+ * rendering, so it yields no probe document. Those instances are reported in
25
+ * `disabled` instead, with the condition path where determinable. Note the
26
+ * ROOT probe document still carries the values subtree a disabled dependency
27
+ * would have received — an installed release omits it (epic findings 14, 15).
28
+ *
29
+ * On the probe output, three provenance products (#1252):
30
+ *
31
+ * - `digest` — `sha256:` over the canonical JSON (core's `canonicalJson`,
32
+ * the #1237 convention) of the per-instance coalesced trees, keyed by
33
+ * scope path. Same inputs, same coalesced values, same digest.
34
+ * - `valueSources` — each coalesced path attributed to its winning layer:
35
+ * chart default / parent override / supplied file / --set.
36
+ * - `deadAssignments` — supplied values that never survive coalescing:
37
+ * shadowed by a later supplied layer, or targeting a disabled or unknown
38
+ * subchart path. WHM504 turns these into lint diagnostics.
39
+ */
40
+ import { execFileSync } from "node:child_process";
41
+ import { createHash } from "node:crypto";
42
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
43
+ import { tmpdir } from "node:os";
44
+ import { join } from "node:path";
45
+ import { canonicalJson } from "@intentius/chant/effect-receipt";
46
+ import yaml from "js-yaml";
47
+ import { splitDocuments } from "./pinnability/render-stream.js";
48
+ import { readChartMeta, readYamlFile, valueAtPath } from "./pinnability/values.js";
49
+ /**
50
+ * The probe template. Renders one document per chart instance carrying the
51
+ * exact `.Values` that instance coalesced. For an aliased dependency helm
52
+ * sets `.Chart.Name` (and the `# Source:` path segment) to the alias, so two
53
+ * instances over the same chart directory stay distinguishable.
54
+ */
55
+ const PROBE_TEMPLATE = "chantValuesProbe: v1\nchart: {{ .Chart.Name }}\nvalues:\n{{ .Values | toYaml | nindent 2 }}\n";
56
+ /** Must not start with `_` — helm skips underscore-prefixed files as partials. */
57
+ const PROBE_FILENAME = "chant-values-probe.yaml";
58
+ function isPlainObject(v) {
59
+ return v !== null && typeof v === "object" && !Array.isArray(v);
60
+ }
61
+ function deepEqual(a, b) {
62
+ return canonicalJson(a ?? null) === canonicalJson(b ?? null);
63
+ }
64
+ /**
65
+ * Leaf paths of a values tree. Arrays and empty maps are leaves — helm
66
+ * replaces arrays wholesale and an empty map carries no mergeable children.
67
+ */
68
+ function leafPaths(v, prefix = []) {
69
+ if (isPlainObject(v) && Object.keys(v).length > 0) {
70
+ const out = [];
71
+ for (const [k, child] of Object.entries(v))
72
+ out.push(...leafPaths(child, [...prefix, k]));
73
+ return out;
74
+ }
75
+ return prefix.length > 0 ? [prefix] : [];
76
+ }
77
+ const scopeKey = (scope) => scope.join(".");
78
+ /**
79
+ * Walk the (copied, tgz-extracted) chart tree the way helm's loader does:
80
+ * declared dependencies under their alias-or-name key, undeclared `charts/`
81
+ * directories as implicit always-enabled dependencies.
82
+ */
83
+ function collectScopeNodes(rootDir) {
84
+ const nodes = [];
85
+ const visit = (dir, scope) => {
86
+ const meta = readChartMeta(dir);
87
+ nodes.push({
88
+ scope,
89
+ name: meta.name,
90
+ dir,
91
+ parentScope: scope.slice(0, -1),
92
+ // condition is attached where the dependency is declared, below.
93
+ });
94
+ const chartsDir = join(dir, "charts");
95
+ const childDirs = existsSync(chartsDir)
96
+ ? readdirSync(chartsDir).filter((e) => {
97
+ const p = join(chartsDir, e);
98
+ return statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"));
99
+ })
100
+ : [];
101
+ const declared = new Set();
102
+ for (const dep of meta.dependencies) {
103
+ const childDir = join(chartsDir, dep.name);
104
+ if (!existsSync(join(childDir, "Chart.yaml")))
105
+ continue;
106
+ declared.add(dep.name);
107
+ const childScope = [...scope, dep.alias ?? dep.name];
108
+ visit(childDir, childScope);
109
+ const node = nodes.find((n) => scopeKey(n.scope) === scopeKey(childScope));
110
+ if (node)
111
+ node.condition = dep.condition;
112
+ }
113
+ for (const name of childDirs) {
114
+ if (!declared.has(name))
115
+ visit(join(chartsDir, name), [...scope, name]);
116
+ }
117
+ };
118
+ visit(rootDir, []);
119
+ return nodes;
120
+ }
121
+ /**
122
+ * Extract packaged subcharts (`charts/*.tgz`) in place so probes can be
123
+ * injected into them, then recurse. Uses the system `tar`; a failed
124
+ * extraction leaves the tgz alone and records a warning — that subchart's
125
+ * values then appear only through its parents' probe documents.
126
+ */
127
+ function extractPackagedSubcharts(dir, warnings) {
128
+ const chartsDir = join(dir, "charts");
129
+ if (!existsSync(chartsDir))
130
+ return;
131
+ for (const entry of readdirSync(chartsDir)) {
132
+ if (!entry.endsWith(".tgz"))
133
+ continue;
134
+ const tgz = join(chartsDir, entry);
135
+ if (!statSync(tgz).isFile())
136
+ continue;
137
+ try {
138
+ // A chart archive unpacks to a single top-level directory named after
139
+ // the chart. Skip when that directory already exists.
140
+ execFileSync("tar", ["-xzf", tgz, "-C", chartsDir], { stdio: "ignore" });
141
+ rmSync(tgz);
142
+ }
143
+ catch {
144
+ warnings.push(`could not extract packaged subchart ${entry} — its own probe document is skipped`);
145
+ }
146
+ }
147
+ for (const entry of readdirSync(chartsDir)) {
148
+ const p = join(chartsDir, entry);
149
+ if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"))) {
150
+ extractPackagedSubcharts(p, warnings);
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Replace each (sub)chart's `templates/` with the single probe template.
156
+ * Deleting the real templates sidesteps render-time failures (`required`,
157
+ * missing capabilities) — values coalescing does not depend on them.
158
+ * Library charts are left alone: helm never renders their templates.
159
+ */
160
+ function injectProbes(dir, warnings) {
161
+ const chartYaml = readYamlFile(join(dir, "Chart.yaml"));
162
+ if (chartYaml?.type === "library") {
163
+ warnings.push(`${dir} is a library chart — helm renders no templates for it, so it yields no probe document`);
164
+ }
165
+ else {
166
+ const templatesDir = join(dir, "templates");
167
+ rmSync(templatesDir, { recursive: true, force: true });
168
+ mkdirSync(templatesDir, { recursive: true });
169
+ writeFileSync(join(templatesDir, PROBE_FILENAME), PROBE_TEMPLATE);
170
+ }
171
+ const chartsDir = join(dir, "charts");
172
+ if (!existsSync(chartsDir))
173
+ return;
174
+ for (const entry of readdirSync(chartsDir)) {
175
+ const p = join(chartsDir, entry);
176
+ if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml")))
177
+ injectProbes(p, warnings);
178
+ }
179
+ }
180
+ function defaultRunHelm(args) {
181
+ try {
182
+ return execFileSync("helm", args, {
183
+ encoding: "utf8",
184
+ stdio: ["ignore", "pipe", "pipe"],
185
+ maxBuffer: 64 * 1024 * 1024,
186
+ });
187
+ }
188
+ catch (err) {
189
+ const stderr = err && typeof err === "object" && "stderr" in err ? String(err.stderr) : String(err);
190
+ throw new Error(`coalesced-values probe render failed:\n${stderr}\n` +
191
+ `Hint: ensure the 'helm' CLI is on PATH and the chart's dependencies are vendored under charts/.`);
192
+ }
193
+ }
194
+ /** Scope of a probe document, from its `# Source:` path — every segment following a `charts/`. */
195
+ function scopeFromSource(sourceLine) {
196
+ const m = sourceLine.match(/^# Source: (\S+)$/m);
197
+ if (!m)
198
+ return undefined;
199
+ const segments = m[1].split("/");
200
+ if (segments[segments.length - 1] !== PROBE_FILENAME)
201
+ return undefined;
202
+ const scope = [];
203
+ for (let i = 1; i < segments.length - 2; i += 2) {
204
+ if (segments[i] !== "charts")
205
+ return undefined;
206
+ scope.push(segments[i + 1]);
207
+ }
208
+ return scope;
209
+ }
210
+ /**
211
+ * The coalesced-values identity: `sha256:` over the canonical JSON of the
212
+ * per-instance trees keyed by scope path — the #1237 digest conventions
213
+ * (core `canonicalJson`, `sha256:` prefix). Two renders that coalesce the
214
+ * same values for every instance share it; any value, in any subchart,
215
+ * changing breaks it.
216
+ */
217
+ export function coalescedValuesDigest(instances) {
218
+ const tree = {};
219
+ for (const i of instances)
220
+ tree[scopeKey(i.scope)] = i.values;
221
+ return `sha256:${createHash("sha256").update(canonicalJson(tree), "utf8").digest("hex")}`;
222
+ }
223
+ /**
224
+ * Attribute each coalesced path to the layer that won it.
225
+ *
226
+ * Keys are dot-joined root-coordinate paths: a subchart instance's paths are
227
+ * prefixed with its scope, so `kidtwo.replicas` is the `replicas` the
228
+ * `kidtwo` instance actually sees. A key of an instance's tree that is
229
+ * itself an enabled child scope is skipped — helm pushes the child's
230
+ * coalesced tree back into the parent's `.Values`, and the child instance
231
+ * owns those paths.
232
+ *
233
+ * Layer precedence mirrors helm: supplied layers (last first), then ancestor
234
+ * chart defaults (root outranks intermediates — "parent override"), then the
235
+ * instance's own defaults ("chart default"). A path no layer explains — an
236
+ * `import-values` product, a helm-computed merge — reports `computed`.
237
+ * Attribution matches on value equality, so a layer that assigned a path but
238
+ * lost it reports as the layer that actually won.
239
+ */
240
+ export function computeValueSources(input) {
241
+ const out = {};
242
+ const instanceScopes = new Set(input.instances.map((i) => scopeKey(i.scope)));
243
+ for (const instance of input.instances) {
244
+ for (const path of leafPaths(instance.values)) {
245
+ // Skip paths under an enabled child scope — the child instance owns them.
246
+ if (instanceScopes.has(scopeKey([...instance.scope, path[0]])))
247
+ continue;
248
+ const coalesced = valueAtPath(instance.values, path);
249
+ out[scopeKey([...instance.scope, ...path])] = attributePath(instance, path, coalesced, input);
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+ function attributePath(instance, path, coalesced, input) {
255
+ const rootPath = [...instance.scope, ...path];
256
+ const isGlobal = path[0] === "global" && instance.scope.length > 0;
257
+ // 1. Supplied layers, highest precedence first. A subchart's `global.*`
258
+ // can be fed either scoped (`<scope>.global.*`) or from the root
259
+ // `global.*` — scoped wins, so check it first.
260
+ for (let i = input.supplied.length - 1; i >= 0; i--) {
261
+ const layer = input.supplied[i];
262
+ let candidate = valueAtPath(layer.values, rootPath);
263
+ if (candidate === undefined && isGlobal)
264
+ candidate = valueAtPath(layer.values, path);
265
+ if (candidate !== undefined && deepEqual(candidate, coalesced))
266
+ return layer.origin;
267
+ }
268
+ // 2. Ancestor chart defaults — the top parent outranks intermediates.
269
+ for (let prefixLen = 0; prefixLen < instance.scope.length; prefixLen++) {
270
+ const ancestorScope = instance.scope.slice(0, prefixLen);
271
+ const defaults = input.defaultsFor(ancestorScope);
272
+ if (defaults === undefined)
273
+ continue;
274
+ const rel = [...instance.scope.slice(prefixLen), ...path];
275
+ let candidate = valueAtPath(defaults, rel);
276
+ if (candidate === undefined && isGlobal)
277
+ candidate = valueAtPath(defaults, path);
278
+ if (candidate !== undefined && deepEqual(candidate, coalesced))
279
+ return "parent override";
280
+ }
281
+ // 3. The instance's own values.yaml.
282
+ const own = input.defaultsFor(instance.scope);
283
+ if (own !== undefined) {
284
+ const candidate = valueAtPath(own, path);
285
+ if (candidate !== undefined && deepEqual(candidate, coalesced))
286
+ return "chart default";
287
+ }
288
+ return "computed";
289
+ }
290
+ const layerLabel = (layer) => layer.name !== undefined ? `${layer.origin} (${layer.name})` : layer.origin;
291
+ /**
292
+ * Supplied values that never survive coalescing:
293
+ *
294
+ * - `shadowed` — a later supplied layer assigns the same path (a map merging
295
+ * into a map is not a shadow; anything else replaces).
296
+ * - `disabled-subchart` — the path targets a dependency a `condition:` (or
297
+ * tags) disabled, so no rendered chart ever reads it.
298
+ * - `unknown-subchart` — a map supplied under a top-level key that matches
299
+ * no dependency, no root default, and not `global`, on a chart that HAS
300
+ * dependencies: the classic silently-ignored typo of a subchart name.
301
+ * Reported once per top-level key per layer.
302
+ */
303
+ export function findDeadAssignments(input, disabled) {
304
+ const out = [];
305
+ const rootDefaults = input.defaultsFor([]) ?? {};
306
+ const knownFirstSegments = new Set(["global", ...Object.keys(rootDefaults)]);
307
+ let hasDependencies = false;
308
+ for (const i of input.instances) {
309
+ if (i.scope.length > 0) {
310
+ knownFirstSegments.add(i.scope[0]);
311
+ hasDependencies = true;
312
+ }
313
+ }
314
+ for (const d of disabled) {
315
+ knownFirstSegments.add(d.scope[0]);
316
+ if (d.name !== d.scope[0])
317
+ knownFirstSegments.add(d.name);
318
+ hasDependencies = true;
319
+ }
320
+ for (let i = 0; i < input.supplied.length; i++) {
321
+ const layer = input.supplied[i];
322
+ const flaggedUnknown = new Set();
323
+ for (const path of leafPaths(layer.values)) {
324
+ const value = valueAtPath(layer.values, path);
325
+ // Shadowed by a later supplied layer?
326
+ const shadow = input.supplied.slice(i + 1).find((later) => {
327
+ const laterValue = valueAtPath(later.values, path);
328
+ if (laterValue === undefined)
329
+ return false;
330
+ return !(isPlainObject(value) && isPlainObject(laterValue));
331
+ });
332
+ if (shadow) {
333
+ out.push({
334
+ path: scopeKey(path),
335
+ origin: layerLabel(layer),
336
+ reason: "shadowed",
337
+ shadowedBy: layerLabel(shadow),
338
+ });
339
+ continue;
340
+ }
341
+ // Targeting a disabled dependency?
342
+ const disabledHit = disabled.find((d) => d.scope.length <= path.length && scopeKey(path.slice(0, d.scope.length)) === scopeKey(d.scope));
343
+ if (disabledHit) {
344
+ out.push({
345
+ path: scopeKey(path),
346
+ origin: layerLabel(layer),
347
+ reason: "disabled-subchart",
348
+ shadowedBy: disabledHit.condition !== undefined
349
+ ? `disabled subchart ${scopeKey(disabledHit.scope)} (condition ${disabledHit.condition} is false)`
350
+ : `disabled subchart ${scopeKey(disabledHit.scope)}`,
351
+ });
352
+ continue;
353
+ }
354
+ // A map under an unknown top-level key on a chart with dependencies?
355
+ if (hasDependencies &&
356
+ path.length > 1 &&
357
+ !knownFirstSegments.has(path[0]) &&
358
+ isPlainObject(layer.values[path[0]]) &&
359
+ !flaggedUnknown.has(path[0])) {
360
+ flaggedUnknown.add(path[0]);
361
+ out.push({
362
+ path: path[0],
363
+ origin: layerLabel(layer),
364
+ reason: "unknown-subchart",
365
+ shadowedBy: `no dependency, chart default, or global named "${path[0]}"`,
366
+ });
367
+ }
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+ /**
373
+ * Run the probe: copy the chart, inject probe templates, render, lift the
374
+ * probe documents out, analyze, discard the copy. The source chart is never
375
+ * touched. Every run is recorded for WHM504 (`getValuesProbeRecords`).
376
+ */
377
+ export function probeCoalescedValues(options) {
378
+ if (!existsSync(join(options.chartDir, "Chart.yaml"))) {
379
+ throw new Error(`coalesced-values probe: ${options.chartDir} has no Chart.yaml`);
380
+ }
381
+ const warnings = [];
382
+ const supplied = options.supplied ?? [];
383
+ const workRoot = mkdtempSync(join(tmpdir(), "chant-values-probe-"));
384
+ try {
385
+ const chartCopy = join(workRoot, "chart");
386
+ cpSync(options.chartDir, chartCopy, { recursive: true });
387
+ extractPackagedSubcharts(chartCopy, warnings);
388
+ injectProbes(chartCopy, warnings);
389
+ const scopeNodes = collectScopeNodes(chartCopy);
390
+ const releaseName = options.releaseName ?? "chant-values-probe";
391
+ const args = ["template", releaseName, chartCopy];
392
+ if (options.namespace !== undefined)
393
+ args.push("--namespace", options.namespace);
394
+ supplied.forEach((layer, i) => {
395
+ const layerPath = join(workRoot, `supplied-${i}.yaml`);
396
+ writeFileSync(layerPath, yaml.dump(layer.values, { lineWidth: -1, noRefs: true }));
397
+ args.push("--values", layerPath);
398
+ });
399
+ const rendered = (options.runHelm ?? defaultRunHelm)(args);
400
+ // Lift the probe documents out.
401
+ const instances = [];
402
+ for (const doc of splitDocuments(rendered)) {
403
+ if (!doc.includes("chantValuesProbe:"))
404
+ continue;
405
+ let parsed;
406
+ try {
407
+ parsed = yaml.load(doc);
408
+ }
409
+ catch {
410
+ warnings.push("a probe document failed to parse as YAML — skipped");
411
+ continue;
412
+ }
413
+ if (!isPlainObject(parsed) || parsed.chantValuesProbe !== "v1")
414
+ continue;
415
+ const scope = scopeFromSource(doc);
416
+ if (scope === undefined) {
417
+ warnings.push("a probe document carried no usable # Source: header — skipped");
418
+ continue;
419
+ }
420
+ instances.push({
421
+ scope,
422
+ chartName: typeof parsed.chart === "string" ? parsed.chart : "",
423
+ values: isPlainObject(parsed.values) ? parsed.values : {},
424
+ });
425
+ }
426
+ instances.sort((a, b) => a.scope.length - b.scope.length || scopeKey(a.scope).localeCompare(scopeKey(b.scope)));
427
+ if (instances.length === 0) {
428
+ throw new Error(`coalesced-values probe: render of ${options.chartDir} produced no probe document — is the chart renderable?`);
429
+ }
430
+ const root = instances[0];
431
+ // Declared dependencies with no probe document were pruned by helm —
432
+ // disabled by a condition (evaluated here against the EXACT coalesced
433
+ // root values) or by tags. Only nodes whose parent rendered are
434
+ // reported; a disabled subtree's descendants are unreachable anyway.
435
+ const probedScopes = new Set(instances.map((i) => scopeKey(i.scope)));
436
+ const disabled = [];
437
+ for (const node of scopeNodes) {
438
+ if (node.scope.length === 0 || probedScopes.has(scopeKey(node.scope)))
439
+ continue;
440
+ if (!probedScopes.has(scopeKey(node.parentScope)))
441
+ continue;
442
+ let condition;
443
+ for (const rawPath of (node.condition ?? "").split(",")) {
444
+ const path = rawPath.trim();
445
+ if (path === "")
446
+ continue;
447
+ const v = valueAtPath(root.values, path.split("."));
448
+ if (v === undefined)
449
+ continue;
450
+ if (v === false)
451
+ condition = path;
452
+ break;
453
+ }
454
+ disabled.push({ scope: node.scope, name: node.name, condition });
455
+ }
456
+ const defaultsCache = new Map();
457
+ const defaultsFor = (scope) => {
458
+ const key = scopeKey(scope);
459
+ if (!defaultsCache.has(key)) {
460
+ const node = scopeNodes.find((n) => scopeKey(n.scope) === key);
461
+ const loaded = node ? readYamlFile(join(node.dir, "values.yaml")) : undefined;
462
+ defaultsCache.set(key, isPlainObject(loaded) ? loaded : undefined);
463
+ }
464
+ return defaultsCache.get(key);
465
+ };
466
+ const attribution = { instances, supplied, defaultsFor };
467
+ const probe = {
468
+ instances,
469
+ disabled,
470
+ digest: coalescedValuesDigest(instances),
471
+ valueSources: computeValueSources(attribution),
472
+ deadAssignments: findDeadAssignments(attribution, disabled),
473
+ warnings,
474
+ };
475
+ probeRecords.push({ name: options.name ?? releaseName, chartDir: options.chartDir, probe });
476
+ return probe;
477
+ }
478
+ finally {
479
+ rmSync(workRoot, { recursive: true, force: true });
480
+ }
481
+ }
482
+ const probeRecords = [];
483
+ /** Every probe recorded in this process, in invocation order. */
484
+ export function getValuesProbeRecords() {
485
+ return probeRecords;
486
+ }
487
+ /** Record a probe computed elsewhere (the render path integration point). */
488
+ export function recordValuesProbe(record) {
489
+ probeRecords.push(record);
490
+ }
491
+ /** Reset the record list (test isolation). */
492
+ export function clearValuesProbeRecords() {
493
+ probeRecords.length = 0;
494
+ }
495
+ /**
496
+ * The values gap between the probe and an installed release: an installed
497
+ * release's `helm get values --all` omits subtrees for disabled
498
+ * dependencies; the probe's root document keeps them (epic findings 14, 15).
499
+ * On shared keys the two agree — this helper names the shared-key view for
500
+ * comparisons against a live release.
501
+ */
502
+ export function rootCoalescedValues(probe) {
503
+ return probe.instances[0]?.values ?? {};
504
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Wrapper-chart materialization for the pinned install path (#1242, epic
3
+ * #1228 Phase 4).
4
+ *
5
+ * Turns a `RoutedRender` (#1239) into an installable helm chart on disk.
6
+ * The wrapper preserves the recorded bytes exactly:
7
+ *
8
+ * - CRD-group documents are written verbatim into the wrapper's `crds/`,
9
+ * which is what keeps `helm uninstall` from deleting them — the A/B test
10
+ * behind epic finding 4 showed a flat wrap converts uninstall from safe
11
+ * to data-destroying.
12
+ * - Every other document (main and hook groups) is written verbatim into
13
+ * `manifests/`, and each gets a one-line shim in `templates/` that reads
14
+ * it back with `.Files.Get`. The shim exists because rendered output can
15
+ * legitimately contain template-looking text — a ConfigMap carrying
16
+ * alertmanager notification templates renders `{{ $labels }}` as literal
17
+ * bytes — and a document placed directly in `templates/` would be run
18
+ * through the template engine a second time, mangling or failing on
19
+ * exactly those bytes. `.Files.Get` returns file content untemplated, so
20
+ * what helm installs is what the render store recorded.
21
+ * - Hook documents go through the same shim; their `helm.sh/hook`
22
+ * annotations are part of the recorded bytes, so helm registers and runs
23
+ * them from the rendered output unchanged (epic finding 5).
24
+ *
25
+ * The wrapper inherits the source chart's name and version (epic
26
+ * Decisions), keeping `helm history` continuous across the pinned
27
+ * migration. A local chart path becomes its sanitized basename — helm
28
+ * requires a bare chart name — and a render recorded without a version
29
+ * falls back to `0.0.0`.
30
+ */
31
+ import { mkdirSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ /**
34
+ * A valid helm chart name from a chart reference that may be a local path
35
+ * (`./charts/web`) or a repo-qualified name. Takes the last path segment,
36
+ * lowercases it, and collapses anything outside `[a-z0-9-]` to `-` — helm
37
+ * chart names are DNS-label-shaped. Falls back to `pinned-chart` when
38
+ * nothing survives.
39
+ */
40
+ export function wrapperChartName(chart) {
41
+ const base = chart.split("/").filter((s) => s.length > 0).pop() ?? "";
42
+ const sanitized = base
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9-]+/g, "-")
45
+ .replace(/^-+|-+$/g, "");
46
+ return sanitized.length > 0 ? sanitized : "pinned-chart";
47
+ }
48
+ /** `doc-0007-my-deployment` — a stable, ordered, filesystem-safe stem for one routed document. */
49
+ function fileStem(prefix, index, doc) {
50
+ const label = (doc.name ?? doc.kind ?? "document")
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9-]+/g, "-")
53
+ .replace(/^-+|-+$/g, "")
54
+ .slice(0, 40);
55
+ return `${prefix}-${String(index).padStart(4, "0")}${label ? `-${label}` : ""}`;
56
+ }
57
+ /**
58
+ * Write `routed` as an installable wrapper chart under `dir` (which must
59
+ * exist and should be empty). Document text is written byte-for-byte; the
60
+ * only authored files are `Chart.yaml` and the `.Files.Get` shims.
61
+ */
62
+ export function materializeWrapperChart(routed, dir) {
63
+ const chartName = wrapperChartName(routed.chart);
64
+ const chartVersion = routed.chartVersion ?? "0.0.0";
65
+ writeFileSync(join(dir, "Chart.yaml"), [
66
+ "apiVersion: v2",
67
+ `name: ${chartName}`,
68
+ `version: ${chartVersion}`,
69
+ "description: chant pinned render wrapper (epic #1228) — installs recorded bytes; templates are .Files.Get shims and are never re-rendered",
70
+ "",
71
+ ].join("\n"));
72
+ const crdFiles = [];
73
+ if (routed.crds.length > 0) {
74
+ mkdirSync(join(dir, "crds"), { recursive: true });
75
+ routed.crds.forEach((doc, i) => {
76
+ const rel = `crds/${fileStem("crd", i, doc)}.yaml`;
77
+ writeFileSync(join(dir, rel), doc.text.endsWith("\n") ? doc.text : `${doc.text}\n`);
78
+ crdFiles.push(rel);
79
+ });
80
+ }
81
+ const manifestFiles = [];
82
+ const docs = [...routed.main, ...routed.hooks];
83
+ if (docs.length > 0) {
84
+ mkdirSync(join(dir, "manifests"), { recursive: true });
85
+ mkdirSync(join(dir, "templates"), { recursive: true });
86
+ docs.forEach((doc, i) => {
87
+ const stem = fileStem("doc", i, doc);
88
+ const rel = `manifests/${stem}.yaml`;
89
+ writeFileSync(join(dir, rel), doc.text.endsWith("\n") ? doc.text : `${doc.text}\n`);
90
+ writeFileSync(join(dir, `templates/${stem}.yaml`), `{{ .Files.Get "${rel}" }}\n`);
91
+ manifestFiles.push(rel);
92
+ });
93
+ }
94
+ return { dir, chartName, chartVersion, crdFiles, manifestFiles, hookCount: routed.hooks.length };
95
+ }