@davesheffer/hunch 1.46.0 → 1.47.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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -1,786 +1,415 @@
1
1
  import { posix } from "node:path";
2
2
  import { eolLengthAt, sourceLines } from "../core/eol.js";
3
- // ReplicaSet is included alongside the pod-spec-embedding/networking kinds
4
- // specifically because it's the dominant real-world bearer of
5
- // ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
6
- // without it, the single most common ownerReferences case would be silently
7
- // dropped by the allowlist gate below, despite Goal 2 explicitly scoping
8
- // "any resource -> its owner" as in scope. Still a well-known core/apps kind,
9
- // not a CRD -- consistent with the allowlist's own rationale, not an exception to it.
10
- const ALLOWED_KINDS = new Set([
11
- "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod", "ReplicaSet",
12
- "Service", "ConfigMap", "Secret", "PersistentVolumeClaim", "Ingress", "HTTPRoute",
3
+ export const POD_SPEC_PATH_BY_KIND = {
4
+ Pod: "spec",
5
+ Deployment: "spec.template.spec",
6
+ StatefulSet: "spec.template.spec",
7
+ DaemonSet: "spec.template.spec",
8
+ Job: "spec.template.spec",
9
+ ReplicaSet: "spec.template.spec",
10
+ CronJob: "spec.jobTemplate.spec.template.spec",
11
+ };
12
+ export const LABELS_PATH_BY_KIND = {
13
+ Pod: "metadata.labels",
14
+ Deployment: "spec.template.metadata.labels",
15
+ StatefulSet: "spec.template.metadata.labels",
16
+ DaemonSet: "spec.template.metadata.labels",
17
+ Job: "spec.template.metadata.labels",
18
+ ReplicaSet: "spec.template.metadata.labels",
19
+ CronJob: "spec.jobTemplate.spec.template.metadata.labels",
20
+ };
21
+ const KNOWN_KINDS = new Set([
22
+ "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod", "ReplicaSet", "Service", "ConfigMap",
23
+ "Secret", "PersistentVolumeClaim", "Ingress", "HTTPRoute",
24
+ ]);
25
+ // Kinds a namespace transformer must leave alone. None is in KNOWN_KINDS today.
26
+ const CLUSTER_SCOPED_KINDS = new Set([
27
+ "Namespace", "Node", "PersistentVolume", "StorageClass", "CustomResourceDefinition", "ClusterRole",
28
+ "ClusterRoleBinding", "PriorityClass", "IngressClass", "RuntimeClass", "MutatingWebhookConfiguration",
29
+ "ValidatingWebhookConfiguration", "APIService",
13
30
  ]);
14
- /** Normalize a concrete path's sequence indices to "[]" for table matching. */
15
- function wildcardPath(path) {
16
- return path.replace(/\[\d+\]/g, "[]");
31
+ function podPatterns(spec) {
32
+ const out = [];
33
+ for (const list of ["containers[]", "initContainers[]"]) {
34
+ out.push([`${spec}.${list}.envFrom[].configMapRef.name`, "ConfigMap"], [`${spec}.${list}.envFrom[].secretRef.name`, "Secret"], [`${spec}.${list}.env[].valueFrom.configMapKeyRef.name`, "ConfigMap"], [`${spec}.${list}.env[].valueFrom.secretKeyRef.name`, "Secret"]);
35
+ }
36
+ out.push([`${spec}.volumes[].configMap.name`, "ConfigMap"], [`${spec}.volumes[].secret.secretName`, "Secret"], [`${spec}.volumes[].persistentVolumeClaim.claimName`, "PersistentVolumeClaim"], [`${spec}.imagePullSecrets[].name`, "Secret"]);
37
+ return out;
17
38
  }
18
- /** Dot-joined field path for a frame stack (or a prefix of one), e.g.
19
- * `spec.template.spec.containers[0].env` -- `\.\[` collapses to `[` since a
20
- * sequence-item frame's own key is already `[N]`, and joining it after a
21
- * `.` would double the separator. */
22
- function framePath(frames) {
23
- return frames.map((f) => f.key).filter(Boolean).join(".").replace(/\.\[/g, "[");
39
+ const HTTPROUTE_BACKEND_NAME = "spec.rules[].backendRefs[].name";
40
+ const PATTERNS_BY_KIND = new Map();
41
+ for (const [kind, spec] of Object.entries(POD_SPEC_PATH_BY_KIND))
42
+ PATTERNS_BY_KIND.set(kind, new Map(podPatterns(spec)));
43
+ PATTERNS_BY_KIND.set("Ingress", new Map([
44
+ ["spec.rules[].http.paths[].backend.service.name", "Service"],
45
+ ["spec.defaultBackend.service.name", "Service"],
46
+ ]));
47
+ PATTERNS_BY_KIND.set("HTTPRoute", new Map([[HTTPROUTE_BACKEND_NAME, "Service"]]));
48
+ const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
49
+ const BLOCK_HEADER = /^[|>](?:[+-]\d*|\d+[+-]?)?$/u;
50
+ const KEY_LINE = /^([ \t]*)(-[ \t]+)?([A-Za-z0-9_./-]+):[ \t]*([\s\S]*)$/u;
51
+ const BARE_TEMPLATE = /^[ \t]*(?:-[ \t]+)?\{\{/u;
52
+ const BARE_MARKER = /^([ \t]*)-[ \t]*$/u;
53
+ const MARKER_TEXT = /^([ \t]*)-[ \t]+([\s\S]+)$/u;
54
+ const SEPARATOR = /^(?:---(?:[ \t][\s\S]*)?|\.\.\.[ \t]*)$/u;
55
+ function isValidNamespace(value) {
56
+ return value !== "null" && DNS_LABEL.test(value);
24
57
  }
25
- // sourceLines removes line terminators while retaining offsets in the original
26
- // source, so every line parser sees the same content on LF, CRLF, and CR input.
27
- const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)$/;
28
- // A line that is ENTIRELY a `{{ ... }}` template action (no `key:` prefix at
29
- // all) -- e.g. a block-form injection appearing as a SIBLING after other
30
- // literal keys under the same mapping (`app: my-app` then, on its own later
31
- // line, `{{- include "mychart.selectorLabels" . | nindent 4 }}`). This never
32
- // matches KEY_LINE (there's no colon-terminated key), so without explicit
33
- // handling it's silently invisible to the scanner -- neither contributing a
34
- // value nor marking its container as unresolved, which lets a literal-looking
35
- // map that's actually partially templated pass as fully literal. No capture
36
- // groups: unlike an unrecognized-but-real YAML line, this line's OWN
37
- // indentation is deliberately never inspected -- see
38
- // markAllOpenContainersUnresolved's comment for why.
39
- const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
40
- // A list-item marker with NOTHING after it on the same line -- the item's
41
- // content is entirely on the following, more-indented lines
42
- // (`containers:\n -\n name: app`). Legal YAML, distinct from the inline
43
- // `- name: app` form KEY_LINE already handles: this line has no key of its
44
- // own at all, so without explicit handling it fell to the unrecognized-line
45
- // branch, which used ORDINARY popping and lost the list-item frame entirely
46
- // -- reparenting the item's real children one level up (`containers.name`
47
- // instead of `containers[0].name`), silently dropping every reference under it.
48
- const BARE_LIST_MARKER = /^(\s*)-[ \t]*$/;
49
- // A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
50
- // `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
51
- // script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
52
- // yet the body lines look exactly like manifest fields (a heredoc'd
53
- // `secretKeyRef:` block is the canonical case), and this line matches neither
54
- // KEY_LINE (no colon) nor BARE_LIST_MARKER (something follows the dash), so it
55
- // fell to the unrecognized-line branch and the script body was then scanned as
56
- // the surrounding container's children -- inventing a reference to a resource
57
- // that only ever existed as text. The `key: |` form needs no such handling:
58
- // its body nests under the key's OWN frame, where no reference field-path can
59
- // match. The rest is captured (not `[|>]` inline) so the header can be run
60
- // through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
61
- // key line's value is.
62
- const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)$/;
63
- // A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
64
- // `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
65
- // `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
66
- // scalar is on the FOLLOWING indented lines, not this one -- treating the
67
- // header token itself as the value (e.g. a resource literally named "|-")
68
- // would be silently, confidently wrong, not merely incomplete.
69
- const BLOCK_SCALAR_HEADER = /^[|>](?:[+-]\d*|\d+[+-]?)?$/;
70
- /** Strip a trailing YAML comment: `#` only opens one at the start of the
71
- * value or after whitespace, and never inside a quoted scalar -- so
72
- * `key: "a # b"` keeps its `#` and `key: {{ .x | default "#fff" }}` keeps
73
- * its Sprig default intact, but `key: my-config # app settings` drops the
74
- * comment. Without this, a comment silently becomes part of the value text:
75
- * it never matches the same resource's uncommented name elsewhere, so the
76
- * reference quietly resolves to nothing instead of erroring -- the worst
77
- * failure mode for a "no match -> no edge, never guess" design, since it's
78
- * indistinguishable from correctly declining to guess. */
79
- function stripTrailingComment(raw) {
58
+ function unquote(value) {
59
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
60
+ return value.slice(1, -1);
61
+ }
62
+ return value;
63
+ }
64
+ /** Drop a trailing YAML comment; quotes only open at a word start. */
65
+ function stripComment(value) {
80
66
  let quote = null;
81
- for (let i = 0; i < raw.length; i++) {
82
- const ch = raw[i];
67
+ for (let i = 0; i < value.length; i++) {
68
+ const c = value[i];
83
69
  if (quote) {
84
- if (ch === quote)
70
+ if (c === quote)
85
71
  quote = null;
86
72
  continue;
87
73
  }
88
- // A quote only OPENS a quoted scalar at the value's start or after
89
- // whitespace -- mirrors the # rule below, and keeps a Sprig
90
- // `default "#fff"` working (its " follows a space) while an apostrophe
91
- // mid-word (`it's-fine`) no longer opens a phantom quote that would
92
- // swallow a real trailing comment whole.
93
- if ((ch === '"' || ch === "'") && (i === 0 || /\s/.test(raw[i - 1]))) {
94
- quote = ch;
74
+ const wordStart = i === 0 || value[i - 1] === " " || value[i - 1] === "\t";
75
+ if (!wordStart)
95
76
  continue;
96
- }
97
- if (ch === "#" && (i === 0 || /\s/.test(raw[i - 1])))
98
- return raw.slice(0, i);
99
- }
100
- return raw;
101
- }
102
- function stripQuotes(value) {
103
- if (value.length >= 2) {
104
- const first = value[0];
105
- const last = value.at(-1);
106
- if ((first === '"' && last === '"') || (first === "'" && last === "'"))
107
- return value.slice(1, -1);
77
+ if (c === '"' || c === "'")
78
+ quote = c;
79
+ else if (c === "#")
80
+ return value.slice(0, i);
108
81
  }
109
82
  return value;
110
83
  }
111
- /** Line-oriented indentation-stack scan. Handles BOTH real-world YAML list
112
- * styles: sequence items at the SAME indent as their parent key
113
- * (`containers:\n- name: app`) and at a DEEPER indent (`env:\n - name: X`) --
114
- * both occur in real manifests. A value-less key frame is converted to a
115
- * sequence frame IN PLACE (not popped) the first time a `-` line arrives at or
116
- * below its own indent; a sequence frame is only ever closed by a
117
- * shallower-indent line, never by an equal-indent one (equal-indent means
118
- * "next item"). */
119
- function scanFieldPaths(text, baseChar) {
84
+ function indentOf(line) {
85
+ let n = 0;
86
+ while (n < line.length && (line[n] === " " || line[n] === "\t"))
87
+ n++;
88
+ return n;
89
+ }
90
+ function makeRef(value, at) {
91
+ const inner = unquote(value);
92
+ const endChar = at + value.length;
93
+ return inner.startsWith("{{")
94
+ ? { form: "template", sourceText: inner, atChar: at, endChar }
95
+ : { form: "literal", value: inner, atChar: at, endChar };
96
+ }
97
+ function scanDocument(docText, base) {
120
98
  const entries = [];
121
- // A container (mapping) this scanner could not fully account for -- either
122
- // an explicit {{ }} template injection, OR a line shape KEY_LINE doesn't
123
- // recognize at all (a quoted key, a YAML merge key `<<:`, ...). Both get the
124
- // SAME treatment: a dropped/unrecognized key would make a selector/labels
125
- // map strictly MORE permissive (fewer real constraints), which risks a
126
- // false-positive edge -- the failure mode this whole module exists to
127
- // avoid. Silently ignoring what the scanner can't parse is not safe here;
128
- // "I can't tell" must read as "unresolved," the same as a real template.
129
- const unresolvedContainers = new Set();
130
- // The framePath of every container that DIRECTLY holds a key written with no
131
- // value at all (`tier:`). Deliberately its own set rather than more entries
132
- // in unresolvedContainers: an ordinary container like `spec:` legitimately
133
- // holds value-less keys (every nested mapping starts with one), so folding
134
- // these in would destroy that set's meaning -- "the scanner could not
135
- // account for this line". Only a map that is supposed to be FLAT
136
- // (selector/labels) cares, and it checks this set itself.
137
- const valuelessKeyParents = new Set();
99
+ const tainted = new Set();
100
+ const valueless = new Set();
138
101
  const stack = [];
139
- // Indent of the `-` of a `- |` list item whose block-scalar body is being
140
- // skipped, or null when no such skip is active. The body ends at the first
141
- // line indented at or shallower than that dash (YAML's own rule for where a
142
- // sequence item's content stops).
143
- let blockScalarSkipIndent = null;
144
- const popToForListItem = (dashIndent) => {
145
- while (stack.length) {
146
- const top = stack[stack.length - 1];
147
- if (top.indent <= dashIndent) {
148
- if (!top.isSeq && !top.hasValue)
149
- top.isSeq = true; // convert in place, first time only
150
- return; // either already/now a seq frame at or above this indent -- reuse it
151
- }
152
- stack.pop();
153
- }
154
- };
155
- const popOrdinary = (indent) => {
102
+ let skipBody = null; // dash column of a list-item block scalar
103
+ const container = () => (stack.length ? stack[stack.length - 1].path : "");
104
+ const closeFrom = (indent) => {
156
105
  while (stack.length && stack[stack.length - 1].indent >= indent)
157
106
  stack.pop();
158
107
  };
159
- // Shared by KEY_LINE's inline `- key: value` form and the bare `-`-alone
160
- // form: converts/reuses the enclosing sequence frame, then pushes this
161
- // item's own `[idx]` frame at `itemFrameIndent` -- the caller picks that
162
- // value so it sits strictly between the sequence frame's own indent and
163
- // whatever the item's real children will be indented at (itemIndent - 1
164
- // for the inline form; dashIndent + 1 for the bare form, since a bare
165
- // marker's children are entirely on later lines with no itemIndent to
166
- // derive from).
167
- const enterListItem = (dashIndent, itemFrameIndent) => {
168
- popToForListItem(dashIndent);
169
- let top = stack[stack.length - 1];
170
- if (!top || !top.isSeq) {
171
- top = { indent: dashIndent, key: "", isSeq: true, hasValue: false, nextIndex: 0 };
172
- stack.push(top);
173
- }
174
- const idx = top.nextIndex ?? 0;
175
- top.nextIndex = idx + 1;
176
- stack.push({ indent: itemFrameIndent, key: `[${idx}]`, isSeq: false, hasValue: false });
177
- };
178
- // Pops to a line's context (same rule an ordinary/list-item key line would
179
- // use) WITHOUT pushing a frame -- the line has no key of its own -- then
180
- // marks whatever container it now sits inside as unresolved. Used ONLY for
181
- // a line that IS real YAML structure the scanner just can't decode the key
182
- // of (a quoted key, a merge key) -- there, the line's indentation is
183
- // genuine, meaningful nesting depth. Always uses ordinary (non-list)
184
- // popping: its one call site never sees a list-marker-prefixed line (that
185
- // shape -- e.g. `- <<: *base` -- is a known, separate, non-blocking gap,
186
- // not something this function is meant to special-case).
187
- const markUnresolvedContainer = (indent) => {
188
- popOrdinary(indent);
189
- unresolvedContainers.add(framePath(stack));
108
+ const taintOpen = () => {
109
+ tainted.add("");
110
+ for (const f of stack)
111
+ tainted.add(f.path);
190
112
  };
191
- // A `{{ }}` action line's OWN indentation carries NO structural meaning:
192
- // `{{-` chomps it away entirely, and the extremely common `| indent N`
193
- // idiom REQUIRES the action to sit at column 0 while injecting content at
194
- // depth N. Popping the frame stack by that column (as an ordinary line
195
- // would) either taints the wrong ancestor container or, at column 0,
196
- // destroys every open frame -- reparenting every subsequent line in the
197
- // document to the root and losing all their field-paths. Never pop the
198
- // real stack for this; instead, conservatively taint every container
199
- // currently open (root down through the innermost), since the injection
200
- // could be targeting any of them and there is no way to tell which.
201
- const markAllOpenContainersUnresolved = () => {
202
- for (let depth = 0; depth <= stack.length; depth++) {
203
- unresolvedContainers.add(framePath(stack.slice(0, depth)));
113
+ const openItem = (dash, itemIndent) => {
114
+ while (stack.length && stack[stack.length - 1].indent > dash)
115
+ stack.pop();
116
+ let seq = stack[stack.length - 1];
117
+ if (seq && seq.kind === "key" && !seq.inline) {
118
+ seq.kind = "seq";
119
+ seq.next = 0;
204
120
  }
121
+ else if (!seq || seq.kind !== "seq") {
122
+ seq = { kind: "seq", indent: dash, path: seq ? seq.path : "", inline: true, next: 0 };
123
+ stack.push(seq);
124
+ }
125
+ stack.push({ kind: "item", indent: itemIndent, path: `${seq.path}[${seq.next++}]`, inline: true, next: 0 });
205
126
  };
206
- for (const { content: line, start } of sourceLines(text)) {
207
- const lineStartChar = baseChar + start;
208
- // Inside a `- |` item's body: these lines are script/config TEXT, so they
209
- // must never be read as field structure. sourceLines retains original
210
- // offsets even when skipped lines use CRLF.
211
- if (blockScalarSkipIndent !== null) {
212
- // A blank line is part of the block scalar (YAML lets a scalar body
213
- // contain empty lines at any indent), never its terminator.
214
- if (line.trim().length === 0)
127
+ for (const line of sourceLines(docText)) {
128
+ const content = line.content;
129
+ if (skipBody !== null) {
130
+ if (content.trim() === "")
215
131
  continue;
216
- if (BARE_TEMPLATE_LINE.test(line)) {
217
- // A `{{ }}` action line's own indentation carries no structural
218
- // meaning (`{{-` chomps it; the `| indent N` idiom puts the action at
219
- // column 0 -- see markAllOpenContainersUnresolved), so a column-0
220
- // action inside a script body must NOT be read as a dedent that ends
221
- // the skip and re-exposes the remaining body lines as fields. Taint
222
- // conservatively and stay in the skip: we cannot tell whether the
223
- // action sits inside the scalar or after it.
224
- markAllOpenContainersUnresolved();
132
+ if (BARE_TEMPLATE.test(content)) {
133
+ taintOpen();
225
134
  continue;
226
135
  }
227
- const indent = line.length - line.trimStart().length;
228
- if (indent > blockScalarSkipIndent)
229
- continue; // still the scalar's body
230
- blockScalarSkipIndent = null; // dedented out of the item -- real YAML again
136
+ if (indentOf(content) > skipBody)
137
+ continue;
138
+ skipBody = null;
231
139
  }
232
- if (BARE_TEMPLATE_LINE.test(line)) {
233
- markAllOpenContainersUnresolved();
140
+ if (BARE_TEMPLATE.test(content)) {
141
+ taintOpen();
234
142
  continue;
235
143
  }
236
- const bareListMarker = BARE_LIST_MARKER.exec(line);
237
- if (bareListMarker) {
238
- const dashIndent = bareListMarker[1].length;
239
- enterListItem(dashIndent, dashIndent + 1);
144
+ const bare = BARE_MARKER.exec(content);
145
+ if (bare) {
146
+ const dash = bare[1].length;
147
+ openItem(dash, dash + 0.5); // children sit anywhere deeper than the dash
240
148
  continue;
241
149
  }
242
- // Checked BEFORE the KEY_LINE match: KEY_LINE can't match this shape
243
- // anyway (no colon), but without this branch it would land in the
244
- // unrecognized-line branch below and the body would be scanned as fields.
245
- const blockScalarItem = BLOCK_SCALAR_LIST_ITEM.exec(line);
246
- if (blockScalarItem && BLOCK_SCALAR_HEADER.test(stripTrailingComment(blockScalarItem[2]).trim())) {
247
- const dashIndent = blockScalarItem[1].length;
248
- // It IS a real sequence item, so enter it exactly as the bare-marker
249
- // form does -- sibling items after this one must keep their indices.
250
- enterListItem(dashIndent, dashIndent + 1);
251
- blockScalarSkipIndent = dashIndent;
150
+ const itemText = MARKER_TEXT.exec(content);
151
+ if (itemText && BLOCK_HEADER.test(stripComment(itemText[2]).trim())) {
152
+ const dash = itemText[1].length;
153
+ openItem(dash, dash + 1);
154
+ skipBody = dash;
252
155
  continue;
253
156
  }
254
- const m = KEY_LINE.exec(line);
255
- if (!m) {
256
- // Any other non-blank, non-comment line is a shape this scanner can't
257
- // account for at all (a quoted key, a merge key, ...) -- see
258
- // unresolvedContainers' own comment above for why this can't be a
259
- // silent skip.
260
- const trimmed = line.trim();
261
- if (trimmed.length > 0 && !trimmed.startsWith("#")) {
262
- markUnresolvedContainer(line.length - line.trimStart().length);
157
+ const key = KEY_LINE.exec(content);
158
+ if (key) {
159
+ const indent = key[1].length;
160
+ const keyCol = indent + (key[2]?.length ?? 0);
161
+ if (key[2])
162
+ openItem(indent, keyCol - 1);
163
+ closeFrom(keyCol);
164
+ const parent = container();
165
+ const path = parent ? `${parent}.${key[3]}` : key[3];
166
+ const value = stripComment(key[4]).trim();
167
+ let inline = true;
168
+ if (value === "") {
169
+ valueless.add(parent);
170
+ inline = false;
263
171
  }
172
+ else {
173
+ if (value.startsWith("[") || (value.startsWith("{") && !value.startsWith("{{")))
174
+ tainted.add(path);
175
+ if (BLOCK_HEADER.test(value)) {
176
+ tainted.add(parent);
177
+ }
178
+ else {
179
+ const colon = content.indexOf(":", indent);
180
+ let pos = content.indexOf(value, colon + 1);
181
+ if (pos < 0)
182
+ pos = colon + 1;
183
+ entries.push({ path, parent, ref: makeRef(value, base + line.start + pos) });
184
+ }
185
+ }
186
+ stack.push({ kind: "key", indent: keyCol, path, inline, next: 0 });
264
187
  continue;
265
188
  }
266
- const [, indentStr, listMarker, key, rawValue] = m;
267
- const dashIndent = indentStr.length;
268
- const itemIndent = dashIndent + (listMarker?.length ?? 0);
269
- if (listMarker) {
270
- // itemIndent - 1: strictly between the seq frame's own indent and its
271
- // children's indent, so a sibling key within this item pops back to
272
- // (but never past) this frame.
273
- enterListItem(dashIndent, itemIndent - 1);
274
- }
275
- else {
276
- popOrdinary(dashIndent);
277
- }
278
- const value = stripTrailingComment(rawValue).trim();
279
- const parentPath = framePath(stack);
280
- stack.push({ indent: itemIndent, key: key, isSeq: false, hasValue: value.length > 0 });
281
- const path = framePath(stack);
282
- // A value that OPENS a flow collection (`{`/`[`, never a `{{` template
283
- // action) means this key's real content is flow syntax, possibly spread
284
- // over later lines -- a line-oriented scan can't see keys written on the
285
- // opening line itself (`selector: {app: x,` loses `app` entirely once a
286
- // later line like `tier: web` gets read as this key's only child). Same
287
- // rule as any other shape the scanner can't fully account for: mark it
288
- // unresolved rather than let a partially-seen map read as complete. Also
289
- // covers the single-line case (`selector: {app: x}`) harmlessly -- that
290
- // already returned null via "no children found", this just makes the
291
- // reason explicit instead of incidental.
292
- if ((value.startsWith("{") && !value.startsWith("{{")) || value.startsWith("["))
293
- unresolvedContainers.add(path);
294
- // A block-scalar header taints the PARENT container, not this entry's own
295
- // path: the header is a LEAF value's header (e.g. `app: |-` under
296
- // `spec.selector`), and extractLiteralLabelMap only ever checks a
297
- // container's own path (spec.selector) for taint, never a leaf's --
298
- // tainting the leaf's path here would silently leave the map resolving
299
- // MINUS this one key, which is the same over-permissive risk as not
300
- // tainting at all. Checked on the raw (pre-quote-strip) value, same as
301
- // the flow-collection check above, so a genuine quoted `name: "|"` stays
302
- // a literal and isn't mistaken for an unterminated block scalar.
303
- if (BLOCK_SCALAR_HEADER.test(value)) {
304
- unresolvedContainers.add(parentPath);
189
+ const trimmed = content.trim();
190
+ if (trimmed === "" || trimmed.startsWith("#"))
191
+ continue;
192
+ if (/^\s/u.test(content.slice(indentOf(content)))) {
193
+ // Non-ASCII whitespace in the indent: depth unknowable, so every open map is unknown.
194
+ taintOpen();
305
195
  continue;
306
196
  }
307
- // A key written with NO value (`tier:`) creates a frame but never an
308
- // entry, so it is invisible to extractLiteralLabelMap -- which then
309
- // returns the map MINUS that key. For a flat string->string map
310
- // (selector/labels) a value-less direct child is either a null value or a
311
- // nested container; either way the key is silently absent, and a selector
312
- // short one key matches strictly MORE workloads than the real one -- the
313
- // same over-permissive failure as every other unresolved case here.
314
- // Recorded after the block-scalar branch above, which already taints the
315
- // parent for its own reason. Note `tier: ""` is NOT value-less: quotes
316
- // survive stripTrailingComment/trim, so it stays a literal empty string.
317
- if (value.length === 0)
318
- valuelessKeyParents.add(parentPath);
319
- if (value.length > 0) {
320
- const colonIdx = line.indexOf(":", dashIndent);
321
- const valueStartInLine = line.indexOf(value, colonIdx);
322
- const atChar = lineStartChar + valueStartInLine;
323
- const endChar = atChar + value.length;
324
- // Classify on the QUOTE-STRIPPED text, not the raw value: idiomatic
325
- // Helm text is pre-render, not valid YAML yet, so a template expression
326
- // routinely appears both bare (`name: {{ include "c.fullname" . }}`)
327
- // and wrapped in quotes elsewhere in the same chart (`name: "{{ include
328
- // "c.fullname" . }}"`, e.g. helm create's own test-connection.yaml
329
- // pattern). Both forms carry the identical expression text once quotes
330
- // are stripped -- classifying on the raw value would tag one "literal"
331
- // and the other "template", giving them different nameKeyText (L:/T:)
332
- // prefixes in indexer.ts and silently breaking the match between a
333
- // resource's own name and a quoted reference to it.
334
- //
335
- // A value like `prefix-{{ .Values.x }}` (template text NOT at the very
336
- // start) is still classified "literal" here, not "template" --
337
- // deliberately narrow, matching only the whole-value case. Matching
338
- // still stays correct either way: both a literal/literal and a
339
- // template/template comparison require the two sides' raw text to be
340
- // byte-identical, so a "prefix-{{ x }}" value only ever matches another
341
- // identical "prefix-{{ x }}" value, never a bare "{{ x }}" -- just via
342
- // the "literal" bucket instead of the "template" one.
343
- const unquoted = stripQuotes(value);
344
- entries.push({
345
- path,
346
- parentPath,
347
- key: key,
348
- value: unquoted.startsWith("{{")
349
- ? { form: "template", sourceText: unquoted, atChar, endChar }
350
- : { form: "literal", value: unquoted, atChar, endChar },
351
- });
352
- }
353
- // A value-less key (e.g. `selector:`) needs no bookkeeping of its own
354
- // here: whatever follows it (a real nested mapping, a `{{ }}` block
355
- // injection, or an unrecognized line) is handled uniformly by the
356
- // BARE_TEMPLATE_LINE / "unrecognized line" branches above on ITS OWN
357
- // line, since that line's own popToForListItem/popOrdinary call pops
358
- // back to (but never past) this key's frame.
197
+ closeFrom(indentOf(content));
198
+ tainted.add(container());
359
199
  }
360
- return { entries, unresolvedContainers, valuelessKeyParents };
361
- }
362
- function findEntry(entries, path) {
363
- return entries.find((e) => e.path === path);
200
+ return { entries, tainted, valueless };
364
201
  }
365
- /** The RFC 1123 DNS label a real Kubernetes namespace name must be -- the
366
- * positive test literalNamespace applies (see there for why). */
367
- const DNS_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
368
- /** A namespace a reference can be MATCHED on, or null meaning UNKNOWN.
369
- * Deliberately narrower than the ManifestNameRef literal/template split that
370
- * names get: a name is a resolution KEY (a template's raw text matches
371
- * another identical template's raw text, which is real evidence), whereas a
372
- * namespace here is only ever a FILTER, and the product rule is that unknown
373
- * matches anything. So a templated `namespace: {{ .Release.Namespace }}` is
374
- * unknown rather than a `T:`-keyed literal -- two documents whose namespace
375
- * renders identically must not be blocked from linking just because one
376
- * spells it via a template and the other spells it out, and two DIFFERENT
377
- * template expressions are not evidence of different namespaces either.
378
- * Absent (no entry at all) and an explicit empty string are unknown for the
379
- * same reason: neither names a namespace this scanner can compare.
380
- *
381
- * The literal-ness test is therefore POSITIVE, not a list of excluded
382
- * spellings: a value counts only when it already looks like the RFC 1123 DNS
383
- * label a real namespace must be. One test then covers every shape the
384
- * scanner cannot resolve, without a special case per spelling -- the scanner
385
- * only tags a value "template" when the template action starts the value, so
386
- * `namespace: app-{{ .Values.env }}` arrives here as a "literal" and would
387
- * otherwise block edges against the very namespace it renders to; likewise
388
- * YAML null spellings (`~`, `Null`), anchors and tags (`&ns prod`,
389
- * `!!str prod`), uppercase, whitespace and empty. Blocking is the damaging
390
- * direction (unknown never blocks an edge), so anything uncomparable is
391
- * unknown.
392
- *
393
- * `null` is the one spelling the regex admits but must still be rejected,
394
- * since it is also the bare YAML null word. It is excluded explicitly rather
395
- * than by inspecting quotes: stripQuotes runs in the scanner, so a genuinely
396
- * quoted `namespace: "null"` is indistinguishable here -- and reading it as
397
- * unknown errs in the conservative direction. */
398
- function literalNamespace(entry) {
399
- if (!entry || entry.value.form !== "literal")
202
+ /** Agreeing, valid, literal namespaces only; anything else is unknown. */
203
+ function resolveNamespace(refs) {
204
+ if (refs.length === 0)
400
205
  return null;
401
- const value = entry.value.value;
402
- if (value === "null" || !DNS_LABEL.test(value))
403
- return null;
404
- return value;
405
- }
406
- /** The namespace named at `path`, reading EVERY entry there rather than the
407
- * first. A Helm `{{- if }} namespace: prod {{- else }} namespace: staging
408
- * {{- end }}` emits both branches as entries on the same path, and taking
409
- * findEntry's first one would read the document as confidently `prod` and
410
- * block every edge to `staging`. Only an unambiguous agreement -- at least
411
- * one entry, all of them the same literal -- is a namespace this scanner can
412
- * compare; disagreement is unknown, like any other unresolved shape. */
413
- function namespaceAt(entries, path) {
414
- let agreed = null;
415
- let seen = false;
416
- for (const e of entries) {
417
- if (e.path !== path)
418
- continue;
419
- const ns = literalNamespace(e);
420
- if (ns === null)
206
+ let found = null;
207
+ for (const ref of refs) {
208
+ if (ref.form !== "literal" || !isValidNamespace(ref.value))
421
209
  return null;
422
- if (seen && ns !== agreed)
210
+ if (found !== null && found !== ref.value)
423
211
  return null;
424
- agreed = ns;
425
- seen = true;
426
- }
427
- return agreed;
428
- }
429
- /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
430
- * matches anything; two DIFFERENT literal namespaces block the edge. */
431
- export function namespacesCompatible(a, b) {
432
- return a === null || b === null || a === b;
433
- }
434
- /** Where a kind's pod spec lives -- factored once so container/volume field
435
- * paths below aren't hand-duplicated per kind. */
436
- export const POD_SPEC_PATH_BY_KIND = {
437
- Pod: "spec",
438
- Deployment: "spec.template.spec",
439
- StatefulSet: "spec.template.spec",
440
- DaemonSet: "spec.template.spec",
441
- Job: "spec.template.spec",
442
- CronJob: "spec.jobTemplate.spec.template.spec",
443
- // ReplicaSet embeds a pod spec the same shape as Deployment -- it's
444
- // allowlisted primarily as the dominant ownerReferences bearer (see
445
- // ALLOWED_KINDS above), but a hand-written ReplicaSet's own env/volume
446
- // references and pod-template labels are real and worth extracting too,
447
- // not silently dropped just because it's a secondary use case.
448
- ReplicaSet: "spec.template.spec",
449
- };
450
- const CONTAINER_REF_SUFFIXES = [
451
- { suffix: "envFrom[].configMapRef.name", refKind: "ConfigMap" },
452
- { suffix: "envFrom[].secretRef.name", refKind: "Secret" },
453
- { suffix: "env[].valueFrom.configMapKeyRef.name", refKind: "ConfigMap" },
454
- { suffix: "env[].valueFrom.secretKeyRef.name", refKind: "Secret" },
455
- ];
456
- // volumeClaimTemplates is deliberately excluded: on a StatefulSet it's a
457
- // TEMPLATE the controller uses to create its own PVCs, not a reference to a
458
- // separately-authored PersistentVolumeClaim resource elsewhere in the repo.
459
- // Only volumes[].persistentVolumeClaim.claimName is a real reference.
460
- const VOLUME_REF_SUFFIXES = [
461
- { suffix: "volumes[].configMap.name", refKind: "ConfigMap" },
462
- { suffix: "volumes[].secret.secretName", refKind: "Secret" },
463
- { suffix: "volumes[].persistentVolumeClaim.claimName", refKind: "PersistentVolumeClaim" },
464
- ];
465
- // Pod-spec-level (not container- or volume-scoped) references: a registry
466
- // pull secret is a legitimate Secret reference, distinct from the
467
- // container/volume-scoped ones above.
468
- const POD_SPEC_REF_SUFFIXES = [
469
- { suffix: "imagePullSecrets[].name", refKind: "Secret" },
470
- ];
471
- function fieldSpecsForKind(kind) {
472
- const specs = [];
473
- const podSpecPath = POD_SPEC_PATH_BY_KIND[kind];
474
- if (podSpecPath) {
475
- for (const containerList of ["containers[]", "initContainers[]"]) {
476
- for (const { suffix, refKind } of CONTAINER_REF_SUFFIXES)
477
- specs.push({ path: `${podSpecPath}.${containerList}.${suffix}`, refKind });
478
- }
479
- for (const { suffix, refKind } of VOLUME_REF_SUFFIXES)
480
- specs.push({ path: `${podSpecPath}.${suffix}`, refKind });
481
- for (const { suffix, refKind } of POD_SPEC_REF_SUFFIXES)
482
- specs.push({ path: `${podSpecPath}.${suffix}`, refKind });
212
+ found = ref.value;
483
213
  }
484
- if (kind === "Ingress") {
485
- specs.push({ path: "spec.rules[].http.paths[].backend.service.name", refKind: "Service" });
486
- specs.push({ path: "spec.defaultBackend.service.name", refKind: "Service" });
487
- }
488
- if (kind === "HTTPRoute")
489
- specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
490
- return specs;
214
+ return found;
491
215
  }
492
- /** The ONE reference shape in fieldSpecsForKind that Kubernetes lets point at
493
- * another namespace: a Gateway API `backendRefs[]` entry carries an optional
494
- * `namespace` SIBLING of its own `name`. Every other shape here
495
- * (configMapRef/secretRef/volumes/imagePullSecrets/Ingress backend/
496
- * ownerReferences) is same-namespace by API definition and has no namespace
497
- * field at all, so none of them needs an override. Read off the concrete
498
- * (non-wildcarded) entry list by exact parentPath -- the same
499
- * pair-siblings-by-index technique extractOwnerReferenceCandidates uses --
500
- * rather than new parsing machinery. */
501
- const NAMESPACE_SIBLING_PATHS = new Set(["spec.rules[].backendRefs[]"]);
502
- function extractFieldReferences(kind, entries, docNamespace) {
503
- const specs = fieldSpecsForKind(kind);
504
- const out = [];
505
- for (const e of entries) {
506
- const wp = wildcardPath(e.path);
507
- const spec = specs.find((s) => s.path === wp);
508
- if (!spec)
216
+ function labelMap(scan, at) {
217
+ if (scan.tainted.has(at) || scan.valueless.has(at))
218
+ return null;
219
+ const prefix = `${at}.`;
220
+ const out = {};
221
+ let count = 0;
222
+ for (const e of scan.entries) {
223
+ if (e.parent.startsWith(prefix))
224
+ return null;
225
+ if (e.parent !== at)
509
226
  continue;
510
- // A sibling that EXISTS but isn't a comparable literal (templated, empty)
511
- // must read as unknown, NOT fall back to the document's namespace: it is
512
- // an explicit statement that the target lives somewhere this scanner
513
- // cannot name, which is the opposite of "same namespace as me". So the
514
- // sibling's existence is checked before its literal-ness.
515
- const siblingPath = `${e.parentPath}.namespace`;
516
- const hasSibling = NAMESPACE_SIBLING_PATHS.has(wildcardPath(e.parentPath)) && entries.some((c) => c.path === siblingPath);
517
- out.push({ refKind: spec.refKind, name: e.value, namespace: hasSibling ? namespaceAt(entries, siblingPath) : docNamespace });
227
+ if (e.ref.form !== "literal")
228
+ return null;
229
+ Object.defineProperty(out, e.path.slice(prefix.length), { value: e.ref.value, enumerable: true, writable: true, configurable: true });
230
+ count++;
518
231
  }
519
- return out;
232
+ return count === 0 ? null : out;
520
233
  }
521
- /** ownerReferences' target kind is DATA (a sibling `.kind` field), not a fixed
522
- * literal like every other refKind here -- this pairs each ownerReferences[N]
523
- * list element's `.name` with its OWN `.kind` by concrete index, so two owner
524
- * entries never get cross-paired. Not expressible via FieldPathSpec's
525
- * single-fixed-refKind model, so it's a dedicated pass over the concrete
526
- * (non-wildcarded) entries. */
527
- function extractOwnerReferenceCandidates(entries, docNamespace) {
528
- const byIndex = new Map();
234
+ function assemble(scan, docStart, docText, opts) {
235
+ const empty = { resource: null, references: [], selector: null, labels: null };
236
+ const { entries } = scan;
237
+ const kindEntry = entries.find((e) => e.path === "kind");
238
+ if (!kindEntry || kindEntry.ref.form !== "literal" || !KNOWN_KINDS.has(kindEntry.ref.value))
239
+ return empty;
240
+ const kind = kindEntry.ref.value;
241
+ const byPath = new Map();
529
242
  for (const e of entries) {
530
- const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
531
- if (!m)
532
- continue;
533
- const slot = byIndex.get(m[1]) ?? {};
534
- slot[m[2]] = e;
535
- byIndex.set(m[1], slot);
243
+ const list = byPath.get(e.path);
244
+ if (list)
245
+ list.push(e);
246
+ else
247
+ byPath.set(e.path, [e]);
536
248
  }
537
- const out = [];
538
- for (const { name, kind } of byIndex.values()) {
539
- if (!name || !kind || kind.value.form !== "literal")
540
- continue; // an owner's kind must be a literal to type the reference at all
541
- // An ownerReferences entry has NO namespace field in the API at all -- an
542
- // owner is always in the owned object's own namespace (or cluster-scoped),
543
- // so the document's namespace is the only correct answer here.
544
- out.push({ refKind: kind.value.value, name: name.value, namespace: docNamespace });
249
+ const refsAt = (path) => (byPath.get(path) ?? []).map((e) => e.ref);
250
+ const override = opts?.namespace;
251
+ const namespace = override !== undefined && !CLUSTER_SCOPED_KINDS.has(kind) ? override : resolveNamespace(refsAt("metadata.namespace"));
252
+ const nameEntry = byPath.get("metadata.name")?.[0];
253
+ const resource = nameEntry
254
+ ? { kind, name: nameEntry.ref, namespace, startChar: docStart, endChar: docStart + docText.length }
255
+ : null;
256
+ const references = [];
257
+ const patterns = PATTERNS_BY_KIND.get(kind);
258
+ if (patterns) {
259
+ for (const e of entries) {
260
+ const refKind = patterns.get(e.path.replace(/\[\d+\]/gu, "[]"));
261
+ if (!refKind)
262
+ continue;
263
+ let ns = namespace;
264
+ if (kind === "HTTPRoute") {
265
+ const sibling = refsAt(`${e.parent}.namespace`);
266
+ if (sibling.length > 0)
267
+ ns = resolveNamespace(sibling);
268
+ }
269
+ references.push({ refKind, name: e.ref, namespace: ns });
270
+ }
545
271
  }
546
- return out;
547
- }
548
- export const LABELS_PATH_BY_KIND = {
549
- Deployment: "spec.template.metadata.labels",
550
- StatefulSet: "spec.template.metadata.labels",
551
- DaemonSet: "spec.template.metadata.labels",
552
- Job: "spec.template.metadata.labels",
553
- CronJob: "spec.jobTemplate.spec.template.metadata.labels",
554
- Pod: "metadata.labels",
555
- ReplicaSet: "spec.template.metadata.labels",
556
- };
557
- /** A literal label map at `prefix.<key>` for each direct child leaf. Returns
558
- * null (not an empty map) when: the prefix itself is a block-form template
559
- * injection (no literal keys exist to read at all), no direct-child leaf
560
- * exists, or any direct-child leaf is itself templated -- a partially-literal
561
- * map is still unusable for subset-match without evaluating the templated
562
- * half, so the whole map is treated as unresolved. */
563
- function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valuelessKeyParents) {
564
- if (unresolvedContainers.has(prefix))
565
- return null;
566
- // A value-less direct child (`tier:`) never produced an entry at all, so the
567
- // map below would come back without it -- see valuelessKeyParents' comment
568
- // in scanFieldPaths for why a short map is the dangerous direction.
569
- if (valuelessKeyParents.has(prefix))
570
- return null;
571
- // A real Kubernetes label/selector map is always flat (string -> string) --
572
- // any entry whose parentPath is a DEEPER descendant of prefix (not prefix
573
- // itself) means some direct child of prefix was itself a nested container
574
- // (block-form `team: {owner: p}`, invalid k8s but not rejected by this
575
- // scanner), which would otherwise just be silently absent from the flat
576
- // map returned below -- the same "dropped key makes the map more
577
- // permissive" risk as every other unresolved-container case here.
578
- if (entries.some((e) => e.parentPath !== prefix && e.parentPath.startsWith(`${prefix}.`)))
579
- return null;
580
- // Built via entries + Object.fromEntries, not plain `map[key] = value`
581
- // assignment: a label key of "__proto__" assigned that way is silently
582
- // swallowed by a plain object literal (it sets the prototype, not an own
583
- // property) while `found` still gets set true -- the result is an
584
- // empty-looking map that Object.entries() treats as vacuously satisfied by
585
- // every workload, i.e. a Service selecting everything. Not reachable via a
586
- // syntactically valid Kubernetes label key, but the blast radius (matches
587
- // EVERY workload, not just a wrong one) is disproportionate to how cheap
588
- // this guard is. Object.fromEntries's own key-setting is NOT the special
589
- // __proto__ accessor (verified: it creates a real own property, and the
590
- // result still has the normal Object.prototype -- Object.create(null)
591
- // would also close the hole but changes every map's prototype, which
592
- // breaks plain-object equality elsewhere).
593
- const pairs = [];
272
+ const owners = new Map();
273
+ const ownerPath = /^metadata\.ownerReferences\[(\d+)\]\.(name|kind)$/u;
594
274
  for (const e of entries) {
595
- // Match on parentPath, never by slicing e.path on the prefix length: a
596
- // Kubernetes label key legitimately contains dots (app.kubernetes.io/
597
- // instance is the `helm create` default), which is indistinguishable
598
- // from nesting once folded into one dot-joined path string. parentPath
599
- // is computed structurally from the frame stack, so it's exact -- no
600
- // guessing by counting dots in what's left after the prefix.
601
- if (e.parentPath !== prefix)
275
+ const m = ownerPath.exec(e.path);
276
+ if (!m)
602
277
  continue;
603
- if (e.value.form !== "template")
604
- pairs.push([e.key, e.value.value]);
605
- else
606
- return null; // any templated label value makes the whole map unusable for subset matching
278
+ const group = owners.get(m[1]) ?? {};
279
+ group[m[2]] = e.ref;
280
+ owners.set(m[1], group);
281
+ }
282
+ for (const g of owners.values()) {
283
+ if (g.name && g.kind && g.kind.form === "literal")
284
+ references.push({ refKind: g.kind.value, name: g.name, namespace });
607
285
  }
608
- return pairs.length > 0 ? Object.fromEntries(pairs) : null;
609
- }
610
- /** Cluster-scoped kinds Kustomize's namespace transformer leaves alone. None
611
- * of them is in ALLOWED_KINDS today, so this is a guard for the override in
612
- * buildDocument rather than a live branch: an allowlist widening must not
613
- * start stamping a namespace onto a resource that cannot have one. */
614
- const CLUSTER_SCOPED_KINDS = new Set([
615
- "Namespace", "Node", "PersistentVolume", "StorageClass", "CustomResourceDefinition",
616
- "ClusterRole", "ClusterRoleBinding", "PriorityClass", "IngressClass", "RuntimeClass",
617
- "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration", "APIService",
618
- ]);
619
- function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents, namespaceOverride) {
620
- const kindEntry = findEntry(entries, "kind");
621
- const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
622
- if (!kind || !ALLOWED_KINDS.has(kind))
623
- return { resource: null, references: [], selector: null, labels: null };
624
- // Found with the same entry machinery as metadata.name, so it inherits the
625
- // scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
626
- // Read OUTSIDE the `resource` branch below: a document with no
627
- // metadata.name still emits references, and those references carry this
628
- // document's namespace. A Kustomize namespace transformer (see
629
- // kustomizeNamespaces) REPLACES metadata.namespace on every namespaced
630
- // resource, explicit or not, so it wins here; an explicit backendRefs
631
- // namespace sibling is not rewritten and keeps its own.
632
- const namespace = namespaceOverride !== undefined && !CLUSTER_SCOPED_KINDS.has(kind)
633
- ? namespaceOverride
634
- : namespaceAt(entries, "metadata.namespace");
635
- const nameEntry = findEntry(entries, "metadata.name");
636
- const resource = nameEntry
637
- ? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
638
- : null;
639
- const references = [...extractFieldReferences(kind, entries, namespace), ...extractOwnerReferenceCandidates(entries, namespace)];
640
- const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
641
286
  const labelsPath = LABELS_PATH_BY_KIND[kind];
642
- const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
643
- return { resource, references, selector, labels };
287
+ return {
288
+ resource,
289
+ references,
290
+ selector: kind === "Service" ? labelMap(scan, "spec.selector") : null,
291
+ labels: labelsPath ? labelMap(scan, labelsPath) : null,
292
+ };
644
293
  }
645
- // Matches a YAML document-start marker (`---`) or a document-end marker
646
- // (`...`). Per the YAML spec `---` FOLLOWED BY WHITESPACE starts a document
647
- // whatever trails it on that line -- a comment (`--- # second doc`), a tag
648
- // (`--- !!map`), an anchor (`--- &base`) -- so the trailing text is matched
649
- // generically rather than as a comment only. Without that, a tagged or
650
- // anchored separator silently failed to split at all, merging two documents
651
- // into one: the later document's fields overwrite the earlier one's (object
652
- // spread order), and the earlier resource's symbol/edges vanish entirely.
653
- // Whatever trails the marker is consumed WITH the boundary -- for a tag or
654
- // anchor nothing is lost, and inline content written after the marker
655
- // (`--- kind: Pod`, legal but vanishingly rare) is dropped, which errs toward
656
- // fewer edges, this module's standing bias. `----` (four or more dashes) is
657
- // deliberately NOT a separator -- real YAML doesn't treat it as one either,
658
- // and it still isn't here: the dashes must be followed by whitespace or end
659
- // of line.
660
- const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*$/;
661
- /** `namespace` is a Kustomize transformer namespace for this file (from
662
- * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
663
- * kustomizations render this file into different namespaces). */
664
- export function extractK8sManifest(source, opts = {}) {
665
- const docs = [];
666
- const boundaries = [];
294
+ export function extractK8sManifest(source, opts) {
295
+ // Split on separator lines, independent of YAML structure.
296
+ const spans = [];
297
+ let start = 0;
667
298
  for (const line of sourceLines(source)) {
668
- if (!DOC_SEPARATOR.test(line.content))
299
+ if (!SEPARATOR.test(line.content))
669
300
  continue;
670
- // The next document starts AFTER the separator's line terminator, so the
671
- // same document text has the same span wherever it sits (#451).
672
- boundaries.push(line.start, line.end + eolLengthAt(source, line.end));
301
+ spans.push({ start, end: line.start });
302
+ start = line.end + eolLengthAt(source, line.end);
673
303
  }
674
- const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
675
- const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
676
- for (let i = 0; i < starts.length; i++) {
677
- const start = starts[i];
678
- const end = ends[i];
679
- const text = source.slice(start, end);
680
- const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
681
- docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents, opts.namespace));
682
- }
683
- return docs;
304
+ spans.push({ start, end: source.length });
305
+ return spans.map(({ start: s, end }) => {
306
+ const docText = source.slice(s, end);
307
+ return assemble(scanDocument(docText, s), s, docText, opts);
308
+ });
309
+ }
310
+ export function namespacesCompatible(a, b) {
311
+ return a === null || b === null || a === b;
684
312
  }
685
- const KUSTOMIZATION_FILE = /(?:^|\/)(?:kustomization\.ya?ml|Kustomization)$/;
686
313
  export function isKustomizationFile(path) {
687
- return KUSTOMIZATION_FILE.test(path);
314
+ return /(?:^|\/)(?:kustomization\.ya?ml|Kustomization)$/u.test(path);
688
315
  }
689
- /** Reads only the two top-level fields the namespace transformer needs:
690
- * `namespace:` and the `resources:` list (block `- item` lines or a one-line
691
- * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
692
316
  export function extractKustomization(source) {
693
317
  let namespace = null;
694
318
  const resources = [];
695
319
  let inResources = false;
696
- for (const { content } of sourceLines(source)) {
697
- const line = stripTrailingComment(content).trimEnd();
698
- if (line.trim() === "")
699
- continue;
700
- const item = /^\s*-\s+(.+)$/.exec(line);
701
- if (inResources && item) {
702
- resources.push(stripQuotes(item[1].trim()));
320
+ for (const line of sourceLines(source)) {
321
+ const text = stripComment(line.content).trimEnd();
322
+ if (text === "")
703
323
  continue;
324
+ if (inResources) {
325
+ const item = /^[ \t]*-[ \t]+(\S[\s\S]*)$/u.exec(text);
326
+ if (item) {
327
+ resources.push(unquote(item[1].trim()));
328
+ continue;
329
+ }
704
330
  }
705
- if (/^\s/.test(line))
706
- continue; // nested content of some other top-level key
331
+ if (/^[ \t]/u.test(text))
332
+ continue;
707
333
  inResources = false;
708
- const top = /^([A-Za-z]+):[ \t]*(.*)$/.exec(line);
709
- if (!top)
334
+ const m = /^([A-Za-z]+):(?:[ \t]+([\s\S]*))?$/u.exec(text);
335
+ if (!m)
710
336
  continue;
711
- const value = top[2].trim();
712
- if (top[1] === "namespace") {
713
- const ns = stripQuotes(value);
714
- namespace = DNS_LABEL.test(ns) && ns !== "null" ? ns : null;
337
+ const value = (m[2] ?? "").trim();
338
+ if (m[1] === "namespace") {
339
+ const ns = unquote(value);
340
+ namespace = isValidNamespace(ns) ? ns : null;
715
341
  }
716
- else if (top[1] === "resources") {
342
+ else if (m[1] === "resources") {
717
343
  if (value === "")
718
344
  inResources = true;
719
345
  else if (value.startsWith("[") && value.endsWith("]")) {
720
- for (const part of value.slice(1, -1).split(","))
721
- if (part.trim())
722
- resources.push(stripQuotes(part.trim()));
346
+ for (const part of value.slice(1, -1).split(",")) {
347
+ const entry = unquote(part.trim());
348
+ if (entry !== "")
349
+ resources.push(entry);
350
+ }
723
351
  }
724
352
  }
725
353
  }
726
354
  return { namespace, resources };
727
355
  }
728
- /** The Kustomize namespace each manifest file is rendered into, keyed by repo
729
- * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
730
- * the repo root); `files` is the indexed file set.
731
- *
732
- * A `resources:` entry naming a file applies the namespace to that file; an
733
- * entry naming a directory that holds its own kustomization is a base, and
734
- * the referring (outer) kustomization's namespace wins over the base's, as
735
- * Kustomize applies the overlay transformer to the base's output. Only
736
- * kustomizations no other kustomization references are rendered as roots.
737
- * A file rendered into two different namespaces (one base shared by dev and
738
- * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
739
- * only without any transformer is absent. Not modelled: remote resources,
740
- * `components:`, the deprecated `bases:` field, and globs. */
356
+ /**
357
+ * Effective namespace per manifest file after Kustomize's namespace transformer.
358
+ * A file reached with conflicting namespaces maps to null; a file that is only
359
+ * ever kept as written (or never rendered) is absent.
360
+ */
741
361
  export function kustomizeNamespaces(kustomizations, files) {
742
- const KEEP = "\0keep";
743
- const childFiles = new Map();
744
- const childDirs = new Map();
362
+ const filesOf = new Map();
363
+ const basesOf = new Map();
745
364
  const referenced = new Set();
746
- for (const [dir, k] of kustomizations) {
747
- for (const entry of k.resources) {
748
- let p = posix.normalize(posix.join(dir, entry)).replace(/\/+$/, "");
749
- if (p === ".")
750
- p = "";
751
- if (p === ".." || p.startsWith("../"))
365
+ for (const [dir, info] of kustomizations) {
366
+ const ownFiles = [];
367
+ const bases = [];
368
+ for (const entry of info.resources) {
369
+ if (entry === "")
370
+ continue;
371
+ let resolved = posix.join(dir, entry).replace(/\/+$/u, "");
372
+ if (resolved === ".")
373
+ resolved = "";
374
+ if (resolved === ".." || resolved.startsWith("../"))
752
375
  continue;
753
- if (files.has(p))
754
- (childFiles.get(dir) ?? childFiles.set(dir, []).get(dir)).push(p);
755
- else if (p !== dir && kustomizations.has(p)) {
756
- (childDirs.get(dir) ?? childDirs.set(dir, []).get(dir)).push(p);
757
- referenced.add(p);
376
+ if (files.has(resolved))
377
+ ownFiles.push(resolved);
378
+ else if (kustomizations.has(resolved) && resolved !== dir) {
379
+ bases.push(resolved);
380
+ referenced.add(resolved);
758
381
  }
759
382
  }
383
+ filesOf.set(dir, ownFiles);
384
+ basesOf.set(dir, bases);
760
385
  }
761
- const seen = new Map();
762
- const visited = new Set();
386
+ // null stands for "keep as written".
387
+ const seen = new Set();
388
+ const recorded = new Map();
763
389
  const render = (dir, outer) => {
764
- const ns = outer ?? kustomizations.get(dir).namespace;
765
- const key = `${dir}\0${ns ?? KEEP}`;
766
- if (visited.has(key))
767
- return; // memoised per (dir, namespace); also breaks cycles
768
- visited.add(key);
769
- for (const f of childFiles.get(dir) ?? [])
770
- (seen.get(f) ?? seen.set(f, new Set()).get(f)).add(ns ?? KEEP);
771
- for (const child of childDirs.get(dir) ?? [])
772
- render(child, ns);
390
+ const key = `${dir}\u0000${outer ?? ""}`;
391
+ if (seen.has(key))
392
+ return;
393
+ seen.add(key);
394
+ const effective = outer ?? kustomizations.get(dir).namespace;
395
+ for (const file of filesOf.get(dir)) {
396
+ const set = recorded.get(file) ?? new Set();
397
+ set.add(effective);
398
+ recorded.set(file, set);
399
+ }
400
+ for (const base of basesOf.get(dir))
401
+ render(base, effective);
773
402
  };
774
- for (const dir of [...kustomizations.keys()].sort())
403
+ for (const dir of kustomizations.keys())
775
404
  if (!referenced.has(dir))
776
405
  render(dir, null);
777
406
  const out = new Map();
778
- for (const [file, set] of seen) {
407
+ for (const [file, set] of recorded) {
779
408
  if (set.size > 1)
780
409
  out.set(file, null);
781
410
  else {
782
411
  const only = [...set][0];
783
- if (only !== KEEP)
412
+ if (only !== null)
784
413
  out.set(file, only);
785
414
  }
786
415
  }