@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.
- package/README.md +19 -13
- package/dist/cli/index.js +5 -2
- package/dist/constitution/delta.js +3 -3
- package/dist/constitution/evaluator.js +3 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/core/ids.js +16 -0
- package/dist/core/migrate.js +20 -1
- package/dist/core/types.js +207 -1
- package/dist/extractors/git.js +7 -0
- package/dist/extractors/helm.js +77 -0
- package/dist/extractors/indexer.js +100 -24
- package/dist/extractors/landscapeDiscovery.js +492 -0
- package/dist/extractors/languages.js +36 -1
- package/dist/extractors/nativeTreeSitter.js +17 -8
- package/dist/extractors/parse.js +58 -14
- package/dist/mcp/server.js +18 -4
- package/dist/store/hunchStore.js +79 -1
- package/dist/store/jsonStore.js +8 -1
- package/dist/store/schema.js +25 -2
- package/package.json +4 -3
- package/server.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// relative/absolute Python rules as everything
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
498
|
+
paths,
|
|
423
499
|
status: "active",
|
|
424
500
|
owners: [],
|
|
425
501
|
fragility: 0,
|