@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.
Files changed (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. 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;