@davesheffer/hunch 1.39.3 → 1.40.1
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 +61 -17
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +181 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +12 -6
- package/dist/core/glob.js +12 -6
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +21 -0
- package/dist/core/paths.js +39 -1
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +40 -4
- package/dist/core/taskReportRender.d.ts +3 -0
- package/dist/core/taskReportRender.js +22 -12
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/extractors/indexer.d.ts +5 -0
- package/dist/extractors/indexer.js +56 -11
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +103 -7
- 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.d.ts +3 -1
- package/dist/extractors/parse.js +12 -3
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hooks.d.ts +43 -4
- package/dist/integrations/hooks.js +309 -22
- package/dist/mcp/server.js +19 -11
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- 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 +74 -16
- package/dist/store/stateBinding.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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>;
|
|
@@ -390,6 +390,75 @@ function scanFieldPaths(text, baseChar) {
|
|
|
390
390
|
function findEntry(entries, path) {
|
|
391
391
|
return entries.find((e) => e.path === path);
|
|
392
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
|
+
}
|
|
393
462
|
/** Where a kind's pod spec lives -- factored once so container/volume field
|
|
394
463
|
* paths below aren't hand-duplicated per kind. */
|
|
395
464
|
export const POD_SPEC_PATH_BY_KIND = {
|
|
@@ -448,14 +517,32 @@ function fieldSpecsForKind(kind) {
|
|
|
448
517
|
specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
|
|
449
518
|
return specs;
|
|
450
519
|
}
|
|
451
|
-
|
|
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) {
|
|
452
531
|
const specs = fieldSpecsForKind(kind);
|
|
453
532
|
const out = [];
|
|
454
533
|
for (const e of entries) {
|
|
455
534
|
const wp = wildcardPath(e.path);
|
|
456
535
|
const spec = specs.find((s) => s.path === wp);
|
|
457
|
-
if (spec)
|
|
458
|
-
|
|
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 });
|
|
459
546
|
}
|
|
460
547
|
return out;
|
|
461
548
|
}
|
|
@@ -465,7 +552,7 @@ function extractFieldReferences(kind, entries) {
|
|
|
465
552
|
* entries never get cross-paired. Not expressible via FieldPathSpec's
|
|
466
553
|
* single-fixed-refKind model, so it's a dedicated pass over the concrete
|
|
467
554
|
* (non-wildcarded) entries. */
|
|
468
|
-
function extractOwnerReferenceCandidates(entries) {
|
|
555
|
+
function extractOwnerReferenceCandidates(entries, docNamespace) {
|
|
469
556
|
const byIndex = new Map();
|
|
470
557
|
for (const e of entries) {
|
|
471
558
|
const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
|
|
@@ -479,7 +566,10 @@ function extractOwnerReferenceCandidates(entries) {
|
|
|
479
566
|
for (const { name, kind } of byIndex.values()) {
|
|
480
567
|
if (!name || !kind || kind.value.form !== "literal")
|
|
481
568
|
continue; // an owner's kind must be a literal to type the reference at all
|
|
482
|
-
|
|
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 });
|
|
483
573
|
}
|
|
484
574
|
return out;
|
|
485
575
|
}
|
|
@@ -550,11 +640,17 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
|
|
|
550
640
|
const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
|
|
551
641
|
if (!kind || !ALLOWED_KINDS.has(kind))
|
|
552
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");
|
|
553
649
|
const nameEntry = findEntry(entries, "metadata.name");
|
|
554
650
|
const resource = nameEntry
|
|
555
|
-
? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
651
|
+
? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
556
652
|
: null;
|
|
557
|
-
const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
|
|
653
|
+
const references = [...extractFieldReferences(kind, entries, namespace), ...extractOwnerReferenceCandidates(entries, namespace)];
|
|
558
654
|
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
559
655
|
const labelsPath = LABELS_PATH_BY_KIND[kind];
|
|
560
656
|
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
@@ -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]);
|
|
@@ -34,7 +34,9 @@ export interface ParsedFile {
|
|
|
34
34
|
/** Cap on a stored symbol's bodyText — large enough for review context, small
|
|
35
35
|
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
36
36
|
export declare const MAX_BODY_TEXT_CHARS = 4000;
|
|
37
|
-
export declare function parseSource(file: string, source: string
|
|
37
|
+
export declare function parseSource(file: string, source: string, opts?: {
|
|
38
|
+
throwOnParseError?: boolean;
|
|
39
|
+
}): ParsedFile | null;
|
|
38
40
|
/** Map each call site to the innermost symbol whose byte-range contains it.
|
|
39
41
|
* Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
|
|
40
42
|
* startByte, which is not a reliable per-symbol identity: a language whose
|
package/dist/extractors/parse.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { languageFor } from "./languages.js";
|
|
2
2
|
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
3
|
-
|
|
3
|
+
/** Resolved on first parse, not at import: loading the native addons copies six
|
|
4
|
+
* `.node` files into a temp dir and dlopens them (~1.5s+ cold), which every CLI
|
|
5
|
+
* command and every editor hook would otherwise pay just for importing this
|
|
6
|
+
* module. loadNativeTreeSitter() memoizes the runtime itself. */
|
|
7
|
+
const parserRuntime = () => loadNativeTreeSitter().Parser;
|
|
4
8
|
const cache = new Map();
|
|
5
9
|
function bundleFor(spec) {
|
|
6
10
|
let b = cache.get(spec.grammarKey);
|
|
7
11
|
if (!b) {
|
|
12
|
+
const Parser = parserRuntime();
|
|
8
13
|
const parser = new Parser();
|
|
9
14
|
const grammar = spec.loadGrammar();
|
|
10
15
|
parser.setLanguage(grammar);
|
|
@@ -18,7 +23,7 @@ const STR_QUOTES = /^['"`]|['"`]$/g;
|
|
|
18
23
|
/** Cap on a stored symbol's bodyText — large enough for review context, small
|
|
19
24
|
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
20
25
|
export const MAX_BODY_TEXT_CHARS = 4000;
|
|
21
|
-
export function parseSource(file, source) {
|
|
26
|
+
export function parseSource(file, source, opts = {}) {
|
|
22
27
|
const spec = languageFor(file);
|
|
23
28
|
if (!spec)
|
|
24
29
|
return null;
|
|
@@ -44,7 +49,11 @@ export function parseSource(file, source) {
|
|
|
44
49
|
try {
|
|
45
50
|
tree = parser.parse(source, undefined, { bufferSize: Math.max(32 * 1024, source.length * 2 + 1024) });
|
|
46
51
|
}
|
|
47
|
-
catch {
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Index scans need the underlying diagnostic for whole-language failures.
|
|
54
|
+
// Other callers retain the historical best-effort null result.
|
|
55
|
+
if (opts.throwOnParseError)
|
|
56
|
+
throw error;
|
|
48
57
|
return null;
|
|
49
58
|
}
|
|
50
59
|
const symbols = [];
|
|
@@ -47,7 +47,7 @@ export function renderHunchSection(store, root) {
|
|
|
47
47
|
lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
|
|
48
48
|
lines.push("");
|
|
49
49
|
lines.push("**Orient (session/task start):**");
|
|
50
|
-
lines.push("-
|
|
50
|
+
lines.push("- If the host's prompt hook already opened the task and printed a task ID plus a `task verify` command, reuse that exact ID and command — do NOT call `hunch_task(action: \"start\")` for it. Otherwise start the task yourself: call `hunch_task(action: \"start\", title: <short task title>)` once and take `verification_argv` from its result. Each new prompt has its own ID; reuse the ID for follow-up work on the same task and never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
|
|
51
51
|
lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
|
|
52
52
|
lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
|
|
53
53
|
lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
|
|
@@ -73,10 +73,10 @@ export function renderHunchSection(store, root) {
|
|
|
73
73
|
lines.push("- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?");
|
|
74
74
|
lines.push("");
|
|
75
75
|
lines.push("**Before the final response — make Hunch's contribution visible:**");
|
|
76
|
-
lines.push("- When running a relevant check, use the exact verification_argv
|
|
76
|
+
lines.push("- When running a relevant check, use the exact launcher the prompt hook printed — or, on a host without one, the `verification_argv` returned by hunch_task start — followed by the check command and its arguments, from this worktree. It runs `hunch task verify <task_id> -- <command> [arguments]` using the same installation as MCP, avoiding stale global binaries. This retains the actual exit result and source snapshot; raw output is not stored. Do not rerun an expensive check solely for reporting; missing evidence stays unverified.");
|
|
77
77
|
lines.push("- Include the current task_id when calling hunch_record_decision, hunch_record_correction, or hunch_record_finding. The save path records its actual memory home and verifies exact Git revisions when committing or pushing; never infer publication from a successful capture alone.");
|
|
78
78
|
lines.push("- Before claiming an application, call `hunch_report(task_id)` and copy the exact occurrence_id, record_id and content_hash from application_references, adding an action you actually took. Never derive an occurrence ID by replacing a receipt prefix or use the task's scope hash as a record hash. If you did not apply a lesson, omit applications.");
|
|
79
|
-
lines.push("-
|
|
79
|
+
lines.push("- When this task actually used Hunch (a `hunch_*` call carrying the task_id, a verified check, Hunch hook context you acted on, or an application to claim), call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Skip the finish call only when it used none of those AND the host's own stop hook closes the task and shows the evidence for you (its prompt-hook instruction says so); where no host hook closes the task, and for a task you started yourself with `hunch_task(action: \"start\")`, always finish it yourself. Copy the card verbatim, including its Evidence line (the command that renders the local report on demand) and the agent-reported label; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
|
|
80
80
|
lines.push("- If interrupted, finish with `outcome: \"interrupted\"` when possible. `hunch_report(task_id, html: true)` opens the evidence trail by generating a local file; it may contain private memory and is not a public export. If report tools are unavailable after an update, say so and reconnect the host rather than inventing a report.");
|
|
81
81
|
lines.push("");
|
|
82
82
|
lines.push("**Build the Constitution review queue:**");
|
|
@@ -10,17 +10,45 @@ export interface HookInstall {
|
|
|
10
10
|
/** The hook file written; for a non-writing result, the file the user should edit. */
|
|
11
11
|
path: string;
|
|
12
12
|
/** created/appended/updated/unchanged: the block is (now) in a file git reaches.
|
|
13
|
+
* kept-shared: the shared hook already runs a Hunch that is not inside a
|
|
14
|
+
* linked worktree, so it was deliberately left as it is rather than
|
|
15
|
+
* re-pointed at one (issue #316); a success, not a failure.
|
|
13
16
|
* managed-elsewhere: a hook manager owns the hook — nothing was written.
|
|
14
|
-
* unreachable: the existing hook ends in exec/exit before our block
|
|
15
|
-
|
|
17
|
+
* unreachable: the existing hook ends in exec/exit before our block, or the
|
|
18
|
+
* block in it has no closing marker and so cannot be replaced in place —
|
|
19
|
+
* nothing was written. */
|
|
20
|
+
action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable";
|
|
16
21
|
manager?: HookManagerKind;
|
|
17
22
|
/** Why nothing was written (non-writing actions only). */
|
|
18
23
|
reason?: string;
|
|
19
24
|
/** What to add to `path` by hand (non-writing actions only). */
|
|
20
25
|
snippet?: string;
|
|
21
|
-
/** The file already carries a Hunch block
|
|
22
|
-
* REPLACES it rather than being added
|
|
26
|
+
/** The file already carries a Hunch block — a dead one, or one that cannot be
|
|
27
|
+
* updated in place: the snippet REPLACES it rather than being added
|
|
28
|
+
* (non-writing actions only). */
|
|
23
29
|
stale?: boolean;
|
|
30
|
+
/** The block that could not be updated still RUNS as it is (it only lacks its
|
|
31
|
+
* closing marker), so the failure is this run's update, not the install. */
|
|
32
|
+
live?: boolean;
|
|
33
|
+
/** The block in the file deliberately runs the repo's EXISTING shared
|
|
34
|
+
* invocation instead of the caller's worktree-bound one (issue #316). Set on
|
|
35
|
+
* kept-shared, and on created/appended/updated when that substitution happened. */
|
|
36
|
+
sharedInvocation?: string;
|
|
37
|
+
/** The options the existing SHARED block carries that this run did not ask for
|
|
38
|
+
* and that were kept anyway — its own flags (BLOCK_FLAGS), e.g. a strict
|
|
39
|
+
* pre-commit guard that a worktree's advisory re-run must not silently
|
|
40
|
+
* downgrade, and the post-commit block's local-only provider line (named
|
|
41
|
+
* `HUNCH_SYNTH_PROVIDER=deterministic`). Set on kept-shared, and on updated
|
|
42
|
+
* when the run also added an option of its own. */
|
|
43
|
+
keptFlags?: string[];
|
|
44
|
+
/** The options this run ADDED to the existing shared block (updated only). */
|
|
45
|
+
addedFlags?: string[];
|
|
46
|
+
/** Set when a DEAD block was rebuilt from the repo's shared launcher and the
|
|
47
|
+
* previous block's own option flags were not requested again. Reported, not
|
|
48
|
+
* restored: the flags come from the caller's options, so re-adding one would
|
|
49
|
+
* invent a request nobody made — but losing a strict guard in silence is
|
|
50
|
+
* exactly the issue-#316 failure mode. */
|
|
51
|
+
droppedFlags?: string[];
|
|
24
52
|
}
|
|
25
53
|
/** Portable invocation for text that lands in a TRACKED file (a husky script, a
|
|
26
54
|
* committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
|
|
@@ -125,4 +153,15 @@ export declare function hookInvocationLines(report: HookReport, running: string)
|
|
|
125
153
|
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
126
154
|
* the manager's own file. */
|
|
127
155
|
export declare function formatHookInstall(root: string, label: string, h: HookInstall, detail?: string): string[];
|
|
156
|
+
/** The closing line `hunch init` prints about the SHARED hooks dir (issue #316).
|
|
157
|
+
* Setup used to claim "sharing the repo's hooks + memory" unconditionally —
|
|
158
|
+
* true of memory, but the hooks dir is genuinely shared, so a block pointing
|
|
159
|
+
* inside a linked worktree dies for every checkout when that worktree goes.
|
|
160
|
+
*
|
|
161
|
+
* The verdict is read from what the shared blocks ACTUALLY run, not from this
|
|
162
|
+
* run's actions: a second `hunch init` writes nothing yet the hooks are just as
|
|
163
|
+
* broken, and the main checkout — which never used to get a note at all — is
|
|
164
|
+
* where the user can most easily fix it. `installs` only decides between the
|
|
165
|
+
* healthy variants. `[]` when the repo has no linked worktree. Read-only. */
|
|
166
|
+
export declare function sharedHooksNote(root: string, installs: HookInstall[]): string[];
|
|
128
167
|
export {};
|