@intentius/chant-lexicon-helm 0.124.31 → 0.124.32

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,518 @@
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
+ import { readFileSync } from "node:fs";
33
+ import { extractActions, scopeActions, } from "./actions.js";
34
+ import { collectOwnTemplateFiles, } from "./classify.js";
35
+ import { parseExpr } from "./conditions.js";
36
+ import { splitDocuments, sourcePath } from "./render-stream.js";
37
+ import { buildChartInstances, mergeValues, readYamlFile, valueAtPath, } from "./values.js";
38
+ const PROBE_VALUE = "chant-localizer-probe";
39
+ function docIdOf(doc) {
40
+ const kind = doc.match(/^kind: (\S+)/m)?.[1];
41
+ const name = doc.match(/^ {2}name: "?([^"\n]+)"?$/m)?.[1];
42
+ if (kind === undefined)
43
+ return "?";
44
+ return name === undefined ? kind : `${kind}/${name}`;
45
+ }
46
+ function lineKeyOf(text, index) {
47
+ const m = text.match(/^\s*(?:- )?"?([^:"]{1,120}?)"?:/);
48
+ return m ? m[1] : `@${index}`;
49
+ }
50
+ /** Differing line indices of one paired document, positional. */
51
+ function diffDocLines(la, lb) {
52
+ if (la.length === lb.length) {
53
+ const out = [];
54
+ for (let i = 0; i < la.length; i++)
55
+ if (la[i] !== lb[i])
56
+ out.push(i);
57
+ return out;
58
+ }
59
+ // Unequal shapes: peel the common prefix and suffix, count A's middle.
60
+ let start = 0;
61
+ while (start < la.length && start < lb.length && la[start] === lb[start])
62
+ start++;
63
+ let endA = la.length;
64
+ let endB = lb.length;
65
+ while (endA > start && endB > start && la[endA - 1] === lb[endB - 1]) {
66
+ endA--;
67
+ endB--;
68
+ }
69
+ const out = [];
70
+ for (let i = start; i < endA; i++)
71
+ out.push(i);
72
+ return out;
73
+ }
74
+ /**
75
+ * Diff two renders of the SAME inputs into a set of differing-line
76
+ * occurrences with identities stable across separate probe runs: documents
77
+ * pair by (`# Source:` origin, ordinal), lines identify by their YAML key
78
+ * plus ordinal rather than raw content — the content is exactly what varies.
79
+ */
80
+ export function diffRenderPair(a, b) {
81
+ const out = new Map();
82
+ const group = (docs) => {
83
+ const g = new Map();
84
+ for (const d of docs) {
85
+ const src = sourcePath(d) ?? "?";
86
+ const arr = g.get(src);
87
+ if (arr === undefined)
88
+ g.set(src, [d]);
89
+ else
90
+ arr.push(d);
91
+ }
92
+ return g;
93
+ };
94
+ const groupsA = group(splitDocuments(a));
95
+ const groupsB = group(splitDocuments(b));
96
+ for (const [src, docsA] of groupsA) {
97
+ const docsB = groupsB.get(src) ?? [];
98
+ for (let ord = 0; ord < docsA.length; ord++) {
99
+ const docA = docsA[ord];
100
+ const docB = docsB[ord];
101
+ const linesA = docA.split("\n");
102
+ const differing = docB === undefined
103
+ ? linesA.map((_, i) => i)
104
+ : diffDocLines(linesA, docB.split("\n"));
105
+ if (differing.length === 0)
106
+ continue;
107
+ const docKey = `${src}#${ord}`;
108
+ const docId = docIdOf(docA);
109
+ const keyCounts = new Map();
110
+ for (const i of differing) {
111
+ const key = lineKeyOf(linesA[i], i);
112
+ const n = keyCounts.get(key) ?? 0;
113
+ keyCounts.set(key, n + 1);
114
+ out.set(`${docKey}\u0000${key}\u0000${n}`, {
115
+ ident: `${docKey}\u0000${key}\u0000${n}`,
116
+ doc: src,
117
+ docId,
118
+ key,
119
+ line: i + 1,
120
+ });
121
+ }
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ // --- static pin-candidate discovery ----------------------------------------
127
+ /**
128
+ * Values paths that, set truthy, make `expr` FALSE — closing a gate the
129
+ * generator sits behind. Only shapes a single string pin can definitively
130
+ * close are extracted: `not .Values.x` and `and`-conjunctions of them.
131
+ */
132
+ function closingPins(node) {
133
+ if (node === undefined)
134
+ return [];
135
+ if (node.type === "call") {
136
+ if (node.op === "not" && node.args.length === 1)
137
+ return openingPins(node.args[0]);
138
+ if (node.op === "and")
139
+ return node.args.flatMap((a) => closingPins(a));
140
+ if (node.op === "or" && node.args.length === 1)
141
+ return closingPins(node.args[0]);
142
+ }
143
+ return [];
144
+ }
145
+ /** Values paths that, set truthy, make `expr` TRUE — killing an else branch. */
146
+ function openingPins(node) {
147
+ if (node === undefined)
148
+ return [];
149
+ if (node.type === "atom" && node.token.t === "word") {
150
+ const m = node.token.v.match(/^\$?\.Values((?:\.[A-Za-z0-9_-]+)+)$/);
151
+ return m ? [m[1].slice(1)] : [];
152
+ }
153
+ if (node.type === "call") {
154
+ if (node.op === "or")
155
+ return node.args.flatMap((a) => openingPins(a));
156
+ if (node.op === "and" && node.args.length === 1)
157
+ return openingPins(node.args[0]);
158
+ if (node.op === "not" && node.args.length === 1)
159
+ return closingPins(node.args[0]);
160
+ }
161
+ return [];
162
+ }
163
+ const STYLE_RANK = { supply: 0, "existing-slot": 1, gate: 2 };
164
+ function styleOfGatePath(path) {
165
+ return /existing/i.test(path.split(".").pop() ?? "") ? "existing-slot" : "gate";
166
+ }
167
+ const INCLUDE_RE = /(?:include|template)\s+"([^"]+)"/g;
168
+ const DEFINE_NAME_RE = /^(?:define|block)\s+"([^"]+)"/;
169
+ function scanChart(chartDir, supplied) {
170
+ const { instances } = buildChartInstances(chartDir, supplied);
171
+ const files = new Map();
172
+ const includeSites = new Map();
173
+ const seenDirs = new Set();
174
+ for (const instance of instances) {
175
+ // Aliased dependencies scan the same directory twice; the first
176
+ // instance's scope stands in for both (pin paths differ per alias, but
177
+ // the FILES exist once — same convention the classifier uses).
178
+ if (seenDirs.has(instance.dir))
179
+ continue;
180
+ seenDirs.add(instance.dir);
181
+ for (const abs of collectOwnTemplateFiles(instance.dir).sort()) {
182
+ const rel = abs.slice(instance.dir.length + 1);
183
+ const file = instance.relDir === "" ? rel : `${instance.relDir}/${rel}`;
184
+ const scoped = scopeActions(extractActions(readFileSync(abs, "utf8"), file));
185
+ // Track the enclosing define per action: define/block/if/with/range
186
+ // push, end pops — mirroring scopeActions' stack discipline.
187
+ const stack = [];
188
+ const containingDefine = [];
189
+ for (let i = 0; i < scoped.length; i++) {
190
+ const action = scoped[i].action;
191
+ const nearest = () => {
192
+ for (let s = stack.length - 1; s >= 0; s--) {
193
+ if (stack[s] !== undefined)
194
+ return stack[s];
195
+ }
196
+ return undefined;
197
+ };
198
+ switch (action.kind) {
199
+ case "define":
200
+ case "block": {
201
+ containingDefine.push(nearest());
202
+ stack.push(action.body.match(DEFINE_NAME_RE)?.[1]);
203
+ break;
204
+ }
205
+ case "if":
206
+ case "with":
207
+ case "range":
208
+ containingDefine.push(nearest());
209
+ stack.push(undefined);
210
+ break;
211
+ case "end":
212
+ stack.pop();
213
+ containingDefine.push(nearest());
214
+ break;
215
+ default:
216
+ containingDefine.push(nearest());
217
+ }
218
+ for (const m of action.body.matchAll(INCLUDE_RE)) {
219
+ const sites = includeSites.get(m[1]);
220
+ const site = { file, index: i };
221
+ if (sites === undefined)
222
+ includeSites.set(m[1], [site]);
223
+ else
224
+ sites.push(site);
225
+ }
226
+ }
227
+ files.set(file, { file, instance, scoped, containingDefine });
228
+ }
229
+ }
230
+ return { files, includeSites };
231
+ }
232
+ /**
233
+ * Candidate pins for one scoped action: gates from enclosing frames
234
+ * (closing a condition, or satisfying an else branch's negated condition),
235
+ * scoped into root coordinates.
236
+ */
237
+ function pinsForAction(scan, index) {
238
+ const out = [];
239
+ const scope = scan.instance.scope;
240
+ const scopePath = (rel) => [...scope, rel].join(".");
241
+ for (const frame of scan.scoped[index].frames) {
242
+ for (const cond of frame.conditions) {
243
+ for (const p of closingPins(parseExpr(cond))) {
244
+ out.push({ path: scopePath(p), style: styleOfGatePath(p) });
245
+ }
246
+ }
247
+ for (const neg of frame.negatedConditions) {
248
+ // The else branch of `if .Values.x` runs the generator; supplying x
249
+ // is the supply-style pin — the value that replaces the generated one.
250
+ for (const p of openingPins(parseExpr(neg))) {
251
+ out.push({ path: scopePath(p), style: "supply" });
252
+ }
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+ /**
258
+ * All candidate pins for a generator site, walking the include chain: a
259
+ * generator inside a define inherits the gates of every include site of
260
+ * that define, recursively (bounded).
261
+ */
262
+ function candidatesForSite(scan, site, rootValues) {
263
+ const collected = [];
264
+ if (site.suppliable === true && site.valuesPath !== undefined) {
265
+ collected.push({ path: site.valuesPath, style: "supply" });
266
+ }
267
+ const fileScan = site.file !== undefined ? scan.files.get(site.file) : undefined;
268
+ if (fileScan !== undefined) {
269
+ const fnRe = new RegExp(`(?<![.\\w$])${site.fn}\\b`);
270
+ const index = fileScan.scoped.findIndex((s) => s.action.line === site.line && fnRe.test(s.action.body));
271
+ if (index !== -1) {
272
+ const visitedDefines = new Set();
273
+ const visit = (fs, actionIndex, depth) => {
274
+ collected.push(...pinsForAction(fs, actionIndex));
275
+ if (depth >= 4)
276
+ return;
277
+ const define = fs.containingDefine[actionIndex];
278
+ if (define === undefined || visitedDefines.has(define))
279
+ return;
280
+ visitedDefines.add(define);
281
+ for (const inc of scan.includeSites.get(define) ?? []) {
282
+ const incScan = scan.files.get(inc.file);
283
+ if (incScan !== undefined)
284
+ visit(incScan, inc.index, depth + 1);
285
+ }
286
+ };
287
+ visit(fileScan, index, 0);
288
+ }
289
+ }
290
+ // Dedupe by path (strongest style wins); drop pins that cannot change the
291
+ // render: already-truthy values (the gate would already be closed) and
292
+ // structured values (a string pin would corrupt them).
293
+ const byPath = new Map();
294
+ for (const pin of collected) {
295
+ const current = valueAtPath(rootValues, pin.path.split("."));
296
+ if (current !== undefined && current !== null) {
297
+ if (typeof current === "object")
298
+ continue;
299
+ if (current !== "" && current !== false && current !== 0)
300
+ continue;
301
+ }
302
+ const existing = byPath.get(pin.path);
303
+ if (existing === undefined || STYLE_RANK[pin.style] < STYLE_RANK[existing.style]) {
304
+ byPath.set(pin.path, pin);
305
+ }
306
+ }
307
+ return [...byPath.values()].sort((a, b) => STYLE_RANK[a.style] - STYLE_RANK[b.style]);
308
+ }
309
+ // --- localization ----------------------------------------------------------
310
+ function suggestedPinFor(pin) {
311
+ switch (pin.style) {
312
+ case "supply":
313
+ return `${pin.path}: <generate once and supply>`;
314
+ case "existing-slot":
315
+ return `${pin.path}: <existing Secret name>`;
316
+ case "gate":
317
+ return `${pin.path}: <supply to disable in-template generation>`;
318
+ }
319
+ }
320
+ /**
321
+ * Localize the open generated inputs of a chart: double-render, and map
322
+ * every differing line back to the input that produced it by pinning
323
+ * candidate inputs one at a time. See the module doc for the mechanism.
324
+ */
325
+ export function localizeOpenInputs(chartDir, report, options) {
326
+ let supplied;
327
+ for (const file of options.valuesFiles ?? []) {
328
+ supplied = mergeValues(supplied ?? {}, readYamlFile(file));
329
+ }
330
+ if (options.values !== undefined)
331
+ supplied = mergeValues(supplied ?? {}, options.values);
332
+ let renders = 0;
333
+ const probeCache = new Map();
334
+ const probe = (pins) => {
335
+ const key = JSON.stringify(Object.entries(pins).sort());
336
+ const cached = probeCache.get(key);
337
+ if (cached !== undefined)
338
+ return cached;
339
+ const a = options.render(pins);
340
+ const b = options.render(pins);
341
+ renders += 2;
342
+ const diff = diffRenderPair(a, b);
343
+ probeCache.set(key, diff);
344
+ return diff;
345
+ };
346
+ const base = probe({});
347
+ const sites = report.closedInputs.filter((c) => c.kind === "generated-value" &&
348
+ c.fn !== undefined &&
349
+ c.file !== undefined &&
350
+ c.line !== undefined);
351
+ const inputs = sites.map((s) => ({
352
+ fn: s.fn,
353
+ file: s.file,
354
+ line: s.line,
355
+ valuesPath: undefined,
356
+ suppliable: false,
357
+ suggestedPin: undefined,
358
+ existingSlots: [],
359
+ occurrences: [],
360
+ }));
361
+ if (base.size === 0) {
362
+ // Deterministic: the statically-named inputs did not fire. Report them
363
+ // with their static supply paths and no occurrences; no probes needed.
364
+ for (let i = 0; i < sites.length; i++) {
365
+ if (sites[i].suppliable === true && sites[i].valuesPath !== undefined) {
366
+ inputs[i].valuesPath = sites[i].valuesPath;
367
+ inputs[i].suppliable = true;
368
+ inputs[i].suggestedPin = suggestedPinFor({
369
+ path: sites[i].valuesPath,
370
+ style: "supply",
371
+ });
372
+ }
373
+ }
374
+ return {
375
+ deterministic: true,
376
+ differingLines: 0,
377
+ inputs,
378
+ unlocalized: [],
379
+ renders,
380
+ };
381
+ }
382
+ const rootValues = mergeValues(readYamlFile(`${chartDir}/values.yaml`) ?? {}, supplied);
383
+ const scan = scanChart(chartDir, supplied);
384
+ const maxProbes = options.maxProbesPerInput ?? 6;
385
+ const attributed = new Set();
386
+ const attributedTo = new Map();
387
+ const bestPin = new Array(sites.length).fill(undefined);
388
+ const attribute = (siteIndex, ident) => {
389
+ let set = attributedTo.get(siteIndex);
390
+ if (set === undefined) {
391
+ set = new Set();
392
+ attributedTo.set(siteIndex, set);
393
+ }
394
+ set.add(ident);
395
+ attributed.add(ident);
396
+ };
397
+ // Phase A — individual pin probes. A pin validates when it stabilizes at
398
+ // least one base-differing line; those lines belong to its site.
399
+ for (let i = 0; i < sites.length; i++) {
400
+ const cands = candidatesForSite(scan, sites[i], rootValues);
401
+ let probes = 0;
402
+ for (const cand of cands) {
403
+ if (probes >= maxProbes)
404
+ break;
405
+ // Plain gates are a last resort: once any pin validated, only probe
406
+ // remaining supply/existing-slot candidates (they name user slots).
407
+ if (cand.style === "gate" && bestPin[i] !== undefined)
408
+ continue;
409
+ probes += 1;
410
+ const pinned = probe({ [cand.path]: PROBE_VALUE });
411
+ const stabilized = [...base.keys()].filter((id) => !pinned.has(id));
412
+ if (stabilized.length === 0)
413
+ continue;
414
+ for (const id of stabilized)
415
+ attribute(i, id);
416
+ if (cand.style === "existing-slot")
417
+ inputs[i].existingSlots.push(cand.path);
418
+ if (bestPin[i] === undefined ||
419
+ STYLE_RANK[cand.style] < STYLE_RANK[bestPin[i].style]) {
420
+ bestPin[i] = cand;
421
+ }
422
+ }
423
+ if (bestPin[i] !== undefined) {
424
+ const pin = bestPin[i];
425
+ inputs[i].suppliable = true;
426
+ inputs[i].suggestedPin = suggestedPinFor(pin);
427
+ if (pin.style === "supply")
428
+ inputs[i].valuesPath = pin.path;
429
+ }
430
+ }
431
+ // Phase B — per-file joint probes for cascades with several roots: a
432
+ // checksum over a secret carrying two generated values stabilizes only
433
+ // when BOTH are pinned, so it attributes to every generator of that file.
434
+ const byFile = new Map();
435
+ for (let i = 0; i < sites.length; i++) {
436
+ if (bestPin[i] === undefined)
437
+ continue;
438
+ const arr = byFile.get(sites[i].file);
439
+ if (arr === undefined)
440
+ byFile.set(sites[i].file, [i]);
441
+ else
442
+ arr.push(i);
443
+ }
444
+ if ([...attributed].length < base.size) {
445
+ for (const members of byFile.values()) {
446
+ if (members.length < 2)
447
+ continue;
448
+ const pins = {};
449
+ for (const i of members)
450
+ pins[bestPin[i].path] = PROBE_VALUE;
451
+ const pinned = probe(pins);
452
+ for (const id of base.keys()) {
453
+ if (attributed.has(id) || pinned.has(id))
454
+ continue;
455
+ for (const i of members)
456
+ attribute(i, id);
457
+ }
458
+ }
459
+ }
460
+ // Phase C — all validated pins at once: confirms that closing every
461
+ // localized input closes the chart, and catches cross-file joint cascades.
462
+ let stableWithAllPins;
463
+ const pinnedSites = bestPin.flatMap((p, i) => (p === undefined ? [] : [i]));
464
+ if (pinnedSites.length > 0) {
465
+ const pins = {};
466
+ for (const i of pinnedSites)
467
+ pins[bestPin[i].path] = PROBE_VALUE;
468
+ const finalDiff = probe(pins);
469
+ stableWithAllPins = finalDiff.size === 0;
470
+ for (const id of base.keys()) {
471
+ if (attributed.has(id) || finalDiff.has(id))
472
+ continue;
473
+ for (const i of pinnedSites)
474
+ attribute(i, id);
475
+ }
476
+ }
477
+ // Phase D — source fallback for sites no pin can close (a genCA behind an
478
+ // include-computed gate): a differing line in a document rendered FROM the
479
+ // generator's own template maps to it without a probe.
480
+ for (const [id, occ] of base) {
481
+ if (attributed.has(id))
482
+ continue;
483
+ for (let i = 0; i < sites.length; i++) {
484
+ if (sites[i].file === occ.doc)
485
+ attribute(i, id);
486
+ }
487
+ }
488
+ const toOccurrence = (occ, derived) => ({
489
+ doc: occ.doc,
490
+ docId: occ.docId,
491
+ key: occ.key,
492
+ line: occ.line,
493
+ derived,
494
+ });
495
+ const unlocalized = [];
496
+ for (const [id, occ] of base) {
497
+ if (!attributed.has(id)) {
498
+ unlocalized.push(toOccurrence(occ, false));
499
+ }
500
+ }
501
+ for (let i = 0; i < sites.length; i++) {
502
+ const ids = attributedTo.get(i);
503
+ if (ids === undefined)
504
+ continue;
505
+ inputs[i].occurrences = [...ids]
506
+ .map((id) => base.get(id))
507
+ .sort((a, b) => (a.doc === b.doc ? a.line - b.line : a.doc < b.doc ? -1 : 1))
508
+ .map((occ) => toOccurrence(occ, occ.doc !== sites[i].file));
509
+ }
510
+ return {
511
+ deterministic: false,
512
+ differingLines: base.size,
513
+ inputs,
514
+ unlocalized,
515
+ stableWithAllPins,
516
+ renders,
517
+ };
518
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Helpers over a `helm template` output stream (#1234, epic #1228).
3
+ *
4
+ * The CRD routing rule here is what the structure-preserving wrapper
5
+ * (#1239) builds on, and the line diff seeds the double-render localizer
6
+ * (#1236). Both were proven in the survey harness before promotion.
7
+ */
8
+ /** Split a `helm template` stream into non-empty documents. */
9
+ export function splitDocuments(rendered) {
10
+ return rendered
11
+ .split(/^---$/m)
12
+ .map((d) => d.trim())
13
+ .filter((d) => d.length > 0);
14
+ }
15
+ /** The `# Source: <chart>/<path>` origin of a rendered document, if any. */
16
+ export function sourcePath(doc) {
17
+ const m = doc.match(/^# Source: \S+?\/(\S+)$/m);
18
+ return m ? m[1] : undefined;
19
+ }
20
+ /**
21
+ * Finding 10: segment matching, not prefix matching. A subchart's CRDs
22
+ * arrive as `charts/<child>/crds/<file>`; `startsWith("crds/")` catches
23
+ * 0 of them and would route every one into templates/, making it deletable
24
+ * on uninstall.
25
+ */
26
+ export function isCrdSource(path) {
27
+ return /(^|\/)crds\//.test(path);
28
+ }
29
+ /** Route rendered documents by origin: `crds/`-segment sources vs the rest. */
30
+ export function routeBySource(rendered) {
31
+ const routed = { crds: [], templates: [] };
32
+ for (const doc of splitDocuments(rendered)) {
33
+ const p = sourcePath(doc);
34
+ (p !== undefined && isCrdSource(p) ? routed.crds : routed.templates).push(doc);
35
+ }
36
+ return routed;
37
+ }
38
+ /** Count lines that differ between two renders (symmetric, order-blind). */
39
+ export function countDifferingLines(a, b) {
40
+ if (a === b)
41
+ return 0;
42
+ const linesA = a.split("\n");
43
+ const linesB = b.split("\n");
44
+ const count = new Map();
45
+ for (const l of linesA)
46
+ count.set(l, (count.get(l) ?? 0) + 1);
47
+ for (const l of linesB)
48
+ count.set(l, (count.get(l) ?? 0) - 1);
49
+ let diff = 0;
50
+ for (const n of count.values())
51
+ diff += Math.abs(n);
52
+ return diff;
53
+ }