@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,207 @@
1
+ /**
2
+ * Go template action extraction and control-flow scoping (#1234, epic #1228).
3
+ *
4
+ * The pinnability gate inspects template ACTIONS (`{{ ... }}`), never raw
5
+ * text. That distinction is finding 9: cert-manager carries 52 textual
6
+ * "lookup" occurrences, all comment prose, and a real template-action count
7
+ * of 0 — a text-matching gate refuses a chart that is perfectly pinnable.
8
+ *
9
+ * Beyond extraction, this module reconstructs the control-flow structure of a
10
+ * template file — which `if` / `with` / `range` blocks enclose each action —
11
+ * so the classifier can ask whether a construct is REACHABLE under the
12
+ * supplied values, not merely present. Reachability is what separates
13
+ * "unpinnable" from "pinnable with a recorded conditional hazard": bundled
14
+ * grafana's control-flow `lookup` sits behind `persistence.enabled`, off by
15
+ * default, so a default-values render never consults the cluster.
16
+ */
17
+ const KIND_RE = /^(if\b|else\s+if\b|else\b|with\b|range\b|define\b|block\b|end\b)/;
18
+ function kindOf(body) {
19
+ const m = body.match(KIND_RE);
20
+ if (!m)
21
+ return "other";
22
+ const kw = m[1].replace(/\s+/g, " ").trim();
23
+ if (kw === "else if")
24
+ return "elseif";
25
+ return kw.replace(/\b\s.*$/, "");
26
+ }
27
+ /**
28
+ * Extract template actions from a source file, skipping comment actions
29
+ * (`{{/* ... *\/}}`). Text outside actions — including YAML `#` comments,
30
+ * where finding 9's 52 false positives lived — is never inspected by any
31
+ * scan built on this.
32
+ */
33
+ export function extractActions(source, file) {
34
+ const actions = [];
35
+ let i = 0;
36
+ let line = 1;
37
+ let lineScanPos = 0;
38
+ const lineAt = (pos) => {
39
+ // Incremental line counting: `pos` is monotonically increasing.
40
+ for (; lineScanPos < pos; lineScanPos++) {
41
+ if (source[lineScanPos] === "\n")
42
+ line += 1;
43
+ }
44
+ return line;
45
+ };
46
+ while (i < source.length) {
47
+ const open = source.indexOf("{{", i);
48
+ if (open === -1)
49
+ break;
50
+ const actionLine = lineAt(open);
51
+ // Body starts after `{{` and an optional trim marker.
52
+ let bodyStart = open + 2;
53
+ if (source[bodyStart] === "-")
54
+ bodyStart += 1;
55
+ const rest = source.slice(bodyStart);
56
+ // Comment action: {{/* ... */}} — skip to its closing */}}.
57
+ if (/^\s*\/\*/.test(rest)) {
58
+ const closeComment = source.indexOf("*/", bodyStart);
59
+ if (closeComment === -1)
60
+ break;
61
+ const close = source.indexOf("}}", closeComment);
62
+ i = close === -1 ? source.length : close + 2;
63
+ continue;
64
+ }
65
+ const close = source.indexOf("}}", bodyStart);
66
+ if (close === -1)
67
+ break;
68
+ let body = source.slice(bodyStart, close);
69
+ if (body.endsWith("-"))
70
+ body = body.slice(0, -1);
71
+ body = body.trim();
72
+ actions.push({ file, line: actionLine, body, kind: kindOf(body) });
73
+ i = close + 2;
74
+ }
75
+ return actions;
76
+ }
77
+ /**
78
+ * The pipeline of an action, structural keyword and variable assignment
79
+ * stripped: `if X` → `X`, `range $i, $v := X` → `X`, `$pw := X` → `X`.
80
+ * This is what condition evaluation and call-reachability parse.
81
+ */
82
+ export function actionPipeline(action) {
83
+ let expr = action.body.replace(KIND_RE, "").trim();
84
+ // `range $i, $v := .Values.x` / `$pw := .Values.x` — the pipeline is what
85
+ // follows the assignment.
86
+ const assign = expr.match(/^\$[\w]*\s*(?:,\s*\$[\w]*\s*)?:?=\s*(.*)$/);
87
+ if (assign)
88
+ expr = assign[1].trim();
89
+ return expr;
90
+ }
91
+ /** `.Values.a.b` (or `$.Values.a.b`) as a path array, else undefined. */
92
+ function valuesPathOf(expr) {
93
+ const m = expr.match(/^\$?\.Values((?:\.[A-Za-z0-9_-]+)+)$/);
94
+ return m ? m[1].slice(1).split(".") : undefined;
95
+ }
96
+ /**
97
+ * Walk a file's actions and attach the enclosing control-flow frames to each.
98
+ * `else` / `else if` branches carry the prior branch conditions as
99
+ * `negatedConditions`. Structure that cannot be tracked (an unbalanced
100
+ * `end`, template text split across files) degrades to fewer frames — which
101
+ * only ever makes the classifier MORE conservative, never less: a missing
102
+ * frame means a gate we cannot prove closed.
103
+ */
104
+ export function scopeActions(actions) {
105
+ const scoped = [];
106
+ const stack = [];
107
+ for (const action of actions) {
108
+ switch (action.kind) {
109
+ case "if": {
110
+ const cond = actionPipeline(action);
111
+ // The `if` action itself is evaluated in the ENCLOSING scope.
112
+ scoped.push({ action, frames: stack.map((s) => s.frame) });
113
+ stack.push({
114
+ frame: { kind: "if", conditions: [cond], negatedConditions: [] },
115
+ chain: [cond],
116
+ });
117
+ break;
118
+ }
119
+ case "elseif": {
120
+ scoped.push({
121
+ action,
122
+ frames: stack.slice(0, -1).map((s) => s.frame),
123
+ });
124
+ const top = stack[stack.length - 1];
125
+ const cond = action.body.replace(/^else\s+if\b/, "").trim();
126
+ if (top) {
127
+ top.frame = {
128
+ kind: "if",
129
+ conditions: [cond],
130
+ negatedConditions: [...top.chain],
131
+ };
132
+ top.chain.push(cond);
133
+ }
134
+ break;
135
+ }
136
+ case "else": {
137
+ scoped.push({
138
+ action,
139
+ frames: stack.slice(0, -1).map((s) => s.frame),
140
+ });
141
+ const top = stack[stack.length - 1];
142
+ if (top) {
143
+ top.frame = {
144
+ kind: top.frame.kind === "if" ? "if" : top.frame.kind,
145
+ conditions: [],
146
+ negatedConditions: [...top.chain],
147
+ };
148
+ }
149
+ break;
150
+ }
151
+ case "with": {
152
+ const expr = actionPipeline(action);
153
+ scoped.push({ action, frames: stack.map((s) => s.frame) });
154
+ const path = valuesPathOf(expr);
155
+ stack.push({
156
+ frame: {
157
+ kind: "with",
158
+ conditions: [expr],
159
+ negatedConditions: [],
160
+ dotValuesPath: path,
161
+ dotUnresolvable: path === undefined,
162
+ },
163
+ chain: [expr],
164
+ });
165
+ break;
166
+ }
167
+ case "range": {
168
+ const expr = actionPipeline(action);
169
+ scoped.push({ action, frames: stack.map((s) => s.frame) });
170
+ stack.push({
171
+ frame: {
172
+ kind: "range",
173
+ conditions: [expr],
174
+ negatedConditions: [],
175
+ dotUnresolvable: true,
176
+ },
177
+ chain: [expr],
178
+ });
179
+ break;
180
+ }
181
+ case "define":
182
+ case "block": {
183
+ scoped.push({ action, frames: stack.map((s) => s.frame) });
184
+ // A define body runs with whatever context `include`/`template`
185
+ // passes. Most charts pass the root context; the frame records
186
+ // nothing to gate on, so constructs inside a define are treated as
187
+ // reachable unless gated within the define itself.
188
+ stack.push({
189
+ frame: { kind: action.kind, conditions: [], negatedConditions: [] },
190
+ chain: [],
191
+ });
192
+ break;
193
+ }
194
+ case "end": {
195
+ scoped.push({
196
+ action,
197
+ frames: stack.slice(0, -1).map((s) => s.frame),
198
+ });
199
+ stack.pop();
200
+ break;
201
+ }
202
+ default:
203
+ scoped.push({ action, frames: stack.map((s) => s.frame) });
204
+ }
205
+ }
206
+ return scoped;
207
+ }
@@ -0,0 +1,416 @@
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
+ import { readFileSync, readdirSync, statSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { actionPipeline, extractActions, scopeActions, } from "./actions.js";
41
+ import { UNKNOWN, callReachability, evaluateCondition, truthy, } from "./conditions.js";
42
+ import { buildChartInstances, mergeValues, readYamlFile, valueAtPath, } from "./values.js";
43
+ /**
44
+ * Matches a call to the `lookup` template function: the word `lookup`
45
+ * followed by whitespace (it always takes arguments), not preceded by a word
46
+ * character, `.` or `$` — so `.Values.persistence.lookupVolumeName` (a value
47
+ * named after the feature, present in grafana) is not a hit. Finding 9's
48
+ * word-boundary lesson: `\blookup\b` false-positives on the very chart the
49
+ * refusal is about.
50
+ */
51
+ const LOOKUP_CALL = /(?<![.\w$])lookup\s/;
52
+ const LOOKUP_FN = /^lookup$/;
53
+ /**
54
+ * Template functions whose output differs per render: sprig's random and
55
+ * crypto generators, plus `now`. Detected statically so the classifier can
56
+ * NAME the open input; the double-render localizer (#1236) is what proves
57
+ * one fired.
58
+ */
59
+ const GENERATED_VALUE_FNS = [
60
+ "randAlphaNum",
61
+ "randAlpha",
62
+ "randNumeric",
63
+ "randAscii",
64
+ "randBytes",
65
+ "randInt",
66
+ "uuidv4",
67
+ "derivePassword",
68
+ "genPrivateKey",
69
+ "genCA",
70
+ "genCAWithKey",
71
+ "genSelfSignedCert",
72
+ "genSelfSignedCertWithKey",
73
+ "genSignedCert",
74
+ "genSignedCertWithKey",
75
+ "htpasswd",
76
+ "bcrypt",
77
+ "now",
78
+ ];
79
+ const GENERATED_CALL = new RegExp(`(?<![.\\w$])(${GENERATED_VALUE_FNS.join("|")})\\b`);
80
+ const CAPABILITY_REF = /\.Capabilities\.(KubeVersion|APIVersions)/g;
81
+ /** Structural kinds whose pipeline decides which documents exist. */
82
+ const CONTROL_FLOW_KINDS = new Set(["if", "elseif", "with", "range"]);
83
+ const VALUES_PATH_IN_ACTION = /\$?\.Values((?:\.[A-Za-z0-9_-]+)+)/;
84
+ /**
85
+ * Template files of ONE chart directory — `templates/` at any depth below
86
+ * it, excluding `charts/` (subcharts are their own instances) and NOTES.txt
87
+ * (rendered for the console, not the cluster).
88
+ */
89
+ export function collectOwnTemplateFiles(chartDir) {
90
+ const out = [];
91
+ const walk = (dir, underTemplates) => {
92
+ for (const entry of readdirSync(dir)) {
93
+ const p = join(dir, entry);
94
+ if (statSync(p).isDirectory()) {
95
+ if (entry === "charts" && !underTemplates)
96
+ continue;
97
+ walk(p, underTemplates || entry === "templates");
98
+ continue;
99
+ }
100
+ if (!underTemplates)
101
+ continue;
102
+ if (entry === "NOTES.txt")
103
+ continue;
104
+ if (/\.(ya?ml|tpl)$/.test(entry))
105
+ out.push(p);
106
+ }
107
+ };
108
+ walk(chartDir, false);
109
+ return out;
110
+ }
111
+ function scanDir(chartDir) {
112
+ return collectOwnTemplateFiles(chartDir)
113
+ .sort()
114
+ .map((file) => {
115
+ const rel = file.slice(chartDir.length + 1);
116
+ return { rel, scoped: scopeActions(extractActions(readFileSync(file, "utf8"), rel)) };
117
+ });
118
+ }
119
+ // --- gating ----------------------------------------------------------------
120
+ /**
121
+ * Walk the enclosing frames of an action and decide whether the supplied
122
+ * values leave it unreachable. Returns the gates that close it, or null when
123
+ * every frame is (or may be) open — in which case the construct counts as
124
+ * reachable.
125
+ */
126
+ function framesGate(frames, instance) {
127
+ // Dot binding threads through the frames: `with .Values.persistence`
128
+ // rebinds bare `.enabled` to `persistence.enabled` for everything inside.
129
+ let dot = null;
130
+ let allKnown = true;
131
+ for (const frame of frames) {
132
+ const ctx = {
133
+ values: instance.values,
134
+ scope: instance.scope,
135
+ dotValuesPath: dot,
136
+ };
137
+ for (const cond of frame.conditions) {
138
+ const r = evaluateCondition(cond, ctx);
139
+ if (r.value === false)
140
+ return { gates: r.gates, allKnown };
141
+ if (r.value === UNKNOWN)
142
+ allKnown = false;
143
+ }
144
+ for (const negated of frame.negatedConditions) {
145
+ // An else/else-if branch runs only when every earlier condition was
146
+ // false; a definitively TRUE earlier condition closes the branch.
147
+ const r = evaluateCondition(negated, ctx);
148
+ if (r.value === true) {
149
+ const m = negated.match(VALUES_PATH_IN_ACTION);
150
+ return {
151
+ gates: m
152
+ ? [{ path: [...instance.scope, ...m[1].slice(1).split(".")].join("."), value: true }]
153
+ : [],
154
+ allKnown,
155
+ };
156
+ }
157
+ if (r.value === UNKNOWN)
158
+ allKnown = false;
159
+ }
160
+ if (frame.kind === "with")
161
+ dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
162
+ else if (frame.kind === "range")
163
+ dot = undefined;
164
+ else if (frame.kind === "define" || frame.kind === "block")
165
+ dot = null;
166
+ }
167
+ return { gates: null, allKnown };
168
+ }
169
+ /** Reachability of a function call inside an action, frames + same-action gates. */
170
+ function assessCall(scopedAction, fnRe, instance) {
171
+ if (instance.disabledBy !== undefined) {
172
+ return {
173
+ reachable: false,
174
+ gates: [{ path: instance.disabledBy, value: false }],
175
+ };
176
+ }
177
+ const enclosing = framesGate(scopedAction.frames, instance);
178
+ if (enclosing.gates !== null)
179
+ return { reachable: false, gates: enclosing.gates };
180
+ // Same-action short-circuit gating: `if and .Values.gate (lookup ...)`
181
+ // never calls lookup when the gate is false.
182
+ let dot = null;
183
+ for (const frame of scopedAction.frames) {
184
+ if (frame.kind === "with")
185
+ dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
186
+ else if (frame.kind === "range")
187
+ dot = undefined;
188
+ else if (frame.kind === "define" || frame.kind === "block")
189
+ dot = null;
190
+ }
191
+ const ctx = { values: instance.values, scope: instance.scope, dotValuesPath: dot };
192
+ const inAction = callReachability(actionPipeline(scopedAction.action), fnRe, ctx);
193
+ // An enclosing gate we could not resolve leaves reachability unproven even
194
+ // when nothing in the action itself gates the call.
195
+ if (inAction.reachable === true && !enclosing.allKnown) {
196
+ return { reachable: UNKNOWN, gates: [] };
197
+ }
198
+ return inAction;
199
+ }
200
+ // --- classification --------------------------------------------------------
201
+ /**
202
+ * Classify a chart directory against the values a render would be given.
203
+ * Subchart-recursive: dependencies vendored under `charts/` are scanned with
204
+ * their own effective values (aliases get their own scope, `condition:`-
205
+ * disabled dependencies gate their contents — findings 11 and 12).
206
+ */
207
+ export function classifyChart(chartDir, options = {}) {
208
+ let supplied;
209
+ for (const file of options.valuesFiles ?? []) {
210
+ supplied = mergeValues(supplied ?? {}, readYamlFile(file));
211
+ }
212
+ if (options.values !== undefined)
213
+ supplied = mergeValues(supplied ?? {}, options.values);
214
+ const valuesSupplied = supplied !== undefined;
215
+ const { instances, warnings } = buildChartInstances(chartDir, supplied);
216
+ const requiresProfile = [];
217
+ const valuePosition = [];
218
+ const controlFlowSites = new Map();
219
+ const generatedSites = new Map();
220
+ const dirScans = new Map();
221
+ const staticallyRecordedDirs = new Set();
222
+ for (const instance of instances) {
223
+ let files = dirScans.get(instance.dir);
224
+ if (files === undefined) {
225
+ files = scanDir(instance.dir);
226
+ dirScans.set(instance.dir, files);
227
+ }
228
+ // Static findings (capability refs, value-position lookups) are
229
+ // per-FILE: an aliased dependency scans the same directory for two
230
+ // instances but its files exist once.
231
+ const recordStatic = !staticallyRecordedDirs.has(instance.dir);
232
+ staticallyRecordedDirs.add(instance.dir);
233
+ for (const { rel, scoped } of files) {
234
+ const file = instance.relDir === "" ? rel : `${instance.relDir}/${rel}`;
235
+ for (const s of scoped) {
236
+ const { action } = s;
237
+ if (recordStatic) {
238
+ for (const m of action.body.matchAll(CAPABILITY_REF)) {
239
+ requiresProfile.push({
240
+ file,
241
+ line: action.line,
242
+ capability: m[1],
243
+ });
244
+ }
245
+ }
246
+ if (LOOKUP_CALL.test(action.body)) {
247
+ if (CONTROL_FLOW_KINDS.has(action.kind)) {
248
+ const key = `${file}:${action.line}`;
249
+ let site = controlFlowSites.get(key);
250
+ if (site === undefined) {
251
+ site = { file, line: action.line, action: action.body, assessments: [] };
252
+ controlFlowSites.set(key, site);
253
+ }
254
+ const a = assessCall(s, LOOKUP_FN, instance);
255
+ site.assessments.push({
256
+ chart: instance.scope.join("."),
257
+ reachable: a.reachable,
258
+ gates: a.gates,
259
+ });
260
+ }
261
+ else if (recordStatic) {
262
+ valuePosition.push({ file, line: action.line, action: action.body });
263
+ }
264
+ }
265
+ const gen = action.body.match(GENERATED_CALL);
266
+ if (gen !== null) {
267
+ const a = assessCall(s, new RegExp(`^${gen[1]}$`), instance);
268
+ // A generator whose enclosing gates are definitively closed cannot
269
+ // fire for this render; re-classification on a values flip
270
+ // rediscovers it.
271
+ if (a.reachable !== false) {
272
+ const valuesRef = action.body.match(VALUES_PATH_IN_ACTION);
273
+ // A value closes a generator only through the `x | default
274
+ // (rand)` shape, where a truthy x discards the generated result.
275
+ // A generator that CONSUMES values (`htpasswd .Values.user
276
+ // .Values.password` — random salt per render) stays open no
277
+ // matter what the values hold, so the path it reads is not a
278
+ // suppliable slot; the localizer (#1236) finds the real pin
279
+ // (harbor's `registry.credentials.htpasswdString` else-branch).
280
+ const closable = valuesRef !== null && /\bdefault\b/.test(action.body);
281
+ const relPath = closable && valuesRef ? valuesRef[1].slice(1).split(".") : undefined;
282
+ // Open unless the suppliable value is already set (a truthy
283
+ // value makes `x | default (randAlphaNum ...)` deterministic).
284
+ const open = relPath === undefined || !truthy(valueAtPath(instance.values, relPath));
285
+ const key = `${file}:${action.line}:${gen[1]}`;
286
+ const existing = generatedSites.get(key);
287
+ if (existing === undefined) {
288
+ generatedSites.set(key, {
289
+ file,
290
+ line: action.line,
291
+ fn: gen[1],
292
+ open,
293
+ valuesPath: relPath ? [...instance.scope, ...relPath].join(".") : undefined,
294
+ suppliable: relPath !== undefined,
295
+ });
296
+ }
297
+ else if (open) {
298
+ existing.open = true;
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ // --- fold findings into the report ---------------------------------------
306
+ const reasons = [];
307
+ const hazards = [];
308
+ const controlFlow = [];
309
+ let refusals = 0;
310
+ for (const site of controlFlowSites.values()) {
311
+ const reachableIn = site.assessments.filter((a) => a.reachable !== false);
312
+ if (reachableIn.length > 0) {
313
+ refusals += 1;
314
+ controlFlow.push({
315
+ file: site.file,
316
+ line: site.line,
317
+ action: site.action,
318
+ status: "refused",
319
+ charts: site.assessments.map((a) => a.chart),
320
+ });
321
+ for (const a of reachableIn) {
322
+ const where = a.chart === "" ? "" : ` (subchart ${a.chart})`;
323
+ const proof = a.reachable === UNKNOWN
324
+ ? "not provably gated off by the supplied values"
325
+ : "reachable with the supplied values";
326
+ reasons.push(`lookup in control flow at ${site.file}:${site.line}${where} — ${proof}; which documents exist depends on cluster state`);
327
+ }
328
+ }
329
+ else {
330
+ controlFlow.push({
331
+ file: site.file,
332
+ line: site.line,
333
+ action: site.action,
334
+ status: "hazard",
335
+ charts: site.assessments.map((a) => a.chart),
336
+ });
337
+ for (const a of site.assessments) {
338
+ const gateText = a.gates.map((g) => `${g.path}=${JSON.stringify(g.value ?? null)}`).join(", ");
339
+ hazards.push({
340
+ chart: a.chart,
341
+ file: site.file,
342
+ line: site.line,
343
+ gates: a.gates,
344
+ detail: `control-flow lookup at ${site.file}:${site.line} is gated off by ${gateText} — flipping it makes this render unpinnable`,
345
+ });
346
+ }
347
+ }
348
+ }
349
+ // The report stays fully populated even for a refused chart: the caller
350
+ // renders unpinned and still benefits from knowing the other open inputs.
351
+ const closedInputs = [];
352
+ if (requiresProfile.length > 0) {
353
+ reasons.push(`declare capability profile (${requiresProfile.length} .Capabilities refs)`);
354
+ }
355
+ for (const v of valuePosition) {
356
+ closedInputs.push({
357
+ kind: "value-position-lookup",
358
+ file: v.file,
359
+ line: v.line,
360
+ suppliable: true,
361
+ detail: `declare input for value-position lookup at ${v.file}:${v.line}`,
362
+ });
363
+ reasons.push(`declare input for value-position lookup at ${v.file}:${v.line}`);
364
+ }
365
+ for (const g of generatedSites.values()) {
366
+ if (!g.open)
367
+ continue;
368
+ const supply = g.suppliable
369
+ ? `suppliable via ${g.valuesPath}`
370
+ : "no suppliable value found in the action — the double-render localizer (#1236) must confirm and localize it";
371
+ closedInputs.push({
372
+ kind: "generated-value",
373
+ file: g.file,
374
+ line: g.line,
375
+ fn: g.fn,
376
+ valuesPath: g.valuesPath,
377
+ suppliable: g.suppliable,
378
+ detail: `open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`,
379
+ });
380
+ reasons.push(`open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`);
381
+ }
382
+ if (options.renderEvidence !== undefined && !options.renderEvidence.stable) {
383
+ reasons.push(`open generated input: double render differs on ${options.renderEvidence.unstableLines} lines — supply the value(s) as declared inputs`);
384
+ }
385
+ for (const h of hazards) {
386
+ reasons.push(`conditional hazard: ${h.detail}`);
387
+ }
388
+ if (valuesSupplied) {
389
+ closedInputs.push({ kind: "supplied-values", detail: "values supplied as closed input" });
390
+ reasons.push("values supplied as closed input");
391
+ }
392
+ for (const w of warnings)
393
+ reasons.push(`warning: ${w}`);
394
+ let verdict;
395
+ if (refusals > 0) {
396
+ verdict = "unpinnable";
397
+ }
398
+ else if (requiresProfile.length > 0 ||
399
+ closedInputs.length > 0 ||
400
+ hazards.length > 0 ||
401
+ (options.renderEvidence !== undefined && !options.renderEvidence.stable)) {
402
+ verdict = "pinnable";
403
+ }
404
+ else {
405
+ verdict = "deterministic";
406
+ }
407
+ return {
408
+ verdict,
409
+ requiresProfile,
410
+ closedInputs,
411
+ hazards,
412
+ reasons,
413
+ lookups: { controlFlow, valuePosition },
414
+ warnings,
415
+ };
416
+ }
Binary file
@@ -0,0 +1,16 @@
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
+ export { classifyChart, collectOwnTemplateFiles, } from "./classify.js";
12
+ export { actionPipeline, extractActions, scopeActions, } from "./actions.js";
13
+ export { UNKNOWN, truthy, evaluateCondition, callReachability, parseExpr, } from "./conditions.js";
14
+ export { buildChartInstances, mergeValues, valueAtPath, readChartMeta, } from "./values.js";
15
+ export { splitDocuments, sourcePath, isCrdSource, routeBySource, countDifferingLines, } from "./render-stream.js";
16
+ export { localizeOpenInputs, diffRenderPair, } from "./localize.js";