@davesheffer/hunch 1.45.2 → 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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
- import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
13
+ import { parseSource, attributeCalls, attributeQualifiedCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
14
  import { isParserLoadError } from "./nativeTreeSitter.js";
15
15
  import { extractHelmDirectives } from "./helm.js";
16
16
  import { extractK8sManifest, extractKustomization, isKustomizationFile, kustomizeNamespaces, namespacesCompatible } from "./k8sManifest.js";
@@ -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,8 +321,8 @@ 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);
335
- perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
324
+ fileSymbolIndexId.set(rel, indexToId);
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) });
338
328
  if (languageFor(rel)?.id === "php") {
@@ -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) => {
@@ -359,17 +345,17 @@ export function scanRepo(store, root, opts = {}) {
359
345
  if (langId === "php") {
360
346
  return resolvePhpImportTargets(file, spec, phpNamespaces.get(file) ?? null, phpUseDeclarations.get(file) ?? [], phpPsr4, trackedFiles);
361
347
  }
362
- return [resolveImport(file, spec, fileSymbols)].filter((target) => !!target);
348
+ return [resolveImport(file, spec, trackedFiles)].filter((target) => !!target);
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 -------------------------
@@ -381,14 +367,10 @@ export function scanRepo(store, root, opts = {}) {
381
367
  edgeSeen.add(e.id);
382
368
  edges.push(e);
383
369
  };
384
- for (const { file, bySym } 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".
370
+ for (const { file, bySym, qualified } of perFileCalls) {
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;
@@ -423,12 +405,47 @@ export function scanRepo(store, root, opts = {}) {
423
405
  });
424
406
  }
425
407
  }
408
+ // Calls pinned to one imported module (`ns.fn()`, aliased `g()`): the member
409
+ // gate above drops them, so resolve the name ONLY inside that module's files —
410
+ // a unique match there is the binding; zero or several -> no edge.
411
+ // A module binding exposes top-level exports, so `U.helper()` prefers a function
412
+ // `helper` over a same-named class method `Foo.helper`; a sole method match still
413
+ // binds, since `module.exports = { helper() {} }` parses as a method too.
414
+ const moduleFiles = new Map(); // resolve each module once per file
415
+ for (const [callerIndex, targets] of qualified) {
416
+ const callerId = indexToId.get(callerIndex);
417
+ if (!callerId)
418
+ continue;
419
+ const callerName = byId.get(callerId)?.name ?? "?";
420
+ for (const { callee, module } of targets) {
421
+ let files = moduleFiles.get(module);
422
+ if (!files)
423
+ moduleFiles.set(module, files = new Set(resolveImportTargets(file, module)));
424
+ const inModule = (nameIndex.get(callee) ?? []).filter((id) => {
425
+ const sym = byId.get(id);
426
+ return !!sym && files.has(sym.file);
427
+ });
428
+ const nonMethods = inModule.filter((id) => byId.get(id).kind !== "method");
429
+ const matches = nonMethods.length > 0 ? nonMethods : inModule;
430
+ if (matches.length !== 1 || matches[0] === callerId)
431
+ continue;
432
+ const calleeId = matches[0];
433
+ addEdge({
434
+ schema: "hunch.edge/1",
435
+ id: edgeId(callerId, calleeId, edgeType),
436
+ from: callerId, to: calleeId, type: edgeType,
437
+ reason: `${callerName} ${edgeType} ${callee}`, strength: 0.8,
438
+ provenance: extracted(0.8, [file]),
439
+ environment: null,
440
+ metadata: {},
441
+ });
442
+ }
443
+ }
426
444
  }
427
- // ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
428
- // Own resolver, not resolveName(): resolveName() indexes by bare symbol name
429
- // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
430
- // happen to share a name would incorrectly conflate. Same ambiguity contract
431
- // 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.
432
449
  //
433
450
  // Namespace is a FILTER applied to the candidate list, not part of the key
434
451
  // (issue #297): an unknown namespace on either side must still match, which
@@ -445,7 +462,7 @@ export function scanRepo(store, root, opts = {}) {
445
462
  const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
446
463
  const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
447
464
  if (candidates.length !== 1)
448
- continue; // 0 or 2+ -> ambiguous or absent, don't guess
465
+ continue; // zero = absent, two-plus = ambiguous; never guess
449
466
  const toId = candidates[0].symbolId;
450
467
  if (toId === ref.fromSymbolId)
451
468
  continue;
@@ -459,33 +476,20 @@ export function scanRepo(store, root, opts = {}) {
459
476
  metadata: {},
460
477
  });
461
478
  }
462
- // ---- K8s manifest cross-resource references (Phase 2: label-selector) ----
463
- // Structurally different from Phase 1: no name to look up, a SUBSET match
464
- // between a Service's selector and a workload's pod-template labels, within
465
- // the same scope. Only ever fires on LITERAL selector/labels (k8sSelectors/
466
- // k8sWorkloadLabels are already filtered to literal-only by k8sManifest.ts --
467
- // a block-form templated value is never guessed at).
468
- //
469
- // Deliberately NO ambiguity guard here, unlike Phase 1's "0 or 2+ candidates
470
- // -> no edge": a Service legitimately fronting multiple workloads (blue/green,
471
- // canary, a shared-label pair of Deployments) is normal, intentional
472
- // Kubernetes usage, not an ambiguous match to decline -- Phase 1's guard
473
- // exists because a ConfigMap named X is exactly one resource by definition,
474
- // 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.
475
483
  const selectorsByScope = new Map();
476
- for (const s of k8sSelectors)
477
- pushInto(selectorsByScope, s.scope, s);
478
- const labelsByScope = new Map();
479
- for (const l of k8sWorkloadLabels)
480
- 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);
481
489
  for (const [scope, selectors] of selectorsByScope) {
482
- const workloads = labelsByScope.get(scope) ?? [];
490
+ const workloads = workloadsByScope.get(scope) ?? [];
483
491
  for (const svc of selectors) {
484
492
  for (const wl of workloads) {
485
- // Defensive, not currently reachable: k8sSelectors only ever holds
486
- // Service symbols and k8sWorkloadLabels only ever holds symbols for
487
- // kinds in LABELS_PATH_BY_KIND, which excludes Service -- so the two
488
- // ids can never collide today.
489
493
  if (svc.symbolId === wl.symbolId)
490
494
  continue;
491
495
  // A Service only ever selects pods in its OWN namespace -- a
@@ -493,8 +497,7 @@ export function scanRepo(store, root, opts = {}) {
493
497
  // #297). Unknown on either side still matches, same rule as Phase 1.
494
498
  if (!namespacesCompatible(svc.namespace, wl.namespace))
495
499
  continue;
496
- const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
497
- if (!isSubset)
500
+ if (!Object.entries(svc.selector).every(([key, value]) => Object.hasOwn(wl.labels, key) && wl.labels[key] === value))
498
501
  continue;
499
502
  addEdge({
500
503
  schema: "hunch.edge/1",
@@ -540,10 +543,10 @@ export function scanRepo(store, root, opts = {}) {
540
543
  }
541
544
  }
542
545
  }
543
- // fan-in / fan-out from resolved call/reference edges
544
- 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"]);
545
548
  for (const e of edges) {
546
- if (!CALL_LIKE_EDGE_TYPES.has(e.type))
549
+ if (!CALL_LIKE_EDGES.has(e.type))
547
550
  continue;
548
551
  const from = byId.get(e.from);
549
552
  const to = byId.get(e.to);
@@ -681,52 +684,49 @@ export function indexRepo(store, root, opts = {}) {
681
684
  return scan.result;
682
685
  }
683
686
  // ---- helpers --------------------------------------------------------------
684
- /** Append `value` to the array at `key`, creating the array on first use.
685
- * Function declaration (not `const`) so it's usable from pass-1 code above
686
- * this section via hoisting, without reordering. */
687
687
  function pushInto(map, key, value) {
688
- const bucket = map.get(key);
689
- if (bucket)
690
- bucket.push(value);
688
+ const list = map.get(key);
689
+ if (list)
690
+ list.push(value);
691
691
  else
692
692
  map.set(key, [value]);
693
693
  }
694
- /** Nearest-ancestor Chart.yaml lookup, memoized per directory: walks a file's
695
- * own directory upward through the tracked-file set until it finds
696
- * `<dir>/Chart.yaml`, or returns null if the file isn't under any chart.
697
- * Nested subcharts (their own Chart.yaml under charts/<name>/) resolve to
698
- * their OWN chart root, not the parent's. This is a conservative
699
- * approximation, not full Helm semantics: Helm's template namespace is
700
- * actually release-global, so a parent chart can legitimately include a
701
- * subchart's define — nearest-ancestor scoping will miss that edge rather
702
- * than fabricate a wrong one (test coverage: indexer.test.ts's nested-subchart
703
- * and subchart-miss cases). Modeling the release-global namespace itself
704
- * remains open — issue #42. */
705
- function nearestChartRoot(rels) {
706
- const tracked = new Set(rels);
707
- const cache = new Map();
708
- const resolveDir = (dir) => {
709
- if (cache.has(dir))
710
- return cache.get(dir);
711
- const chartYaml = dir ? `${dir}/Chart.yaml` : "Chart.yaml";
712
- const result = tracked.has(chartYaml)
713
- ? dir
714
- : dir === "" ? null : resolveDir(dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "");
715
- 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);
716
716
  return result;
717
717
  };
718
- 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
+ };
719
722
  }
720
- /** Human-readable text for a name/kind field -- a literal value as-is, or a
721
- * template's exact raw `{{ }}` source text (never evaluated). Used for
722
- * display (symbol names, edge reasons); NOT for resolution-key equality --
723
- * 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). */
724
725
  function displayNameText(ref) {
725
726
  return ref.form === "literal" ? ref.value : ref.sourceText;
726
727
  }
727
- /** Normalized resolution-key text for a name/kind field: a literal value or a
728
- * template's exact raw source text, EACH PREFIXED so a literal "foo" can
729
- * 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. */
730
730
  function nameKeyText(ref) {
731
731
  return ref.form === "literal" ? `L:${ref.value}` : `T:${ref.sourceText}`;
732
732
  }
@@ -747,57 +747,45 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
747
747
  return imported.length === 1 ? imported[0] : null;
748
748
  }
749
749
  /** Resolve a relative import specifier to a concrete tracked file path. */
750
- function resolveImport(fromFile, spec, fileSymbols) {
751
- return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
750
+ function resolveImport(fromFile, spec, trackedFiles) {
751
+ return resolveRelativeImport(fromFile, spec, trackedFiles).path;
752
752
  }
753
- /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
754
- * or null — the shared "module file vs. package __init__" candidate check used by
755
- * both resolvePythonImport branches below. */
756
- function firstExistingPyModule(modulePath, fileSymbols) {
757
- const candidates = [`${modulePath}.py`, `${modulePath}/__init__.py`];
758
- for (const c of candidates)
759
- if (fileSymbols.has(c))
760
- 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
+ }
761
759
  return null;
762
760
  }
763
- /** Resolve a Python import specifier (relative or absolute) to a concrete tracked
764
- * file path. Sibling to resolveImport() — Python's leading dot means "N levels up
765
- * from the importing module's own directory," not "a relative file-path fragment"
766
- * the way JS/TS's `./`/`../` does. Absolute imports are resolved best-effort
767
- * against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
768
- * no sys.path/PYTHONPATH emulation. A module's own package directory is always
769
- * its containing directory, so relative resolution needs no repo-wide
770
- * 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. */
771
765
  function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
772
766
  if (!spec.startsWith(".")) {
773
- const specPath = spec.split(".").join("/");
767
+ const path = spec.split(".").join("/");
774
768
  for (const root of pyRoots) {
775
- const modulePath = root ? `${root}/${specPath}` : specPath;
776
- const found = firstExistingPyModule(modulePath, fileSymbols);
769
+ const found = pythonModuleFile(root ? `${root}/${path}` : path, fileSymbols);
777
770
  if (found)
778
771
  return found;
779
772
  }
780
773
  return null;
781
774
  }
782
- const level = spec.length - spec.replace(/^\.+/, "").length;
783
- const tail = spec.slice(level);
784
- const dir = toPosix(dirname(fromFile));
785
- const segments = dir === "." ? [] : dir.split("/");
786
- const pop = level - 1;
787
- if (pop > segments.length)
788
- return null; // import points above the repo root — don't guess
789
- const baseSegments = pop > 0 ? segments.slice(0, segments.length - pop) : segments;
790
- 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
+ }
791
783
  if (!tail) {
792
- // bare `.`/`..`/etc — `from . import x` only ever resolves to the package's
793
- // own __init__.py (we track the module path, never the imported name itself,
794
- // matching resolveImport()'s granularity for JS/TS named imports).
795
- const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
796
- return fileSymbols.has(initPy) ? initPy : null;
784
+ const init = base === "." ? "__init__.py" : `${base}/__init__.py`;
785
+ return fileSymbols.has(init) ? init : null;
797
786
  }
798
- const tailPath = tail.split(".").join("/");
799
- const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
800
- return firstExistingPyModule(modulePath, fileSymbols);
787
+ const path = tail.split(".").join("/");
788
+ return pythonModuleFile(base === "." ? path : `${base}/${path}`, fileSymbols);
801
789
  }
802
790
  /** The `module` path declared in the repo's go.mod, or null. A resolution HINT
803
791
  * only (it widens depends_on edge coverage); reading it best-effort from the
@@ -853,16 +841,12 @@ function deriveComponents(symbols) {
853
841
  const out = [];
854
842
  for (const [dir, fileSet] of groups) {
855
843
  const name = dir.split("/").filter(Boolean).pop() ?? dir;
856
- // root-level files (dir === ".") have no directory to glob under — list them
857
- // exactly rather than emitting "./**", which normalizes to a match-everything
858
- // glob (issue #34). A single "*" glob was rejected too: it matches root files
859
- // correctly under pathMatchesGlob, but globPrefix("*") is "" and owns() skips
860
- // empty prefixes, so the wiki would again own nothing for this component —
861
- // the exact failure mode this fix closes. Don't "simplify" this back to a
862
- // glob without re-checking both matchers agree. As a consequence, this
863
- // component only covers root files present (and symbol-bearing) at the last
864
- // index — unlike directory components, a new root file isn't owned until reindex.
865
- 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(/\/+$/, "")}/**`];
866
850
  out.push({
867
851
  id: componentId(dir),
868
852
  kind: "module",