@davesheffer/hunch 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +151 -321
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.js +9 -9
- 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/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- 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 +45 -64
- 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 +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +60 -73
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -112,7 +112,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
112
112
|
const symbols = [];
|
|
113
113
|
const nameIndex = new Map(); // symbol name -> [symbol ids]
|
|
114
114
|
const fileSymbols = new Map(); // file -> symbol ids (in-file resolution)
|
|
115
|
-
const fileSymbolIndexId = new Map(); // file -> (symbol index
|
|
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,7 +321,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
331
321
|
k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
|
|
332
322
|
}
|
|
333
323
|
fileSymbols.set(rel, idsInFile);
|
|
334
|
-
fileSymbolIndexId.set(rel,
|
|
324
|
+
fileSymbolIndexId.set(rel, indexToId);
|
|
335
325
|
perFileCalls.push({ file: rel, bySym: attributeCalls(parsed), qualified: attributeQualifiedCalls(parsed) });
|
|
336
326
|
perFileImports.push({ file: rel, imports: parsed.imports });
|
|
337
327
|
perFileRelations.push({ file: rel, bySym: attributeRelations(parsed) });
|
|
@@ -341,13 +331,9 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
341
331
|
}
|
|
342
332
|
}
|
|
343
333
|
const byId = new Map(symbols.map((s) => [s.id, s]));
|
|
344
|
-
//
|
|
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) => {
|
|
@@ -363,13 +349,13 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
363
349
|
};
|
|
364
350
|
const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
|
|
365
351
|
const targets = new Set(imports.flatMap((specifier) => resolveImportTargets(file, specifier)));
|
|
366
|
-
|
|
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 -------------------------
|
|
@@ -382,13 +368,9 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
382
368
|
edges.push(e);
|
|
383
369
|
};
|
|
384
370
|
for (const { file, bySym, qualified } of perFileCalls) {
|
|
385
|
-
// Most languages leave this unset and get "calls" — YAML's alias->anchor
|
|
386
|
-
// references aren't function calls, so its LanguageSpec declares "references".
|
|
387
371
|
const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
|
|
388
372
|
const indexToId = fileSymbolIndexId.get(file) ?? new Map();
|
|
389
373
|
for (const [callerIndex, callees] of bySym) {
|
|
390
|
-
// resolve caller by its stable position in parsed.symbols (not startByte —
|
|
391
|
-
// startByte is not unique across symbols; see attributeCalls's doc comment)
|
|
392
374
|
const callerId = indexToId.get(callerIndex);
|
|
393
375
|
if (!callerId)
|
|
394
376
|
continue;
|
|
@@ -460,11 +442,10 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
460
442
|
}
|
|
461
443
|
}
|
|
462
444
|
}
|
|
463
|
-
// ---- K8s
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
|
|
445
|
+
// ---- pass 2b: K8s cross-resource references, phase 1 (name-keyed) ----------
|
|
446
|
+
// A dedicated resolver instead of resolveName: names alone ignore the Kubernetes
|
|
447
|
+
// kind (a ConfigMap and a Secret sharing a name must not conflate), while the
|
|
448
|
+
// ambiguity contract stays the same -- zero or two-plus matches yield no edge.
|
|
468
449
|
//
|
|
469
450
|
// Namespace is a FILTER applied to the candidate list, not part of the key
|
|
470
451
|
// (issue #297): an unknown namespace on either side must still match, which
|
|
@@ -481,7 +462,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
481
462
|
const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
|
|
482
463
|
const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
|
|
483
464
|
if (candidates.length !== 1)
|
|
484
|
-
continue; //
|
|
465
|
+
continue; // zero = absent, two-plus = ambiguous; never guess
|
|
485
466
|
const toId = candidates[0].symbolId;
|
|
486
467
|
if (toId === ref.fromSymbolId)
|
|
487
468
|
continue;
|
|
@@ -495,33 +476,20 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
495
476
|
metadata: {},
|
|
496
477
|
});
|
|
497
478
|
}
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// k8sWorkloadLabels are already filtered to literal-only by k8sManifest.ts --
|
|
503
|
-
// a block-form templated value is never guessed at).
|
|
504
|
-
//
|
|
505
|
-
// Deliberately NO ambiguity guard here, unlike Phase 1's "0 or 2+ candidates
|
|
506
|
-
// -> no edge": a Service legitimately fronting multiple workloads (blue/green,
|
|
507
|
-
// canary, a shared-label pair of Deployments) is normal, intentional
|
|
508
|
-
// Kubernetes usage, not an ambiguous match to decline -- Phase 1's guard
|
|
509
|
-
// exists because a ConfigMap named X is exactly one resource by definition,
|
|
510
|
-
// which has no analogue here. Fan-out is the correct behavior, not a gap.
|
|
479
|
+
// Phase 2 (label selector): a Service's selector is a SUBSET match against
|
|
480
|
+
// workloads' pod-template labels within the same scope. Only literal values get
|
|
481
|
+
// here (filtered upstream). There is deliberately no ambiguity guard: a Service
|
|
482
|
+
// fronting several workloads is normal, so fan-out is correct.
|
|
511
483
|
const selectorsByScope = new Map();
|
|
512
|
-
for (const
|
|
513
|
-
pushInto(selectorsByScope,
|
|
514
|
-
const
|
|
515
|
-
for (const
|
|
516
|
-
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);
|
|
517
489
|
for (const [scope, selectors] of selectorsByScope) {
|
|
518
|
-
const workloads =
|
|
490
|
+
const workloads = workloadsByScope.get(scope) ?? [];
|
|
519
491
|
for (const svc of selectors) {
|
|
520
492
|
for (const wl of workloads) {
|
|
521
|
-
// Defensive, not currently reachable: k8sSelectors only ever holds
|
|
522
|
-
// Service symbols and k8sWorkloadLabels only ever holds symbols for
|
|
523
|
-
// kinds in LABELS_PATH_BY_KIND, which excludes Service -- so the two
|
|
524
|
-
// ids can never collide today.
|
|
525
493
|
if (svc.symbolId === wl.symbolId)
|
|
526
494
|
continue;
|
|
527
495
|
// A Service only ever selects pods in its OWN namespace -- a
|
|
@@ -529,8 +497,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
529
497
|
// #297). Unknown on either side still matches, same rule as Phase 1.
|
|
530
498
|
if (!namespacesCompatible(svc.namespace, wl.namespace))
|
|
531
499
|
continue;
|
|
532
|
-
|
|
533
|
-
if (!isSubset)
|
|
500
|
+
if (!Object.entries(svc.selector).every(([key, value]) => Object.hasOwn(wl.labels, key) && wl.labels[key] === value))
|
|
534
501
|
continue;
|
|
535
502
|
addEdge({
|
|
536
503
|
schema: "hunch.edge/1",
|
|
@@ -576,10 +543,10 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
576
543
|
}
|
|
577
544
|
}
|
|
578
545
|
}
|
|
579
|
-
//
|
|
580
|
-
const
|
|
546
|
+
// Fan-in/fan-out come from resolved call and reference edges only.
|
|
547
|
+
const CALL_LIKE_EDGES = new Set(["calls", "references"]);
|
|
581
548
|
for (const e of edges) {
|
|
582
|
-
if (!
|
|
549
|
+
if (!CALL_LIKE_EDGES.has(e.type))
|
|
583
550
|
continue;
|
|
584
551
|
const from = byId.get(e.from);
|
|
585
552
|
const to = byId.get(e.to);
|
|
@@ -717,52 +684,49 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
717
684
|
return scan.result;
|
|
718
685
|
}
|
|
719
686
|
// ---- helpers --------------------------------------------------------------
|
|
720
|
-
/** Append `value` to the array at `key`, creating the array on first use.
|
|
721
|
-
* Function declaration (not `const`) so it's usable from pass-1 code above
|
|
722
|
-
* this section via hoisting, without reordering. */
|
|
723
687
|
function pushInto(map, key, value) {
|
|
724
|
-
const
|
|
725
|
-
if (
|
|
726
|
-
|
|
688
|
+
const list = map.get(key);
|
|
689
|
+
if (list)
|
|
690
|
+
list.push(value);
|
|
727
691
|
else
|
|
728
692
|
map.set(key, [value]);
|
|
729
693
|
}
|
|
730
|
-
/**
|
|
731
|
-
*
|
|
732
|
-
* `<dir>/Chart.yaml
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
?
|
|
750
|
-
|
|
751
|
-
|
|
694
|
+
/** Build a lookup from a tracked file to the root directory of its nearest Helm
|
|
695
|
+
* chart ("" = repo root), or null. A directory is a chart root when
|
|
696
|
+
* `<dir>/Chart.yaml` is tracked; nested subcharts resolve to their OWN root. This
|
|
697
|
+
* is a conservative approximation of Helm's release-global template namespace
|
|
698
|
+
* (issue #42): a parent including a subchart define is missed, never wrongly linked. */
|
|
699
|
+
export function nearestChartRoot(tracked) {
|
|
700
|
+
const trackedSet = new Set(tracked);
|
|
701
|
+
const memo = new Map();
|
|
702
|
+
const rootOf = (dir) => {
|
|
703
|
+
if (memo.has(dir))
|
|
704
|
+
return memo.get(dir);
|
|
705
|
+
let result;
|
|
706
|
+
if (trackedSet.has(dir === "" ? "Chart.yaml" : `${dir}/Chart.yaml`))
|
|
707
|
+
result = dir;
|
|
708
|
+
else if (dir === "")
|
|
709
|
+
result = null;
|
|
710
|
+
else {
|
|
711
|
+
const parent = posix.dirname(dir);
|
|
712
|
+
// dirname is a fixed point at the filesystem root ("/"): stop, no chart found.
|
|
713
|
+
result = parent === dir ? null : rootOf(parent === "." ? "" : parent);
|
|
714
|
+
}
|
|
715
|
+
memo.set(dir, result);
|
|
752
716
|
return result;
|
|
753
717
|
};
|
|
754
|
-
return (file) =>
|
|
718
|
+
return (file) => {
|
|
719
|
+
const dir = posix.dirname(toPosix(file));
|
|
720
|
+
return rootOf(dir === "." ? "" : dir);
|
|
721
|
+
};
|
|
755
722
|
}
|
|
756
|
-
/** Human-readable
|
|
757
|
-
*
|
|
758
|
-
* display (symbol names, edge reasons); NOT for resolution-key equality --
|
|
759
|
-
* see nameKeyText below for that. */
|
|
723
|
+
/** Human-readable form of a manifest name: a literal's value, or a template's raw
|
|
724
|
+
* source text (never evaluated). */
|
|
760
725
|
function displayNameText(ref) {
|
|
761
726
|
return ref.form === "literal" ? ref.value : ref.sourceText;
|
|
762
727
|
}
|
|
763
|
-
/**
|
|
764
|
-
*
|
|
765
|
-
* never collide with a template whose source text happens to read "foo". */
|
|
728
|
+
/** Resolution key for a manifest name. The `L:`/`T:` prefix keeps a literal from
|
|
729
|
+
* ever colliding with a template whose source text reads the same. */
|
|
766
730
|
function nameKeyText(ref) {
|
|
767
731
|
return ref.form === "literal" ? `L:${ref.value}` : `T:${ref.sourceText}`;
|
|
768
732
|
}
|
|
@@ -786,54 +750,42 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
|
|
|
786
750
|
function resolveImport(fromFile, spec, trackedFiles) {
|
|
787
751
|
return resolveRelativeImport(fromFile, spec, trackedFiles).path;
|
|
788
752
|
}
|
|
789
|
-
/**
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if (fileSymbols.has(c))
|
|
796
|
-
return c;
|
|
753
|
+
/** `<modulePath>.py`, else `<modulePath>/__init__.py`, whichever is tracked. */
|
|
754
|
+
function pythonModuleFile(modulePath, fileSymbols) {
|
|
755
|
+
for (const candidate of [`${modulePath}.py`, `${modulePath}/__init__.py`]) {
|
|
756
|
+
if (fileSymbols.has(candidate))
|
|
757
|
+
return candidate;
|
|
758
|
+
}
|
|
797
759
|
return null;
|
|
798
760
|
}
|
|
799
|
-
/** Resolve a Python import
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
* against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
|
|
804
|
-
* no sys.path/PYTHONPATH emulation. A module's own package directory is always
|
|
805
|
-
* its containing directory, so relative resolution needs no repo-wide
|
|
806
|
-
* package-root search — only dot-counting from `fromFile`'s own location. */
|
|
761
|
+
/** Resolve a Python import spec to a tracked file. An absolute spec is tried under
|
|
762
|
+
* each of `pyRoots` in order; a relative one starts at the importing file's
|
|
763
|
+
* directory (one dot) and climbs one directory per extra dot, failing if that
|
|
764
|
+
* passes above the repo root. No sys.path emulation. */
|
|
807
765
|
function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
|
|
808
766
|
if (!spec.startsWith(".")) {
|
|
809
|
-
const
|
|
767
|
+
const path = spec.split(".").join("/");
|
|
810
768
|
for (const root of pyRoots) {
|
|
811
|
-
const
|
|
812
|
-
const found = firstExistingPyModule(modulePath, fileSymbols);
|
|
769
|
+
const found = pythonModuleFile(root ? `${root}/${path}` : path, fileSymbols);
|
|
813
770
|
if (found)
|
|
814
771
|
return found;
|
|
815
772
|
}
|
|
816
773
|
return null;
|
|
817
774
|
}
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const baseDir = baseSegments.join("/");
|
|
775
|
+
const tail = spec.replace(/^\.+/, "");
|
|
776
|
+
const level = spec.length - tail.length;
|
|
777
|
+
let base = toPosix(dirname(fromFile)); // "." is the repo root
|
|
778
|
+
for (let i = 1; i < level; i++) {
|
|
779
|
+
if (base === ".")
|
|
780
|
+
return null;
|
|
781
|
+
base = posix.dirname(base);
|
|
782
|
+
}
|
|
827
783
|
if (!tail) {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
// matching resolveImport()'s granularity for JS/TS named imports).
|
|
831
|
-
const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
|
|
832
|
-
return fileSymbols.has(initPy) ? initPy : null;
|
|
784
|
+
const init = base === "." ? "__init__.py" : `${base}/__init__.py`;
|
|
785
|
+
return fileSymbols.has(init) ? init : null;
|
|
833
786
|
}
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
return firstExistingPyModule(modulePath, fileSymbols);
|
|
787
|
+
const path = tail.split(".").join("/");
|
|
788
|
+
return pythonModuleFile(base === "." ? path : `${base}/${path}`, fileSymbols);
|
|
837
789
|
}
|
|
838
790
|
/** The `module` path declared in the repo's go.mod, or null. A resolution HINT
|
|
839
791
|
* only (it widens depends_on edge coverage); reading it best-effort from the
|
|
@@ -889,16 +841,12 @@ function deriveComponents(symbols) {
|
|
|
889
841
|
const out = [];
|
|
890
842
|
for (const [dir, fileSet] of groups) {
|
|
891
843
|
const name = dir.split("/").filter(Boolean).pop() ?? dir;
|
|
892
|
-
// root
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
// glob without re-checking both matchers agree. As a consequence, this
|
|
899
|
-
// component only covers root files present (and symbol-bearing) at the last
|
|
900
|
-
// index — unlike directory components, a new root file isn't owned until reindex.
|
|
901
|
-
const paths = dir === "." ? [...fileSet].sort() : [dir.endsWith("/") ? dir + "**" : dir + "/**"];
|
|
844
|
+
// The root group lists its files exactly: a `./**` glob would match everything,
|
|
845
|
+
// and a lone `*` breaks prefix-based ownership (empty prefix) -- issue #34.
|
|
846
|
+
// Consequence: a NEW root file is not owned until the next reindex.
|
|
847
|
+
const paths = dir === "."
|
|
848
|
+
? [...fileSet].sort(compareCodeUnits)
|
|
849
|
+
: [`${dir.replace(/\/+$/, "")}/**`];
|
|
902
850
|
out.push({
|
|
903
851
|
id: componentId(dir),
|
|
904
852
|
kind: "module",
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic text scan for Kubernetes manifest cross-resource references —
|
|
3
|
-
* NOT a tree-sitter walk. Confirmed directly (not assumed): parsing a realistic
|
|
4
|
-
* templated Deployment with this repo's own tree-sitter-yaml bundle showed a
|
|
5
|
-
* same-line templated scalar (`name: {{ include "x" . }}`) parses cleanly, but
|
|
6
|
-
* a block-form injection on its own line (`labels:\n {{- include ... }}` — the
|
|
7
|
-
* pattern real charts use for metadata.labels and spec.selector) collapses the
|
|
8
|
-
* WHOLE REST OF THE DOCUMENT'S error recovery into a flat, unstructured ERROR
|
|
9
|
-
* node. Real charts place such an injection early (right after metadata.name),
|
|
10
|
-
* so it would poison parsing for every field-path this module needs further
|
|
11
|
-
* down the same document. parseSource() (parse.ts) also never exposes its
|
|
12
|
-
* tree-sitter tree to callers at all -- helm.ts's own text-scan precedent
|
|
13
|
-
* exists for the same reason.
|
|
14
|
-
*
|
|
15
|
-
* This is a line-oriented, indentation-tracking scanner (not flat regex, unlike
|
|
16
|
-
* helm.ts's `{{ }}`-action scan) because K8s field-paths are nested block
|
|
17
|
-
* structure (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.name`)
|
|
18
|
-
* that a flat scan can't reconstruct. It never interprets `{{ }}` as YAML syntax
|
|
19
|
-
* at all -- a `{{ ... }}` on a line's value side is just that line's raw value
|
|
20
|
-
* text, same-line or block-form alike -- so it is immune to the exact failure
|
|
21
|
-
* mode that broke tree-sitter.
|
|
22
|
-
*/
|
|
23
1
|
export type ManifestNameRef = {
|
|
24
2
|
form: "literal";
|
|
25
3
|
value: string;
|
|
@@ -31,72 +9,37 @@ export type ManifestNameRef = {
|
|
|
31
9
|
atChar: number;
|
|
32
10
|
endChar: number;
|
|
33
11
|
};
|
|
34
|
-
interface
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
12
|
+
export interface K8sManifestDocument {
|
|
13
|
+
resource: {
|
|
14
|
+
kind: string;
|
|
15
|
+
name: ManifestNameRef;
|
|
16
|
+
namespace: string | null;
|
|
17
|
+
startChar: number;
|
|
18
|
+
endChar: number;
|
|
19
|
+
} | null;
|
|
20
|
+
references: Array<{
|
|
21
|
+
refKind: string;
|
|
22
|
+
name: ManifestNameRef;
|
|
23
|
+
namespace: string | null;
|
|
24
|
+
}>;
|
|
25
|
+
selector: Record<string, string> | null;
|
|
26
|
+
labels: Record<string, string> | null;
|
|
46
27
|
}
|
|
47
|
-
|
|
48
|
-
interface K8sResourceDoc {
|
|
49
|
-
kind: string;
|
|
50
|
-
name: ManifestNameRef;
|
|
51
|
-
/** `metadata.namespace` when it is a plain literal, else null = UNKNOWN (see
|
|
52
|
-
* literalNamespace). Cluster-scoped kinds need no special case: they simply
|
|
53
|
-
* never carry the field, so they are unknown and compatible with anything. */
|
|
28
|
+
export interface KustomizationInfo {
|
|
54
29
|
namespace: string | null;
|
|
55
|
-
|
|
56
|
-
endChar: number;
|
|
57
|
-
}
|
|
58
|
-
export interface K8sManifestDocument {
|
|
59
|
-
resource: K8sResourceDoc | null;
|
|
60
|
-
references: K8sReferenceCandidate[];
|
|
61
|
-
selector: ManifestLabelMap | null;
|
|
62
|
-
labels: ManifestLabelMap | null;
|
|
30
|
+
resources: string[];
|
|
63
31
|
}
|
|
64
|
-
/** Namespace compatibility per the product rule: a MISSING (unknown) namespace
|
|
65
|
-
* matches anything; two DIFFERENT literal namespaces block the edge. */
|
|
66
|
-
export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
|
|
67
|
-
/** Where a kind's pod spec lives -- factored once so container/volume field
|
|
68
|
-
* paths below aren't hand-duplicated per kind. */
|
|
69
32
|
export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
|
|
70
33
|
export declare const LABELS_PATH_BY_KIND: Record<string, string>;
|
|
71
|
-
/** `namespace` is a Kustomize transformer namespace for this file (from
|
|
72
|
-
* kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
|
|
73
|
-
* kustomizations render this file into different namespaces). */
|
|
74
34
|
export declare function extractK8sManifest(source: string, opts?: {
|
|
75
35
|
namespace?: string | null;
|
|
76
36
|
}): K8sManifestDocument[];
|
|
77
|
-
export
|
|
78
|
-
/** Top-level `namespace:` when it is a valid literal namespace, else null. */
|
|
79
|
-
namespace: string | null;
|
|
80
|
-
/** `resources:` entries exactly as written (paths relative to the kustomization's directory). */
|
|
81
|
-
resources: string[];
|
|
82
|
-
}
|
|
37
|
+
export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
|
|
83
38
|
export declare function isKustomizationFile(path: string): boolean;
|
|
84
|
-
/** Reads only the two top-level fields the namespace transformer needs:
|
|
85
|
-
* `namespace:` and the `resources:` list (block `- item` lines or a one-line
|
|
86
|
-
* flow `[a, b]`). One linear pass; everything else in the file is ignored. */
|
|
87
39
|
export declare function extractKustomization(source: string): KustomizationInfo;
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
* entry naming a directory that holds its own kustomization is a base, and
|
|
94
|
-
* the referring (outer) kustomization's namespace wins over the base's, as
|
|
95
|
-
* Kustomize applies the overlay transformer to the base's output. Only
|
|
96
|
-
* kustomizations no other kustomization references are rendered as roots.
|
|
97
|
-
* A file rendered into two different namespaces (one base shared by dev and
|
|
98
|
-
* prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
|
|
99
|
-
* only without any transformer is absent. Not modelled: remote resources,
|
|
100
|
-
* `components:`, the deprecated `bases:` field, and globs. */
|
|
40
|
+
/**
|
|
41
|
+
* Effective namespace per manifest file after Kustomize's namespace transformer.
|
|
42
|
+
* A file reached with conflicting namespaces maps to null; a file that is only
|
|
43
|
+
* ever kept as written (or never rendered) is absent.
|
|
44
|
+
*/
|
|
101
45
|
export declare function kustomizeNamespaces(kustomizations: ReadonlyMap<string, KustomizationInfo>, files: ReadonlySet<string>): Map<string, string | null>;
|
|
102
|
-
export {};
|