@davesheffer/hunch 1.39.2 → 1.40.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 (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -11,8 +11,9 @@
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
13
  import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
+ import { isParserLoadError } from "./nativeTreeSitter.js";
14
15
  import { extractHelmDirectives } from "./helm.js";
15
- import { extractK8sManifest } from "./k8sManifest.js";
16
+ import { extractK8sManifest, namespacesCompatible } from "./k8sManifest.js";
16
17
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
17
18
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
18
19
  import { resolveRelativeImport } from "../core/relativeImports.js";
@@ -101,6 +102,12 @@ export function scanRepo(store, root, opts = {}) {
101
102
  const perFileCalls = [];
102
103
  const perFileImports = [];
103
104
  const perFileRelations = [];
105
+ // `namespace` on all four: a Kubernetes reference only resolves WITHIN a
106
+ // namespace, so a same-named resource in a different one is a different
107
+ // resource (issue #297). null means UNKNOWN (absent/templated/empty), which
108
+ // matches anything -- see namespacesCompatible. All four are in-memory
109
+ // resolution scratch, never persisted: the edges they produce keep their
110
+ // existing shape.
104
111
  const k8sResourceIndex = [];
105
112
  const k8sReferenceCandidates = [];
106
113
  const k8sSelectors = [];
@@ -145,7 +152,16 @@ export function scanRepo(store, root, opts = {}) {
145
152
  try {
146
153
  parsed = parseSource(rel, src);
147
154
  }
148
- catch {
155
+ catch (error) {
156
+ // …but a dead PARSER is not a bad file. The native addons load on first
157
+ // parse, so a broken load (unwritable TMPDIR, missing prebuild, an addon
158
+ // preloaded past the isolation guard) surfaces here and would mark every
159
+ // file parse_failed, after which indexRepo replaces symbols/edges/
160
+ // components with empty arrays and exits 0 — the whole graph silently
161
+ // wiped. Rethrow so the scan dies before its first store write, the way
162
+ // the import-time load did.
163
+ if (isParserLoadError(error))
164
+ throw error;
149
165
  skipped++;
150
166
  issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
151
167
  noteSkip(rel, "parse_failed");
@@ -248,17 +264,18 @@ export function scanRepo(store, root, opts = {}) {
248
264
  if (!fromId)
249
265
  continue;
250
266
  const scope = chartRoot ?? rel;
251
- k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name) });
267
+ const namespace = doc.resource.namespace;
268
+ k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name), namespace });
252
269
  for (const ref of doc.references) {
253
270
  k8sReferenceCandidates.push({
254
- fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
271
+ fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name), namespace: ref.namespace,
255
272
  reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
256
273
  });
257
274
  }
258
275
  if (doc.selector)
259
- k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
276
+ k8sSelectors.push({ symbolId: fromId, scope, namespace, selector: doc.selector });
260
277
  if (doc.labels)
261
- k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
278
+ k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
262
279
  }
263
280
  fileSymbols.set(rel, idsInFile);
264
281
  fileSymbolIndexId.set(rel, symbolIndexId);
@@ -359,16 +376,24 @@ export function scanRepo(store, root, opts = {}) {
359
376
  // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
360
377
  // happen to share a name would incorrectly conflate. Same ambiguity contract
361
378
  // as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
379
+ //
380
+ // Namespace is a FILTER applied to the candidate list, not part of the key
381
+ // (issue #297): an unknown namespace on either side must still match, which
382
+ // a key can't express. Filtering before the uniqueness check -- rather than
383
+ // after picking a single candidate -- is what makes "ref in a, candidates in
384
+ // a and b" resolve to a instead of declining as ambiguous, while "ref in a,
385
+ // candidates in a and unknown" correctly stays ambiguous.
362
386
  const kindNameIndex = new Map();
363
387
  for (const r of k8sResourceIndex) {
364
388
  const key = `${r.scope}:${r.kind}:${r.nameKey}`;
365
- pushInto(kindNameIndex, key, r.symbolId);
389
+ pushInto(kindNameIndex, key, r);
366
390
  }
367
391
  for (const ref of k8sReferenceCandidates) {
368
- const candidates = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
392
+ const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
393
+ const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
369
394
  if (candidates.length !== 1)
370
395
  continue; // 0 or 2+ -> ambiguous or absent, don't guess
371
- const toId = candidates[0];
396
+ const toId = candidates[0].symbolId;
372
397
  if (toId === ref.fromSymbolId)
373
398
  continue;
374
399
  addEdge({
@@ -410,6 +435,11 @@ export function scanRepo(store, root, opts = {}) {
410
435
  // ids can never collide today.
411
436
  if (svc.symbolId === wl.symbolId)
412
437
  continue;
438
+ // A Service only ever selects pods in its OWN namespace -- a
439
+ // label-identical workload next door is a different workload (issue
440
+ // #297). Unknown on either side still matches, same rule as Phase 1.
441
+ if (!namespacesCompatible(svc.namespace, wl.namespace))
442
+ continue;
413
443
  const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
414
444
  if (!isSubset)
415
445
  continue;
@@ -560,6 +590,22 @@ export function scanRepo(store, root, opts = {}) {
560
590
  issues,
561
591
  };
562
592
  }
593
+ /** True for an edge this indexer produced: the scan re-derives exactly these on
594
+ * every pass, so they — and only they — are safe to replace wholesale. */
595
+ export function isExtractorEdge(edge) {
596
+ return edge.schema === "hunch.edge/1" && edge.provenance.source === "extracted";
597
+ }
598
+ /** Merge a fresh scan into the stored edge set. A scan can only re-derive what
599
+ * it extracted; `supersedes` edges (written by the store) and human-reviewed
600
+ * Landscape relationships have no other source, so replacing the whole index
601
+ * with the scan deleted them on every `hunch index` (issue #288). Non-extractor
602
+ * edges are carried forward in their stored order and win an id collision:
603
+ * a reviewed or store-written fact outranks a re-derivable one. */
604
+ export function mergeScannedEdges(stored, scanned) {
605
+ const carried = stored.filter((edge) => !isExtractorEdge(edge));
606
+ const carriedIds = new Set(carried.map((edge) => edge.id));
607
+ return [...carried, ...scanned.filter((edge) => !carriedIds.has(edge.id))];
608
+ }
563
609
  /** Persist one pure scan into the Git-native source of truth. */
564
610
  export function indexRepo(store, root, opts = {}) {
565
611
  if (opts.requireClean) {
@@ -576,7 +622,7 @@ export function indexRepo(store, root, opts = {}) {
576
622
  if (opts.requireComplete)
577
623
  assertCompleteRepoScan(scan);
578
624
  store.json.replaceAll("symbols", scan.symbols);
579
- store.json.replaceAll("edges", scan.edges);
625
+ store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), scan.edges));
580
626
  store.json.replaceAll("components", scan.components);
581
627
  return scan.result;
582
628
  }
@@ -37,11 +37,21 @@ interface K8sReferenceCandidate {
37
37
  * value (data-dependent, not statically known). */
38
38
  refKind: string;
39
39
  name: ManifestNameRef;
40
+ /** The namespace this reference resolves IN, or null for "unknown" (see
41
+ * literalNamespace). Kubernetes name references are same-namespace by
42
+ * definition, so this defaults to the referring DOCUMENT's own namespace;
43
+ * the one shape that can legitimately point elsewhere (a Gateway API
44
+ * `backendRefs[].namespace` sibling) overrides it. */
45
+ namespace: string | null;
40
46
  }
41
47
  type ManifestLabelMap = Record<string, string>;
42
48
  interface K8sResourceDoc {
43
49
  kind: string;
44
50
  name: ManifestNameRef;
51
+ /** `metadata.namespace` when it is a plain literal, else null = UNKNOWN (see
52
+ * literalNamespace). Cluster-scoped kinds need no special case: they simply
53
+ * never carry the field, so they are unknown and compatible with anything. */
54
+ namespace: string | null;
45
55
  startChar: number;
46
56
  endChar: number;
47
57
  }
@@ -51,6 +61,9 @@ export interface K8sManifestDocument {
51
61
  selector: ManifestLabelMap | null;
52
62
  labels: ManifestLabelMap | null;
53
63
  }
64
+ /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
65
+ * matches anything; two DIFFERENT literal namespaces block the edge. */
66
+ export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
54
67
  /** Where a kind's pod spec lives -- factored once so container/volume field
55
68
  * paths below aren't hand-duplicated per kind. */
56
69
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
@@ -71,6 +71,20 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
71
71
  // -- reparenting the item's real children one level up (`containers.name`
72
72
  // instead of `containers[0].name`), silently dropping every reference under it.
73
73
  const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
74
+ // A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
75
+ // `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
76
+ // script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
77
+ // yet the body lines look exactly like manifest fields (a heredoc'd
78
+ // `secretKeyRef:` block is the canonical case), and this line matches neither
79
+ // KEY_LINE (no colon) nor BARE_LIST_MARKER (something follows the dash), so it
80
+ // fell to the unrecognized-line branch and the script body was then scanned as
81
+ // the surrounding container's children -- inventing a reference to a resource
82
+ // that only ever existed as text. The `key: |` form needs no such handling:
83
+ // its body nests under the key's OWN frame, where no reference field-path can
84
+ // match. The rest is captured (not `[|>]` inline) so the header can be run
85
+ // through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
86
+ // key line's value is.
87
+ const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
74
88
  // A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
75
89
  // `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
76
90
  // `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
@@ -138,8 +152,21 @@ function scanFieldPaths(text, baseChar) {
138
152
  // avoid. Silently ignoring what the scanner can't parse is not safe here;
139
153
  // "I can't tell" must read as "unresolved," the same as a real template.
140
154
  const unresolvedContainers = new Set();
155
+ // The framePath of every container that DIRECTLY holds a key written with no
156
+ // value at all (`tier:`). Deliberately its own set rather than more entries
157
+ // in unresolvedContainers: an ordinary container like `spec:` legitimately
158
+ // holds value-less keys (every nested mapping starts with one), so folding
159
+ // these in would destroy that set's meaning -- "the scanner could not
160
+ // account for this line". Only a map that is supposed to be FLAT
161
+ // (selector/labels) cares, and it checks this set itself.
162
+ const valuelessKeyParents = new Set();
141
163
  const stack = [];
142
164
  let charOffset = baseChar;
165
+ // Indent of the `-` of a `- |` list item whose block-scalar body is being
166
+ // skipped, or null when no such skip is active. The body ends at the first
167
+ // line indented at or shallower than that dash (YAML's own rule for where a
168
+ // sequence item's content stops).
169
+ let blockScalarSkipIndent = null;
143
170
  const popToForListItem = (dashIndent) => {
144
171
  while (stack.length) {
145
172
  const top = stack[stack.length - 1];
@@ -205,6 +232,31 @@ function scanFieldPaths(text, baseChar) {
205
232
  for (const line of text.split("\n")) {
206
233
  const lineStartChar = charOffset;
207
234
  charOffset += line.length + 1; // +1 for the \n split() consumed
235
+ // Inside a `- |` item's body: these lines are script/config TEXT, so they
236
+ // must never be read as field structure. Runs after the charOffset
237
+ // bookkeeping above so skipped lines still advance the offset exactly --
238
+ // every atChar/endChar after the block scalar depends on it.
239
+ if (blockScalarSkipIndent !== null) {
240
+ // A blank line is part of the block scalar (YAML lets a scalar body
241
+ // contain empty lines at any indent), never its terminator.
242
+ if (line.trim().length === 0)
243
+ continue;
244
+ if (BARE_TEMPLATE_LINE.test(line)) {
245
+ // A `{{ }}` action line's own indentation carries no structural
246
+ // meaning (`{{-` chomps it; the `| indent N` idiom puts the action at
247
+ // column 0 -- see markAllOpenContainersUnresolved), so a column-0
248
+ // action inside a script body must NOT be read as a dedent that ends
249
+ // the skip and re-exposes the remaining body lines as fields. Taint
250
+ // conservatively and stay in the skip: we cannot tell whether the
251
+ // action sits inside the scalar or after it.
252
+ markAllOpenContainersUnresolved();
253
+ continue;
254
+ }
255
+ const indent = line.length - line.trimStart().length;
256
+ if (indent > blockScalarSkipIndent)
257
+ continue; // still the scalar's body
258
+ blockScalarSkipIndent = null; // dedented out of the item -- real YAML again
259
+ }
208
260
  if (BARE_TEMPLATE_LINE.test(line)) {
209
261
  markAllOpenContainersUnresolved();
210
262
  continue;
@@ -215,6 +267,18 @@ function scanFieldPaths(text, baseChar) {
215
267
  enterListItem(dashIndent, dashIndent + 1);
216
268
  continue;
217
269
  }
270
+ // Checked BEFORE the KEY_LINE match: KEY_LINE can't match this shape
271
+ // anyway (no colon), but without this branch it would land in the
272
+ // unrecognized-line branch below and the body would be scanned as fields.
273
+ const blockScalarItem = BLOCK_SCALAR_LIST_ITEM.exec(line);
274
+ if (blockScalarItem && BLOCK_SCALAR_HEADER.test(stripTrailingComment(blockScalarItem[2]).trim())) {
275
+ const dashIndent = blockScalarItem[1].length;
276
+ // It IS a real sequence item, so enter it exactly as the bare-marker
277
+ // form does -- sibling items after this one must keep their indices.
278
+ enterListItem(dashIndent, dashIndent + 1);
279
+ blockScalarSkipIndent = dashIndent;
280
+ continue;
281
+ }
218
282
  const m = KEY_LINE.exec(line);
219
283
  if (!m) {
220
284
  // Any other non-blank, non-comment line is a shape this scanner can't
@@ -268,6 +332,18 @@ function scanFieldPaths(text, baseChar) {
268
332
  unresolvedContainers.add(parentPath);
269
333
  continue;
270
334
  }
335
+ // A key written with NO value (`tier:`) creates a frame but never an
336
+ // entry, so it is invisible to extractLiteralLabelMap -- which then
337
+ // returns the map MINUS that key. For a flat string->string map
338
+ // (selector/labels) a value-less direct child is either a null value or a
339
+ // nested container; either way the key is silently absent, and a selector
340
+ // short one key matches strictly MORE workloads than the real one -- the
341
+ // same over-permissive failure as every other unresolved case here.
342
+ // Recorded after the block-scalar branch above, which already taints the
343
+ // parent for its own reason. Note `tier: ""` is NOT value-less: quotes
344
+ // survive stripTrailingComment/trim, so it stays a literal empty string.
345
+ if (value.length === 0)
346
+ valuelessKeyParents.add(parentPath);
271
347
  if (value.length > 0) {
272
348
  const colonIdx = line.indexOf(":", dashIndent);
273
349
  const valueStartInLine = line.indexOf(value, colonIdx);
@@ -309,11 +385,80 @@ function scanFieldPaths(text, baseChar) {
309
385
  // line, since that line's own popToForListItem/popOrdinary call pops
310
386
  // back to (but never past) this key's frame.
311
387
  }
312
- return { entries, unresolvedContainers };
388
+ return { entries, unresolvedContainers, valuelessKeyParents };
313
389
  }
314
390
  function findEntry(entries, path) {
315
391
  return entries.find((e) => e.path === path);
316
392
  }
393
+ /** The RFC 1123 DNS label a real Kubernetes namespace name must be -- the
394
+ * positive test literalNamespace applies (see there for why). */
395
+ const DNS_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
396
+ /** A namespace a reference can be MATCHED on, or null meaning UNKNOWN.
397
+ * Deliberately narrower than the ManifestNameRef literal/template split that
398
+ * names get: a name is a resolution KEY (a template's raw text matches
399
+ * another identical template's raw text, which is real evidence), whereas a
400
+ * namespace here is only ever a FILTER, and the product rule is that unknown
401
+ * matches anything. So a templated `namespace: {{ .Release.Namespace }}` is
402
+ * unknown rather than a `T:`-keyed literal -- two documents whose namespace
403
+ * renders identically must not be blocked from linking just because one
404
+ * spells it via a template and the other spells it out, and two DIFFERENT
405
+ * template expressions are not evidence of different namespaces either.
406
+ * Absent (no entry at all) and an explicit empty string are unknown for the
407
+ * same reason: neither names a namespace this scanner can compare.
408
+ *
409
+ * The literal-ness test is therefore POSITIVE, not a list of excluded
410
+ * spellings: a value counts only when it already looks like the RFC 1123 DNS
411
+ * label a real namespace must be. One test then covers every shape the
412
+ * scanner cannot resolve, without a special case per spelling -- the scanner
413
+ * only tags a value "template" when the template action starts the value, so
414
+ * `namespace: app-{{ .Values.env }}` arrives here as a "literal" and would
415
+ * otherwise block edges against the very namespace it renders to; likewise
416
+ * YAML null spellings (`~`, `Null`), anchors and tags (`&ns prod`,
417
+ * `!!str prod`), uppercase, whitespace and empty. Blocking is the damaging
418
+ * direction (unknown never blocks an edge), so anything uncomparable is
419
+ * unknown.
420
+ *
421
+ * `null` is the one spelling the regex admits but must still be rejected,
422
+ * since it is also the bare YAML null word. It is excluded explicitly rather
423
+ * than by inspecting quotes: stripQuotes runs in the scanner, so a genuinely
424
+ * quoted `namespace: "null"` is indistinguishable here -- and reading it as
425
+ * unknown errs in the conservative direction. */
426
+ function literalNamespace(entry) {
427
+ if (!entry || entry.value.form !== "literal")
428
+ return null;
429
+ const value = entry.value.value;
430
+ if (value === "null" || !DNS_LABEL.test(value))
431
+ return null;
432
+ return value;
433
+ }
434
+ /** The namespace named at `path`, reading EVERY entry there rather than the
435
+ * first. A Helm `{{- if }} namespace: prod {{- else }} namespace: staging
436
+ * {{- end }}` emits both branches as entries on the same path, and taking
437
+ * findEntry's first one would read the document as confidently `prod` and
438
+ * block every edge to `staging`. Only an unambiguous agreement -- at least
439
+ * one entry, all of them the same literal -- is a namespace this scanner can
440
+ * compare; disagreement is unknown, like any other unresolved shape. */
441
+ function namespaceAt(entries, path) {
442
+ let agreed = null;
443
+ let seen = false;
444
+ for (const e of entries) {
445
+ if (e.path !== path)
446
+ continue;
447
+ const ns = literalNamespace(e);
448
+ if (ns === null)
449
+ return null;
450
+ if (seen && ns !== agreed)
451
+ return null;
452
+ agreed = ns;
453
+ seen = true;
454
+ }
455
+ return agreed;
456
+ }
457
+ /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
458
+ * matches anything; two DIFFERENT literal namespaces block the edge. */
459
+ export function namespacesCompatible(a, b) {
460
+ return a === null || b === null || a === b;
461
+ }
317
462
  /** Where a kind's pod spec lives -- factored once so container/volume field
318
463
  * paths below aren't hand-duplicated per kind. */
319
464
  export const POD_SPEC_PATH_BY_KIND = {
@@ -372,14 +517,32 @@ function fieldSpecsForKind(kind) {
372
517
  specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
373
518
  return specs;
374
519
  }
375
- function extractFieldReferences(kind, entries) {
520
+ /** The ONE reference shape in fieldSpecsForKind that Kubernetes lets point at
521
+ * another namespace: a Gateway API `backendRefs[]` entry carries an optional
522
+ * `namespace` SIBLING of its own `name`. Every other shape here
523
+ * (configMapRef/secretRef/volumes/imagePullSecrets/Ingress backend/
524
+ * ownerReferences) is same-namespace by API definition and has no namespace
525
+ * field at all, so none of them needs an override. Read off the concrete
526
+ * (non-wildcarded) entry list by exact parentPath -- the same
527
+ * pair-siblings-by-index technique extractOwnerReferenceCandidates uses --
528
+ * rather than new parsing machinery. */
529
+ const NAMESPACE_SIBLING_PATHS = new Set(["spec.rules[].backendRefs[]"]);
530
+ function extractFieldReferences(kind, entries, docNamespace) {
376
531
  const specs = fieldSpecsForKind(kind);
377
532
  const out = [];
378
533
  for (const e of entries) {
379
534
  const wp = wildcardPath(e.path);
380
535
  const spec = specs.find((s) => s.path === wp);
381
- if (spec)
382
- out.push({ refKind: spec.refKind, name: e.value });
536
+ if (!spec)
537
+ continue;
538
+ // A sibling that EXISTS but isn't a comparable literal (templated, empty)
539
+ // must read as unknown, NOT fall back to the document's namespace: it is
540
+ // an explicit statement that the target lives somewhere this scanner
541
+ // cannot name, which is the opposite of "same namespace as me". So the
542
+ // sibling's existence is checked before its literal-ness.
543
+ const siblingPath = `${e.parentPath}.namespace`;
544
+ const hasSibling = NAMESPACE_SIBLING_PATHS.has(wildcardPath(e.parentPath)) && entries.some((c) => c.path === siblingPath);
545
+ out.push({ refKind: spec.refKind, name: e.value, namespace: hasSibling ? namespaceAt(entries, siblingPath) : docNamespace });
383
546
  }
384
547
  return out;
385
548
  }
@@ -389,7 +552,7 @@ function extractFieldReferences(kind, entries) {
389
552
  * entries never get cross-paired. Not expressible via FieldPathSpec's
390
553
  * single-fixed-refKind model, so it's a dedicated pass over the concrete
391
554
  * (non-wildcarded) entries. */
392
- function extractOwnerReferenceCandidates(entries) {
555
+ function extractOwnerReferenceCandidates(entries, docNamespace) {
393
556
  const byIndex = new Map();
394
557
  for (const e of entries) {
395
558
  const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
@@ -403,7 +566,10 @@ function extractOwnerReferenceCandidates(entries) {
403
566
  for (const { name, kind } of byIndex.values()) {
404
567
  if (!name || !kind || kind.value.form !== "literal")
405
568
  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 });
569
+ // An ownerReferences entry has NO namespace field in the API at all -- an
570
+ // owner is always in the owned object's own namespace (or cluster-scoped),
571
+ // so the document's namespace is the only correct answer here.
572
+ out.push({ refKind: kind.value.value, name: name.value, namespace: docNamespace });
407
573
  }
408
574
  return out;
409
575
  }
@@ -422,9 +588,14 @@ export const LABELS_PATH_BY_KIND = {
422
588
  * exists, or any direct-child leaf is itself templated -- a partially-literal
423
589
  * map is still unusable for subset-match without evaluating the templated
424
590
  * half, so the whole map is treated as unresolved. */
425
- function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
591
+ function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valuelessKeyParents) {
426
592
  if (unresolvedContainers.has(prefix))
427
593
  return null;
594
+ // A value-less direct child (`tier:`) never produced an entry at all, so the
595
+ // map below would come back without it -- see valuelessKeyParents' comment
596
+ // in scanFieldPaths for why a short map is the dangerous direction.
597
+ if (valuelessKeyParents.has(prefix))
598
+ return null;
428
599
  // A real Kubernetes label/selector map is always flat (string -> string) --
429
600
  // any entry whose parentPath is a DEEPER descendant of prefix (not prefix
430
601
  // itself) means some direct child of prefix was itself a nested container
@@ -464,30 +635,43 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
464
635
  }
465
636
  return pairs.length > 0 ? Object.fromEntries(pairs) : null;
466
637
  }
467
- function buildDocument(text, docStartChar, entries, unresolvedContainers) {
638
+ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
468
639
  const kindEntry = findEntry(entries, "kind");
469
640
  const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
470
641
  if (!kind || !ALLOWED_KINDS.has(kind))
471
642
  return { resource: null, references: [], selector: null, labels: null };
643
+ // Found with the same entry machinery as metadata.name, so it inherits the
644
+ // scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
645
+ // Read OUTSIDE the `resource` branch below: a document with no
646
+ // metadata.name still emits references, and those references carry this
647
+ // document's namespace.
648
+ const namespace = namespaceAt(entries, "metadata.namespace");
472
649
  const nameEntry = findEntry(entries, "metadata.name");
473
650
  const resource = nameEntry
474
- ? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
651
+ ? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
475
652
  : null;
476
- const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
477
- const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers) : null;
653
+ const references = [...extractFieldReferences(kind, entries, namespace), ...extractOwnerReferenceCandidates(entries, namespace)];
654
+ const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
478
655
  const labelsPath = LABELS_PATH_BY_KIND[kind];
479
- const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers) : null;
656
+ const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
480
657
  return { resource, references, selector, labels };
481
658
  }
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;
659
+ // Matches a YAML document-start marker (`---`) or a document-end marker
660
+ // (`...`). Per the YAML spec `---` FOLLOWED BY WHITESPACE starts a document
661
+ // whatever trails it on that line -- a comment (`--- # second doc`), a tag
662
+ // (`--- !!map`), an anchor (`--- &base`) -- so the trailing text is matched
663
+ // generically rather than as a comment only. Without that, a tagged or
664
+ // anchored separator silently failed to split at all, merging two documents
665
+ // into one: the later document's fields overwrite the earlier one's (object
666
+ // spread order), and the earlier resource's symbol/edges vanish entirely.
667
+ // Whatever trails the marker is consumed WITH the boundary -- for a tag or
668
+ // anchor nothing is lost, and inline content written after the marker
669
+ // (`--- kind: Pod`, legal but vanishingly rare) is dropped, which errs toward
670
+ // fewer edges, this module's standing bias. `----` (four or more dashes) is
671
+ // deliberately NOT a separator -- real YAML doesn't treat it as one either,
672
+ // and it still isn't here: the dashes must be followed by whitespace or end
673
+ // of line.
674
+ const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
491
675
  export function extractK8sManifest(source) {
492
676
  const docs = [];
493
677
  const boundaries = [];
@@ -499,8 +683,8 @@ export function extractK8sManifest(source) {
499
683
  const start = starts[i];
500
684
  const end = ends[i];
501
685
  const text = source.slice(start, end);
502
- const { entries, unresolvedContainers } = scanFieldPaths(text, start);
503
- docs.push(buildDocument(text, start, entries, unresolvedContainers));
686
+ const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
687
+ docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
504
688
  }
505
689
  return docs;
506
690
  }
@@ -6,6 +6,7 @@ import { compareCodeUnits } from "../core/canonicalOrder.js";
6
6
  import { resourceId, resourceRelationshipId } from "../core/ids.js";
7
7
  import { parseJsonc } from "../core/jsonc.js";
8
8
  import { parseSource } from "./parse.js";
9
+ import { isParserLoadError } from "./nativeTreeSitter.js";
9
10
  import { EdgeSchema, ResourceSchema, isCredentialFreeText, } from "../core/types.js";
10
11
  import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice, } from "./git.js";
11
12
  export const LANDSCAPE_DISCOVERY_SCHEMA_VERSION = "hunch.landscape-discovery/1";
@@ -2026,7 +2027,14 @@ function sloDeclarations(root, tree, issues) {
2026
2027
  try {
2027
2028
  valid = blob.format === "json" ? validJsonOpenSlo(source) : validYamlOpenSlo(blob.path, source);
2028
2029
  }
2029
- catch {
2030
+ catch (error) {
2031
+ // A malformed declaration is a bad file and stays one issue. A dead
2032
+ // native parser is not: swallowing it made `landscape review` report
2033
+ // every valid SLO as slo_declaration_invalid, drop the resource, and
2034
+ // still exit 0 with a ready-made `landscape adopt --acknowledge-issues`
2035
+ // line — a store write of a false verdict.
2036
+ if (isParserLoadError(error))
2037
+ throw error;
2030
2038
  valid = false;
2031
2039
  }
2032
2040
  if (!valid) {
@@ -8,6 +8,30 @@ export interface NativeTreeSitterRuntime {
8
8
  php: unknown;
9
9
  yaml: unknown;
10
10
  }
11
+ /** The parser runtime itself is unavailable — the addons could not be copied or
12
+ * dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
13
+ * replacing the package mid-session, or the fail-closed "preloaded addon"
14
+ * guard). Distinct from a per-file parse error on purpose: now that the load
15
+ * happens on first parse rather than at import, every swallow-and-continue
16
+ * catch on a parse path would otherwise read this as "this one file is bad"
17
+ * and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
18
+ * graph with nothing. Callers rethrow it so the run dies before any write,
19
+ * the way the import-time load used to. The original error is kept as `cause`
20
+ * and its text is carried in the message so the EACCES/dlopen detail a user
21
+ * needs is never lost. */
22
+ export declare class NativeTreeSitterLoadError extends Error {
23
+ constructor(message: string, options?: {
24
+ cause?: unknown;
25
+ });
26
+ }
27
+ /** The ONE predicate every rethrow site uses — a dead parser is recognised the
28
+ * same way everywhere, so no catch can quietly disagree about what counts.
29
+ * `instanceof` alone is not enough: a duplicated module instance (two copies of
30
+ * this file in one process, e.g. dist/ + src/ under tsx, or a worker that
31
+ * re-resolves it) gives a different class identity, and an error rebuilt across
32
+ * a worker/IPC boundary keeps only its plain properties. The name check
33
+ * survives both. */
34
+ export declare function isParserLoadError(e: unknown): boolean;
11
35
  /** Load all native tree-sitter addons (the parser runtime + every grammar) from
12
36
  * process-owned temp copies. Windows keeps loaded `.node` files locked for the
13
37
  * process lifetime; redirecting the upstream loaders means npm can replace the
@@ -13,6 +13,40 @@ const NATIVE_PACKAGES = [
13
13
  "@tree-sitter-grammars/tree-sitter-yaml",
14
14
  ];
15
15
  let runtime = null;
16
+ /** The parser runtime itself is unavailable — the addons could not be copied or
17
+ * dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
18
+ * replacing the package mid-session, or the fail-closed "preloaded addon"
19
+ * guard). Distinct from a per-file parse error on purpose: now that the load
20
+ * happens on first parse rather than at import, every swallow-and-continue
21
+ * catch on a parse path would otherwise read this as "this one file is bad"
22
+ * and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
23
+ * graph with nothing. Callers rethrow it so the run dies before any write,
24
+ * the way the import-time load used to. The original error is kept as `cause`
25
+ * and its text is carried in the message so the EACCES/dlopen detail a user
26
+ * needs is never lost. */
27
+ export class NativeTreeSitterLoadError extends Error {
28
+ constructor(message, options) {
29
+ super(message, options);
30
+ this.name = "NativeTreeSitterLoadError";
31
+ }
32
+ }
33
+ /** The ONE predicate every rethrow site uses — a dead parser is recognised the
34
+ * same way everywhere, so no catch can quietly disagree about what counts.
35
+ * `instanceof` alone is not enough: a duplicated module instance (two copies of
36
+ * this file in one process, e.g. dist/ + src/ under tsx, or a worker that
37
+ * re-resolves it) gives a different class identity, and an error rebuilt across
38
+ * a worker/IPC boundary keeps only its plain properties. The name check
39
+ * survives both. */
40
+ export function isParserLoadError(e) {
41
+ return e instanceof NativeTreeSitterLoadError || e?.name === "NativeTreeSitterLoadError";
42
+ }
43
+ /** Wrap any loader failure once, preserving an already-typed one. */
44
+ function loadFailure(error) {
45
+ if (error instanceof NativeTreeSitterLoadError)
46
+ return error;
47
+ const detail = error instanceof Error ? error.message : String(error);
48
+ return new NativeTreeSitterLoadError(`native tree-sitter parser unavailable: ${detail}`, { cause: error });
49
+ }
16
50
  function processIsAlive(pid) {
17
51
  if (pid === process.pid)
18
52
  return true;
@@ -71,6 +105,19 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
71
105
  export function loadNativeTreeSitter() {
72
106
  if (runtime)
73
107
  return runtime;
108
+ // Only SUCCESS is memoized (in `runtime`), never the failure: the conditions
109
+ // that break the load are transient — a full or read-only TMPDIR, an npm
110
+ // install swapping the package out from under a live process. A long-lived
111
+ // MCP server must be able to parse again once the condition clears, so every
112
+ // call retries the load from scratch.
113
+ try {
114
+ return loadRuntime();
115
+ }
116
+ catch (error) {
117
+ throw loadFailure(error);
118
+ }
119
+ }
120
+ function loadRuntime() {
74
121
  // Three binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node
75
122
  // or, for a scoped package like @tree-sitter-grammars/tree-sitter-yaml, as
76
123
  // @scope+name.node; from-source builds are named after the binding.gyp target
@@ -85,9 +132,15 @@ export function loadNativeTreeSitter() {
85
132
  }
86
133
  removeStaleCopies();
87
134
  const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
88
- const nodeGypBuild = runtimeRequire("node-gyp-build");
89
135
  const previous = new Map();
90
136
  try {
137
+ // Resolved INSIDE the try: if node-gyp-build cannot be resolved (a partial
138
+ // install, npm mid-swap) the catch below still removes the copy dir we just
139
+ // created. Outside it, every retry — and failure is deliberately not
140
+ // memoized, so a long-lived MCP server retries forever — leaked one empty
141
+ // hunch-tree-sitter-<pid>-* dir that removeStaleCopies can never prune,
142
+ // because it only prunes dirs whose pid is dead.
143
+ const nodeGypBuild = runtimeRequire("node-gyp-build");
91
144
  for (const packageName of NATIVE_PACKAGES) {
92
145
  const key = environmentKey(packageName);
93
146
  previous.set(key, process.env[key]);