@davesheffer/hunch 1.46.0 → 1.47.1
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.
- package/dist/cli/index.js +280 -358
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/cli/taskReport.d.ts +6 -0
- package/dist/cli/taskReport.js +31 -8
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.d.ts +2 -0
- package/dist/core/correction.js +23 -10
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskRecord.d.ts +1 -1
- package/dist/core/taskRecord.js +2 -2
- package/dist/core/taskReport.d.ts +8 -3
- package/dist/core/taskReport.js +7 -2
- package/dist/core/taskReportEvidence.d.ts +7 -0
- package/dist/core/taskReportEvidence.js +16 -4
- package/dist/core/taskReportHook.js +2 -2
- package/dist/core/taskReportPublic.js +2 -2
- package/dist/core/taskReportRender.js +2 -2
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +18 -23
- package/dist/extractors/git.js +72 -64
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.js +1 -1
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/integrations/probe.d.ts +11 -0
- package/dist/integrations/probe.js +29 -4
- package/dist/integrations/sync.d.ts +16 -4
- package/dist/integrations/sync.js +54 -3
- package/dist/integrations/workspaceLedger.d.ts +1 -1
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +71 -83
- package/dist/mcp/taskReportTools.js +4 -4
- package/dist/serve/app.js +2 -2
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/dist/taskReports.d.ts +1 -0
- package/dist/taskReports.js +2 -2
- package/package.json +2 -2
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 <
|
|
82
|
-
const
|
|
67
|
+
for (let i = 0; i < value.length; i++) {
|
|
68
|
+
const c = value[i];
|
|
83
69
|
if (quote) {
|
|
84
|
-
if (
|
|
70
|
+
if (c === quote)
|
|
85
71
|
quote = null;
|
|
86
72
|
continue;
|
|
87
73
|
}
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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 (
|
|
217
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 (
|
|
233
|
-
|
|
140
|
+
if (BARE_TEMPLATE.test(content)) {
|
|
141
|
+
taintOpen();
|
|
234
142
|
continue;
|
|
235
143
|
}
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
const
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
//
|
|
271
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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,
|
|
361
|
-
}
|
|
362
|
-
function findEntry(entries, path) {
|
|
363
|
-
return entries.find((e) => e.path === path);
|
|
200
|
+
return { entries, tainted, valueless };
|
|
364
201
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 (
|
|
210
|
+
if (found !== null && found !== ref.value)
|
|
423
211
|
return null;
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
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
|
|
531
|
-
if (
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
-
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
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 (!
|
|
299
|
+
if (!SEPARATOR.test(line.content))
|
|
669
300
|
continue;
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
|
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
|
|
697
|
-
const
|
|
698
|
-
if (
|
|
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 (
|
|
706
|
-
continue;
|
|
331
|
+
if (/^[ \t]/u.test(text))
|
|
332
|
+
continue;
|
|
707
333
|
inResources = false;
|
|
708
|
-
const
|
|
709
|
-
if (!
|
|
334
|
+
const m = /^([A-Za-z]+):(?:[ \t]+([\s\S]*))?$/u.exec(text);
|
|
335
|
+
if (!m)
|
|
710
336
|
continue;
|
|
711
|
-
const value =
|
|
712
|
-
if (
|
|
713
|
-
const ns =
|
|
714
|
-
namespace =
|
|
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 (
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
|
|
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
|
|
743
|
-
const
|
|
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,
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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(
|
|
754
|
-
|
|
755
|
-
else if (
|
|
756
|
-
|
|
757
|
-
referenced.add(
|
|
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
|
-
|
|
762
|
-
const
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
for (const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
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
|
|
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 !==
|
|
412
|
+
if (only !== null)
|
|
784
413
|
out.set(file, only);
|
|
785
414
|
}
|
|
786
415
|
}
|