@intentius/chant-lexicon-helm 0.46.0 → 0.50.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 (122) 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 +19 -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 +25 -13
  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/op/builders.d.ts +47 -0
  34. package/dist/op/builders.d.ts.map +1 -0
  35. package/dist/pinnability/actions.d.ts +76 -0
  36. package/dist/pinnability/actions.d.ts.map +1 -0
  37. package/dist/pinnability/classify.d.ts +129 -0
  38. package/dist/pinnability/classify.d.ts.map +1 -0
  39. package/dist/pinnability/conditions.d.ts +109 -0
  40. package/dist/pinnability/conditions.d.ts.map +1 -0
  41. package/dist/pinnability/index.d.ts +17 -0
  42. package/dist/pinnability/index.d.ts.map +1 -0
  43. package/dist/pinnability/localize.d.ts +124 -0
  44. package/dist/pinnability/localize.d.ts.map +1 -0
  45. package/dist/pinnability/render-stream.d.ts +27 -0
  46. package/dist/pinnability/render-stream.d.ts.map +1 -0
  47. package/dist/pinnability/values.d.ts +69 -0
  48. package/dist/pinnability/values.d.ts.map +1 -0
  49. package/dist/plugin.d.ts.map +1 -1
  50. package/dist/release-observe.d.ts +109 -0
  51. package/dist/release-observe.d.ts.map +1 -0
  52. package/dist/render-diff.d.ts +192 -0
  53. package/dist/render-diff.d.ts.map +1 -0
  54. package/dist/render-digest.d.ts +135 -0
  55. package/dist/render-digest.d.ts.map +1 -0
  56. package/dist/render-store.d.ts +310 -0
  57. package/dist/render-store.d.ts.map +1 -0
  58. package/dist/render-wrapper.d.ts +140 -0
  59. package/dist/render-wrapper.d.ts.map +1 -0
  60. package/dist/render.d.ts +81 -0
  61. package/dist/render.d.ts.map +1 -1
  62. package/dist/rules/whm503.ts +101 -0
  63. package/dist/rules/whm504.ts +52 -0
  64. package/dist/skills/chant-helm.md +7 -0
  65. package/dist/values-probe.d.ts +177 -0
  66. package/dist/values-probe.d.ts.map +1 -0
  67. package/dist/wrapper-chart.d.ts +61 -0
  68. package/dist/wrapper-chart.d.ts.map +1 -0
  69. package/package.json +10 -4
  70. package/src/archive-render.test.ts +201 -0
  71. package/src/archive-render.ts +119 -0
  72. package/src/commands.test.ts +487 -0
  73. package/src/commands.ts +445 -0
  74. package/src/config.test.ts +130 -0
  75. package/src/config.ts +220 -0
  76. package/src/deep-observe-hooks.ts +20 -0
  77. package/src/deep-observe.test.ts +165 -0
  78. package/src/deep-observe.ts +121 -0
  79. package/src/describe-resources.test.ts +255 -0
  80. package/src/describe-resources.ts +198 -0
  81. package/src/index.ts +106 -2
  82. package/src/lifecycle-integration.test.ts +289 -0
  83. package/src/lint/audit-catalog.ts +2 -0
  84. package/src/lint/post-synth/index.ts +4 -0
  85. package/src/lint/post-synth/whm503.test.ts +134 -0
  86. package/src/lint/post-synth/whm503.ts +101 -0
  87. package/src/lint/post-synth/whm504.test.ts +105 -0
  88. package/src/lint/post-synth/whm504.ts +52 -0
  89. package/src/op/activities/cluster-probe.test.ts +66 -0
  90. package/src/op/activities/cluster-probe.ts +148 -0
  91. package/src/op/activities/helm.test.ts +824 -0
  92. package/src/op/activities/helm.ts +628 -5
  93. package/src/op/activities/index.ts +17 -2
  94. package/src/op/builders.test.ts +70 -0
  95. package/src/op/builders.ts +66 -0
  96. package/src/pinnability/actions.ts +263 -0
  97. package/src/pinnability/classify.ts +579 -0
  98. package/src/pinnability/conditions.ts +0 -0
  99. package/src/pinnability/index.ts +77 -0
  100. package/src/pinnability/localize.test.ts +234 -0
  101. package/src/pinnability/localize.ts +644 -0
  102. package/src/pinnability/pinnability.test.ts +560 -0
  103. package/src/pinnability/render-stream.ts +59 -0
  104. package/src/pinnability/values.ts +205 -0
  105. package/src/plugin.test.ts +3 -1
  106. package/src/plugin.ts +97 -12
  107. package/src/release-observe.ts +392 -0
  108. package/src/render-diff.test.ts +366 -0
  109. package/src/render-diff.ts +407 -0
  110. package/src/render-digest.test.ts +186 -0
  111. package/src/render-digest.ts +207 -0
  112. package/src/render-store.test.ts +412 -0
  113. package/src/render-store.ts +621 -0
  114. package/src/render-wrapper.test.ts +259 -0
  115. package/src/render-wrapper.ts +274 -0
  116. package/src/render.test.ts +383 -4
  117. package/src/render.ts +319 -8
  118. package/src/skills/chant-helm.md +7 -0
  119. package/src/values-probe.test.ts +400 -0
  120. package/src/values-probe.ts +627 -0
  121. package/src/wrapper-chart.test.ts +110 -0
  122. package/src/wrapper-chart.ts +124 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Double-render localizer for open generated inputs (#1236, epic #1228
3
+ * Phase 1).
4
+ *
5
+ * The static classifier (#1234) NAMES generator sites; this module proves
6
+ * which of them actually fire and maps every unstable output line back to
7
+ * the input that produced it. The mechanism is the epic's own: pin the
8
+ * input, not the output. For each candidate values path that could close a
9
+ * generator, render the chart twice with that path pinned — the lines that
10
+ * stop differing are the lines that input produced, cascades included
11
+ * (finding 6: one `randAlphaNum` admin password differed as the secret line
12
+ * PLUS a `checksum/secret` annotation derived from it; hoisting the secret
13
+ * alone would have desynchronized the checksum and silently broken
14
+ * restart-on-change).
15
+ *
16
+ * Candidate pins come from three static sources, each validated dynamically
17
+ * before being suggested:
18
+ *
19
+ * 1. the generator's own action (`x | default (randAlphaNum 16)` — the
20
+ * classifier already names `x`),
21
+ * 2. enclosing control-flow gates (`if not .Values.core.existingSecret`
22
+ * around the generator: supplying the existing-secret slot closes it),
23
+ * 3. the include chain — a generator inside `define "grafana.password"`
24
+ * inherits the gates of every `include "grafana.password"` site, which
25
+ * is where grafana's `{{- if .Values.adminPassword }} ... {{- else }}`
26
+ * supply slot lives.
27
+ *
28
+ * A differing line no probe and no source mapping can explain is reported as
29
+ * UNLOCALIZED instability — never silently pinnable. A base double render
30
+ * with zero differing lines confirms the chart deterministic.
31
+ */
32
+
33
+ import { readFileSync } from "node:fs";
34
+
35
+ import {
36
+ extractActions,
37
+ scopeActions,
38
+ type ScopedAction,
39
+ } from "./actions";
40
+ import {
41
+ collectOwnTemplateFiles,
42
+ type ClosedInput,
43
+ type PinnabilityReport,
44
+ } from "./classify";
45
+ import { parseExpr, type ExprNode } from "./conditions";
46
+ import { splitDocuments, sourcePath } from "./render-stream";
47
+ import {
48
+ buildChartInstances,
49
+ mergeValues,
50
+ readYamlFile,
51
+ valueAtPath,
52
+ type ChartInstance,
53
+ } from "./values";
54
+
55
+ /** Renders the chart with the given values paths pinned to literal strings. */
56
+ export type RenderFn = (pins: Record<string, string>) => string;
57
+
58
+ export type PinStyle = "supply" | "existing-slot" | "gate";
59
+
60
+ export interface CandidatePin {
61
+ /** Root-scoped values path. */
62
+ path: string;
63
+ style: PinStyle;
64
+ }
65
+
66
+ /** One unstable output line, located in the rendered document set. */
67
+ export interface LocalizedOccurrence {
68
+ /** Source template of the rendered document (`# Source:` origin). */
69
+ doc: string;
70
+ /** `Kind/name` of the document, when parseable. */
71
+ docId: string;
72
+ /** YAML key of the differing line (`admin-password`, `checksum/secret`). */
73
+ key: string;
74
+ /** 1-based line within the document, from the first base render. */
75
+ line: number;
76
+ /**
77
+ * The line lives in a different template than the generator — a derived
78
+ * field (checksum annotation, re-included secret) grouped under its root
79
+ * input rather than reported as independent instability.
80
+ */
81
+ derived: boolean;
82
+ }
83
+
84
+ /** One open generated input with the unstable lines localized to it. */
85
+ export interface LocalizedInput {
86
+ fn: string;
87
+ /** Template file and line of the generator (classifier coordinates). */
88
+ file: string;
89
+ line: number;
90
+ /** The values path a supply-style pin uses, when one validated. */
91
+ valuesPath?: string;
92
+ /** A validated pin exists — supplying a value closes this input. */
93
+ suppliable: boolean;
94
+ /**
95
+ * The concrete values entry a user adds to close the input, e.g.
96
+ * `adminPassword: <generate once and supply>`. Absent when no candidate
97
+ * pin validated (the input is real but not closable via values).
98
+ */
99
+ suggestedPin?: string;
100
+ /** Chart-provided slots that also close it (`admin.existingSecret`). */
101
+ existingSlots: string[];
102
+ occurrences: LocalizedOccurrence[];
103
+ }
104
+
105
+ export interface LocalizationReport {
106
+ /** The base double render differed on zero lines. */
107
+ deterministic: boolean;
108
+ /** Differing line count of the base double render (per-render lines). */
109
+ differingLines: number;
110
+ inputs: LocalizedInput[];
111
+ /** Differing lines no probe and no source mapping explains. */
112
+ unlocalized: LocalizedOccurrence[];
113
+ /**
114
+ * Pinning every validated pin at once yields a stable render. Present
115
+ * only when at least one pin validated.
116
+ */
117
+ stableWithAllPins?: boolean;
118
+ /** How many `helm template` invocations the localization used. */
119
+ renders: number;
120
+ }
121
+
122
+ export interface LocalizeOptions {
123
+ render: RenderFn;
124
+ /** Values overrides, as `helm template --values` would merge them. */
125
+ values?: unknown;
126
+ /** Values files merged (in order) over the chart defaults. */
127
+ valuesFiles?: string[];
128
+ /** Probe budget per generator site (default 6). */
129
+ maxProbesPerInput?: number;
130
+ }
131
+
132
+ const PROBE_VALUE = "chant-localizer-probe";
133
+
134
+ // --- diffing with stable line identity -------------------------------------
135
+
136
+ interface DiffOccurrence {
137
+ ident: string;
138
+ doc: string;
139
+ docId: string;
140
+ key: string;
141
+ line: number;
142
+ }
143
+
144
+ function docIdOf(doc: string): string {
145
+ const kind = doc.match(/^kind: (\S+)/m)?.[1];
146
+ const name = doc.match(/^ {2}name: "?([^"\n]+)"?$/m)?.[1];
147
+ if (kind === undefined) return "?";
148
+ return name === undefined ? kind : `${kind}/${name}`;
149
+ }
150
+
151
+ function lineKeyOf(text: string, index: number): string {
152
+ const m = text.match(/^\s*(?:- )?"?([^:"]{1,120}?)"?:/);
153
+ return m ? m[1] : `@${index}`;
154
+ }
155
+
156
+ /** Differing line indices of one paired document, positional. */
157
+ function diffDocLines(la: string[], lb: string[]): number[] {
158
+ if (la.length === lb.length) {
159
+ const out: number[] = [];
160
+ for (let i = 0; i < la.length; i++) if (la[i] !== lb[i]) out.push(i);
161
+ return out;
162
+ }
163
+ // Unequal shapes: peel the common prefix and suffix, count A's middle.
164
+ let start = 0;
165
+ while (start < la.length && start < lb.length && la[start] === lb[start]) start++;
166
+ let endA = la.length;
167
+ let endB = lb.length;
168
+ while (endA > start && endB > start && la[endA - 1] === lb[endB - 1]) {
169
+ endA--;
170
+ endB--;
171
+ }
172
+ const out: number[] = [];
173
+ for (let i = start; i < endA; i++) out.push(i);
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Diff two renders of the SAME inputs into a set of differing-line
179
+ * occurrences with identities stable across separate probe runs: documents
180
+ * pair by (`# Source:` origin, ordinal), lines identify by their YAML key
181
+ * plus ordinal rather than raw content — the content is exactly what varies.
182
+ */
183
+ export function diffRenderPair(a: string, b: string): Map<string, DiffOccurrence> {
184
+ const out = new Map<string, DiffOccurrence>();
185
+ const group = (docs: string[]): Map<string, string[]> => {
186
+ const g = new Map<string, string[]>();
187
+ for (const d of docs) {
188
+ const src = sourcePath(d) ?? "?";
189
+ const arr = g.get(src);
190
+ if (arr === undefined) g.set(src, [d]);
191
+ else arr.push(d);
192
+ }
193
+ return g;
194
+ };
195
+ const groupsA = group(splitDocuments(a));
196
+ const groupsB = group(splitDocuments(b));
197
+
198
+ for (const [src, docsA] of groupsA) {
199
+ const docsB = groupsB.get(src) ?? [];
200
+ for (let ord = 0; ord < docsA.length; ord++) {
201
+ const docA = docsA[ord];
202
+ const docB = docsB[ord];
203
+ const linesA = docA.split("\n");
204
+ const differing =
205
+ docB === undefined
206
+ ? linesA.map((_, i) => i)
207
+ : diffDocLines(linesA, docB.split("\n"));
208
+ if (differing.length === 0) continue;
209
+ const docKey = `${src}#${ord}`;
210
+ const docId = docIdOf(docA);
211
+ const keyCounts = new Map<string, number>();
212
+ for (const i of differing) {
213
+ const key = lineKeyOf(linesA[i], i);
214
+ const n = keyCounts.get(key) ?? 0;
215
+ keyCounts.set(key, n + 1);
216
+ out.set(`${docKey}\u0000${key}\u0000${n}`, {
217
+ ident: `${docKey}\u0000${key}\u0000${n}`,
218
+ doc: src,
219
+ docId,
220
+ key,
221
+ line: i + 1,
222
+ });
223
+ }
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+
229
+ // --- static pin-candidate discovery ----------------------------------------
230
+
231
+ /**
232
+ * Values paths that, set truthy, make `expr` FALSE — closing a gate the
233
+ * generator sits behind. Only shapes a single string pin can definitively
234
+ * close are extracted: `not .Values.x` and `and`-conjunctions of them.
235
+ */
236
+ function closingPins(node: ExprNode | undefined): string[] {
237
+ if (node === undefined) return [];
238
+ if (node.type === "call") {
239
+ if (node.op === "not" && node.args.length === 1) return openingPins(node.args[0]);
240
+ if (node.op === "and") return node.args.flatMap((a) => closingPins(a));
241
+ if (node.op === "or" && node.args.length === 1) return closingPins(node.args[0]);
242
+ }
243
+ return [];
244
+ }
245
+
246
+ /** Values paths that, set truthy, make `expr` TRUE — killing an else branch. */
247
+ function openingPins(node: ExprNode | undefined): string[] {
248
+ if (node === undefined) return [];
249
+ if (node.type === "atom" && node.token.t === "word") {
250
+ const m = node.token.v.match(/^\$?\.Values((?:\.[A-Za-z0-9_-]+)+)$/);
251
+ return m ? [m[1].slice(1)] : [];
252
+ }
253
+ if (node.type === "call") {
254
+ if (node.op === "or") return node.args.flatMap((a) => openingPins(a));
255
+ if (node.op === "and" && node.args.length === 1) return openingPins(node.args[0]);
256
+ if (node.op === "not" && node.args.length === 1) return closingPins(node.args[0]);
257
+ }
258
+ return [];
259
+ }
260
+
261
+ const STYLE_RANK: Record<PinStyle, number> = { supply: 0, "existing-slot": 1, gate: 2 };
262
+
263
+ function styleOfGatePath(path: string): PinStyle {
264
+ return /existing/i.test(path.split(".").pop() ?? "") ? "existing-slot" : "gate";
265
+ }
266
+
267
+ // --- chart scanning (define/include graph) ---------------------------------
268
+
269
+ interface FileScan {
270
+ /** Root-relative file path (matches classifier report coordinates). */
271
+ file: string;
272
+ instance: ChartInstance;
273
+ scoped: ScopedAction[];
274
+ /** Innermost enclosing `define`/`block` name per action, when named. */
275
+ containingDefine: (string | undefined)[];
276
+ }
277
+
278
+ interface ChartScan {
279
+ files: Map<string, FileScan>;
280
+ /** define name -> include sites (file + action index). */
281
+ includeSites: Map<string, { file: string; index: number }[]>;
282
+ }
283
+
284
+ const INCLUDE_RE = /(?:include|template)\s+"([^"]+)"/g;
285
+ const DEFINE_NAME_RE = /^(?:define|block)\s+"([^"]+)"/;
286
+
287
+ function scanChart(chartDir: string, supplied: unknown): ChartScan {
288
+ const { instances } = buildChartInstances(chartDir, supplied);
289
+ const files = new Map<string, FileScan>();
290
+ const includeSites = new Map<string, { file: string; index: number }[]>();
291
+ const seenDirs = new Set<string>();
292
+
293
+ for (const instance of instances) {
294
+ // Aliased dependencies scan the same directory twice; the first
295
+ // instance's scope stands in for both (pin paths differ per alias, but
296
+ // the FILES exist once — same convention the classifier uses).
297
+ if (seenDirs.has(instance.dir)) continue;
298
+ seenDirs.add(instance.dir);
299
+
300
+ for (const abs of collectOwnTemplateFiles(instance.dir).sort()) {
301
+ const rel = abs.slice(instance.dir.length + 1);
302
+ const file = instance.relDir === "" ? rel : `${instance.relDir}/${rel}`;
303
+ const scoped = scopeActions(extractActions(readFileSync(abs, "utf8"), file));
304
+
305
+ // Track the enclosing define per action: define/block/if/with/range
306
+ // push, end pops — mirroring scopeActions' stack discipline.
307
+ const stack: (string | undefined)[] = [];
308
+ const containingDefine: (string | undefined)[] = [];
309
+ for (let i = 0; i < scoped.length; i++) {
310
+ const action = scoped[i].action;
311
+ const nearest = (): string | undefined => {
312
+ for (let s = stack.length - 1; s >= 0; s--) {
313
+ if (stack[s] !== undefined) return stack[s];
314
+ }
315
+ return undefined;
316
+ };
317
+ switch (action.kind) {
318
+ case "define":
319
+ case "block": {
320
+ containingDefine.push(nearest());
321
+ stack.push(action.body.match(DEFINE_NAME_RE)?.[1]);
322
+ break;
323
+ }
324
+ case "if":
325
+ case "with":
326
+ case "range":
327
+ containingDefine.push(nearest());
328
+ stack.push(undefined);
329
+ break;
330
+ case "end":
331
+ stack.pop();
332
+ containingDefine.push(nearest());
333
+ break;
334
+ default:
335
+ containingDefine.push(nearest());
336
+ }
337
+ for (const m of action.body.matchAll(INCLUDE_RE)) {
338
+ const sites = includeSites.get(m[1]);
339
+ const site = { file, index: i };
340
+ if (sites === undefined) includeSites.set(m[1], [site]);
341
+ else sites.push(site);
342
+ }
343
+ }
344
+ files.set(file, { file, instance, scoped, containingDefine });
345
+ }
346
+ }
347
+ return { files, includeSites };
348
+ }
349
+
350
+ /**
351
+ * Candidate pins for one scoped action: gates from enclosing frames
352
+ * (closing a condition, or satisfying an else branch's negated condition),
353
+ * scoped into root coordinates.
354
+ */
355
+ function pinsForAction(scan: FileScan, index: number): CandidatePin[] {
356
+ const out: CandidatePin[] = [];
357
+ const scope = scan.instance.scope;
358
+ const scopePath = (rel: string): string => [...scope, rel].join(".");
359
+ for (const frame of scan.scoped[index].frames) {
360
+ for (const cond of frame.conditions) {
361
+ for (const p of closingPins(parseExpr(cond))) {
362
+ out.push({ path: scopePath(p), style: styleOfGatePath(p) });
363
+ }
364
+ }
365
+ for (const neg of frame.negatedConditions) {
366
+ // The else branch of `if .Values.x` runs the generator; supplying x
367
+ // is the supply-style pin — the value that replaces the generated one.
368
+ for (const p of openingPins(parseExpr(neg))) {
369
+ out.push({ path: scopePath(p), style: "supply" });
370
+ }
371
+ }
372
+ }
373
+ return out;
374
+ }
375
+
376
+ /**
377
+ * All candidate pins for a generator site, walking the include chain: a
378
+ * generator inside a define inherits the gates of every include site of
379
+ * that define, recursively (bounded).
380
+ */
381
+ function candidatesForSite(
382
+ scan: ChartScan,
383
+ site: ClosedInput,
384
+ rootValues: unknown,
385
+ ): CandidatePin[] {
386
+ const collected: CandidatePin[] = [];
387
+ if (site.suppliable === true && site.valuesPath !== undefined) {
388
+ collected.push({ path: site.valuesPath, style: "supply" });
389
+ }
390
+
391
+ const fileScan = site.file !== undefined ? scan.files.get(site.file) : undefined;
392
+ if (fileScan !== undefined) {
393
+ const fnRe = new RegExp(`(?<![.\\w$])${site.fn}\\b`);
394
+ const index = fileScan.scoped.findIndex(
395
+ (s) => s.action.line === site.line && fnRe.test(s.action.body),
396
+ );
397
+ if (index !== -1) {
398
+ const visitedDefines = new Set<string>();
399
+ const visit = (fs: FileScan, actionIndex: number, depth: number): void => {
400
+ collected.push(...pinsForAction(fs, actionIndex));
401
+ if (depth >= 4) return;
402
+ const define = fs.containingDefine[actionIndex];
403
+ if (define === undefined || visitedDefines.has(define)) return;
404
+ visitedDefines.add(define);
405
+ for (const inc of scan.includeSites.get(define) ?? []) {
406
+ const incScan = scan.files.get(inc.file);
407
+ if (incScan !== undefined) visit(incScan, inc.index, depth + 1);
408
+ }
409
+ };
410
+ visit(fileScan, index, 0);
411
+ }
412
+ }
413
+
414
+ // Dedupe by path (strongest style wins); drop pins that cannot change the
415
+ // render: already-truthy values (the gate would already be closed) and
416
+ // structured values (a string pin would corrupt them).
417
+ const byPath = new Map<string, CandidatePin>();
418
+ for (const pin of collected) {
419
+ const current = valueAtPath(rootValues, pin.path.split("."));
420
+ if (current !== undefined && current !== null) {
421
+ if (typeof current === "object") continue;
422
+ if (current !== "" && current !== false && current !== 0) continue;
423
+ }
424
+ const existing = byPath.get(pin.path);
425
+ if (existing === undefined || STYLE_RANK[pin.style] < STYLE_RANK[existing.style]) {
426
+ byPath.set(pin.path, pin);
427
+ }
428
+ }
429
+ return [...byPath.values()].sort((a, b) => STYLE_RANK[a.style] - STYLE_RANK[b.style]);
430
+ }
431
+
432
+ // --- localization ----------------------------------------------------------
433
+
434
+ function suggestedPinFor(pin: CandidatePin): string {
435
+ switch (pin.style) {
436
+ case "supply":
437
+ return `${pin.path}: <generate once and supply>`;
438
+ case "existing-slot":
439
+ return `${pin.path}: <existing Secret name>`;
440
+ case "gate":
441
+ return `${pin.path}: <supply to disable in-template generation>`;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Localize the open generated inputs of a chart: double-render, and map
447
+ * every differing line back to the input that produced it by pinning
448
+ * candidate inputs one at a time. See the module doc for the mechanism.
449
+ */
450
+ export function localizeOpenInputs(
451
+ chartDir: string,
452
+ report: PinnabilityReport,
453
+ options: LocalizeOptions,
454
+ ): LocalizationReport {
455
+ let supplied: unknown;
456
+ for (const file of options.valuesFiles ?? []) {
457
+ supplied = mergeValues(supplied ?? {}, readYamlFile(file));
458
+ }
459
+ if (options.values !== undefined) supplied = mergeValues(supplied ?? {}, options.values);
460
+
461
+ let renders = 0;
462
+ const probeCache = new Map<string, Map<string, DiffOccurrence>>();
463
+ const probe = (pins: Record<string, string>): Map<string, DiffOccurrence> => {
464
+ const key = JSON.stringify(Object.entries(pins).sort());
465
+ const cached = probeCache.get(key);
466
+ if (cached !== undefined) return cached;
467
+ const a = options.render(pins);
468
+ const b = options.render(pins);
469
+ renders += 2;
470
+ const diff = diffRenderPair(a, b);
471
+ probeCache.set(key, diff);
472
+ return diff;
473
+ };
474
+
475
+ const base = probe({});
476
+ const sites = report.closedInputs.filter(
477
+ (c): c is ClosedInput & { fn: string; file: string; line: number } =>
478
+ c.kind === "generated-value" &&
479
+ c.fn !== undefined &&
480
+ c.file !== undefined &&
481
+ c.line !== undefined,
482
+ );
483
+
484
+ const inputs: LocalizedInput[] = sites.map((s) => ({
485
+ fn: s.fn,
486
+ file: s.file,
487
+ line: s.line,
488
+ valuesPath: undefined,
489
+ suppliable: false,
490
+ suggestedPin: undefined,
491
+ existingSlots: [],
492
+ occurrences: [],
493
+ }));
494
+
495
+ if (base.size === 0) {
496
+ // Deterministic: the statically-named inputs did not fire. Report them
497
+ // with their static supply paths and no occurrences; no probes needed.
498
+ for (let i = 0; i < sites.length; i++) {
499
+ if (sites[i].suppliable === true && sites[i].valuesPath !== undefined) {
500
+ inputs[i].valuesPath = sites[i].valuesPath;
501
+ inputs[i].suppliable = true;
502
+ inputs[i].suggestedPin = suggestedPinFor({
503
+ path: sites[i].valuesPath as string,
504
+ style: "supply",
505
+ });
506
+ }
507
+ }
508
+ return {
509
+ deterministic: true,
510
+ differingLines: 0,
511
+ inputs,
512
+ unlocalized: [],
513
+ renders,
514
+ };
515
+ }
516
+
517
+ const rootValues = mergeValues(readYamlFile(`${chartDir}/values.yaml`) ?? {}, supplied);
518
+ const scan = scanChart(chartDir, supplied);
519
+ const maxProbes = options.maxProbesPerInput ?? 6;
520
+
521
+ const attributed = new Set<string>();
522
+ const attributedTo = new Map<number, Set<string>>();
523
+ const bestPin: (CandidatePin | undefined)[] = new Array(sites.length).fill(undefined);
524
+
525
+ const attribute = (siteIndex: number, ident: string): void => {
526
+ let set = attributedTo.get(siteIndex);
527
+ if (set === undefined) {
528
+ set = new Set();
529
+ attributedTo.set(siteIndex, set);
530
+ }
531
+ set.add(ident);
532
+ attributed.add(ident);
533
+ };
534
+
535
+ // Phase A — individual pin probes. A pin validates when it stabilizes at
536
+ // least one base-differing line; those lines belong to its site.
537
+ for (let i = 0; i < sites.length; i++) {
538
+ const cands = candidatesForSite(scan, sites[i], rootValues);
539
+ let probes = 0;
540
+ for (const cand of cands) {
541
+ if (probes >= maxProbes) break;
542
+ // Plain gates are a last resort: once any pin validated, only probe
543
+ // remaining supply/existing-slot candidates (they name user slots).
544
+ if (cand.style === "gate" && bestPin[i] !== undefined) continue;
545
+ probes += 1;
546
+ const pinned = probe({ [cand.path]: PROBE_VALUE });
547
+ const stabilized = [...base.keys()].filter((id) => !pinned.has(id));
548
+ if (stabilized.length === 0) continue;
549
+ for (const id of stabilized) attribute(i, id);
550
+ if (cand.style === "existing-slot") inputs[i].existingSlots.push(cand.path);
551
+ if (
552
+ bestPin[i] === undefined ||
553
+ STYLE_RANK[cand.style] < STYLE_RANK[(bestPin[i] as CandidatePin).style]
554
+ ) {
555
+ bestPin[i] = cand;
556
+ }
557
+ }
558
+ if (bestPin[i] !== undefined) {
559
+ const pin = bestPin[i] as CandidatePin;
560
+ inputs[i].suppliable = true;
561
+ inputs[i].suggestedPin = suggestedPinFor(pin);
562
+ if (pin.style === "supply") inputs[i].valuesPath = pin.path;
563
+ }
564
+ }
565
+
566
+ // Phase B — per-file joint probes for cascades with several roots: a
567
+ // checksum over a secret carrying two generated values stabilizes only
568
+ // when BOTH are pinned, so it attributes to every generator of that file.
569
+ const byFile = new Map<string, number[]>();
570
+ for (let i = 0; i < sites.length; i++) {
571
+ if (bestPin[i] === undefined) continue;
572
+ const arr = byFile.get(sites[i].file);
573
+ if (arr === undefined) byFile.set(sites[i].file, [i]);
574
+ else arr.push(i);
575
+ }
576
+ if ([...attributed].length < base.size) {
577
+ for (const members of byFile.values()) {
578
+ if (members.length < 2) continue;
579
+ const pins: Record<string, string> = {};
580
+ for (const i of members) pins[(bestPin[i] as CandidatePin).path] = PROBE_VALUE;
581
+ const pinned = probe(pins);
582
+ for (const id of base.keys()) {
583
+ if (attributed.has(id) || pinned.has(id)) continue;
584
+ for (const i of members) attribute(i, id);
585
+ }
586
+ }
587
+ }
588
+
589
+ // Phase C — all validated pins at once: confirms that closing every
590
+ // localized input closes the chart, and catches cross-file joint cascades.
591
+ let stableWithAllPins: boolean | undefined;
592
+ const pinnedSites = bestPin.flatMap((p, i) => (p === undefined ? [] : [i]));
593
+ if (pinnedSites.length > 0) {
594
+ const pins: Record<string, string> = {};
595
+ for (const i of pinnedSites) pins[(bestPin[i] as CandidatePin).path] = PROBE_VALUE;
596
+ const finalDiff = probe(pins);
597
+ stableWithAllPins = finalDiff.size === 0;
598
+ for (const id of base.keys()) {
599
+ if (attributed.has(id) || finalDiff.has(id)) continue;
600
+ for (const i of pinnedSites) attribute(i, id);
601
+ }
602
+ }
603
+
604
+ // Phase D — source fallback for sites no pin can close (a genCA behind an
605
+ // include-computed gate): a differing line in a document rendered FROM the
606
+ // generator's own template maps to it without a probe.
607
+ for (const [id, occ] of base) {
608
+ if (attributed.has(id)) continue;
609
+ for (let i = 0; i < sites.length; i++) {
610
+ if (sites[i].file === occ.doc) attribute(i, id);
611
+ }
612
+ }
613
+
614
+ const toOccurrence = (occ: DiffOccurrence, derived: boolean): LocalizedOccurrence => ({
615
+ doc: occ.doc,
616
+ docId: occ.docId,
617
+ key: occ.key,
618
+ line: occ.line,
619
+ derived,
620
+ });
621
+ const unlocalized: LocalizedOccurrence[] = [];
622
+ for (const [id, occ] of base) {
623
+ if (!attributed.has(id)) {
624
+ unlocalized.push(toOccurrence(occ, false));
625
+ }
626
+ }
627
+ for (let i = 0; i < sites.length; i++) {
628
+ const ids = attributedTo.get(i);
629
+ if (ids === undefined) continue;
630
+ inputs[i].occurrences = [...ids]
631
+ .map((id) => base.get(id) as DiffOccurrence)
632
+ .sort((a, b) => (a.doc === b.doc ? a.line - b.line : a.doc < b.doc ? -1 : 1))
633
+ .map((occ) => toOccurrence(occ, occ.doc !== sites[i].file));
634
+ }
635
+
636
+ return {
637
+ deterministic: false,
638
+ differingLines: base.size,
639
+ inputs,
640
+ unlocalized,
641
+ stableWithAllPins,
642
+ renders,
643
+ };
644
+ }