@davesheffer/hunch 1.38.0 → 1.39.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.
- package/dist/cli/index.js +255 -9
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/core/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic text scan for Kubernetes manifest cross-resource references —
|
|
3
|
+
* NOT a tree-sitter walk. Confirmed directly (not assumed): parsing a realistic
|
|
4
|
+
* templated Deployment with this repo's own tree-sitter-yaml bundle showed a
|
|
5
|
+
* same-line templated scalar (`name: {{ include "x" . }}`) parses cleanly, but
|
|
6
|
+
* a block-form injection on its own line (`labels:\n {{- include ... }}` — the
|
|
7
|
+
* pattern real charts use for metadata.labels and spec.selector) collapses the
|
|
8
|
+
* WHOLE REST OF THE DOCUMENT'S error recovery into a flat, unstructured ERROR
|
|
9
|
+
* node. Real charts place such an injection early (right after metadata.name),
|
|
10
|
+
* so it would poison parsing for every field-path this module needs further
|
|
11
|
+
* down the same document. parseSource() (parse.ts) also never exposes its
|
|
12
|
+
* tree-sitter tree to callers at all -- helm.ts's own text-scan precedent
|
|
13
|
+
* exists for the same reason.
|
|
14
|
+
*
|
|
15
|
+
* This is a line-oriented, indentation-tracking scanner (not flat regex, unlike
|
|
16
|
+
* helm.ts's `{{ }}`-action scan) because K8s field-paths are nested block
|
|
17
|
+
* structure (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.name`)
|
|
18
|
+
* that a flat scan can't reconstruct. It never interprets `{{ }}` as YAML syntax
|
|
19
|
+
* at all -- a `{{ ... }}` on a line's value side is just that line's raw value
|
|
20
|
+
* text, same-line or block-form alike -- so it is immune to the exact failure
|
|
21
|
+
* mode that broke tree-sitter.
|
|
22
|
+
*/
|
|
23
|
+
// ReplicaSet is included alongside the pod-spec-embedding/networking kinds
|
|
24
|
+
// specifically because it's the dominant real-world bearer of
|
|
25
|
+
// ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
|
|
26
|
+
// without it, the single most common ownerReferences case would be silently
|
|
27
|
+
// dropped by the allowlist gate below, despite Goal 2 explicitly scoping
|
|
28
|
+
// "any resource -> its owner" as in scope. Still a well-known core/apps kind,
|
|
29
|
+
// not a CRD -- consistent with the allowlist's own rationale, not an exception to it.
|
|
30
|
+
const ALLOWED_KINDS = new Set([
|
|
31
|
+
"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod", "ReplicaSet",
|
|
32
|
+
"Service", "ConfigMap", "Secret", "PersistentVolumeClaim", "Ingress", "HTTPRoute",
|
|
33
|
+
]);
|
|
34
|
+
/** Normalize a concrete path's sequence indices to "[]" for table matching. */
|
|
35
|
+
function wildcardPath(path) {
|
|
36
|
+
return path.replace(/\[\d+\]/g, "[]");
|
|
37
|
+
}
|
|
38
|
+
/** Dot-joined field path for a frame stack (or a prefix of one), e.g.
|
|
39
|
+
* `spec.template.spec.containers[0].env` -- `\.\[` collapses to `[` since a
|
|
40
|
+
* sequence-item frame's own key is already `[N]`, and joining it after a
|
|
41
|
+
* `.` would double the separator. */
|
|
42
|
+
function framePath(frames) {
|
|
43
|
+
return frames.map((f) => f.key).filter(Boolean).join(".").replace(/\.\[/g, "[");
|
|
44
|
+
}
|
|
45
|
+
// `\r?` before `$`: without it, a CRLF-terminated line (the Git-for-Windows
|
|
46
|
+
// `core.autocrlf=true` default -- every .yaml file in a Windows checkout) never
|
|
47
|
+
// matches at all, since JS `.` never matches `\r` and `$` (no /m flag) only
|
|
48
|
+
// matches at the true end of the string. That silently zeroes out this whole
|
|
49
|
+
// module's output on any Windows clone, with no error. `.*?` (lazy, not `.*`
|
|
50
|
+
// greedy) so `\r?` gets first claim on a trailing `\r` instead of the value
|
|
51
|
+
// capture swallowing it.
|
|
52
|
+
const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)\r?$/;
|
|
53
|
+
// A line that is ENTIRELY a `{{ ... }}` template action (no `key:` prefix at
|
|
54
|
+
// all) -- e.g. a block-form injection appearing as a SIBLING after other
|
|
55
|
+
// literal keys under the same mapping (`app: my-app` then, on its own later
|
|
56
|
+
// line, `{{- include "mychart.selectorLabels" . | nindent 4 }}`). This never
|
|
57
|
+
// matches KEY_LINE (there's no colon-terminated key), so without explicit
|
|
58
|
+
// handling it's silently invisible to the scanner -- neither contributing a
|
|
59
|
+
// value nor marking its container as unresolved, which lets a literal-looking
|
|
60
|
+
// map that's actually partially templated pass as fully literal. No capture
|
|
61
|
+
// groups: unlike an unrecognized-but-real YAML line, this line's OWN
|
|
62
|
+
// indentation is deliberately never inspected -- see
|
|
63
|
+
// markAllOpenContainersUnresolved's comment for why.
|
|
64
|
+
const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
|
|
65
|
+
// A list-item marker with NOTHING after it on the same line -- the item's
|
|
66
|
+
// content is entirely on the following, more-indented lines
|
|
67
|
+
// (`containers:\n -\n name: app`). Legal YAML, distinct from the inline
|
|
68
|
+
// `- name: app` form KEY_LINE already handles: this line has no key of its
|
|
69
|
+
// own at all, so without explicit handling it fell to the unrecognized-line
|
|
70
|
+
// branch, which used ORDINARY popping and lost the list-item frame entirely
|
|
71
|
+
// -- reparenting the item's real children one level up (`containers.name`
|
|
72
|
+
// instead of `containers[0].name`), silently dropping every reference under it.
|
|
73
|
+
const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
|
|
74
|
+
// A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
|
|
75
|
+
// `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
|
|
76
|
+
// `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
|
|
77
|
+
// scalar is on the FOLLOWING indented lines, not this one -- treating the
|
|
78
|
+
// header token itself as the value (e.g. a resource literally named "|-")
|
|
79
|
+
// would be silently, confidently wrong, not merely incomplete.
|
|
80
|
+
const BLOCK_SCALAR_HEADER = /^[|>](?:[+-]\d*|\d+[+-]?)?$/;
|
|
81
|
+
/** Strip a trailing YAML comment: `#` only opens one at the start of the
|
|
82
|
+
* value or after whitespace, and never inside a quoted scalar -- so
|
|
83
|
+
* `key: "a # b"` keeps its `#` and `key: {{ .x | default "#fff" }}` keeps
|
|
84
|
+
* its Sprig default intact, but `key: my-config # app settings` drops the
|
|
85
|
+
* comment. Without this, a comment silently becomes part of the value text:
|
|
86
|
+
* it never matches the same resource's uncommented name elsewhere, so the
|
|
87
|
+
* reference quietly resolves to nothing instead of erroring -- the worst
|
|
88
|
+
* failure mode for a "no match -> no edge, never guess" design, since it's
|
|
89
|
+
* indistinguishable from correctly declining to guess. */
|
|
90
|
+
function stripTrailingComment(raw) {
|
|
91
|
+
let quote = null;
|
|
92
|
+
for (let i = 0; i < raw.length; i++) {
|
|
93
|
+
const ch = raw[i];
|
|
94
|
+
if (quote) {
|
|
95
|
+
if (ch === quote)
|
|
96
|
+
quote = null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// A quote only OPENS a quoted scalar at the value's start or after
|
|
100
|
+
// whitespace -- mirrors the # rule below, and keeps a Sprig
|
|
101
|
+
// `default "#fff"` working (its " follows a space) while an apostrophe
|
|
102
|
+
// mid-word (`it's-fine`) no longer opens a phantom quote that would
|
|
103
|
+
// swallow a real trailing comment whole.
|
|
104
|
+
if ((ch === '"' || ch === "'") && (i === 0 || /\s/.test(raw[i - 1]))) {
|
|
105
|
+
quote = ch;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === "#" && (i === 0 || /\s/.test(raw[i - 1])))
|
|
109
|
+
return raw.slice(0, i);
|
|
110
|
+
}
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
function stripQuotes(value) {
|
|
114
|
+
if (value.length >= 2) {
|
|
115
|
+
const first = value[0];
|
|
116
|
+
const last = value.at(-1);
|
|
117
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'"))
|
|
118
|
+
return value.slice(1, -1);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
/** Line-oriented indentation-stack scan. Handles BOTH real-world YAML list
|
|
123
|
+
* styles: sequence items at the SAME indent as their parent key
|
|
124
|
+
* (`containers:\n- name: app`) and at a DEEPER indent (`env:\n - name: X`) --
|
|
125
|
+
* both occur in real manifests. A value-less key frame is converted to a
|
|
126
|
+
* sequence frame IN PLACE (not popped) the first time a `-` line arrives at or
|
|
127
|
+
* below its own indent; a sequence frame is only ever closed by a
|
|
128
|
+
* shallower-indent line, never by an equal-indent one (equal-indent means
|
|
129
|
+
* "next item"). */
|
|
130
|
+
function scanFieldPaths(text, baseChar) {
|
|
131
|
+
const entries = [];
|
|
132
|
+
// A container (mapping) this scanner could not fully account for -- either
|
|
133
|
+
// an explicit {{ }} template injection, OR a line shape KEY_LINE doesn't
|
|
134
|
+
// recognize at all (a quoted key, a YAML merge key `<<:`, ...). Both get the
|
|
135
|
+
// SAME treatment: a dropped/unrecognized key would make a selector/labels
|
|
136
|
+
// map strictly MORE permissive (fewer real constraints), which risks a
|
|
137
|
+
// false-positive edge -- the failure mode this whole module exists to
|
|
138
|
+
// avoid. Silently ignoring what the scanner can't parse is not safe here;
|
|
139
|
+
// "I can't tell" must read as "unresolved," the same as a real template.
|
|
140
|
+
const unresolvedContainers = new Set();
|
|
141
|
+
const stack = [];
|
|
142
|
+
let charOffset = baseChar;
|
|
143
|
+
const popToForListItem = (dashIndent) => {
|
|
144
|
+
while (stack.length) {
|
|
145
|
+
const top = stack[stack.length - 1];
|
|
146
|
+
if (top.indent <= dashIndent) {
|
|
147
|
+
if (!top.isSeq && !top.hasValue)
|
|
148
|
+
top.isSeq = true; // convert in place, first time only
|
|
149
|
+
return; // either already/now a seq frame at or above this indent -- reuse it
|
|
150
|
+
}
|
|
151
|
+
stack.pop();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const popOrdinary = (indent) => {
|
|
155
|
+
while (stack.length && stack[stack.length - 1].indent >= indent)
|
|
156
|
+
stack.pop();
|
|
157
|
+
};
|
|
158
|
+
// Shared by KEY_LINE's inline `- key: value` form and the bare `-`-alone
|
|
159
|
+
// form: converts/reuses the enclosing sequence frame, then pushes this
|
|
160
|
+
// item's own `[idx]` frame at `itemFrameIndent` -- the caller picks that
|
|
161
|
+
// value so it sits strictly between the sequence frame's own indent and
|
|
162
|
+
// whatever the item's real children will be indented at (itemIndent - 1
|
|
163
|
+
// for the inline form; dashIndent + 1 for the bare form, since a bare
|
|
164
|
+
// marker's children are entirely on later lines with no itemIndent to
|
|
165
|
+
// derive from).
|
|
166
|
+
const enterListItem = (dashIndent, itemFrameIndent) => {
|
|
167
|
+
popToForListItem(dashIndent);
|
|
168
|
+
let top = stack[stack.length - 1];
|
|
169
|
+
if (!top || !top.isSeq) {
|
|
170
|
+
top = { indent: dashIndent, key: "", isSeq: true, hasValue: false, nextIndex: 0 };
|
|
171
|
+
stack.push(top);
|
|
172
|
+
}
|
|
173
|
+
const idx = top.nextIndex ?? 0;
|
|
174
|
+
top.nextIndex = idx + 1;
|
|
175
|
+
stack.push({ indent: itemFrameIndent, key: `[${idx}]`, isSeq: false, hasValue: false });
|
|
176
|
+
};
|
|
177
|
+
// Pops to a line's context (same rule an ordinary/list-item key line would
|
|
178
|
+
// use) WITHOUT pushing a frame -- the line has no key of its own -- then
|
|
179
|
+
// marks whatever container it now sits inside as unresolved. Used ONLY for
|
|
180
|
+
// a line that IS real YAML structure the scanner just can't decode the key
|
|
181
|
+
// of (a quoted key, a merge key) -- there, the line's indentation is
|
|
182
|
+
// genuine, meaningful nesting depth. Always uses ordinary (non-list)
|
|
183
|
+
// popping: its one call site never sees a list-marker-prefixed line (that
|
|
184
|
+
// shape -- e.g. `- <<: *base` -- is a known, separate, non-blocking gap,
|
|
185
|
+
// not something this function is meant to special-case).
|
|
186
|
+
const markUnresolvedContainer = (indent) => {
|
|
187
|
+
popOrdinary(indent);
|
|
188
|
+
unresolvedContainers.add(framePath(stack));
|
|
189
|
+
};
|
|
190
|
+
// A `{{ }}` action line's OWN indentation carries NO structural meaning:
|
|
191
|
+
// `{{-` chomps it away entirely, and the extremely common `| indent N`
|
|
192
|
+
// idiom REQUIRES the action to sit at column 0 while injecting content at
|
|
193
|
+
// depth N. Popping the frame stack by that column (as an ordinary line
|
|
194
|
+
// would) either taints the wrong ancestor container or, at column 0,
|
|
195
|
+
// destroys every open frame -- reparenting every subsequent line in the
|
|
196
|
+
// document to the root and losing all their field-paths. Never pop the
|
|
197
|
+
// real stack for this; instead, conservatively taint every container
|
|
198
|
+
// currently open (root down through the innermost), since the injection
|
|
199
|
+
// could be targeting any of them and there is no way to tell which.
|
|
200
|
+
const markAllOpenContainersUnresolved = () => {
|
|
201
|
+
for (let depth = 0; depth <= stack.length; depth++) {
|
|
202
|
+
unresolvedContainers.add(framePath(stack.slice(0, depth)));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
for (const line of text.split("\n")) {
|
|
206
|
+
const lineStartChar = charOffset;
|
|
207
|
+
charOffset += line.length + 1; // +1 for the \n split() consumed
|
|
208
|
+
if (BARE_TEMPLATE_LINE.test(line)) {
|
|
209
|
+
markAllOpenContainersUnresolved();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const bareListMarker = BARE_LIST_MARKER.exec(line);
|
|
213
|
+
if (bareListMarker) {
|
|
214
|
+
const dashIndent = bareListMarker[1].length;
|
|
215
|
+
enterListItem(dashIndent, dashIndent + 1);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const m = KEY_LINE.exec(line);
|
|
219
|
+
if (!m) {
|
|
220
|
+
// Any other non-blank, non-comment line is a shape this scanner can't
|
|
221
|
+
// account for at all (a quoted key, a merge key, ...) -- see
|
|
222
|
+
// unresolvedContainers' own comment above for why this can't be a
|
|
223
|
+
// silent skip.
|
|
224
|
+
const trimmed = line.trim();
|
|
225
|
+
if (trimmed.length > 0 && !trimmed.startsWith("#")) {
|
|
226
|
+
markUnresolvedContainer(line.length - line.trimStart().length);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const [, indentStr, listMarker, key, rawValue] = m;
|
|
231
|
+
const dashIndent = indentStr.length;
|
|
232
|
+
const itemIndent = dashIndent + (listMarker?.length ?? 0);
|
|
233
|
+
if (listMarker) {
|
|
234
|
+
// itemIndent - 1: strictly between the seq frame's own indent and its
|
|
235
|
+
// children's indent, so a sibling key within this item pops back to
|
|
236
|
+
// (but never past) this frame.
|
|
237
|
+
enterListItem(dashIndent, itemIndent - 1);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
popOrdinary(dashIndent);
|
|
241
|
+
}
|
|
242
|
+
const value = stripTrailingComment(rawValue).trim();
|
|
243
|
+
const parentPath = framePath(stack);
|
|
244
|
+
stack.push({ indent: itemIndent, key: key, isSeq: false, hasValue: value.length > 0 });
|
|
245
|
+
const path = framePath(stack);
|
|
246
|
+
// A value that OPENS a flow collection (`{`/`[`, never a `{{` template
|
|
247
|
+
// action) means this key's real content is flow syntax, possibly spread
|
|
248
|
+
// over later lines -- a line-oriented scan can't see keys written on the
|
|
249
|
+
// opening line itself (`selector: {app: x,` loses `app` entirely once a
|
|
250
|
+
// later line like `tier: web` gets read as this key's only child). Same
|
|
251
|
+
// rule as any other shape the scanner can't fully account for: mark it
|
|
252
|
+
// unresolved rather than let a partially-seen map read as complete. Also
|
|
253
|
+
// covers the single-line case (`selector: {app: x}`) harmlessly -- that
|
|
254
|
+
// already returned null via "no children found", this just makes the
|
|
255
|
+
// reason explicit instead of incidental.
|
|
256
|
+
if ((value.startsWith("{") && !value.startsWith("{{")) || value.startsWith("["))
|
|
257
|
+
unresolvedContainers.add(path);
|
|
258
|
+
// A block-scalar header taints the PARENT container, not this entry's own
|
|
259
|
+
// path: the header is a LEAF value's header (e.g. `app: |-` under
|
|
260
|
+
// `spec.selector`), and extractLiteralLabelMap only ever checks a
|
|
261
|
+
// container's own path (spec.selector) for taint, never a leaf's --
|
|
262
|
+
// tainting the leaf's path here would silently leave the map resolving
|
|
263
|
+
// MINUS this one key, which is the same over-permissive risk as not
|
|
264
|
+
// tainting at all. Checked on the raw (pre-quote-strip) value, same as
|
|
265
|
+
// the flow-collection check above, so a genuine quoted `name: "|"` stays
|
|
266
|
+
// a literal and isn't mistaken for an unterminated block scalar.
|
|
267
|
+
if (BLOCK_SCALAR_HEADER.test(value)) {
|
|
268
|
+
unresolvedContainers.add(parentPath);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (value.length > 0) {
|
|
272
|
+
const colonIdx = line.indexOf(":", dashIndent);
|
|
273
|
+
const valueStartInLine = line.indexOf(value, colonIdx);
|
|
274
|
+
const atChar = lineStartChar + valueStartInLine;
|
|
275
|
+
const endChar = atChar + value.length;
|
|
276
|
+
// Classify on the QUOTE-STRIPPED text, not the raw value: idiomatic
|
|
277
|
+
// Helm text is pre-render, not valid YAML yet, so a template expression
|
|
278
|
+
// routinely appears both bare (`name: {{ include "c.fullname" . }}`)
|
|
279
|
+
// and wrapped in quotes elsewhere in the same chart (`name: "{{ include
|
|
280
|
+
// "c.fullname" . }}"`, e.g. helm create's own test-connection.yaml
|
|
281
|
+
// pattern). Both forms carry the identical expression text once quotes
|
|
282
|
+
// are stripped -- classifying on the raw value would tag one "literal"
|
|
283
|
+
// and the other "template", giving them different nameKeyText (L:/T:)
|
|
284
|
+
// prefixes in indexer.ts and silently breaking the match between a
|
|
285
|
+
// resource's own name and a quoted reference to it.
|
|
286
|
+
//
|
|
287
|
+
// A value like `prefix-{{ .Values.x }}` (template text NOT at the very
|
|
288
|
+
// start) is still classified "literal" here, not "template" --
|
|
289
|
+
// deliberately narrow, matching only the whole-value case. Matching
|
|
290
|
+
// still stays correct either way: both a literal/literal and a
|
|
291
|
+
// template/template comparison require the two sides' raw text to be
|
|
292
|
+
// byte-identical, so a "prefix-{{ x }}" value only ever matches another
|
|
293
|
+
// identical "prefix-{{ x }}" value, never a bare "{{ x }}" -- just via
|
|
294
|
+
// the "literal" bucket instead of the "template" one.
|
|
295
|
+
const unquoted = stripQuotes(value);
|
|
296
|
+
entries.push({
|
|
297
|
+
path,
|
|
298
|
+
parentPath,
|
|
299
|
+
key: key,
|
|
300
|
+
value: unquoted.startsWith("{{")
|
|
301
|
+
? { form: "template", sourceText: unquoted, atChar, endChar }
|
|
302
|
+
: { form: "literal", value: unquoted, atChar, endChar },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// A value-less key (e.g. `selector:`) needs no bookkeeping of its own
|
|
306
|
+
// here: whatever follows it (a real nested mapping, a `{{ }}` block
|
|
307
|
+
// injection, or an unrecognized line) is handled uniformly by the
|
|
308
|
+
// BARE_TEMPLATE_LINE / "unrecognized line" branches above on ITS OWN
|
|
309
|
+
// line, since that line's own popToForListItem/popOrdinary call pops
|
|
310
|
+
// back to (but never past) this key's frame.
|
|
311
|
+
}
|
|
312
|
+
return { entries, unresolvedContainers };
|
|
313
|
+
}
|
|
314
|
+
function findEntry(entries, path) {
|
|
315
|
+
return entries.find((e) => e.path === path);
|
|
316
|
+
}
|
|
317
|
+
/** Where a kind's pod spec lives -- factored once so container/volume field
|
|
318
|
+
* paths below aren't hand-duplicated per kind. */
|
|
319
|
+
export const POD_SPEC_PATH_BY_KIND = {
|
|
320
|
+
Pod: "spec",
|
|
321
|
+
Deployment: "spec.template.spec",
|
|
322
|
+
StatefulSet: "spec.template.spec",
|
|
323
|
+
DaemonSet: "spec.template.spec",
|
|
324
|
+
Job: "spec.template.spec",
|
|
325
|
+
CronJob: "spec.jobTemplate.spec.template.spec",
|
|
326
|
+
// ReplicaSet embeds a pod spec the same shape as Deployment -- it's
|
|
327
|
+
// allowlisted primarily as the dominant ownerReferences bearer (see
|
|
328
|
+
// ALLOWED_KINDS above), but a hand-written ReplicaSet's own env/volume
|
|
329
|
+
// references and pod-template labels are real and worth extracting too,
|
|
330
|
+
// not silently dropped just because it's a secondary use case.
|
|
331
|
+
ReplicaSet: "spec.template.spec",
|
|
332
|
+
};
|
|
333
|
+
const CONTAINER_REF_SUFFIXES = [
|
|
334
|
+
{ suffix: "envFrom[].configMapRef.name", refKind: "ConfigMap" },
|
|
335
|
+
{ suffix: "envFrom[].secretRef.name", refKind: "Secret" },
|
|
336
|
+
{ suffix: "env[].valueFrom.configMapKeyRef.name", refKind: "ConfigMap" },
|
|
337
|
+
{ suffix: "env[].valueFrom.secretKeyRef.name", refKind: "Secret" },
|
|
338
|
+
];
|
|
339
|
+
// volumeClaimTemplates is deliberately excluded: on a StatefulSet it's a
|
|
340
|
+
// TEMPLATE the controller uses to create its own PVCs, not a reference to a
|
|
341
|
+
// separately-authored PersistentVolumeClaim resource elsewhere in the repo.
|
|
342
|
+
// Only volumes[].persistentVolumeClaim.claimName is a real reference.
|
|
343
|
+
const VOLUME_REF_SUFFIXES = [
|
|
344
|
+
{ suffix: "volumes[].configMap.name", refKind: "ConfigMap" },
|
|
345
|
+
{ suffix: "volumes[].secret.secretName", refKind: "Secret" },
|
|
346
|
+
{ suffix: "volumes[].persistentVolumeClaim.claimName", refKind: "PersistentVolumeClaim" },
|
|
347
|
+
];
|
|
348
|
+
// Pod-spec-level (not container- or volume-scoped) references: a registry
|
|
349
|
+
// pull secret is a legitimate Secret reference, distinct from the
|
|
350
|
+
// container/volume-scoped ones above.
|
|
351
|
+
const POD_SPEC_REF_SUFFIXES = [
|
|
352
|
+
{ suffix: "imagePullSecrets[].name", refKind: "Secret" },
|
|
353
|
+
];
|
|
354
|
+
function fieldSpecsForKind(kind) {
|
|
355
|
+
const specs = [];
|
|
356
|
+
const podSpecPath = POD_SPEC_PATH_BY_KIND[kind];
|
|
357
|
+
if (podSpecPath) {
|
|
358
|
+
for (const containerList of ["containers[]", "initContainers[]"]) {
|
|
359
|
+
for (const { suffix, refKind } of CONTAINER_REF_SUFFIXES)
|
|
360
|
+
specs.push({ path: `${podSpecPath}.${containerList}.${suffix}`, refKind });
|
|
361
|
+
}
|
|
362
|
+
for (const { suffix, refKind } of VOLUME_REF_SUFFIXES)
|
|
363
|
+
specs.push({ path: `${podSpecPath}.${suffix}`, refKind });
|
|
364
|
+
for (const { suffix, refKind } of POD_SPEC_REF_SUFFIXES)
|
|
365
|
+
specs.push({ path: `${podSpecPath}.${suffix}`, refKind });
|
|
366
|
+
}
|
|
367
|
+
if (kind === "Ingress") {
|
|
368
|
+
specs.push({ path: "spec.rules[].http.paths[].backend.service.name", refKind: "Service" });
|
|
369
|
+
specs.push({ path: "spec.defaultBackend.service.name", refKind: "Service" });
|
|
370
|
+
}
|
|
371
|
+
if (kind === "HTTPRoute")
|
|
372
|
+
specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
|
|
373
|
+
return specs;
|
|
374
|
+
}
|
|
375
|
+
function extractFieldReferences(kind, entries) {
|
|
376
|
+
const specs = fieldSpecsForKind(kind);
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const e of entries) {
|
|
379
|
+
const wp = wildcardPath(e.path);
|
|
380
|
+
const spec = specs.find((s) => s.path === wp);
|
|
381
|
+
if (spec)
|
|
382
|
+
out.push({ refKind: spec.refKind, name: e.value });
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
/** ownerReferences' target kind is DATA (a sibling `.kind` field), not a fixed
|
|
387
|
+
* literal like every other refKind here -- this pairs each ownerReferences[N]
|
|
388
|
+
* list element's `.name` with its OWN `.kind` by concrete index, so two owner
|
|
389
|
+
* entries never get cross-paired. Not expressible via FieldPathSpec's
|
|
390
|
+
* single-fixed-refKind model, so it's a dedicated pass over the concrete
|
|
391
|
+
* (non-wildcarded) entries. */
|
|
392
|
+
function extractOwnerReferenceCandidates(entries) {
|
|
393
|
+
const byIndex = new Map();
|
|
394
|
+
for (const e of entries) {
|
|
395
|
+
const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
|
|
396
|
+
if (!m)
|
|
397
|
+
continue;
|
|
398
|
+
const slot = byIndex.get(m[1]) ?? {};
|
|
399
|
+
slot[m[2]] = e;
|
|
400
|
+
byIndex.set(m[1], slot);
|
|
401
|
+
}
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const { name, kind } of byIndex.values()) {
|
|
404
|
+
if (!name || !kind || kind.value.form !== "literal")
|
|
405
|
+
continue; // an owner's kind must be a literal to type the reference at all
|
|
406
|
+
out.push({ refKind: kind.value.value, name: name.value });
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
export const LABELS_PATH_BY_KIND = {
|
|
411
|
+
Deployment: "spec.template.metadata.labels",
|
|
412
|
+
StatefulSet: "spec.template.metadata.labels",
|
|
413
|
+
DaemonSet: "spec.template.metadata.labels",
|
|
414
|
+
Job: "spec.template.metadata.labels",
|
|
415
|
+
CronJob: "spec.jobTemplate.spec.template.metadata.labels",
|
|
416
|
+
Pod: "metadata.labels",
|
|
417
|
+
ReplicaSet: "spec.template.metadata.labels",
|
|
418
|
+
};
|
|
419
|
+
/** A literal label map at `prefix.<key>` for each direct child leaf. Returns
|
|
420
|
+
* null (not an empty map) when: the prefix itself is a block-form template
|
|
421
|
+
* injection (no literal keys exist to read at all), no direct-child leaf
|
|
422
|
+
* exists, or any direct-child leaf is itself templated -- a partially-literal
|
|
423
|
+
* map is still unusable for subset-match without evaluating the templated
|
|
424
|
+
* half, so the whole map is treated as unresolved. */
|
|
425
|
+
function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
|
|
426
|
+
if (unresolvedContainers.has(prefix))
|
|
427
|
+
return null;
|
|
428
|
+
// A real Kubernetes label/selector map is always flat (string -> string) --
|
|
429
|
+
// any entry whose parentPath is a DEEPER descendant of prefix (not prefix
|
|
430
|
+
// itself) means some direct child of prefix was itself a nested container
|
|
431
|
+
// (block-form `team: {owner: p}`, invalid k8s but not rejected by this
|
|
432
|
+
// scanner), which would otherwise just be silently absent from the flat
|
|
433
|
+
// map returned below -- the same "dropped key makes the map more
|
|
434
|
+
// permissive" risk as every other unresolved-container case here.
|
|
435
|
+
if (entries.some((e) => e.parentPath !== prefix && e.parentPath.startsWith(`${prefix}.`)))
|
|
436
|
+
return null;
|
|
437
|
+
// Built via entries + Object.fromEntries, not plain `map[key] = value`
|
|
438
|
+
// assignment: a label key of "__proto__" assigned that way is silently
|
|
439
|
+
// swallowed by a plain object literal (it sets the prototype, not an own
|
|
440
|
+
// property) while `found` still gets set true -- the result is an
|
|
441
|
+
// empty-looking map that Object.entries() treats as vacuously satisfied by
|
|
442
|
+
// every workload, i.e. a Service selecting everything. Not reachable via a
|
|
443
|
+
// syntactically valid Kubernetes label key, but the blast radius (matches
|
|
444
|
+
// EVERY workload, not just a wrong one) is disproportionate to how cheap
|
|
445
|
+
// this guard is. Object.fromEntries's own key-setting is NOT the special
|
|
446
|
+
// __proto__ accessor (verified: it creates a real own property, and the
|
|
447
|
+
// result still has the normal Object.prototype -- Object.create(null)
|
|
448
|
+
// would also close the hole but changes every map's prototype, which
|
|
449
|
+
// breaks plain-object equality elsewhere).
|
|
450
|
+
const pairs = [];
|
|
451
|
+
for (const e of entries) {
|
|
452
|
+
// Match on parentPath, never by slicing e.path on the prefix length: a
|
|
453
|
+
// Kubernetes label key legitimately contains dots (app.kubernetes.io/
|
|
454
|
+
// instance is the `helm create` default), which is indistinguishable
|
|
455
|
+
// from nesting once folded into one dot-joined path string. parentPath
|
|
456
|
+
// is computed structurally from the frame stack, so it's exact -- no
|
|
457
|
+
// guessing by counting dots in what's left after the prefix.
|
|
458
|
+
if (e.parentPath !== prefix)
|
|
459
|
+
continue;
|
|
460
|
+
if (e.value.form !== "template")
|
|
461
|
+
pairs.push([e.key, e.value.value]);
|
|
462
|
+
else
|
|
463
|
+
return null; // any templated label value makes the whole map unusable for subset matching
|
|
464
|
+
}
|
|
465
|
+
return pairs.length > 0 ? Object.fromEntries(pairs) : null;
|
|
466
|
+
}
|
|
467
|
+
function buildDocument(text, docStartChar, entries, unresolvedContainers) {
|
|
468
|
+
const kindEntry = findEntry(entries, "kind");
|
|
469
|
+
const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
|
|
470
|
+
if (!kind || !ALLOWED_KINDS.has(kind))
|
|
471
|
+
return { resource: null, references: [], selector: null, labels: null };
|
|
472
|
+
const nameEntry = findEntry(entries, "metadata.name");
|
|
473
|
+
const resource = nameEntry
|
|
474
|
+
? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
475
|
+
: null;
|
|
476
|
+
const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
|
|
477
|
+
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers) : null;
|
|
478
|
+
const labelsPath = LABELS_PATH_BY_KIND[kind];
|
|
479
|
+
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers) : null;
|
|
480
|
+
return { resource, references, selector, labels };
|
|
481
|
+
}
|
|
482
|
+
// Matches a YAML document-start marker (`---`, optionally with a trailing
|
|
483
|
+
// comment -- `--- # second doc` is legal YAML) or a document-end marker
|
|
484
|
+
// (`...`). Without the trailing-comment allowance, a commented separator
|
|
485
|
+
// silently failed to split at all, merging two documents into one -- the
|
|
486
|
+
// later document's fields overwrite the earlier one's (object spread order),
|
|
487
|
+
// and the earlier resource's symbol/edges vanish entirely. `----` (four or
|
|
488
|
+
// more dashes) is deliberately NOT a separator -- real YAML doesn't treat it
|
|
489
|
+
// as one either.
|
|
490
|
+
const DOC_SEPARATOR = /^(?:---(?:[ \t]+#.*)?|\.\.\.)[ \t]*\r?$/m;
|
|
491
|
+
export function extractK8sManifest(source) {
|
|
492
|
+
const docs = [];
|
|
493
|
+
const boundaries = [];
|
|
494
|
+
for (const m of source.matchAll(new RegExp(DOC_SEPARATOR, "gm")))
|
|
495
|
+
boundaries.push(m.index, m.index + m[0].length);
|
|
496
|
+
const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
|
|
497
|
+
const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
|
|
498
|
+
for (let i = 0; i < starts.length; i++) {
|
|
499
|
+
const start = starts[i];
|
|
500
|
+
const end = ends[i];
|
|
501
|
+
const text = source.slice(start, end);
|
|
502
|
+
const { entries, unresolvedContainers } = scanFieldPaths(text, start);
|
|
503
|
+
docs.push(buildDocument(text, start, entries, unresolvedContainers));
|
|
504
|
+
}
|
|
505
|
+
return docs;
|
|
506
|
+
}
|
|
507
|
+
//# sourceMappingURL=k8sManifest.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Workspace } from "../core/workspace.js";
|
|
2
|
+
import type { MachineIdentity } from "../core/machine.js";
|
|
3
|
+
/** How far back in the default branch a squash-merge is searched for. */
|
|
4
|
+
export declare const DEFAULT_SQUASH_SEARCH_COMMITS = 2000;
|
|
5
|
+
export interface SnapshotOptions {
|
|
6
|
+
machine: MachineIdentity;
|
|
7
|
+
publish: "full" | "branches";
|
|
8
|
+
/** Run `git fetch --prune` first. Off by default: hooks must stay offline-safe. */
|
|
9
|
+
fetch?: boolean;
|
|
10
|
+
now?: Date;
|
|
11
|
+
squashSearchCommits?: number;
|
|
12
|
+
/** Record bound override (tests). Never above the schema's MAX_BRANCHES. */
|
|
13
|
+
maxBranches?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Snapshot this machine's workspace for the repository at `root`. Validated against the
|
|
16
|
+
* strict schema before it is returned, so the extractor can never emit a record the
|
|
17
|
+
* loader would refuse. */
|
|
18
|
+
export declare function snapshotWorkspace(root: string, opts: SnapshotOptions): Workspace;
|