@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.
- package/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- 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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
140
|
-
if (
|
|
141
|
+
const chart = chartRootOf(path);
|
|
142
|
+
if (chart === null)
|
|
141
143
|
continue;
|
|
142
|
-
pushInto(
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
238
|
-
//
|
|
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
|
-
//
|
|
267
|
-
//
|
|
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
|
|
282
|
-
k8sSymbolObjects.add(
|
|
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
|
|
278
|
+
const indexToId = new Map(); // symbol index -> id
|
|
290
279
|
const idCounts = new Map(); // disambiguate same (file,name,kind)
|
|
291
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
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,
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
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; //
|
|
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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
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
|
|
477
|
-
pushInto(selectorsByScope,
|
|
478
|
-
const
|
|
479
|
-
for (const
|
|
480
|
-
pushInto(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
544
|
-
const
|
|
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 (!
|
|
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
|
|
689
|
-
if (
|
|
690
|
-
|
|
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
|
-
/**
|
|
695
|
-
*
|
|
696
|
-
* `<dir>/Chart.yaml
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
?
|
|
714
|
-
|
|
715
|
-
|
|
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) =>
|
|
718
|
+
return (file) => {
|
|
719
|
+
const dir = posix.dirname(toPosix(file));
|
|
720
|
+
return rootOf(dir === "." ? "" : dir);
|
|
721
|
+
};
|
|
719
722
|
}
|
|
720
|
-
/** Human-readable
|
|
721
|
-
*
|
|
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
|
-
/**
|
|
728
|
-
*
|
|
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,
|
|
751
|
-
return resolveRelativeImport(fromFile, spec,
|
|
750
|
+
function resolveImport(fromFile, spec, trackedFiles) {
|
|
751
|
+
return resolveRelativeImport(fromFile, spec, trackedFiles).path;
|
|
752
752
|
}
|
|
753
|
-
/**
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
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
|
|
767
|
+
const path = spec.split(".").join("/");
|
|
774
768
|
for (const root of pyRoots) {
|
|
775
|
-
const
|
|
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
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
|
799
|
-
|
|
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
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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",
|