@davesheffer/hunch 1.46.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -112,7 +112,7 @@ export function scanRepo(store, root, opts = {}) {
112
112
  const symbols = [];
113
113
  const nameIndex = new Map(); // symbol name -> [symbol ids]
114
114
  const fileSymbols = new Map(); // file -> symbol ids (in-file resolution)
115
- const fileSymbolIndexId = new Map(); // file -> (symbol index in parsed.symbols -> id)
115
+ const fileSymbolIndexId = new Map(); // file -> (symbol index -> symbol id)
116
116
  const perFileCalls = [];
117
117
  const perFileImports = [];
118
118
  const perFileRelations = [];
@@ -131,15 +131,17 @@ export function scanRepo(store, root, opts = {}) {
131
131
  // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
132
132
  // total, instead of two per file — the dominant cost of indexing a large repo.
133
133
  const rels = files.map((file) => file.path);
134
- const chartRootFor = nearestChartRoot(rels);
135
- const chartFiles = new Map();
134
+ // Helm template `include`s are release/chart scoped, not file scoped: every YAML
135
+ // file in a chart implicitly "imports" its sibling chart files.
136
+ const chartRootOf = nearestChartRoot(rels);
137
+ const chartYamlFiles = new Map();
136
138
  for (const path of rels) {
137
139
  if (languageFor(path)?.id !== "yaml")
138
140
  continue;
139
- const chartRoot = chartRootFor(path);
140
- if (chartRoot === null)
141
+ const chart = chartRootOf(path);
142
+ if (chart === null)
141
143
  continue;
142
- pushInto(chartFiles, chartRoot, path);
144
+ pushInto(chartYamlFiles, chart, path);
143
145
  }
144
146
  const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
145
147
  let skipped = 0;
@@ -210,38 +212,25 @@ export function scanRepo(store, root, opts = {}) {
210
212
  continue;
211
213
  }
212
214
  noteCoverage(rel, "parsed");
213
- const chartRoot = languageFor(rel)?.id === "yaml" ? chartRootFor(rel) : null;
214
- // Explicit null check, not truthiness: a chart rooted at the repo root
215
- // itself resolves to "" (empty string), which is a valid chart scope but
216
- // JS-falsy — `if (chartRoot)` would silently skip every repo-root chart.
215
+ // Explicit null check: a chart rooted at the repo root has scope "", which is valid.
216
+ const chartRoot = languageFor(rel)?.id === "yaml" ? chartRootOf(rel) : null;
217
217
  if (chartRoot !== null) {
218
218
  const helm = extractHelmDirectives(src);
219
- // helm.ts's own offsets are named *Char (they're JS char indices, not
220
- // UTF-8 bytes -- see its module doc comment); mapped here into
221
- // parsed.symbols/calls's startByte/endByte/atByte fields, which carry
222
- // the same char-index values under the shared ParsedSymbol/ParsedCall
223
- // naming this merge target already uses.
224
- const helmSymbols = helm.symbols.map((s) => ({
225
- name: s.name, kind: s.kind, startByte: s.startChar, endByte: s.endChar, loc: s.loc, bodyText: s.bodyText,
226
- }));
227
- const helmCalls = helm.calls.map((c) => ({
228
- callee: c.callee, atByte: c.atChar, endByte: c.endChar, member: c.member,
229
- }));
230
- parsed.symbols = [...parsed.symbols, ...helmSymbols].sort((a, b) => a.startByte - b.startByte);
231
- parsed.calls = [...parsed.calls, ...helmCalls];
219
+ parsed.symbols = [
220
+ ...parsed.symbols,
221
+ ...helm.symbols.map((h) => ({
222
+ name: h.name, kind: h.kind, startByte: h.startChar, endByte: h.endChar, loc: h.loc, bodyText: h.bodyText,
223
+ })),
224
+ ].sort((a, b) => a.startByte - b.startByte);
225
+ parsed.calls = [
226
+ ...parsed.calls,
227
+ ...helm.calls.map((c) => ({ callee: c.callee, atByte: c.atChar, endByte: c.endChar, member: c.member })),
228
+ ];
232
229
  }
233
- // Runs for EVERY yaml file, chart or not (unlike the Helm merge above) --
234
- // raw manifests with no Chart.yaml are still in scope; what varies below is
235
- // resolution SCOPE (chartRoot ?? this file), not whether extraction runs.
230
+ // K8s extraction runs for every YAML file, chart or not.
236
231
  let k8sDocs = [];
237
- // THIS file's K8s resource symbol OBJECTS (identity, not their byte
238
- // offsets) -- scopes the id lookup below to exactly the symbols this pass
239
- // creates. Identity, not a Set<number> of startBytes, because a byte
240
- // value is not a reliable per-symbol key: a Helm `define` symbol that
241
- // happens to share a startByte with a K8s doc symbol in the same .yaml
242
- // file (both legitimately synthetic, both can start at byte 0) would
243
- // otherwise be indistinguishable by offset alone, and the wrong (Helm)
244
- // symbol id could get recorded instead of the K8s one.
232
+ // Tracked by object identity, not start byte: a Helm define and a K8s doc
233
+ // symbol can both start at byte 0.
245
234
  const k8sSymbolObjects = new Set();
246
235
  // Identity name a K8s symbol's id is derived from when its kind/name is
247
236
  // NOT unique in this file (issue #382). Absent for the common unique case,
@@ -263,8 +252,8 @@ export function scanRepo(store, root, opts = {}) {
263
252
  nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
264
253
  }
265
254
  const k8sSymbols = resourceDocs
266
- // k8sManifest.ts's own offsets are named *Char; mapped here into the
267
- // shared startByte/endByte fields, same as the Helm merge above.
255
+ // The manifest extractor reports char indices (*Char); they land in the shared
256
+ // startByte/endByte fields, same as the Helm merge above.
268
257
  .map((d) => {
269
258
  const sym = {
270
259
  name: `${d.resource.kind}/${displayNameText(d.resource.name)}`,
@@ -278,17 +267,17 @@ export function scanRepo(store, root, opts = {}) {
278
267
  k8sIdNames.set(sym, `${sym.name}@${d.resource.namespace ?? ""}`);
279
268
  return sym;
280
269
  });
281
- for (const s of k8sSymbols)
282
- k8sSymbolObjects.add(s);
270
+ for (const sym of k8sSymbols)
271
+ k8sSymbolObjects.add(sym);
283
272
  parsed.symbols = [...parsed.symbols, ...k8sSymbols].sort((a, b) => a.startByte - b.startByte);
284
273
  }
285
274
  const m = gitMeta?.get(rel);
286
275
  const churn = opts.churn === false ? (preservedChurn.get(rel) ?? 0) : (m?.churn ?? 0);
287
276
  const last = m?.lastCommit ?? "";
288
277
  const idsInFile = [];
289
- const symbolIndexId = new Map();
278
+ const indexToId = new Map(); // symbol index -> id
290
279
  const idCounts = new Map(); // disambiguate same (file,name,kind)
291
- const k8sSymbolIdByStartByte = new Map();
280
+ const k8sIdByStart = new Map(); // K8s resource start offset -> symbol id
292
281
  for (const [index, ps] of parsed.symbols.entries()) {
293
282
  const base = symbolId(rel, k8sIdNames.get(ps) ?? ps.name, ps.kind);
294
283
  const n = idCounts.get(base) ?? 0;
@@ -298,7 +287,7 @@ export function scanRepo(store, root, opts = {}) {
298
287
  // kind, namespace and name in one file -- indistinguishable by identity)
299
288
  const id = n === 0 ? base : `${base}_${n}`;
300
289
  idsInFile.push(id);
301
- symbolIndexId.set(index, id);
290
+ indexToId.set(index, id);
302
291
  pushInto(nameIndex, ps.name, id);
303
292
  symbols.push({
304
293
  id, file: rel, name: ps.name, kind: ps.kind,
@@ -308,14 +297,15 @@ export function scanRepo(store, root, opts = {}) {
308
297
  last_changed: last,
309
298
  });
310
299
  if (k8sSymbolObjects.has(ps))
311
- k8sSymbolIdByStartByte.set(ps.startByte, id);
300
+ k8sIdByStart.set(ps.startByte, id);
312
301
  }
313
302
  for (const doc of k8sDocs) {
314
303
  if (!doc.resource)
315
304
  continue;
316
- const fromId = k8sSymbolIdByStartByte.get(doc.resource.startChar);
305
+ const fromId = k8sIdByStart.get(doc.resource.startChar);
317
306
  if (!fromId)
318
307
  continue;
308
+ // Chart scope when inside a chart, else the file itself.
319
309
  const scope = chartRoot ?? rel;
320
310
  const namespace = doc.resource.namespace;
321
311
  k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name), namespace });
@@ -331,7 +321,7 @@ export function scanRepo(store, root, opts = {}) {
331
321
  k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
332
322
  }
333
323
  fileSymbols.set(rel, idsInFile);
334
- fileSymbolIndexId.set(rel, symbolIndexId);
324
+ fileSymbolIndexId.set(rel, indexToId);
335
325
  perFileCalls.push({ file: rel, bySym: attributeCalls(parsed), qualified: attributeQualifiedCalls(parsed) });
336
326
  perFileImports.push({ file: rel, imports: parsed.imports });
337
327
  perFileRelations.push({ file: rel, bySym: attributeRelations(parsed) });
@@ -341,13 +331,9 @@ export function scanRepo(store, root, opts = {}) {
341
331
  }
342
332
  }
343
333
  const byId = new Map(symbols.map((s) => [s.id, s]));
344
- // Language-aware import resolution (resolveImportTarget), used by both the
345
- // call-resolution gate below (via importedFiles) and the depends_on edge
346
- // derivation in pass 3 directly: a Python cross-file call/import must
347
- // resolve through the same relative/absolute Python rules as everything
348
- // else, not silently fail the JS/TS resolver and look unimported.
349
- const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
350
- const pyRoots = hasSrcLayout ? ["", "src"] : [""];
334
+ // Import resolution is language-aware; the call-resolution gate and the pass-3
335
+ // depends_on derivation share it.
336
+ const pyRoots = [...fileSymbols.keys()].some((f) => f.startsWith("src/")) ? ["", "src"] : [""];
351
337
  const goModule = readGoModulePath(root);
352
338
  const trackedFiles = new Set(fileSymbols.keys());
353
339
  const resolveImportTargets = (file, spec) => {
@@ -363,13 +349,13 @@ export function scanRepo(store, root, opts = {}) {
363
349
  };
364
350
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
365
351
  const targets = new Set(imports.flatMap((specifier) => resolveImportTargets(file, specifier)));
366
- const chartRoot = languageFor(file)?.id === "yaml" ? chartRootFor(file) : null;
367
- // Explicit null check, not truthiness — see the matching comment above on
368
- // the per-file Helm merge step: a repo-root chart's scope is "", not null.
369
- if (chartRoot !== null)
370
- for (const sibling of chartFiles.get(chartRoot) ?? [])
371
- if (sibling !== file)
372
- targets.add(sibling);
352
+ if (languageFor(file)?.id === "yaml") {
353
+ const chart = chartRootOf(file);
354
+ if (chart !== null)
355
+ for (const sibling of chartYamlFiles.get(chart) ?? [])
356
+ if (sibling !== file)
357
+ targets.add(sibling);
358
+ }
373
359
  return [file, targets];
374
360
  }));
375
361
  // ---- pass 2: resolve calls -> symbol-level edges -------------------------
@@ -382,13 +368,9 @@ export function scanRepo(store, root, opts = {}) {
382
368
  edges.push(e);
383
369
  };
384
370
  for (const { file, bySym, qualified } of perFileCalls) {
385
- // Most languages leave this unset and get "calls" — YAML's alias->anchor
386
- // references aren't function calls, so its LanguageSpec declares "references".
387
371
  const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
388
372
  const indexToId = fileSymbolIndexId.get(file) ?? new Map();
389
373
  for (const [callerIndex, callees] of bySym) {
390
- // resolve caller by its stable position in parsed.symbols (not startByte —
391
- // startByte is not unique across symbols; see attributeCalls's doc comment)
392
374
  const callerId = indexToId.get(callerIndex);
393
375
  if (!callerId)
394
376
  continue;
@@ -460,11 +442,10 @@ export function scanRepo(store, root, opts = {}) {
460
442
  }
461
443
  }
462
444
  }
463
- // ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
464
- // Own resolver, not resolveName(): resolveName() indexes by bare symbol name
465
- // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
466
- // happen to share a name would incorrectly conflate. Same ambiguity contract
467
- // as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
445
+ // ---- pass 2b: K8s cross-resource references, phase 1 (name-keyed) ----------
446
+ // A dedicated resolver instead of resolveName: names alone ignore the Kubernetes
447
+ // kind (a ConfigMap and a Secret sharing a name must not conflate), while the
448
+ // ambiguity contract stays the same -- zero or two-plus matches yield no edge.
468
449
  //
469
450
  // Namespace is a FILTER applied to the candidate list, not part of the key
470
451
  // (issue #297): an unknown namespace on either side must still match, which
@@ -481,7 +462,7 @@ export function scanRepo(store, root, opts = {}) {
481
462
  const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
482
463
  const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
483
464
  if (candidates.length !== 1)
484
- continue; // 0 or 2+ -> ambiguous or absent, don't guess
465
+ continue; // zero = absent, two-plus = ambiguous; never guess
485
466
  const toId = candidates[0].symbolId;
486
467
  if (toId === ref.fromSymbolId)
487
468
  continue;
@@ -495,33 +476,20 @@ export function scanRepo(store, root, opts = {}) {
495
476
  metadata: {},
496
477
  });
497
478
  }
498
- // ---- K8s manifest cross-resource references (Phase 2: label-selector) ----
499
- // Structurally different from Phase 1: no name to look up, a SUBSET match
500
- // between a Service's selector and a workload's pod-template labels, within
501
- // the same scope. Only ever fires on LITERAL selector/labels (k8sSelectors/
502
- // k8sWorkloadLabels are already filtered to literal-only by k8sManifest.ts --
503
- // a block-form templated value is never guessed at).
504
- //
505
- // Deliberately NO ambiguity guard here, unlike Phase 1's "0 or 2+ candidates
506
- // -> no edge": a Service legitimately fronting multiple workloads (blue/green,
507
- // canary, a shared-label pair of Deployments) is normal, intentional
508
- // Kubernetes usage, not an ambiguous match to decline -- Phase 1's guard
509
- // exists because a ConfigMap named X is exactly one resource by definition,
510
- // which has no analogue here. Fan-out is the correct behavior, not a gap.
479
+ // Phase 2 (label selector): a Service's selector is a SUBSET match against
480
+ // workloads' pod-template labels within the same scope. Only literal values get
481
+ // here (filtered upstream). There is deliberately no ambiguity guard: a Service
482
+ // fronting several workloads is normal, so fan-out is correct.
511
483
  const selectorsByScope = new Map();
512
- for (const s of k8sSelectors)
513
- pushInto(selectorsByScope, s.scope, s);
514
- const labelsByScope = new Map();
515
- for (const l of k8sWorkloadLabels)
516
- pushInto(labelsByScope, l.scope, l);
484
+ for (const sel of k8sSelectors)
485
+ pushInto(selectorsByScope, sel.scope, sel);
486
+ const workloadsByScope = new Map();
487
+ for (const w of k8sWorkloadLabels)
488
+ pushInto(workloadsByScope, w.scope, w);
517
489
  for (const [scope, selectors] of selectorsByScope) {
518
- const workloads = labelsByScope.get(scope) ?? [];
490
+ const workloads = workloadsByScope.get(scope) ?? [];
519
491
  for (const svc of selectors) {
520
492
  for (const wl of workloads) {
521
- // Defensive, not currently reachable: k8sSelectors only ever holds
522
- // Service symbols and k8sWorkloadLabels only ever holds symbols for
523
- // kinds in LABELS_PATH_BY_KIND, which excludes Service -- so the two
524
- // ids can never collide today.
525
493
  if (svc.symbolId === wl.symbolId)
526
494
  continue;
527
495
  // A Service only ever selects pods in its OWN namespace -- a
@@ -529,8 +497,7 @@ export function scanRepo(store, root, opts = {}) {
529
497
  // #297). Unknown on either side still matches, same rule as Phase 1.
530
498
  if (!namespacesCompatible(svc.namespace, wl.namespace))
531
499
  continue;
532
- const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
533
- if (!isSubset)
500
+ if (!Object.entries(svc.selector).every(([key, value]) => Object.hasOwn(wl.labels, key) && wl.labels[key] === value))
534
501
  continue;
535
502
  addEdge({
536
503
  schema: "hunch.edge/1",
@@ -576,10 +543,10 @@ export function scanRepo(store, root, opts = {}) {
576
543
  }
577
544
  }
578
545
  }
579
- // fan-in / fan-out from resolved call/reference edges
580
- const CALL_LIKE_EDGE_TYPES = new Set(["calls", "references"]);
546
+ // Fan-in/fan-out come from resolved call and reference edges only.
547
+ const CALL_LIKE_EDGES = new Set(["calls", "references"]);
581
548
  for (const e of edges) {
582
- if (!CALL_LIKE_EDGE_TYPES.has(e.type))
549
+ if (!CALL_LIKE_EDGES.has(e.type))
583
550
  continue;
584
551
  const from = byId.get(e.from);
585
552
  const to = byId.get(e.to);
@@ -717,52 +684,49 @@ export function indexRepo(store, root, opts = {}) {
717
684
  return scan.result;
718
685
  }
719
686
  // ---- helpers --------------------------------------------------------------
720
- /** Append `value` to the array at `key`, creating the array on first use.
721
- * Function declaration (not `const`) so it's usable from pass-1 code above
722
- * this section via hoisting, without reordering. */
723
687
  function pushInto(map, key, value) {
724
- const bucket = map.get(key);
725
- if (bucket)
726
- bucket.push(value);
688
+ const list = map.get(key);
689
+ if (list)
690
+ list.push(value);
727
691
  else
728
692
  map.set(key, [value]);
729
693
  }
730
- /** Nearest-ancestor Chart.yaml lookup, memoized per directory: walks a file's
731
- * own directory upward through the tracked-file set until it finds
732
- * `<dir>/Chart.yaml`, or returns null if the file isn't under any chart.
733
- * Nested subcharts (their own Chart.yaml under charts/<name>/) resolve to
734
- * their OWN chart root, not the parent's. This is a conservative
735
- * approximation, not full Helm semantics: Helm's template namespace is
736
- * actually release-global, so a parent chart can legitimately include a
737
- * subchart's define — nearest-ancestor scoping will miss that edge rather
738
- * than fabricate a wrong one (test coverage: indexer.test.ts's nested-subchart
739
- * and subchart-miss cases). Modeling the release-global namespace itself
740
- * remains open — issue #42. */
741
- function nearestChartRoot(rels) {
742
- const tracked = new Set(rels);
743
- const cache = new Map();
744
- const resolveDir = (dir) => {
745
- if (cache.has(dir))
746
- return cache.get(dir);
747
- const chartYaml = dir ? `${dir}/Chart.yaml` : "Chart.yaml";
748
- const result = tracked.has(chartYaml)
749
- ? dir
750
- : dir === "" ? null : resolveDir(dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "");
751
- cache.set(dir, result);
694
+ /** Build a lookup from a tracked file to the root directory of its nearest Helm
695
+ * chart ("" = repo root), or null. A directory is a chart root when
696
+ * `<dir>/Chart.yaml` is tracked; nested subcharts resolve to their OWN root. This
697
+ * is a conservative approximation of Helm's release-global template namespace
698
+ * (issue #42): a parent including a subchart define is missed, never wrongly linked. */
699
+ export function nearestChartRoot(tracked) {
700
+ const trackedSet = new Set(tracked);
701
+ const memo = new Map();
702
+ const rootOf = (dir) => {
703
+ if (memo.has(dir))
704
+ return memo.get(dir);
705
+ let result;
706
+ if (trackedSet.has(dir === "" ? "Chart.yaml" : `${dir}/Chart.yaml`))
707
+ result = dir;
708
+ else if (dir === "")
709
+ result = null;
710
+ else {
711
+ const parent = posix.dirname(dir);
712
+ // dirname is a fixed point at the filesystem root ("/"): stop, no chart found.
713
+ result = parent === dir ? null : rootOf(parent === "." ? "" : parent);
714
+ }
715
+ memo.set(dir, result);
752
716
  return result;
753
717
  };
754
- return (file) => resolveDir(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
718
+ return (file) => {
719
+ const dir = posix.dirname(toPosix(file));
720
+ return rootOf(dir === "." ? "" : dir);
721
+ };
755
722
  }
756
- /** Human-readable text for a name/kind field -- a literal value as-is, or a
757
- * template's exact raw `{{ }}` source text (never evaluated). Used for
758
- * display (symbol names, edge reasons); NOT for resolution-key equality --
759
- * see nameKeyText below for that. */
723
+ /** Human-readable form of a manifest name: a literal's value, or a template's raw
724
+ * source text (never evaluated). */
760
725
  function displayNameText(ref) {
761
726
  return ref.form === "literal" ? ref.value : ref.sourceText;
762
727
  }
763
- /** Normalized resolution-key text for a name/kind field: a literal value or a
764
- * template's exact raw source text, EACH PREFIXED so a literal "foo" can
765
- * never collide with a template whose source text happens to read "foo". */
728
+ /** Resolution key for a manifest name. The `L:`/`T:` prefix keeps a literal from
729
+ * ever colliding with a template whose source text reads the same. */
766
730
  function nameKeyText(ref) {
767
731
  return ref.form === "literal" ? `L:${ref.value}` : `T:${ref.sourceText}`;
768
732
  }
@@ -786,54 +750,42 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
786
750
  function resolveImport(fromFile, spec, trackedFiles) {
787
751
  return resolveRelativeImport(fromFile, spec, trackedFiles).path;
788
752
  }
789
- /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
790
- * or null — the shared "module file vs. package __init__" candidate check used by
791
- * both resolvePythonImport branches below. */
792
- function firstExistingPyModule(modulePath, fileSymbols) {
793
- const candidates = [`${modulePath}.py`, `${modulePath}/__init__.py`];
794
- for (const c of candidates)
795
- if (fileSymbols.has(c))
796
- return c;
753
+ /** `<modulePath>.py`, else `<modulePath>/__init__.py`, whichever is tracked. */
754
+ function pythonModuleFile(modulePath, fileSymbols) {
755
+ for (const candidate of [`${modulePath}.py`, `${modulePath}/__init__.py`]) {
756
+ if (fileSymbols.has(candidate))
757
+ return candidate;
758
+ }
797
759
  return null;
798
760
  }
799
- /** Resolve a Python import specifier (relative or absolute) to a concrete tracked
800
- * file path. Sibling to resolveImport() — Python's leading dot means "N levels up
801
- * from the importing module's own directory," not "a relative file-path fragment"
802
- * the way JS/TS's `./`/`../` does. Absolute imports are resolved best-effort
803
- * against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
804
- * no sys.path/PYTHONPATH emulation. A module's own package directory is always
805
- * its containing directory, so relative resolution needs no repo-wide
806
- * package-root search — only dot-counting from `fromFile`'s own location. */
761
+ /** Resolve a Python import spec to a tracked file. An absolute spec is tried under
762
+ * each of `pyRoots` in order; a relative one starts at the importing file's
763
+ * directory (one dot) and climbs one directory per extra dot, failing if that
764
+ * passes above the repo root. No sys.path emulation. */
807
765
  function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
808
766
  if (!spec.startsWith(".")) {
809
- const specPath = spec.split(".").join("/");
767
+ const path = spec.split(".").join("/");
810
768
  for (const root of pyRoots) {
811
- const modulePath = root ? `${root}/${specPath}` : specPath;
812
- const found = firstExistingPyModule(modulePath, fileSymbols);
769
+ const found = pythonModuleFile(root ? `${root}/${path}` : path, fileSymbols);
813
770
  if (found)
814
771
  return found;
815
772
  }
816
773
  return null;
817
774
  }
818
- const level = spec.length - spec.replace(/^\.+/, "").length;
819
- const tail = spec.slice(level);
820
- const dir = toPosix(dirname(fromFile));
821
- const segments = dir === "." ? [] : dir.split("/");
822
- const pop = level - 1;
823
- if (pop > segments.length)
824
- return null; // import points above the repo root — don't guess
825
- const baseSegments = pop > 0 ? segments.slice(0, segments.length - pop) : segments;
826
- const baseDir = baseSegments.join("/");
775
+ const tail = spec.replace(/^\.+/, "");
776
+ const level = spec.length - tail.length;
777
+ let base = toPosix(dirname(fromFile)); // "." is the repo root
778
+ for (let i = 1; i < level; i++) {
779
+ if (base === ".")
780
+ return null;
781
+ base = posix.dirname(base);
782
+ }
827
783
  if (!tail) {
828
- // bare `.`/`..`/etc — `from . import x` only ever resolves to the package's
829
- // own __init__.py (we track the module path, never the imported name itself,
830
- // matching resolveImport()'s granularity for JS/TS named imports).
831
- const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
832
- return fileSymbols.has(initPy) ? initPy : null;
784
+ const init = base === "." ? "__init__.py" : `${base}/__init__.py`;
785
+ return fileSymbols.has(init) ? init : null;
833
786
  }
834
- const tailPath = tail.split(".").join("/");
835
- const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
836
- return firstExistingPyModule(modulePath, fileSymbols);
787
+ const path = tail.split(".").join("/");
788
+ return pythonModuleFile(base === "." ? path : `${base}/${path}`, fileSymbols);
837
789
  }
838
790
  /** The `module` path declared in the repo's go.mod, or null. A resolution HINT
839
791
  * only (it widens depends_on edge coverage); reading it best-effort from the
@@ -889,16 +841,12 @@ function deriveComponents(symbols) {
889
841
  const out = [];
890
842
  for (const [dir, fileSet] of groups) {
891
843
  const name = dir.split("/").filter(Boolean).pop() ?? dir;
892
- // root-level files (dir === ".") have no directory to glob under — list them
893
- // exactly rather than emitting "./**", which normalizes to a match-everything
894
- // glob (issue #34). A single "*" glob was rejected too: it matches root files
895
- // correctly under pathMatchesGlob, but globPrefix("*") is "" and owns() skips
896
- // empty prefixes, so the wiki would again own nothing for this component —
897
- // the exact failure mode this fix closes. Don't "simplify" this back to a
898
- // glob without re-checking both matchers agree. As a consequence, this
899
- // component only covers root files present (and symbol-bearing) at the last
900
- // index — unlike directory components, a new root file isn't owned until reindex.
901
- const paths = dir === "." ? [...fileSet].sort() : [dir.endsWith("/") ? dir + "**" : dir + "/**"];
844
+ // The root group lists its files exactly: a `./**` glob would match everything,
845
+ // and a lone `*` breaks prefix-based ownership (empty prefix) -- issue #34.
846
+ // Consequence: a NEW root file is not owned until the next reindex.
847
+ const paths = dir === "."
848
+ ? [...fileSet].sort(compareCodeUnits)
849
+ : [`${dir.replace(/\/+$/, "")}/**`];
902
850
  out.push({
903
851
  id: componentId(dir),
904
852
  kind: "module",
@@ -1,25 +1,3 @@
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
1
  export type ManifestNameRef = {
24
2
  form: "literal";
25
3
  value: string;
@@ -31,72 +9,37 @@ export type ManifestNameRef = {
31
9
  atChar: number;
32
10
  endChar: number;
33
11
  };
34
- interface K8sReferenceCandidate {
35
- /** Fixed literal for most kinds ("ConfigMap", "Secret", "PersistentVolumeClaim",
36
- * "Service"); for an ownerReferences entry, the reference's OWN `kind` field
37
- * value (data-dependent, not statically known). */
38
- refKind: string;
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;
12
+ export interface K8sManifestDocument {
13
+ resource: {
14
+ kind: string;
15
+ name: ManifestNameRef;
16
+ namespace: string | null;
17
+ startChar: number;
18
+ endChar: number;
19
+ } | null;
20
+ references: Array<{
21
+ refKind: string;
22
+ name: ManifestNameRef;
23
+ namespace: string | null;
24
+ }>;
25
+ selector: Record<string, string> | null;
26
+ labels: Record<string, string> | null;
46
27
  }
47
- type ManifestLabelMap = Record<string, string>;
48
- interface K8sResourceDoc {
49
- kind: string;
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. */
28
+ export interface KustomizationInfo {
54
29
  namespace: string | null;
55
- startChar: number;
56
- endChar: number;
57
- }
58
- export interface K8sManifestDocument {
59
- resource: K8sResourceDoc | null;
60
- references: K8sReferenceCandidate[];
61
- selector: ManifestLabelMap | null;
62
- labels: ManifestLabelMap | null;
30
+ resources: string[];
63
31
  }
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;
67
- /** Where a kind's pod spec lives -- factored once so container/volume field
68
- * paths below aren't hand-duplicated per kind. */
69
32
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
70
33
  export declare const LABELS_PATH_BY_KIND: Record<string, string>;
71
- /** `namespace` is a Kustomize transformer namespace for this file (from
72
- * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
73
- * kustomizations render this file into different namespaces). */
74
34
  export declare function extractK8sManifest(source: string, opts?: {
75
35
  namespace?: string | null;
76
36
  }): K8sManifestDocument[];
77
- export interface KustomizationInfo {
78
- /** Top-level `namespace:` when it is a valid literal namespace, else null. */
79
- namespace: string | null;
80
- /** `resources:` entries exactly as written (paths relative to the kustomization's directory). */
81
- resources: string[];
82
- }
37
+ export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
83
38
  export declare function isKustomizationFile(path: string): boolean;
84
- /** Reads only the two top-level fields the namespace transformer needs:
85
- * `namespace:` and the `resources:` list (block `- item` lines or a one-line
86
- * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
87
39
  export declare function extractKustomization(source: string): KustomizationInfo;
88
- /** The Kustomize namespace each manifest file is rendered into, keyed by repo
89
- * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
90
- * the repo root); `files` is the indexed file set.
91
- *
92
- * A `resources:` entry naming a file applies the namespace to that file; an
93
- * entry naming a directory that holds its own kustomization is a base, and
94
- * the referring (outer) kustomization's namespace wins over the base's, as
95
- * Kustomize applies the overlay transformer to the base's output. Only
96
- * kustomizations no other kustomization references are rendered as roots.
97
- * A file rendered into two different namespaces (one base shared by dev and
98
- * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
99
- * only without any transformer is absent. Not modelled: remote resources,
100
- * `components:`, the deprecated `bases:` field, and globs. */
40
+ /**
41
+ * Effective namespace per manifest file after Kustomize's namespace transformer.
42
+ * A file reached with conflicting namespaces maps to null; a file that is only
43
+ * ever kept as written (or never rendered) is absent.
44
+ */
101
45
  export declare function kustomizeNamespaces(kustomizations: ReadonlyMap<string, KustomizationInfo>, files: ReadonlySet<string>): Map<string, string | null>;
102
- export {};