@intentius/chant-lexicon-helm 0.46.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/archive-render.d.ts +94 -0
  2. package/dist/archive-render.d.ts.map +1 -0
  3. package/dist/commands.d.ts +54 -0
  4. package/dist/commands.d.ts.map +1 -0
  5. package/dist/config.d.ts +135 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +2 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +54 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +17 -0
  12. package/dist/describe-resources.d.ts.map +1 -0
  13. package/dist/index.d.ts +18 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/integrity.json +5 -3
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/index.d.ts.map +1 -1
  18. package/dist/lint/post-synth/whm503.d.ts +34 -0
  19. package/dist/lint/post-synth/whm503.d.ts.map +1 -0
  20. package/dist/lint/post-synth/whm504.d.ts +20 -0
  21. package/dist/lint/post-synth/whm504.d.ts.map +1 -0
  22. package/dist/manifest.json +1 -1
  23. package/dist/okf/index.md +2 -0
  24. package/dist/okf/rules/WHM503.md +15 -0
  25. package/dist/okf/rules/WHM504.md +11 -0
  26. package/dist/okf/types/Values.md +1 -0
  27. package/dist/op/activities/cluster-probe.d.ts +44 -0
  28. package/dist/op/activities/cluster-probe.d.ts.map +1 -0
  29. package/dist/op/activities/helm.d.ts +179 -5
  30. package/dist/op/activities/helm.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +4 -2
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/pinnability/actions.d.ts +76 -0
  34. package/dist/pinnability/actions.d.ts.map +1 -0
  35. package/dist/pinnability/classify.d.ts +129 -0
  36. package/dist/pinnability/classify.d.ts.map +1 -0
  37. package/dist/pinnability/conditions.d.ts +109 -0
  38. package/dist/pinnability/conditions.d.ts.map +1 -0
  39. package/dist/pinnability/index.d.ts +17 -0
  40. package/dist/pinnability/index.d.ts.map +1 -0
  41. package/dist/pinnability/localize.d.ts +124 -0
  42. package/dist/pinnability/localize.d.ts.map +1 -0
  43. package/dist/pinnability/render-stream.d.ts +27 -0
  44. package/dist/pinnability/render-stream.d.ts.map +1 -0
  45. package/dist/pinnability/values.d.ts +69 -0
  46. package/dist/pinnability/values.d.ts.map +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/release-observe.d.ts +109 -0
  49. package/dist/release-observe.d.ts.map +1 -0
  50. package/dist/render-diff.d.ts +192 -0
  51. package/dist/render-diff.d.ts.map +1 -0
  52. package/dist/render-digest.d.ts +135 -0
  53. package/dist/render-digest.d.ts.map +1 -0
  54. package/dist/render-store.d.ts +310 -0
  55. package/dist/render-store.d.ts.map +1 -0
  56. package/dist/render-wrapper.d.ts +140 -0
  57. package/dist/render-wrapper.d.ts.map +1 -0
  58. package/dist/render.d.ts +81 -0
  59. package/dist/render.d.ts.map +1 -1
  60. package/dist/rules/whm503.ts +101 -0
  61. package/dist/rules/whm504.ts +52 -0
  62. package/dist/skills/chant-helm.md +7 -0
  63. package/dist/values-probe.d.ts +177 -0
  64. package/dist/values-probe.d.ts.map +1 -0
  65. package/dist/wrapper-chart.d.ts +61 -0
  66. package/dist/wrapper-chart.d.ts.map +1 -0
  67. package/package.json +10 -4
  68. package/src/archive-render.test.ts +201 -0
  69. package/src/archive-render.ts +119 -0
  70. package/src/commands.test.ts +487 -0
  71. package/src/commands.ts +445 -0
  72. package/src/config.test.ts +130 -0
  73. package/src/config.ts +220 -0
  74. package/src/deep-observe-hooks.ts +20 -0
  75. package/src/deep-observe.test.ts +165 -0
  76. package/src/deep-observe.ts +121 -0
  77. package/src/describe-resources.test.ts +255 -0
  78. package/src/describe-resources.ts +198 -0
  79. package/src/index.ts +98 -2
  80. package/src/lifecycle-integration.test.ts +289 -0
  81. package/src/lint/audit-catalog.ts +2 -0
  82. package/src/lint/post-synth/index.ts +4 -0
  83. package/src/lint/post-synth/whm503.test.ts +134 -0
  84. package/src/lint/post-synth/whm503.ts +101 -0
  85. package/src/lint/post-synth/whm504.test.ts +105 -0
  86. package/src/lint/post-synth/whm504.ts +52 -0
  87. package/src/op/activities/cluster-probe.test.ts +66 -0
  88. package/src/op/activities/cluster-probe.ts +148 -0
  89. package/src/op/activities/helm.test.ts +824 -0
  90. package/src/op/activities/helm.ts +628 -5
  91. package/src/op/activities/index.ts +17 -2
  92. package/src/pinnability/actions.ts +263 -0
  93. package/src/pinnability/classify.ts +579 -0
  94. package/src/pinnability/conditions.ts +0 -0
  95. package/src/pinnability/index.ts +77 -0
  96. package/src/pinnability/localize.test.ts +234 -0
  97. package/src/pinnability/localize.ts +644 -0
  98. package/src/pinnability/pinnability.test.ts +560 -0
  99. package/src/pinnability/render-stream.ts +59 -0
  100. package/src/pinnability/values.ts +205 -0
  101. package/src/plugin.test.ts +3 -1
  102. package/src/plugin.ts +47 -0
  103. package/src/release-observe.ts +392 -0
  104. package/src/render-diff.test.ts +366 -0
  105. package/src/render-diff.ts +407 -0
  106. package/src/render-digest.test.ts +186 -0
  107. package/src/render-digest.ts +207 -0
  108. package/src/render-store.test.ts +412 -0
  109. package/src/render-store.ts +621 -0
  110. package/src/render-wrapper.test.ts +259 -0
  111. package/src/render-wrapper.ts +274 -0
  112. package/src/render.test.ts +383 -4
  113. package/src/render.ts +319 -8
  114. package/src/skills/chant-helm.md +7 -0
  115. package/src/values-probe.test.ts +400 -0
  116. package/src/values-probe.ts +627 -0
  117. package/src/wrapper-chart.test.ts +110 -0
  118. package/src/wrapper-chart.ts +124 -0
@@ -0,0 +1,579 @@
1
+ /**
2
+ * The pinnability gate (#1234, epic #1228 Phase 1).
3
+ *
4
+ * A render is pinnable exactly when every input to it is closed. `helm
5
+ * template` has three inputs beyond (chart, values) that are silently
6
+ * defaulted or silently empty:
7
+ *
8
+ * - cluster capabilities — closable via --kube-version / --api-versions
9
+ * - generated values — closable by supplying the value as an input
10
+ * - cluster state (lookup) — not closable offline
11
+ *
12
+ * The gate is therefore not "is this chart deterministic" but "are all of
13
+ * this render's inputs closed" — a property of `(chart, values)`, not of the
14
+ * chart alone. Concretely:
15
+ *
16
+ * - A control-flow `lookup` that is REACHABLE under the supplied values is
17
+ * a hard refusal: it changes which documents exist, and no value
18
+ * substitution recovers that.
19
+ * - A control-flow `lookup` the supplied values leave gated OFF is a
20
+ * recorded conditional hazard, not a refusal — bundled grafana's
21
+ * `lookup` behind `persistence.enabled: false` never runs, so the
22
+ * render is pinnable; flipping that value re-classifies the pair.
23
+ * - Value-position `lookup` is a declared-input requirement (epic
24
+ * Decisions): it changes a field, not the document set.
25
+ * - `.Capabilities.*` references require an explicit capability profile —
26
+ * the default is a property of the helm BINARY (3.16.2 says v1.31.0,
27
+ * 4.1.1 says v1.35.0), so an unpinned render's digest depends on who
28
+ * rendered it. 11 of the 13 survey charts hit this; it is the normal
29
+ * case, not an error.
30
+ * - Generated values (`randAlphaNum` and friends) are detected statically
31
+ * and named; the double-render localizer (#1236) confirms them
32
+ * dynamically. They classify as closable when a `.Values` path in the
33
+ * same action can supply them.
34
+ *
35
+ * A chart that fails is refused with the specific construct and location,
36
+ * and keeps working unpinned. Adoption is incremental.
37
+ */
38
+
39
+ import { readFileSync, readdirSync, statSync } from "node:fs";
40
+ import { join } from "node:path";
41
+
42
+ import {
43
+ actionPipeline,
44
+ extractActions,
45
+ scopeActions,
46
+ type ScopeFrame,
47
+ type ScopedAction,
48
+ } from "./actions";
49
+ import {
50
+ UNKNOWN,
51
+ callReachability,
52
+ evaluateCondition,
53
+ truthy,
54
+ type EvalContext,
55
+ type GatePath,
56
+ type Tri,
57
+ } from "./conditions";
58
+ import {
59
+ buildChartInstances,
60
+ mergeValues,
61
+ readYamlFile,
62
+ valueAtPath,
63
+ type ChartInstance,
64
+ } from "./values";
65
+
66
+ export type PinnabilityVerdict = "deterministic" | "pinnable" | "unpinnable";
67
+
68
+ /** One `.Capabilities.*` reference; any of these makes a profile required. */
69
+ export interface CapabilityRequirement {
70
+ file: string;
71
+ line: number;
72
+ capability: "KubeVersion" | "APIVersions";
73
+ }
74
+
75
+ /** An input that must be (or has been) closed for the render to pin. */
76
+ export interface ClosedInput {
77
+ kind: "generated-value" | "value-position-lookup" | "supplied-values";
78
+ file?: string;
79
+ line?: number;
80
+ /** Template function for generated values (randAlphaNum, genCA, ...). */
81
+ fn?: string;
82
+ /** Root-scoped `.Values` path that can supply the input, when one exists. */
83
+ valuesPath?: string;
84
+ /** Can this input be closed by supplying a value at all? */
85
+ suppliable?: boolean;
86
+ detail: string;
87
+ }
88
+
89
+ /**
90
+ * A control-flow `lookup` the supplied values leave unreachable. Recorded —
91
+ * not refused — so that flipping the gating value later re-classifies the
92
+ * `(chart, values)` pair instead of silently changing which documents exist.
93
+ */
94
+ export interface ConditionalHazard {
95
+ /** Instance scope in root coordinates ("" = the top-level chart, "grafana" = that subchart). */
96
+ chart: string;
97
+ file: string;
98
+ line: number;
99
+ /** The values path(s) currently gating the construct off, with their values. */
100
+ gates: GatePath[];
101
+ detail: string;
102
+ }
103
+
104
+ /** A control-flow `lookup` site, aggregated across chart instances. */
105
+ export interface ControlFlowLookup {
106
+ file: string;
107
+ line: number;
108
+ action: string;
109
+ status: "refused" | "hazard";
110
+ /** Instance scopes the site was assessed in ("" = root). */
111
+ charts: string[];
112
+ }
113
+
114
+ export interface ValuePositionLookup {
115
+ file: string;
116
+ line: number;
117
+ action: string;
118
+ }
119
+
120
+ /** Evidence from an actual double render (#1236 owns producing this). */
121
+ export interface RenderEvidence {
122
+ stable: boolean;
123
+ unstableLines: number;
124
+ }
125
+
126
+ export interface PinnabilityReport {
127
+ verdict: PinnabilityVerdict;
128
+ /** `.Capabilities.*` references, one entry per occurrence. Empty = no profile needed. */
129
+ requiresProfile: CapabilityRequirement[];
130
+ closedInputs: ClosedInput[];
131
+ hazards: ConditionalHazard[];
132
+ /** Human-readable summary; refusal reasons first, specific per construct. */
133
+ reasons: string[];
134
+ /** Raw lookup findings, for surfaces that need counts or locations. */
135
+ lookups: {
136
+ controlFlow: ControlFlowLookup[];
137
+ valuePosition: ValuePositionLookup[];
138
+ };
139
+ /** Non-fatal scan caveats (an unvendored dependency, etc.). */
140
+ warnings: string[];
141
+ }
142
+
143
+ export interface ClassifyChartOptions {
144
+ /** Values overrides, as `helm template --values` would merge them. */
145
+ values?: unknown;
146
+ /** Values files to load and merge (in order) over the chart defaults. */
147
+ valuesFiles?: string[];
148
+ /** Double-render evidence, when the caller has rendered. */
149
+ renderEvidence?: RenderEvidence;
150
+ }
151
+
152
+ /**
153
+ * Matches a call to the `lookup` template function: the word `lookup`
154
+ * followed by whitespace (it always takes arguments), not preceded by a word
155
+ * character, `.` or `$` — so `.Values.persistence.lookupVolumeName` (a value
156
+ * named after the feature, present in grafana) is not a hit. Finding 9's
157
+ * word-boundary lesson: `\blookup\b` false-positives on the very chart the
158
+ * refusal is about.
159
+ */
160
+ const LOOKUP_CALL = /(?<![.\w$])lookup\s/;
161
+ const LOOKUP_FN = /^lookup$/;
162
+
163
+ /**
164
+ * Template functions whose output differs per render: sprig's random and
165
+ * crypto generators, plus `now`. Detected statically so the classifier can
166
+ * NAME the open input; the double-render localizer (#1236) is what proves
167
+ * one fired.
168
+ */
169
+ const GENERATED_VALUE_FNS = [
170
+ "randAlphaNum",
171
+ "randAlpha",
172
+ "randNumeric",
173
+ "randAscii",
174
+ "randBytes",
175
+ "randInt",
176
+ "uuidv4",
177
+ "derivePassword",
178
+ "genPrivateKey",
179
+ "genCA",
180
+ "genCAWithKey",
181
+ "genSelfSignedCert",
182
+ "genSelfSignedCertWithKey",
183
+ "genSignedCert",
184
+ "genSignedCertWithKey",
185
+ "htpasswd",
186
+ "bcrypt",
187
+ "now",
188
+ ] as const;
189
+
190
+ const GENERATED_CALL = new RegExp(
191
+ `(?<![.\\w$])(${GENERATED_VALUE_FNS.join("|")})\\b`,
192
+ );
193
+
194
+ const CAPABILITY_REF = /\.Capabilities\.(KubeVersion|APIVersions)/g;
195
+
196
+ /** Structural kinds whose pipeline decides which documents exist. */
197
+ const CONTROL_FLOW_KINDS = new Set(["if", "elseif", "with", "range"]);
198
+
199
+ const VALUES_PATH_IN_ACTION = /\$?\.Values((?:\.[A-Za-z0-9_-]+)+)/;
200
+
201
+ // --- per-directory scanning ------------------------------------------------
202
+
203
+ interface ScannedFile {
204
+ /** Path relative to the chart directory that owns it. */
205
+ rel: string;
206
+ scoped: ScopedAction[];
207
+ }
208
+
209
+ /**
210
+ * Template files of ONE chart directory — `templates/` at any depth below
211
+ * it, excluding `charts/` (subcharts are their own instances) and NOTES.txt
212
+ * (rendered for the console, not the cluster).
213
+ */
214
+ export function collectOwnTemplateFiles(chartDir: string): string[] {
215
+ const out: string[] = [];
216
+ const walk = (dir: string, underTemplates: boolean): void => {
217
+ for (const entry of readdirSync(dir)) {
218
+ const p = join(dir, entry);
219
+ if (statSync(p).isDirectory()) {
220
+ if (entry === "charts" && !underTemplates) continue;
221
+ walk(p, underTemplates || entry === "templates");
222
+ continue;
223
+ }
224
+ if (!underTemplates) continue;
225
+ if (entry === "NOTES.txt") continue;
226
+ if (/\.(ya?ml|tpl)$/.test(entry)) out.push(p);
227
+ }
228
+ };
229
+ walk(chartDir, false);
230
+ return out;
231
+ }
232
+
233
+ function scanDir(chartDir: string): ScannedFile[] {
234
+ return collectOwnTemplateFiles(chartDir)
235
+ .sort()
236
+ .map((file) => {
237
+ const rel = file.slice(chartDir.length + 1);
238
+ return { rel, scoped: scopeActions(extractActions(readFileSync(file, "utf8"), rel)) };
239
+ });
240
+ }
241
+
242
+ // --- gating ----------------------------------------------------------------
243
+
244
+ /**
245
+ * Walk the enclosing frames of an action and decide whether the supplied
246
+ * values leave it unreachable. Returns the gates that close it, or null when
247
+ * every frame is (or may be) open — in which case the construct counts as
248
+ * reachable.
249
+ */
250
+ function framesGate(
251
+ frames: ScopeFrame[],
252
+ instance: ChartInstance,
253
+ ): { gates: GatePath[] | null; allKnown: boolean } {
254
+ // Dot binding threads through the frames: `with .Values.persistence`
255
+ // rebinds bare `.enabled` to `persistence.enabled` for everything inside.
256
+ let dot: string[] | null | undefined = null;
257
+ let allKnown = true;
258
+ for (const frame of frames) {
259
+ const ctx: EvalContext = {
260
+ values: instance.values,
261
+ scope: instance.scope,
262
+ dotValuesPath: dot,
263
+ };
264
+ for (const cond of frame.conditions) {
265
+ const r = evaluateCondition(cond, ctx);
266
+ if (r.value === false) return { gates: r.gates, allKnown };
267
+ if (r.value === UNKNOWN) allKnown = false;
268
+ }
269
+ for (const negated of frame.negatedConditions) {
270
+ // An else/else-if branch runs only when every earlier condition was
271
+ // false; a definitively TRUE earlier condition closes the branch.
272
+ const r = evaluateCondition(negated, ctx);
273
+ if (r.value === true) {
274
+ const m = negated.match(VALUES_PATH_IN_ACTION);
275
+ return {
276
+ gates: m
277
+ ? [{ path: [...instance.scope, ...m[1].slice(1).split(".")].join("."), value: true }]
278
+ : [],
279
+ allKnown,
280
+ };
281
+ }
282
+ if (r.value === UNKNOWN) allKnown = false;
283
+ }
284
+ if (frame.kind === "with") dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
285
+ else if (frame.kind === "range") dot = undefined;
286
+ else if (frame.kind === "define" || frame.kind === "block") dot = null;
287
+ }
288
+ return { gates: null, allKnown };
289
+ }
290
+
291
+ /** Reachability of a function call inside an action, frames + same-action gates. */
292
+ function assessCall(
293
+ scopedAction: ScopedAction,
294
+ fnRe: RegExp,
295
+ instance: ChartInstance,
296
+ ): { reachable: Tri; gates: GatePath[] } {
297
+ if (instance.disabledBy !== undefined) {
298
+ return {
299
+ reachable: false,
300
+ gates: [{ path: instance.disabledBy, value: false }],
301
+ };
302
+ }
303
+ const enclosing = framesGate(scopedAction.frames, instance);
304
+ if (enclosing.gates !== null) return { reachable: false, gates: enclosing.gates };
305
+ // Same-action short-circuit gating: `if and .Values.gate (lookup ...)`
306
+ // never calls lookup when the gate is false.
307
+ let dot: string[] | null | undefined = null;
308
+ for (const frame of scopedAction.frames) {
309
+ if (frame.kind === "with") dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
310
+ else if (frame.kind === "range") dot = undefined;
311
+ else if (frame.kind === "define" || frame.kind === "block") dot = null;
312
+ }
313
+ const ctx: EvalContext = { values: instance.values, scope: instance.scope, dotValuesPath: dot };
314
+ const inAction = callReachability(actionPipeline(scopedAction.action), fnRe, ctx);
315
+ // An enclosing gate we could not resolve leaves reachability unproven even
316
+ // when nothing in the action itself gates the call.
317
+ if (inAction.reachable === true && !enclosing.allKnown) {
318
+ return { reachable: UNKNOWN, gates: [] };
319
+ }
320
+ return inAction;
321
+ }
322
+
323
+ // --- classification --------------------------------------------------------
324
+
325
+ /**
326
+ * Classify a chart directory against the values a render would be given.
327
+ * Subchart-recursive: dependencies vendored under `charts/` are scanned with
328
+ * their own effective values (aliases get their own scope, `condition:`-
329
+ * disabled dependencies gate their contents — findings 11 and 12).
330
+ */
331
+ export function classifyChart(
332
+ chartDir: string,
333
+ options: ClassifyChartOptions = {},
334
+ ): PinnabilityReport {
335
+ let supplied: unknown;
336
+ for (const file of options.valuesFiles ?? []) {
337
+ supplied = mergeValues(supplied ?? {}, readYamlFile(file));
338
+ }
339
+ if (options.values !== undefined) supplied = mergeValues(supplied ?? {}, options.values);
340
+ const valuesSupplied = supplied !== undefined;
341
+
342
+ const { instances, warnings } = buildChartInstances(chartDir, supplied);
343
+
344
+ const requiresProfile: CapabilityRequirement[] = [];
345
+ const valuePosition: ValuePositionLookup[] = [];
346
+ interface ControlFlowSite {
347
+ file: string;
348
+ line: number;
349
+ action: string;
350
+ assessments: { chart: string; reachable: Tri; gates: GatePath[] }[];
351
+ }
352
+ const controlFlowSites = new Map<string, ControlFlowSite>();
353
+ interface GeneratedSite {
354
+ file: string;
355
+ line: number;
356
+ fn: string;
357
+ /** Open in at least one instance; gates recorded when closed everywhere. */
358
+ open: boolean;
359
+ valuesPath?: string;
360
+ suppliable: boolean;
361
+ }
362
+ const generatedSites = new Map<string, GeneratedSite>();
363
+
364
+ const dirScans = new Map<string, ScannedFile[]>();
365
+ const staticallyRecordedDirs = new Set<string>();
366
+
367
+ for (const instance of instances) {
368
+ let files = dirScans.get(instance.dir);
369
+ if (files === undefined) {
370
+ files = scanDir(instance.dir);
371
+ dirScans.set(instance.dir, files);
372
+ }
373
+ // Static findings (capability refs, value-position lookups) are
374
+ // per-FILE: an aliased dependency scans the same directory for two
375
+ // instances but its files exist once.
376
+ const recordStatic = !staticallyRecordedDirs.has(instance.dir);
377
+ staticallyRecordedDirs.add(instance.dir);
378
+
379
+ for (const { rel, scoped } of files) {
380
+ const file = instance.relDir === "" ? rel : `${instance.relDir}/${rel}`;
381
+ for (const s of scoped) {
382
+ const { action } = s;
383
+
384
+ if (recordStatic) {
385
+ for (const m of action.body.matchAll(CAPABILITY_REF)) {
386
+ requiresProfile.push({
387
+ file,
388
+ line: action.line,
389
+ capability: m[1] as CapabilityRequirement["capability"],
390
+ });
391
+ }
392
+ }
393
+
394
+ if (LOOKUP_CALL.test(action.body)) {
395
+ if (CONTROL_FLOW_KINDS.has(action.kind)) {
396
+ const key = `${file}:${action.line}`;
397
+ let site = controlFlowSites.get(key);
398
+ if (site === undefined) {
399
+ site = { file, line: action.line, action: action.body, assessments: [] };
400
+ controlFlowSites.set(key, site);
401
+ }
402
+ const a = assessCall(s, LOOKUP_FN, instance);
403
+ site.assessments.push({
404
+ chart: instance.scope.join("."),
405
+ reachable: a.reachable,
406
+ gates: a.gates,
407
+ });
408
+ } else if (recordStatic) {
409
+ valuePosition.push({ file, line: action.line, action: action.body });
410
+ }
411
+ }
412
+
413
+ const gen = action.body.match(GENERATED_CALL);
414
+ if (gen !== null) {
415
+ const a = assessCall(s, new RegExp(`^${gen[1]}$`), instance);
416
+ // A generator whose enclosing gates are definitively closed cannot
417
+ // fire for this render; re-classification on a values flip
418
+ // rediscovers it.
419
+ if (a.reachable !== false) {
420
+ const valuesRef = action.body.match(VALUES_PATH_IN_ACTION);
421
+ // A value closes a generator only through the `x | default
422
+ // (rand)` shape, where a truthy x discards the generated result.
423
+ // A generator that CONSUMES values (`htpasswd .Values.user
424
+ // .Values.password` — random salt per render) stays open no
425
+ // matter what the values hold, so the path it reads is not a
426
+ // suppliable slot; the localizer (#1236) finds the real pin
427
+ // (harbor's `registry.credentials.htpasswdString` else-branch).
428
+ const closable = valuesRef !== null && /\bdefault\b/.test(action.body);
429
+ const relPath = closable && valuesRef ? valuesRef[1].slice(1).split(".") : undefined;
430
+ // Open unless the suppliable value is already set (a truthy
431
+ // value makes `x | default (randAlphaNum ...)` deterministic).
432
+ const open =
433
+ relPath === undefined || !truthy(valueAtPath(instance.values, relPath));
434
+ const key = `${file}:${action.line}:${gen[1]}`;
435
+ const existing = generatedSites.get(key);
436
+ if (existing === undefined) {
437
+ generatedSites.set(key, {
438
+ file,
439
+ line: action.line,
440
+ fn: gen[1],
441
+ open,
442
+ valuesPath: relPath ? [...instance.scope, ...relPath].join(".") : undefined,
443
+ suppliable: relPath !== undefined,
444
+ });
445
+ } else if (open) {
446
+ existing.open = true;
447
+ }
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+
454
+ // --- fold findings into the report ---------------------------------------
455
+
456
+ const reasons: string[] = [];
457
+ const hazards: ConditionalHazard[] = [];
458
+ const controlFlow: ControlFlowLookup[] = [];
459
+ let refusals = 0;
460
+
461
+ for (const site of controlFlowSites.values()) {
462
+ const reachableIn = site.assessments.filter((a) => a.reachable !== false);
463
+ if (reachableIn.length > 0) {
464
+ refusals += 1;
465
+ controlFlow.push({
466
+ file: site.file,
467
+ line: site.line,
468
+ action: site.action,
469
+ status: "refused",
470
+ charts: site.assessments.map((a) => a.chart),
471
+ });
472
+ for (const a of reachableIn) {
473
+ const where = a.chart === "" ? "" : ` (subchart ${a.chart})`;
474
+ const proof =
475
+ a.reachable === UNKNOWN
476
+ ? "not provably gated off by the supplied values"
477
+ : "reachable with the supplied values";
478
+ reasons.push(
479
+ `lookup in control flow at ${site.file}:${site.line}${where} — ${proof}; which documents exist depends on cluster state`,
480
+ );
481
+ }
482
+ } else {
483
+ controlFlow.push({
484
+ file: site.file,
485
+ line: site.line,
486
+ action: site.action,
487
+ status: "hazard",
488
+ charts: site.assessments.map((a) => a.chart),
489
+ });
490
+ for (const a of site.assessments) {
491
+ const gateText = a.gates.map((g) => `${g.path}=${JSON.stringify(g.value ?? null)}`).join(", ");
492
+ hazards.push({
493
+ chart: a.chart,
494
+ file: site.file,
495
+ line: site.line,
496
+ gates: a.gates,
497
+ detail: `control-flow lookup at ${site.file}:${site.line} is gated off by ${gateText} — flipping it makes this render unpinnable`,
498
+ });
499
+ }
500
+ }
501
+ }
502
+
503
+ // The report stays fully populated even for a refused chart: the caller
504
+ // renders unpinned and still benefits from knowing the other open inputs.
505
+ const closedInputs: ClosedInput[] = [];
506
+
507
+ if (requiresProfile.length > 0) {
508
+ reasons.push(`declare capability profile (${requiresProfile.length} .Capabilities refs)`);
509
+ }
510
+
511
+ for (const v of valuePosition) {
512
+ closedInputs.push({
513
+ kind: "value-position-lookup",
514
+ file: v.file,
515
+ line: v.line,
516
+ suppliable: true,
517
+ detail: `declare input for value-position lookup at ${v.file}:${v.line}`,
518
+ });
519
+ reasons.push(`declare input for value-position lookup at ${v.file}:${v.line}`);
520
+ }
521
+
522
+ for (const g of generatedSites.values()) {
523
+ if (!g.open) continue;
524
+ const supply = g.suppliable
525
+ ? `suppliable via ${g.valuesPath}`
526
+ : "no suppliable value found in the action — the double-render localizer (#1236) must confirm and localize it";
527
+ closedInputs.push({
528
+ kind: "generated-value",
529
+ file: g.file,
530
+ line: g.line,
531
+ fn: g.fn,
532
+ valuesPath: g.valuesPath,
533
+ suppliable: g.suppliable,
534
+ detail: `open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`,
535
+ });
536
+ reasons.push(`open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`);
537
+ }
538
+
539
+ if (options.renderEvidence !== undefined && !options.renderEvidence.stable) {
540
+ reasons.push(
541
+ `open generated input: double render differs on ${options.renderEvidence.unstableLines} lines — supply the value(s) as declared inputs`,
542
+ );
543
+ }
544
+
545
+ for (const h of hazards) {
546
+ reasons.push(`conditional hazard: ${h.detail}`);
547
+ }
548
+
549
+ if (valuesSupplied) {
550
+ closedInputs.push({ kind: "supplied-values", detail: "values supplied as closed input" });
551
+ reasons.push("values supplied as closed input");
552
+ }
553
+
554
+ for (const w of warnings) reasons.push(`warning: ${w}`);
555
+
556
+ let verdict: PinnabilityVerdict;
557
+ if (refusals > 0) {
558
+ verdict = "unpinnable";
559
+ } else if (
560
+ requiresProfile.length > 0 ||
561
+ closedInputs.length > 0 ||
562
+ hazards.length > 0 ||
563
+ (options.renderEvidence !== undefined && !options.renderEvidence.stable)
564
+ ) {
565
+ verdict = "pinnable";
566
+ } else {
567
+ verdict = "deterministic";
568
+ }
569
+
570
+ return {
571
+ verdict,
572
+ requiresProfile,
573
+ closedInputs,
574
+ hazards,
575
+ reasons,
576
+ lookups: { controlFlow, valuePosition },
577
+ warnings,
578
+ };
579
+ }
Binary file
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pinnability gate for helm renders (#1234, epic #1228 Phase 1).
3
+ *
4
+ * `classifyChart` is the entry point the pinned-render pipeline
5
+ * (#1235–#1240) calls before anything is pinned; the survey harness
6
+ * (lexicons/helm/test/survey) asserts its verdicts over a corpus of real
7
+ * upstream charts. This module deliberately imports nothing from the
8
+ * lexicon's generated artifacts, so it loads on a fresh clone and in the
9
+ * survey CI job without a generation step.
10
+ */
11
+
12
+ export {
13
+ classifyChart,
14
+ type PinnabilityVerdict,
15
+ type PinnabilityReport,
16
+ type ClassifyChartOptions,
17
+ type CapabilityRequirement,
18
+ type ClosedInput,
19
+ type ConditionalHazard,
20
+ type ControlFlowLookup,
21
+ type ValuePositionLookup,
22
+ type RenderEvidence,
23
+ collectOwnTemplateFiles,
24
+ } from "./classify";
25
+
26
+ export {
27
+ actionPipeline,
28
+ extractActions,
29
+ scopeActions,
30
+ type TemplateAction,
31
+ type ActionKind,
32
+ type ScopedAction,
33
+ type ScopeFrame,
34
+ } from "./actions";
35
+
36
+ export {
37
+ UNKNOWN,
38
+ truthy,
39
+ evaluateCondition,
40
+ callReachability,
41
+ parseExpr,
42
+ type Tri,
43
+ type EvalContext,
44
+ type EvalResult,
45
+ type GatePath,
46
+ } from "./conditions";
47
+
48
+ export {
49
+ buildChartInstances,
50
+ mergeValues,
51
+ valueAtPath,
52
+ readChartMeta,
53
+ type ChartInstance,
54
+ type ChartDependency,
55
+ type ChartMeta,
56
+ } from "./values";
57
+
58
+ export {
59
+ splitDocuments,
60
+ sourcePath,
61
+ isCrdSource,
62
+ routeBySource,
63
+ countDifferingLines,
64
+ type RoutedDocuments,
65
+ } from "./render-stream";
66
+
67
+ export {
68
+ localizeOpenInputs,
69
+ diffRenderPair,
70
+ type RenderFn,
71
+ type PinStyle,
72
+ type CandidatePin,
73
+ type LocalizedOccurrence,
74
+ type LocalizedInput,
75
+ type LocalizationReport,
76
+ type LocalizeOptions,
77
+ } from "./localize";