@davesheffer/hunch 1.17.0 → 1.18.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.
@@ -11,6 +11,7 @@
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
13
  import { parseSource, attributeCalls } from "./parse.js";
14
+ import { extractHelmDirectives } from "./helm.js";
14
15
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
15
16
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
16
17
  import { resolveRelativeImport } from "../core/relativeImports.js";
@@ -54,12 +55,22 @@ export function scanRepo(store, root, opts = {}) {
54
55
  const symbols = [];
55
56
  const nameIndex = new Map(); // symbol name -> [symbol ids]
56
57
  const fileSymbols = new Map(); // file -> symbol ids (in-file resolution)
57
- const fileStartByteId = new Map(); // file -> (symbol startByte -> id)
58
+ const fileSymbolIndexId = new Map(); // file -> (symbol index in parsed.symbols -> id)
58
59
  const perFileCalls = [];
59
60
  const perFileImports = [];
60
61
  // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
61
62
  // total, instead of two per file — the dominant cost of indexing a large repo.
62
63
  const rels = files.map((file) => file.path);
64
+ const chartRootFor = nearestChartRoot(rels);
65
+ const chartFiles = new Map();
66
+ for (const path of rels) {
67
+ if (languageFor(path)?.id !== "yaml")
68
+ continue;
69
+ const chartRoot = chartRootFor(path);
70
+ if (chartRoot === null)
71
+ continue;
72
+ (chartFiles.get(chartRoot) ?? chartFiles.set(chartRoot, []).get(chartRoot)).push(path);
73
+ }
63
74
  const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
64
75
  let skipped = 0;
65
76
  const issues = [];
@@ -100,20 +111,29 @@ export function scanRepo(store, root, opts = {}) {
100
111
  issues.push({ path: rel, code: "parse_failed", detail: `${rel} contains syntax errors and cannot prove a complete semantic graph` });
101
112
  continue;
102
113
  }
114
+ const chartRoot = languageFor(rel)?.id === "yaml" ? chartRootFor(rel) : null;
115
+ // Explicit null check, not truthiness: a chart rooted at the repo root
116
+ // itself resolves to "" (empty string), which is a valid chart scope but
117
+ // JS-falsy — `if (chartRoot)` would silently skip every repo-root chart.
118
+ if (chartRoot !== null) {
119
+ const helm = extractHelmDirectives(src);
120
+ parsed.symbols = [...parsed.symbols, ...helm.symbols].sort((a, b) => a.startByte - b.startByte);
121
+ parsed.calls = [...parsed.calls, ...helm.calls];
122
+ }
103
123
  const m = gitMeta?.get(rel);
104
124
  const churn = opts.churn === false ? (preservedChurn.get(rel) ?? 0) : (m?.churn ?? 0);
105
125
  const last = m?.lastCommit ?? "";
106
126
  const idsInFile = [];
107
- const startByteId = new Map();
127
+ const symbolIndexId = new Map();
108
128
  const idCounts = new Map(); // disambiguate same (file,name,kind)
109
- for (const ps of parsed.symbols) {
129
+ for (const [index, ps] of parsed.symbols.entries()) {
110
130
  const base = symbolId(rel, ps.name, ps.kind);
111
131
  const n = idCounts.get(base) ?? 0;
112
132
  idCounts.set(base, n + 1);
113
133
  // parse() returns symbols sorted by start byte, so the ordinal is stable
114
134
  const id = n === 0 ? base : `${base}_${n}`;
115
135
  idsInFile.push(id);
116
- startByteId.set(ps.startByte, id);
136
+ symbolIndexId.set(index, id);
117
137
  (nameIndex.get(ps.name) ?? nameIndex.set(ps.name, []).get(ps.name)).push(id);
118
138
  symbols.push({
119
139
  id, file: rel, name: ps.name, kind: ps.kind,
@@ -124,16 +144,16 @@ export function scanRepo(store, root, opts = {}) {
124
144
  });
125
145
  }
126
146
  fileSymbols.set(rel, idsInFile);
127
- fileStartByteId.set(rel, startByteId);
147
+ fileSymbolIndexId.set(rel, symbolIndexId);
128
148
  perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
129
149
  perFileImports.push({ file: rel, imports: parsed.imports });
130
150
  }
131
151
  const byId = new Map(symbols.map((s) => [s.id, s]));
132
- // Language-aware import resolution, shared by the call-resolution "was this
133
- // name actually imported?" gate (below) and the depends_on edge derivation
134
- // (pass 3): a Python cross-file call/import must resolve through the same
135
- // relative/absolute Python rules as everything else, not silently fail the
136
- // JS/TS resolver and look unimported.
152
+ // Language-aware import resolution (resolveImportTarget), used by both the
153
+ // call-resolution gate below (via importedFiles) and the depends_on edge
154
+ // derivation in pass 3 directly: a Python cross-file call/import must
155
+ // resolve through the same relative/absolute Python rules as everything
156
+ // else, not silently fail the JS/TS resolver and look unimported.
137
157
  const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
138
158
  const pyRoots = hasSrcLayout ? ["", "src"] : [""];
139
159
  const goModule = readGoModulePath(root);
@@ -145,10 +165,17 @@ export function scanRepo(store, root, opts = {}) {
145
165
  return resolveGoImport(spec, fileSymbols, goModule);
146
166
  return resolveImport(file, spec, fileSymbols);
147
167
  };
148
- const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
149
- file,
150
- new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
151
- ]));
168
+ const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
169
+ const targets = new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target));
170
+ const chartRoot = languageFor(file)?.id === "yaml" ? chartRootFor(file) : null;
171
+ // Explicit null check, not truthiness — see the matching comment above on
172
+ // the per-file Helm merge step: a repo-root chart's scope is "", not null.
173
+ if (chartRoot !== null)
174
+ for (const sibling of chartFiles.get(chartRoot) ?? [])
175
+ if (sibling !== file)
176
+ targets.add(sibling);
177
+ return [file, targets];
178
+ }));
152
179
  // ---- pass 2: resolve calls -> symbol-level edges -------------------------
153
180
  const edges = [];
154
181
  const edgeSeen = new Set();
@@ -159,10 +186,14 @@ export function scanRepo(store, root, opts = {}) {
159
186
  edges.push(e);
160
187
  };
161
188
  for (const { file, bySym } of perFileCalls) {
162
- const sbToId = fileStartByteId.get(file) ?? new Map();
163
- for (const [callerStartByte, callees] of bySym) {
164
- // resolve caller by its stable byte-offset identity (not name)
165
- const callerId = sbToId.get(callerStartByte);
189
+ // Most languages leave this unset and get "calls" YAML's alias->anchor
190
+ // references aren't function calls, so its LanguageSpec declares "references".
191
+ const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
192
+ const indexToId = fileSymbolIndexId.get(file) ?? new Map();
193
+ for (const [callerIndex, callees] of bySym) {
194
+ // resolve caller by its stable position in parsed.symbols (not startByte —
195
+ // startByte is not unique across symbols; see attributeCalls's doc comment)
196
+ const callerId = indexToId.get(callerIndex);
166
197
  if (!callerId)
167
198
  continue;
168
199
  const callerName = byId.get(callerId)?.name ?? "?";
@@ -178,17 +209,21 @@ export function scanRepo(store, root, opts = {}) {
178
209
  continue;
179
210
  }
180
211
  addEdge({
181
- id: edgeId(callerId, calleeId, "calls"),
182
- from: callerId, to: calleeId, type: "calls",
183
- reason: `${callerName} calls ${calleeName}`, strength: 0.8,
212
+ schema: "hunch.edge/1",
213
+ id: edgeId(callerId, calleeId, edgeType),
214
+ from: callerId, to: calleeId, type: edgeType,
215
+ reason: `${callerName} ${edgeType} ${calleeName}`, strength: 0.8,
184
216
  provenance: extracted(0.8, [file]),
217
+ environment: null,
218
+ metadata: {},
185
219
  });
186
220
  }
187
221
  }
188
222
  }
189
- // fan-in / fan-out from resolved call edges
223
+ // fan-in / fan-out from resolved call/reference edges
224
+ const CALL_LIKE_EDGE_TYPES = new Set(["calls", "references"]);
190
225
  for (const e of edges) {
191
- if (e.type !== "calls")
226
+ if (!CALL_LIKE_EDGE_TYPES.has(e.type))
192
227
  continue;
193
228
  const from = byId.get(e.from);
194
229
  const to = byId.get(e.to);
@@ -218,10 +253,13 @@ export function scanRepo(store, root, opts = {}) {
218
253
  if (!toCmp || toCmp === fromCmp)
219
254
  continue;
220
255
  addEdge({
256
+ schema: "hunch.edge/1",
221
257
  id: edgeId(fromCmp, toCmp, "depends_on"),
222
258
  from: fromCmp, to: toCmp, type: "depends_on",
223
259
  reason: `${file} imports ${target}`, strength: 0.6,
224
260
  provenance: extracted(0.9, [`${file}:imports:${spec}`]),
261
+ environment: null,
262
+ metadata: {},
225
263
  });
226
264
  continue;
227
265
  }
@@ -232,10 +270,13 @@ export function scanRepo(store, root, opts = {}) {
232
270
  continue;
233
271
  for (const anchor of anchors) {
234
272
  addEdge({
273
+ schema: "hunch.edge/1",
235
274
  id: edgeId(anchor, external, "imports"),
236
275
  from: anchor, to: external, type: "imports",
237
276
  reason: `${file} imports external package ${dependency}`, strength: 1,
238
277
  provenance: extracted(1, [`${file}:imports:${spec}`]),
278
+ environment: null,
279
+ metadata: {},
239
280
  });
240
281
  }
241
282
  }
@@ -291,6 +332,31 @@ export function indexRepo(store, root, opts = {}) {
291
332
  return scan.result;
292
333
  }
293
334
  // ---- helpers --------------------------------------------------------------
335
+ /** Nearest-ancestor Chart.yaml lookup, memoized per directory: walks a file's
336
+ * own directory upward through the tracked-file set until it finds
337
+ * `<dir>/Chart.yaml`, or returns null if the file isn't under any chart.
338
+ * Nested subcharts (their own Chart.yaml under charts/<name>/) resolve to
339
+ * their OWN chart root, not the parent's. This is a conservative
340
+ * approximation, not full Helm semantics: Helm's template namespace is
341
+ * actually release-global, so a parent chart can legitimately include a
342
+ * subchart's define — nearest-ancestor scoping will miss that edge rather
343
+ * than fabricate a wrong one. No test currently covers the nested
344
+ * charts/<sub>/Chart.yaml case — tracked as issue #42. */
345
+ function nearestChartRoot(rels) {
346
+ const tracked = new Set(rels);
347
+ const cache = new Map();
348
+ const resolveDir = (dir) => {
349
+ if (cache.has(dir))
350
+ return cache.get(dir);
351
+ const chartYaml = dir ? `${dir}/Chart.yaml` : "Chart.yaml";
352
+ const result = tracked.has(chartYaml)
353
+ ? dir
354
+ : dir === "" ? null : resolveDir(dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "");
355
+ cache.set(dir, result);
356
+ return result;
357
+ };
358
+ return (file) => resolveDir(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
359
+ }
294
360
  /** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
295
361
  * unique symbol in a statically imported local file. A unique repository-wide
296
362
  * name is not evidence of a binding: callback parameters and built-ins often
@@ -414,12 +480,22 @@ function deriveComponents(symbols) {
414
480
  const out = [];
415
481
  for (const [dir, fileSet] of groups) {
416
482
  const name = dir.split("/").filter(Boolean).pop() ?? dir;
483
+ // root-level files (dir === ".") have no directory to glob under — list them
484
+ // exactly rather than emitting "./**", which normalizes to a match-everything
485
+ // glob (issue #34). A single "*" glob was rejected too: it matches root files
486
+ // correctly under pathMatchesGlob, but globPrefix("*") is "" and owns() skips
487
+ // empty prefixes, so the wiki would again own nothing for this component —
488
+ // the exact failure mode this fix closes. Don't "simplify" this back to a
489
+ // glob without re-checking both matchers agree. As a consequence, this
490
+ // component only covers root files present (and symbol-bearing) at the last
491
+ // index — unlike directory components, a new root file isn't owned until reindex.
492
+ const paths = dir === "." ? [...fileSet].sort() : [dir.endsWith("/") ? dir + "**" : dir + "/**"];
417
493
  out.push({
418
494
  id: componentId(dir),
419
495
  kind: "module",
420
496
  name: capitalize(name),
421
497
  responsibility: "",
422
- paths: [dir.endsWith("/") ? dir + "**" : dir + "/**"],
498
+ paths,
423
499
  status: "active",
424
500
  owners: [],
425
501
  fragility: 0,