@davesheffer/hunch 1.39.2 → 1.40.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 +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, posix } from "node:path";
|
|
13
13
|
import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
|
|
14
|
+
import { isParserLoadError } from "./nativeTreeSitter.js";
|
|
14
15
|
import { extractHelmDirectives } from "./helm.js";
|
|
15
|
-
import { extractK8sManifest } from "./k8sManifest.js";
|
|
16
|
+
import { extractK8sManifest, namespacesCompatible } from "./k8sManifest.js";
|
|
16
17
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
17
18
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
18
19
|
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
@@ -101,6 +102,12 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
101
102
|
const perFileCalls = [];
|
|
102
103
|
const perFileImports = [];
|
|
103
104
|
const perFileRelations = [];
|
|
105
|
+
// `namespace` on all four: a Kubernetes reference only resolves WITHIN a
|
|
106
|
+
// namespace, so a same-named resource in a different one is a different
|
|
107
|
+
// resource (issue #297). null means UNKNOWN (absent/templated/empty), which
|
|
108
|
+
// matches anything -- see namespacesCompatible. All four are in-memory
|
|
109
|
+
// resolution scratch, never persisted: the edges they produce keep their
|
|
110
|
+
// existing shape.
|
|
104
111
|
const k8sResourceIndex = [];
|
|
105
112
|
const k8sReferenceCandidates = [];
|
|
106
113
|
const k8sSelectors = [];
|
|
@@ -145,7 +152,16 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
145
152
|
try {
|
|
146
153
|
parsed = parseSource(rel, src);
|
|
147
154
|
}
|
|
148
|
-
catch {
|
|
155
|
+
catch (error) {
|
|
156
|
+
// …but a dead PARSER is not a bad file. The native addons load on first
|
|
157
|
+
// parse, so a broken load (unwritable TMPDIR, missing prebuild, an addon
|
|
158
|
+
// preloaded past the isolation guard) surfaces here and would mark every
|
|
159
|
+
// file parse_failed, after which indexRepo replaces symbols/edges/
|
|
160
|
+
// components with empty arrays and exits 0 — the whole graph silently
|
|
161
|
+
// wiped. Rethrow so the scan dies before its first store write, the way
|
|
162
|
+
// the import-time load did.
|
|
163
|
+
if (isParserLoadError(error))
|
|
164
|
+
throw error;
|
|
149
165
|
skipped++;
|
|
150
166
|
issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
|
|
151
167
|
noteSkip(rel, "parse_failed");
|
|
@@ -248,17 +264,18 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
248
264
|
if (!fromId)
|
|
249
265
|
continue;
|
|
250
266
|
const scope = chartRoot ?? rel;
|
|
251
|
-
|
|
267
|
+
const namespace = doc.resource.namespace;
|
|
268
|
+
k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name), namespace });
|
|
252
269
|
for (const ref of doc.references) {
|
|
253
270
|
k8sReferenceCandidates.push({
|
|
254
|
-
fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
|
|
271
|
+
fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name), namespace: ref.namespace,
|
|
255
272
|
reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
|
|
256
273
|
});
|
|
257
274
|
}
|
|
258
275
|
if (doc.selector)
|
|
259
|
-
k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
|
|
276
|
+
k8sSelectors.push({ symbolId: fromId, scope, namespace, selector: doc.selector });
|
|
260
277
|
if (doc.labels)
|
|
261
|
-
k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
|
|
278
|
+
k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
|
|
262
279
|
}
|
|
263
280
|
fileSymbols.set(rel, idsInFile);
|
|
264
281
|
fileSymbolIndexId.set(rel, symbolIndexId);
|
|
@@ -359,16 +376,24 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
359
376
|
// only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
|
|
360
377
|
// happen to share a name would incorrectly conflate. Same ambiguity contract
|
|
361
378
|
// as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
|
|
379
|
+
//
|
|
380
|
+
// Namespace is a FILTER applied to the candidate list, not part of the key
|
|
381
|
+
// (issue #297): an unknown namespace on either side must still match, which
|
|
382
|
+
// a key can't express. Filtering before the uniqueness check -- rather than
|
|
383
|
+
// after picking a single candidate -- is what makes "ref in a, candidates in
|
|
384
|
+
// a and b" resolve to a instead of declining as ambiguous, while "ref in a,
|
|
385
|
+
// candidates in a and unknown" correctly stays ambiguous.
|
|
362
386
|
const kindNameIndex = new Map();
|
|
363
387
|
for (const r of k8sResourceIndex) {
|
|
364
388
|
const key = `${r.scope}:${r.kind}:${r.nameKey}`;
|
|
365
|
-
pushInto(kindNameIndex, key, r
|
|
389
|
+
pushInto(kindNameIndex, key, r);
|
|
366
390
|
}
|
|
367
391
|
for (const ref of k8sReferenceCandidates) {
|
|
368
|
-
const
|
|
392
|
+
const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
|
|
393
|
+
const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
|
|
369
394
|
if (candidates.length !== 1)
|
|
370
395
|
continue; // 0 or 2+ -> ambiguous or absent, don't guess
|
|
371
|
-
const toId = candidates[0];
|
|
396
|
+
const toId = candidates[0].symbolId;
|
|
372
397
|
if (toId === ref.fromSymbolId)
|
|
373
398
|
continue;
|
|
374
399
|
addEdge({
|
|
@@ -410,6 +435,11 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
410
435
|
// ids can never collide today.
|
|
411
436
|
if (svc.symbolId === wl.symbolId)
|
|
412
437
|
continue;
|
|
438
|
+
// A Service only ever selects pods in its OWN namespace -- a
|
|
439
|
+
// label-identical workload next door is a different workload (issue
|
|
440
|
+
// #297). Unknown on either side still matches, same rule as Phase 1.
|
|
441
|
+
if (!namespacesCompatible(svc.namespace, wl.namespace))
|
|
442
|
+
continue;
|
|
413
443
|
const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
|
|
414
444
|
if (!isSubset)
|
|
415
445
|
continue;
|
|
@@ -560,6 +590,22 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
560
590
|
issues,
|
|
561
591
|
};
|
|
562
592
|
}
|
|
593
|
+
/** True for an edge this indexer produced: the scan re-derives exactly these on
|
|
594
|
+
* every pass, so they — and only they — are safe to replace wholesale. */
|
|
595
|
+
export function isExtractorEdge(edge) {
|
|
596
|
+
return edge.schema === "hunch.edge/1" && edge.provenance.source === "extracted";
|
|
597
|
+
}
|
|
598
|
+
/** Merge a fresh scan into the stored edge set. A scan can only re-derive what
|
|
599
|
+
* it extracted; `supersedes` edges (written by the store) and human-reviewed
|
|
600
|
+
* Landscape relationships have no other source, so replacing the whole index
|
|
601
|
+
* with the scan deleted them on every `hunch index` (issue #288). Non-extractor
|
|
602
|
+
* edges are carried forward in their stored order and win an id collision:
|
|
603
|
+
* a reviewed or store-written fact outranks a re-derivable one. */
|
|
604
|
+
export function mergeScannedEdges(stored, scanned) {
|
|
605
|
+
const carried = stored.filter((edge) => !isExtractorEdge(edge));
|
|
606
|
+
const carriedIds = new Set(carried.map((edge) => edge.id));
|
|
607
|
+
return [...carried, ...scanned.filter((edge) => !carriedIds.has(edge.id))];
|
|
608
|
+
}
|
|
563
609
|
/** Persist one pure scan into the Git-native source of truth. */
|
|
564
610
|
export function indexRepo(store, root, opts = {}) {
|
|
565
611
|
if (opts.requireClean) {
|
|
@@ -576,7 +622,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
576
622
|
if (opts.requireComplete)
|
|
577
623
|
assertCompleteRepoScan(scan);
|
|
578
624
|
store.json.replaceAll("symbols", scan.symbols);
|
|
579
|
-
store.json.replaceAll("edges", scan.edges);
|
|
625
|
+
store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), scan.edges));
|
|
580
626
|
store.json.replaceAll("components", scan.components);
|
|
581
627
|
return scan.result;
|
|
582
628
|
}
|
|
@@ -37,11 +37,21 @@ interface K8sReferenceCandidate {
|
|
|
37
37
|
* value (data-dependent, not statically known). */
|
|
38
38
|
refKind: string;
|
|
39
39
|
name: ManifestNameRef;
|
|
40
|
+
/** The namespace this reference resolves IN, or null for "unknown" (see
|
|
41
|
+
* literalNamespace). Kubernetes name references are same-namespace by
|
|
42
|
+
* definition, so this defaults to the referring DOCUMENT's own namespace;
|
|
43
|
+
* the one shape that can legitimately point elsewhere (a Gateway API
|
|
44
|
+
* `backendRefs[].namespace` sibling) overrides it. */
|
|
45
|
+
namespace: string | null;
|
|
40
46
|
}
|
|
41
47
|
type ManifestLabelMap = Record<string, string>;
|
|
42
48
|
interface K8sResourceDoc {
|
|
43
49
|
kind: string;
|
|
44
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. */
|
|
54
|
+
namespace: string | null;
|
|
45
55
|
startChar: number;
|
|
46
56
|
endChar: number;
|
|
47
57
|
}
|
|
@@ -51,6 +61,9 @@ export interface K8sManifestDocument {
|
|
|
51
61
|
selector: ManifestLabelMap | null;
|
|
52
62
|
labels: ManifestLabelMap | null;
|
|
53
63
|
}
|
|
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;
|
|
54
67
|
/** Where a kind's pod spec lives -- factored once so container/volume field
|
|
55
68
|
* paths below aren't hand-duplicated per kind. */
|
|
56
69
|
export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
|
|
@@ -71,6 +71,20 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
|
|
|
71
71
|
// -- reparenting the item's real children one level up (`containers.name`
|
|
72
72
|
// instead of `containers[0].name`), silently dropping every reference under it.
|
|
73
73
|
const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
|
|
74
|
+
// A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
|
|
75
|
+
// `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
|
|
76
|
+
// script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
|
|
77
|
+
// yet the body lines look exactly like manifest fields (a heredoc'd
|
|
78
|
+
// `secretKeyRef:` block is the canonical case), and this line matches neither
|
|
79
|
+
// KEY_LINE (no colon) nor BARE_LIST_MARKER (something follows the dash), so it
|
|
80
|
+
// fell to the unrecognized-line branch and the script body was then scanned as
|
|
81
|
+
// the surrounding container's children -- inventing a reference to a resource
|
|
82
|
+
// that only ever existed as text. The `key: |` form needs no such handling:
|
|
83
|
+
// its body nests under the key's OWN frame, where no reference field-path can
|
|
84
|
+
// match. The rest is captured (not `[|>]` inline) so the header can be run
|
|
85
|
+
// through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
|
|
86
|
+
// key line's value is.
|
|
87
|
+
const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
|
|
74
88
|
// A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
|
|
75
89
|
// `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
|
|
76
90
|
// `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
|
|
@@ -138,8 +152,21 @@ function scanFieldPaths(text, baseChar) {
|
|
|
138
152
|
// avoid. Silently ignoring what the scanner can't parse is not safe here;
|
|
139
153
|
// "I can't tell" must read as "unresolved," the same as a real template.
|
|
140
154
|
const unresolvedContainers = new Set();
|
|
155
|
+
// The framePath of every container that DIRECTLY holds a key written with no
|
|
156
|
+
// value at all (`tier:`). Deliberately its own set rather than more entries
|
|
157
|
+
// in unresolvedContainers: an ordinary container like `spec:` legitimately
|
|
158
|
+
// holds value-less keys (every nested mapping starts with one), so folding
|
|
159
|
+
// these in would destroy that set's meaning -- "the scanner could not
|
|
160
|
+
// account for this line". Only a map that is supposed to be FLAT
|
|
161
|
+
// (selector/labels) cares, and it checks this set itself.
|
|
162
|
+
const valuelessKeyParents = new Set();
|
|
141
163
|
const stack = [];
|
|
142
164
|
let charOffset = baseChar;
|
|
165
|
+
// Indent of the `-` of a `- |` list item whose block-scalar body is being
|
|
166
|
+
// skipped, or null when no such skip is active. The body ends at the first
|
|
167
|
+
// line indented at or shallower than that dash (YAML's own rule for where a
|
|
168
|
+
// sequence item's content stops).
|
|
169
|
+
let blockScalarSkipIndent = null;
|
|
143
170
|
const popToForListItem = (dashIndent) => {
|
|
144
171
|
while (stack.length) {
|
|
145
172
|
const top = stack[stack.length - 1];
|
|
@@ -205,6 +232,31 @@ function scanFieldPaths(text, baseChar) {
|
|
|
205
232
|
for (const line of text.split("\n")) {
|
|
206
233
|
const lineStartChar = charOffset;
|
|
207
234
|
charOffset += line.length + 1; // +1 for the \n split() consumed
|
|
235
|
+
// Inside a `- |` item's body: these lines are script/config TEXT, so they
|
|
236
|
+
// must never be read as field structure. Runs after the charOffset
|
|
237
|
+
// bookkeeping above so skipped lines still advance the offset exactly --
|
|
238
|
+
// every atChar/endChar after the block scalar depends on it.
|
|
239
|
+
if (blockScalarSkipIndent !== null) {
|
|
240
|
+
// A blank line is part of the block scalar (YAML lets a scalar body
|
|
241
|
+
// contain empty lines at any indent), never its terminator.
|
|
242
|
+
if (line.trim().length === 0)
|
|
243
|
+
continue;
|
|
244
|
+
if (BARE_TEMPLATE_LINE.test(line)) {
|
|
245
|
+
// A `{{ }}` action line's own indentation carries no structural
|
|
246
|
+
// meaning (`{{-` chomps it; the `| indent N` idiom puts the action at
|
|
247
|
+
// column 0 -- see markAllOpenContainersUnresolved), so a column-0
|
|
248
|
+
// action inside a script body must NOT be read as a dedent that ends
|
|
249
|
+
// the skip and re-exposes the remaining body lines as fields. Taint
|
|
250
|
+
// conservatively and stay in the skip: we cannot tell whether the
|
|
251
|
+
// action sits inside the scalar or after it.
|
|
252
|
+
markAllOpenContainersUnresolved();
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const indent = line.length - line.trimStart().length;
|
|
256
|
+
if (indent > blockScalarSkipIndent)
|
|
257
|
+
continue; // still the scalar's body
|
|
258
|
+
blockScalarSkipIndent = null; // dedented out of the item -- real YAML again
|
|
259
|
+
}
|
|
208
260
|
if (BARE_TEMPLATE_LINE.test(line)) {
|
|
209
261
|
markAllOpenContainersUnresolved();
|
|
210
262
|
continue;
|
|
@@ -215,6 +267,18 @@ function scanFieldPaths(text, baseChar) {
|
|
|
215
267
|
enterListItem(dashIndent, dashIndent + 1);
|
|
216
268
|
continue;
|
|
217
269
|
}
|
|
270
|
+
// Checked BEFORE the KEY_LINE match: KEY_LINE can't match this shape
|
|
271
|
+
// anyway (no colon), but without this branch it would land in the
|
|
272
|
+
// unrecognized-line branch below and the body would be scanned as fields.
|
|
273
|
+
const blockScalarItem = BLOCK_SCALAR_LIST_ITEM.exec(line);
|
|
274
|
+
if (blockScalarItem && BLOCK_SCALAR_HEADER.test(stripTrailingComment(blockScalarItem[2]).trim())) {
|
|
275
|
+
const dashIndent = blockScalarItem[1].length;
|
|
276
|
+
// It IS a real sequence item, so enter it exactly as the bare-marker
|
|
277
|
+
// form does -- sibling items after this one must keep their indices.
|
|
278
|
+
enterListItem(dashIndent, dashIndent + 1);
|
|
279
|
+
blockScalarSkipIndent = dashIndent;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
218
282
|
const m = KEY_LINE.exec(line);
|
|
219
283
|
if (!m) {
|
|
220
284
|
// Any other non-blank, non-comment line is a shape this scanner can't
|
|
@@ -268,6 +332,18 @@ function scanFieldPaths(text, baseChar) {
|
|
|
268
332
|
unresolvedContainers.add(parentPath);
|
|
269
333
|
continue;
|
|
270
334
|
}
|
|
335
|
+
// A key written with NO value (`tier:`) creates a frame but never an
|
|
336
|
+
// entry, so it is invisible to extractLiteralLabelMap -- which then
|
|
337
|
+
// returns the map MINUS that key. For a flat string->string map
|
|
338
|
+
// (selector/labels) a value-less direct child is either a null value or a
|
|
339
|
+
// nested container; either way the key is silently absent, and a selector
|
|
340
|
+
// short one key matches strictly MORE workloads than the real one -- the
|
|
341
|
+
// same over-permissive failure as every other unresolved case here.
|
|
342
|
+
// Recorded after the block-scalar branch above, which already taints the
|
|
343
|
+
// parent for its own reason. Note `tier: ""` is NOT value-less: quotes
|
|
344
|
+
// survive stripTrailingComment/trim, so it stays a literal empty string.
|
|
345
|
+
if (value.length === 0)
|
|
346
|
+
valuelessKeyParents.add(parentPath);
|
|
271
347
|
if (value.length > 0) {
|
|
272
348
|
const colonIdx = line.indexOf(":", dashIndent);
|
|
273
349
|
const valueStartInLine = line.indexOf(value, colonIdx);
|
|
@@ -309,11 +385,80 @@ function scanFieldPaths(text, baseChar) {
|
|
|
309
385
|
// line, since that line's own popToForListItem/popOrdinary call pops
|
|
310
386
|
// back to (but never past) this key's frame.
|
|
311
387
|
}
|
|
312
|
-
return { entries, unresolvedContainers };
|
|
388
|
+
return { entries, unresolvedContainers, valuelessKeyParents };
|
|
313
389
|
}
|
|
314
390
|
function findEntry(entries, path) {
|
|
315
391
|
return entries.find((e) => e.path === path);
|
|
316
392
|
}
|
|
393
|
+
/** The RFC 1123 DNS label a real Kubernetes namespace name must be -- the
|
|
394
|
+
* positive test literalNamespace applies (see there for why). */
|
|
395
|
+
const DNS_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
|
|
396
|
+
/** A namespace a reference can be MATCHED on, or null meaning UNKNOWN.
|
|
397
|
+
* Deliberately narrower than the ManifestNameRef literal/template split that
|
|
398
|
+
* names get: a name is a resolution KEY (a template's raw text matches
|
|
399
|
+
* another identical template's raw text, which is real evidence), whereas a
|
|
400
|
+
* namespace here is only ever a FILTER, and the product rule is that unknown
|
|
401
|
+
* matches anything. So a templated `namespace: {{ .Release.Namespace }}` is
|
|
402
|
+
* unknown rather than a `T:`-keyed literal -- two documents whose namespace
|
|
403
|
+
* renders identically must not be blocked from linking just because one
|
|
404
|
+
* spells it via a template and the other spells it out, and two DIFFERENT
|
|
405
|
+
* template expressions are not evidence of different namespaces either.
|
|
406
|
+
* Absent (no entry at all) and an explicit empty string are unknown for the
|
|
407
|
+
* same reason: neither names a namespace this scanner can compare.
|
|
408
|
+
*
|
|
409
|
+
* The literal-ness test is therefore POSITIVE, not a list of excluded
|
|
410
|
+
* spellings: a value counts only when it already looks like the RFC 1123 DNS
|
|
411
|
+
* label a real namespace must be. One test then covers every shape the
|
|
412
|
+
* scanner cannot resolve, without a special case per spelling -- the scanner
|
|
413
|
+
* only tags a value "template" when the template action starts the value, so
|
|
414
|
+
* `namespace: app-{{ .Values.env }}` arrives here as a "literal" and would
|
|
415
|
+
* otherwise block edges against the very namespace it renders to; likewise
|
|
416
|
+
* YAML null spellings (`~`, `Null`), anchors and tags (`&ns prod`,
|
|
417
|
+
* `!!str prod`), uppercase, whitespace and empty. Blocking is the damaging
|
|
418
|
+
* direction (unknown never blocks an edge), so anything uncomparable is
|
|
419
|
+
* unknown.
|
|
420
|
+
*
|
|
421
|
+
* `null` is the one spelling the regex admits but must still be rejected,
|
|
422
|
+
* since it is also the bare YAML null word. It is excluded explicitly rather
|
|
423
|
+
* than by inspecting quotes: stripQuotes runs in the scanner, so a genuinely
|
|
424
|
+
* quoted `namespace: "null"` is indistinguishable here -- and reading it as
|
|
425
|
+
* unknown errs in the conservative direction. */
|
|
426
|
+
function literalNamespace(entry) {
|
|
427
|
+
if (!entry || entry.value.form !== "literal")
|
|
428
|
+
return null;
|
|
429
|
+
const value = entry.value.value;
|
|
430
|
+
if (value === "null" || !DNS_LABEL.test(value))
|
|
431
|
+
return null;
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
/** The namespace named at `path`, reading EVERY entry there rather than the
|
|
435
|
+
* first. A Helm `{{- if }} namespace: prod {{- else }} namespace: staging
|
|
436
|
+
* {{- end }}` emits both branches as entries on the same path, and taking
|
|
437
|
+
* findEntry's first one would read the document as confidently `prod` and
|
|
438
|
+
* block every edge to `staging`. Only an unambiguous agreement -- at least
|
|
439
|
+
* one entry, all of them the same literal -- is a namespace this scanner can
|
|
440
|
+
* compare; disagreement is unknown, like any other unresolved shape. */
|
|
441
|
+
function namespaceAt(entries, path) {
|
|
442
|
+
let agreed = null;
|
|
443
|
+
let seen = false;
|
|
444
|
+
for (const e of entries) {
|
|
445
|
+
if (e.path !== path)
|
|
446
|
+
continue;
|
|
447
|
+
const ns = literalNamespace(e);
|
|
448
|
+
if (ns === null)
|
|
449
|
+
return null;
|
|
450
|
+
if (seen && ns !== agreed)
|
|
451
|
+
return null;
|
|
452
|
+
agreed = ns;
|
|
453
|
+
seen = true;
|
|
454
|
+
}
|
|
455
|
+
return agreed;
|
|
456
|
+
}
|
|
457
|
+
/** Namespace compatibility per the product rule: a MISSING (unknown) namespace
|
|
458
|
+
* matches anything; two DIFFERENT literal namespaces block the edge. */
|
|
459
|
+
export function namespacesCompatible(a, b) {
|
|
460
|
+
return a === null || b === null || a === b;
|
|
461
|
+
}
|
|
317
462
|
/** Where a kind's pod spec lives -- factored once so container/volume field
|
|
318
463
|
* paths below aren't hand-duplicated per kind. */
|
|
319
464
|
export const POD_SPEC_PATH_BY_KIND = {
|
|
@@ -372,14 +517,32 @@ function fieldSpecsForKind(kind) {
|
|
|
372
517
|
specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
|
|
373
518
|
return specs;
|
|
374
519
|
}
|
|
375
|
-
|
|
520
|
+
/** The ONE reference shape in fieldSpecsForKind that Kubernetes lets point at
|
|
521
|
+
* another namespace: a Gateway API `backendRefs[]` entry carries an optional
|
|
522
|
+
* `namespace` SIBLING of its own `name`. Every other shape here
|
|
523
|
+
* (configMapRef/secretRef/volumes/imagePullSecrets/Ingress backend/
|
|
524
|
+
* ownerReferences) is same-namespace by API definition and has no namespace
|
|
525
|
+
* field at all, so none of them needs an override. Read off the concrete
|
|
526
|
+
* (non-wildcarded) entry list by exact parentPath -- the same
|
|
527
|
+
* pair-siblings-by-index technique extractOwnerReferenceCandidates uses --
|
|
528
|
+
* rather than new parsing machinery. */
|
|
529
|
+
const NAMESPACE_SIBLING_PATHS = new Set(["spec.rules[].backendRefs[]"]);
|
|
530
|
+
function extractFieldReferences(kind, entries, docNamespace) {
|
|
376
531
|
const specs = fieldSpecsForKind(kind);
|
|
377
532
|
const out = [];
|
|
378
533
|
for (const e of entries) {
|
|
379
534
|
const wp = wildcardPath(e.path);
|
|
380
535
|
const spec = specs.find((s) => s.path === wp);
|
|
381
|
-
if (spec)
|
|
382
|
-
|
|
536
|
+
if (!spec)
|
|
537
|
+
continue;
|
|
538
|
+
// A sibling that EXISTS but isn't a comparable literal (templated, empty)
|
|
539
|
+
// must read as unknown, NOT fall back to the document's namespace: it is
|
|
540
|
+
// an explicit statement that the target lives somewhere this scanner
|
|
541
|
+
// cannot name, which is the opposite of "same namespace as me". So the
|
|
542
|
+
// sibling's existence is checked before its literal-ness.
|
|
543
|
+
const siblingPath = `${e.parentPath}.namespace`;
|
|
544
|
+
const hasSibling = NAMESPACE_SIBLING_PATHS.has(wildcardPath(e.parentPath)) && entries.some((c) => c.path === siblingPath);
|
|
545
|
+
out.push({ refKind: spec.refKind, name: e.value, namespace: hasSibling ? namespaceAt(entries, siblingPath) : docNamespace });
|
|
383
546
|
}
|
|
384
547
|
return out;
|
|
385
548
|
}
|
|
@@ -389,7 +552,7 @@ function extractFieldReferences(kind, entries) {
|
|
|
389
552
|
* entries never get cross-paired. Not expressible via FieldPathSpec's
|
|
390
553
|
* single-fixed-refKind model, so it's a dedicated pass over the concrete
|
|
391
554
|
* (non-wildcarded) entries. */
|
|
392
|
-
function extractOwnerReferenceCandidates(entries) {
|
|
555
|
+
function extractOwnerReferenceCandidates(entries, docNamespace) {
|
|
393
556
|
const byIndex = new Map();
|
|
394
557
|
for (const e of entries) {
|
|
395
558
|
const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
|
|
@@ -403,7 +566,10 @@ function extractOwnerReferenceCandidates(entries) {
|
|
|
403
566
|
for (const { name, kind } of byIndex.values()) {
|
|
404
567
|
if (!name || !kind || kind.value.form !== "literal")
|
|
405
568
|
continue; // an owner's kind must be a literal to type the reference at all
|
|
406
|
-
|
|
569
|
+
// An ownerReferences entry has NO namespace field in the API at all -- an
|
|
570
|
+
// owner is always in the owned object's own namespace (or cluster-scoped),
|
|
571
|
+
// so the document's namespace is the only correct answer here.
|
|
572
|
+
out.push({ refKind: kind.value.value, name: name.value, namespace: docNamespace });
|
|
407
573
|
}
|
|
408
574
|
return out;
|
|
409
575
|
}
|
|
@@ -422,9 +588,14 @@ export const LABELS_PATH_BY_KIND = {
|
|
|
422
588
|
* exists, or any direct-child leaf is itself templated -- a partially-literal
|
|
423
589
|
* map is still unusable for subset-match without evaluating the templated
|
|
424
590
|
* half, so the whole map is treated as unresolved. */
|
|
425
|
-
function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
|
|
591
|
+
function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valuelessKeyParents) {
|
|
426
592
|
if (unresolvedContainers.has(prefix))
|
|
427
593
|
return null;
|
|
594
|
+
// A value-less direct child (`tier:`) never produced an entry at all, so the
|
|
595
|
+
// map below would come back without it -- see valuelessKeyParents' comment
|
|
596
|
+
// in scanFieldPaths for why a short map is the dangerous direction.
|
|
597
|
+
if (valuelessKeyParents.has(prefix))
|
|
598
|
+
return null;
|
|
428
599
|
// A real Kubernetes label/selector map is always flat (string -> string) --
|
|
429
600
|
// any entry whose parentPath is a DEEPER descendant of prefix (not prefix
|
|
430
601
|
// itself) means some direct child of prefix was itself a nested container
|
|
@@ -464,30 +635,43 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
|
|
|
464
635
|
}
|
|
465
636
|
return pairs.length > 0 ? Object.fromEntries(pairs) : null;
|
|
466
637
|
}
|
|
467
|
-
function buildDocument(text, docStartChar, entries, unresolvedContainers) {
|
|
638
|
+
function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
|
|
468
639
|
const kindEntry = findEntry(entries, "kind");
|
|
469
640
|
const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
|
|
470
641
|
if (!kind || !ALLOWED_KINDS.has(kind))
|
|
471
642
|
return { resource: null, references: [], selector: null, labels: null };
|
|
643
|
+
// Found with the same entry machinery as metadata.name, so it inherits the
|
|
644
|
+
// scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
|
|
645
|
+
// Read OUTSIDE the `resource` branch below: a document with no
|
|
646
|
+
// metadata.name still emits references, and those references carry this
|
|
647
|
+
// document's namespace.
|
|
648
|
+
const namespace = namespaceAt(entries, "metadata.namespace");
|
|
472
649
|
const nameEntry = findEntry(entries, "metadata.name");
|
|
473
650
|
const resource = nameEntry
|
|
474
|
-
? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
651
|
+
? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
475
652
|
: null;
|
|
476
|
-
const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
|
|
477
|
-
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers) : null;
|
|
653
|
+
const references = [...extractFieldReferences(kind, entries, namespace), ...extractOwnerReferenceCandidates(entries, namespace)];
|
|
654
|
+
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
478
655
|
const labelsPath = LABELS_PATH_BY_KIND[kind];
|
|
479
|
-
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers) : null;
|
|
656
|
+
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
480
657
|
return { resource, references, selector, labels };
|
|
481
658
|
}
|
|
482
|
-
// Matches a YAML document-start marker (
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
659
|
+
// Matches a YAML document-start marker (`---`) or a document-end marker
|
|
660
|
+
// (`...`). Per the YAML spec `---` FOLLOWED BY WHITESPACE starts a document
|
|
661
|
+
// whatever trails it on that line -- a comment (`--- # second doc`), a tag
|
|
662
|
+
// (`--- !!map`), an anchor (`--- &base`) -- so the trailing text is matched
|
|
663
|
+
// generically rather than as a comment only. Without that, a tagged or
|
|
664
|
+
// anchored separator silently failed to split at all, merging two documents
|
|
665
|
+
// into one: the later document's fields overwrite the earlier one's (object
|
|
666
|
+
// spread order), and the earlier resource's symbol/edges vanish entirely.
|
|
667
|
+
// Whatever trails the marker is consumed WITH the boundary -- for a tag or
|
|
668
|
+
// anchor nothing is lost, and inline content written after the marker
|
|
669
|
+
// (`--- kind: Pod`, legal but vanishingly rare) is dropped, which errs toward
|
|
670
|
+
// fewer edges, this module's standing bias. `----` (four or more dashes) is
|
|
671
|
+
// deliberately NOT a separator -- real YAML doesn't treat it as one either,
|
|
672
|
+
// and it still isn't here: the dashes must be followed by whitespace or end
|
|
673
|
+
// of line.
|
|
674
|
+
const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
|
|
491
675
|
export function extractK8sManifest(source) {
|
|
492
676
|
const docs = [];
|
|
493
677
|
const boundaries = [];
|
|
@@ -499,8 +683,8 @@ export function extractK8sManifest(source) {
|
|
|
499
683
|
const start = starts[i];
|
|
500
684
|
const end = ends[i];
|
|
501
685
|
const text = source.slice(start, end);
|
|
502
|
-
const { entries, unresolvedContainers } = scanFieldPaths(text, start);
|
|
503
|
-
docs.push(buildDocument(text, start, entries, unresolvedContainers));
|
|
686
|
+
const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
|
|
687
|
+
docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
|
|
504
688
|
}
|
|
505
689
|
return docs;
|
|
506
690
|
}
|
|
@@ -6,6 +6,7 @@ import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
|
6
6
|
import { resourceId, resourceRelationshipId } from "../core/ids.js";
|
|
7
7
|
import { parseJsonc } from "../core/jsonc.js";
|
|
8
8
|
import { parseSource } from "./parse.js";
|
|
9
|
+
import { isParserLoadError } from "./nativeTreeSitter.js";
|
|
9
10
|
import { EdgeSchema, ResourceSchema, isCredentialFreeText, } from "../core/types.js";
|
|
10
11
|
import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice, } from "./git.js";
|
|
11
12
|
export const LANDSCAPE_DISCOVERY_SCHEMA_VERSION = "hunch.landscape-discovery/1";
|
|
@@ -2026,7 +2027,14 @@ function sloDeclarations(root, tree, issues) {
|
|
|
2026
2027
|
try {
|
|
2027
2028
|
valid = blob.format === "json" ? validJsonOpenSlo(source) : validYamlOpenSlo(blob.path, source);
|
|
2028
2029
|
}
|
|
2029
|
-
catch {
|
|
2030
|
+
catch (error) {
|
|
2031
|
+
// A malformed declaration is a bad file and stays one issue. A dead
|
|
2032
|
+
// native parser is not: swallowing it made `landscape review` report
|
|
2033
|
+
// every valid SLO as slo_declaration_invalid, drop the resource, and
|
|
2034
|
+
// still exit 0 with a ready-made `landscape adopt --acknowledge-issues`
|
|
2035
|
+
// line — a store write of a false verdict.
|
|
2036
|
+
if (isParserLoadError(error))
|
|
2037
|
+
throw error;
|
|
2030
2038
|
valid = false;
|
|
2031
2039
|
}
|
|
2032
2040
|
if (!valid) {
|
|
@@ -8,6 +8,30 @@ export interface NativeTreeSitterRuntime {
|
|
|
8
8
|
php: unknown;
|
|
9
9
|
yaml: unknown;
|
|
10
10
|
}
|
|
11
|
+
/** The parser runtime itself is unavailable — the addons could not be copied or
|
|
12
|
+
* dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
|
|
13
|
+
* replacing the package mid-session, or the fail-closed "preloaded addon"
|
|
14
|
+
* guard). Distinct from a per-file parse error on purpose: now that the load
|
|
15
|
+
* happens on first parse rather than at import, every swallow-and-continue
|
|
16
|
+
* catch on a parse path would otherwise read this as "this one file is bad"
|
|
17
|
+
* and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
|
|
18
|
+
* graph with nothing. Callers rethrow it so the run dies before any write,
|
|
19
|
+
* the way the import-time load used to. The original error is kept as `cause`
|
|
20
|
+
* and its text is carried in the message so the EACCES/dlopen detail a user
|
|
21
|
+
* needs is never lost. */
|
|
22
|
+
export declare class NativeTreeSitterLoadError extends Error {
|
|
23
|
+
constructor(message: string, options?: {
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** The ONE predicate every rethrow site uses — a dead parser is recognised the
|
|
28
|
+
* same way everywhere, so no catch can quietly disagree about what counts.
|
|
29
|
+
* `instanceof` alone is not enough: a duplicated module instance (two copies of
|
|
30
|
+
* this file in one process, e.g. dist/ + src/ under tsx, or a worker that
|
|
31
|
+
* re-resolves it) gives a different class identity, and an error rebuilt across
|
|
32
|
+
* a worker/IPC boundary keeps only its plain properties. The name check
|
|
33
|
+
* survives both. */
|
|
34
|
+
export declare function isParserLoadError(e: unknown): boolean;
|
|
11
35
|
/** Load all native tree-sitter addons (the parser runtime + every grammar) from
|
|
12
36
|
* process-owned temp copies. Windows keeps loaded `.node` files locked for the
|
|
13
37
|
* process lifetime; redirecting the upstream loaders means npm can replace the
|
|
@@ -13,6 +13,40 @@ const NATIVE_PACKAGES = [
|
|
|
13
13
|
"@tree-sitter-grammars/tree-sitter-yaml",
|
|
14
14
|
];
|
|
15
15
|
let runtime = null;
|
|
16
|
+
/** The parser runtime itself is unavailable — the addons could not be copied or
|
|
17
|
+
* dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
|
|
18
|
+
* replacing the package mid-session, or the fail-closed "preloaded addon"
|
|
19
|
+
* guard). Distinct from a per-file parse error on purpose: now that the load
|
|
20
|
+
* happens on first parse rather than at import, every swallow-and-continue
|
|
21
|
+
* catch on a parse path would otherwise read this as "this one file is bad"
|
|
22
|
+
* and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
|
|
23
|
+
* graph with nothing. Callers rethrow it so the run dies before any write,
|
|
24
|
+
* the way the import-time load used to. The original error is kept as `cause`
|
|
25
|
+
* and its text is carried in the message so the EACCES/dlopen detail a user
|
|
26
|
+
* needs is never lost. */
|
|
27
|
+
export class NativeTreeSitterLoadError extends Error {
|
|
28
|
+
constructor(message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "NativeTreeSitterLoadError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The ONE predicate every rethrow site uses — a dead parser is recognised the
|
|
34
|
+
* same way everywhere, so no catch can quietly disagree about what counts.
|
|
35
|
+
* `instanceof` alone is not enough: a duplicated module instance (two copies of
|
|
36
|
+
* this file in one process, e.g. dist/ + src/ under tsx, or a worker that
|
|
37
|
+
* re-resolves it) gives a different class identity, and an error rebuilt across
|
|
38
|
+
* a worker/IPC boundary keeps only its plain properties. The name check
|
|
39
|
+
* survives both. */
|
|
40
|
+
export function isParserLoadError(e) {
|
|
41
|
+
return e instanceof NativeTreeSitterLoadError || e?.name === "NativeTreeSitterLoadError";
|
|
42
|
+
}
|
|
43
|
+
/** Wrap any loader failure once, preserving an already-typed one. */
|
|
44
|
+
function loadFailure(error) {
|
|
45
|
+
if (error instanceof NativeTreeSitterLoadError)
|
|
46
|
+
return error;
|
|
47
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return new NativeTreeSitterLoadError(`native tree-sitter parser unavailable: ${detail}`, { cause: error });
|
|
49
|
+
}
|
|
16
50
|
function processIsAlive(pid) {
|
|
17
51
|
if (pid === process.pid)
|
|
18
52
|
return true;
|
|
@@ -71,6 +105,19 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
|
71
105
|
export function loadNativeTreeSitter() {
|
|
72
106
|
if (runtime)
|
|
73
107
|
return runtime;
|
|
108
|
+
// Only SUCCESS is memoized (in `runtime`), never the failure: the conditions
|
|
109
|
+
// that break the load are transient — a full or read-only TMPDIR, an npm
|
|
110
|
+
// install swapping the package out from under a live process. A long-lived
|
|
111
|
+
// MCP server must be able to parse again once the condition clears, so every
|
|
112
|
+
// call retries the load from scratch.
|
|
113
|
+
try {
|
|
114
|
+
return loadRuntime();
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw loadFailure(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function loadRuntime() {
|
|
74
121
|
// Three binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node
|
|
75
122
|
// or, for a scoped package like @tree-sitter-grammars/tree-sitter-yaml, as
|
|
76
123
|
// @scope+name.node; from-source builds are named after the binding.gyp target
|
|
@@ -85,9 +132,15 @@ export function loadNativeTreeSitter() {
|
|
|
85
132
|
}
|
|
86
133
|
removeStaleCopies();
|
|
87
134
|
const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
|
|
88
|
-
const nodeGypBuild = runtimeRequire("node-gyp-build");
|
|
89
135
|
const previous = new Map();
|
|
90
136
|
try {
|
|
137
|
+
// Resolved INSIDE the try: if node-gyp-build cannot be resolved (a partial
|
|
138
|
+
// install, npm mid-swap) the catch below still removes the copy dir we just
|
|
139
|
+
// created. Outside it, every retry — and failure is deliberately not
|
|
140
|
+
// memoized, so a long-lived MCP server retries forever — leaked one empty
|
|
141
|
+
// hunch-tree-sitter-<pid>-* dir that removeStaleCopies can never prune,
|
|
142
|
+
// because it only prunes dirs whose pid is dead.
|
|
143
|
+
const nodeGypBuild = runtimeRequire("node-gyp-build");
|
|
91
144
|
for (const packageName of NATIVE_PACKAGES) {
|
|
92
145
|
const key = environmentKey(packageName);
|
|
93
146
|
previous.set(key, process.env[key]);
|