@davesheffer/hunch 1.9.4 → 1.10.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 +65 -9
- package/dist/core/drift.js +30 -2
- package/dist/core/format.js +6 -0
- package/dist/core/ids.js +6 -0
- package/dist/core/io.js +64 -9
- package/dist/core/types.js +25 -1
- package/dist/extractors/git.js +106 -31
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +11 -5
- package/dist/integrations/gitignore.js +2 -0
- package/dist/integrations/providers.js +16 -6
- package/dist/integrations/scaffold.js +34 -5
- package/dist/integrations/team.js +1 -0
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +109 -9
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +38 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/store/schema.js +1 -1
- package/dist/synthesis/provider.js +29 -3
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -222,12 +222,26 @@ program
|
|
|
222
222
|
if (opts.autoCommit === false) {
|
|
223
223
|
const localFile = join(paths.hunch, "local.json");
|
|
224
224
|
let existing = {};
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
if (existsSync(localFile)) {
|
|
226
|
+
// Same contract as every other writer of this file (issue #40): an
|
|
227
|
+
// unparseable or non-object local.json may hold the private-overlay
|
|
228
|
+
// pointer — rewriting it from a swallowed parse failure silently
|
|
229
|
+
// re-routes private captures into the committed public store.
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(readFileSync(localFile, "utf8"));
|
|
232
|
+
existing = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
existing = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (existing === null) {
|
|
239
|
+
console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
243
|
+
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
227
244
|
}
|
|
228
|
-
catch { /* absent/invalid → fresh */ }
|
|
229
|
-
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
230
|
-
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
231
245
|
}
|
|
232
246
|
if (isGitRepo(root)) {
|
|
233
247
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
@@ -259,7 +273,10 @@ program
|
|
|
259
273
|
console.log(` ⚠ skipped .mcp.json: ${e.message}`);
|
|
260
274
|
}
|
|
261
275
|
const cmds = writeSlashCommands(root);
|
|
262
|
-
console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
276
|
+
console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
|
|
277
|
+
for (const s of cmds.skipped) {
|
|
278
|
+
console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
|
|
279
|
+
}
|
|
263
280
|
const cmd = updateClaudeMd(root, store);
|
|
264
281
|
console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
|
|
265
282
|
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
@@ -374,7 +391,11 @@ program
|
|
|
374
391
|
// commit is drafted — not per-commit, and not under --deep (an ensemble may
|
|
375
392
|
// fan out to several distinct workers, each with its own configuration).
|
|
376
393
|
if (!opts.deep && commits.length > 0) {
|
|
377
|
-
|
|
394
|
+
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
395
|
+
// inside syncCommit resolves (persisted local.json preference included) —
|
|
396
|
+
// rootless resolution could grade a different provider and swallow the
|
|
397
|
+
// num_ctx warning for an actual Ollama run (issue #46).
|
|
398
|
+
const ctxProvider = await selectProvider({ root });
|
|
378
399
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
379
400
|
if (ctxWarning)
|
|
380
401
|
console.log(ctxWarning);
|
|
@@ -3354,6 +3375,13 @@ const vetoCmd = program
|
|
|
3354
3375
|
store.close();
|
|
3355
3376
|
return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
|
|
3356
3377
|
}
|
|
3378
|
+
// Same guard for --commit: an unfetched/mistyped sha would enumerate zero
|
|
3379
|
+
// files and exit 0 — a vacuous pass where CI expects the Decision Guard
|
|
3380
|
+
// to have actually looked (issue #45).
|
|
3381
|
+
if (opts.commit && !revExists(opts.commit, root)) {
|
|
3382
|
+
store.close();
|
|
3383
|
+
return fail(`--commit sha "${opts.commit}" does not resolve. In CI, ensure the commit is fetched (git fetch --depth=... or fetch-depth: 0).`);
|
|
3384
|
+
}
|
|
3357
3385
|
store.reindex();
|
|
3358
3386
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
3359
3387
|
: opts.base ? rangeFiles(opts.base, root)
|
|
@@ -3593,7 +3621,7 @@ program
|
|
|
3593
3621
|
firm: "surfaces + warns on a violating edit",
|
|
3594
3622
|
strict: "edit-time DENY + CI guard — the teeth are on",
|
|
3595
3623
|
};
|
|
3596
|
-
console.log(`\nHunch — enforcement status (${root
|
|
3624
|
+
console.log(`\nHunch — enforcement status (${basename(root)})\n`);
|
|
3597
3625
|
console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
|
|
3598
3626
|
console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
|
|
3599
3627
|
if (blocking.length) {
|
|
@@ -3855,7 +3883,7 @@ program
|
|
|
3855
3883
|
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
3856
3884
|
// not a block; the commit-time `hunch check` does the actual gating.
|
|
3857
3885
|
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
3858
|
-
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length || docGround;
|
|
3886
|
+
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || ctx.findings.length || retired.length || docGround;
|
|
3859
3887
|
if (!hasContent)
|
|
3860
3888
|
return; // no noise on files Hunch hasn't learned yet
|
|
3861
3889
|
let text = formatContext(ctx).trim();
|
|
@@ -4538,6 +4566,34 @@ program
|
|
|
4538
4566
|
store.close();
|
|
4539
4567
|
}
|
|
4540
4568
|
});
|
|
4569
|
+
// ---- findings (the open-observations ledger) --------------------------------
|
|
4570
|
+
program
|
|
4571
|
+
.command("findings")
|
|
4572
|
+
.description("LIVE findings — observed gaps/debt with no fix landed yet (audits, measurements, incidents; anchored to a date + evidence, not a commit). Same store method as the hunch_findings MCP tool. Read-only, advisory.")
|
|
4573
|
+
.argument("[scope]", "a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all")
|
|
4574
|
+
.option("--all", "include resolved/stale findings (the full history)")
|
|
4575
|
+
.action((scope, opts) => {
|
|
4576
|
+
const { store } = storeFor();
|
|
4577
|
+
try {
|
|
4578
|
+
const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
|
|
4579
|
+
const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(opts.all ? () => true : live))
|
|
4580
|
+
.filter(opts.all ? () => true : live);
|
|
4581
|
+
if (!list.length) {
|
|
4582
|
+
console.log(`No ${opts.all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. Record one after an audit: /audit (or hunch_record_finding via MCP).`);
|
|
4583
|
+
return;
|
|
4584
|
+
}
|
|
4585
|
+
for (const f of list) {
|
|
4586
|
+
const links = [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join(" · ");
|
|
4587
|
+
console.log(`• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})`);
|
|
4588
|
+
console.log(` ${f.observation}`);
|
|
4589
|
+
console.log(` concerns: ${[...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)"}${links ? `\n ${links}` : ""}`);
|
|
4590
|
+
}
|
|
4591
|
+
console.log(`\n${list.length} finding(s).`);
|
|
4592
|
+
}
|
|
4593
|
+
finally {
|
|
4594
|
+
store.close();
|
|
4595
|
+
}
|
|
4596
|
+
});
|
|
4541
4597
|
// ---- path (shortest dependency chain) --------------------------------------
|
|
4542
4598
|
program
|
|
4543
4599
|
.command("path")
|
package/dist/core/drift.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
19
|
import { parseDocAnchors } from "./docanchors.js";
|
|
@@ -118,6 +118,31 @@ export function computeDrift(store, root) {
|
|
|
118
118
|
// component vanished). Deterministic hash comparison against the manifest;
|
|
119
119
|
// fires only when a wiki was adopted. Advisory like every other kind here.
|
|
120
120
|
findings.push(...computeWikiDrift(store, root));
|
|
121
|
+
// 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
|
|
122
|
+
// an affected file that no longer exists, or a violates_constraint pointing at a
|
|
123
|
+
// retired/missing rule. Deterministic + advisory (never the exit-code class):
|
|
124
|
+
// the observation may be fixed, moved, or moot — re-verify (re-run its method)
|
|
125
|
+
// and re-record with triage resolved/stale, or refresh its paths.
|
|
126
|
+
for (const f of store.recs("findings")) {
|
|
127
|
+
if (f.triage === "resolved" || f.triage === "stale")
|
|
128
|
+
continue;
|
|
129
|
+
for (const file of f.affected_files) {
|
|
130
|
+
if (!file || file.includes("*"))
|
|
131
|
+
continue; // globs can't dead-ref
|
|
132
|
+
if (!existsSync(join(root, file))) {
|
|
133
|
+
findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" references missing file "${file}" — re-verify${f.method ? ` (${f.method})` : ""} and re-record, or mark it stale` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (f.violates_constraint) {
|
|
137
|
+
const con = store.getRec("constraints", f.violates_constraint);
|
|
138
|
+
if (!con) {
|
|
139
|
+
findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" claims to violate ${f.violates_constraint}, which does not exist — link the real rule or record it (hunch_record_correction)` });
|
|
140
|
+
}
|
|
141
|
+
else if (con.status === "retired") {
|
|
142
|
+
findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" violates ${f.violates_constraint}, but that constraint is RETIRED — resolve the finding or re-link it` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
121
146
|
return { findings };
|
|
122
147
|
}
|
|
123
148
|
/** Resolve a decision file reference without making private-memory paths depend on
|
|
@@ -138,7 +163,10 @@ function referenceExists(store, root, decisionId, ref) {
|
|
|
138
163
|
// A private-scoped reference is an overlay-repo-relative path, not an escape
|
|
139
164
|
// hatch into arbitrary local files.
|
|
140
165
|
const rel = relative(privateRoot, candidate);
|
|
141
|
-
|
|
166
|
+
// NOTE: sep, not an escaped literal — `"\\\\"` in a template is the TWO-char
|
|
167
|
+
// string `\\`, which `relative()` never produces, silently disabling the
|
|
168
|
+
// containment check on Windows (issue #31).
|
|
169
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
142
170
|
return false;
|
|
143
171
|
return existsSync(candidate);
|
|
144
172
|
}
|
package/dist/core/format.js
CHANGED
|
@@ -21,6 +21,12 @@ export function formatContext(ctx) {
|
|
|
21
21
|
for (const b of ctx.bugs)
|
|
22
22
|
out.push(`- [${b.status}/${b.severity}] ${b.title} — root cause: ${b.root_cause}${prov(b.provenance)}`);
|
|
23
23
|
}
|
|
24
|
+
if (ctx.findings.length) {
|
|
25
|
+
out.push(`\n## 🔍 Known findings (observed, unresolved — no fix landed yet)`);
|
|
26
|
+
for (const f of ctx.findings) {
|
|
27
|
+
out.push(`- [${f.triage}/${f.severity}] ${f.title} — ${f.observation}${prov(f.provenance)}\n (${f.id}; observed ${f.observed_at.slice(0, 10)}${f.violates_constraint ? `; violates ${f.violates_constraint}` : ""}${f.method ? `; re-verify via ${f.method}` : ""})`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
24
30
|
if (ctx.blast_radius.length) {
|
|
25
31
|
out.push(`\n## 💥 Blast radius (transitive dependents)`);
|
|
26
32
|
out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
|
package/dist/core/ids.js
CHANGED
|
@@ -43,4 +43,10 @@ export function runbookId(seed) {
|
|
|
43
43
|
export function constraintId(statement) {
|
|
44
44
|
return "con_" + shortHash(statement.trim().toLowerCase());
|
|
45
45
|
}
|
|
46
|
+
/** Finding id seeded by its title (trim + lowercase, same idiom as constraints):
|
|
47
|
+
* re-recording the same observation UPDATES it (e.g. a triage change) instead of
|
|
48
|
+
* minting a duplicate. A genuinely new observation deserves a new title. */
|
|
49
|
+
export function findingId(title) {
|
|
50
|
+
return "fnd_" + shortHash(title.trim().toLowerCase());
|
|
51
|
+
}
|
|
46
52
|
//# sourceMappingURL=ids.js.map
|
package/dist/core/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { linkSync,
|
|
2
|
+
import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
let counter = 0;
|
|
4
5
|
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
5
6
|
const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
@@ -7,6 +8,13 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
7
8
|
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
8
9
|
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
9
10
|
*
|
|
11
|
+
* Durability (issue #34): the temp file is fsync'd BEFORE the rename, and the
|
|
12
|
+
* parent directory best-effort after it. A process kill was always safe (page
|
|
13
|
+
* cache preserves ordering), but on power loss / OS crash the rename's metadata
|
|
14
|
+
* could reach disk before the temp file's data blocks — leaving the target
|
|
15
|
+
* present but truncated or garbage, the exact state the atomic-write invariant
|
|
16
|
+
* (con_902759b3dc) exists to prevent.
|
|
17
|
+
*
|
|
10
18
|
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
11
19
|
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
12
20
|
* while a CLI writes. Retry that atomic replacement with bounded backoff. If the
|
|
@@ -17,19 +25,42 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
17
25
|
export function writeFileAtomic(file, data) {
|
|
18
26
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
19
27
|
try {
|
|
20
|
-
|
|
28
|
+
const fd = openSync(tmp, "w");
|
|
29
|
+
try {
|
|
30
|
+
writeSync(fd, data);
|
|
31
|
+
fsyncSync(fd); // data blocks reach disk before the rename's metadata can
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
21
36
|
}
|
|
22
37
|
catch (e) {
|
|
23
|
-
|
|
38
|
+
cleanupTmp(tmp);
|
|
24
39
|
throw e;
|
|
25
40
|
}
|
|
26
41
|
try {
|
|
27
42
|
renameWithContentionRetry(tmp, file);
|
|
28
43
|
}
|
|
29
44
|
catch (e) {
|
|
30
|
-
|
|
45
|
+
cleanupTmp(tmp);
|
|
31
46
|
throw e;
|
|
32
47
|
}
|
|
48
|
+
fsyncDirBestEffort(dirname(file));
|
|
49
|
+
}
|
|
50
|
+
/** Persist the rename itself (the directory entry). POSIX semantics; Windows
|
|
51
|
+
* cannot open directories for fsync, so this is a silent no-op there — NTFS
|
|
52
|
+
* journals the metadata on its own schedule. */
|
|
53
|
+
function fsyncDirBestEffort(dir) {
|
|
54
|
+
try {
|
|
55
|
+
const fd = openSync(dir, "r");
|
|
56
|
+
try {
|
|
57
|
+
fsyncSync(fd);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* platform without directory fsync — best effort by contract */ }
|
|
33
64
|
}
|
|
34
65
|
function renameWithContentionRetry(from, to) {
|
|
35
66
|
for (let attempt = 0;; attempt++) {
|
|
@@ -55,7 +86,7 @@ function isRenameContention(error) {
|
|
|
55
86
|
export function writeFileAtomicIfAbsent(file, data) {
|
|
56
87
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
57
88
|
try {
|
|
58
|
-
|
|
89
|
+
writeFileAtomicTmp(tmp, data);
|
|
59
90
|
linkSync(tmp, file);
|
|
60
91
|
return true;
|
|
61
92
|
}
|
|
@@ -65,15 +96,39 @@ export function writeFileAtomicIfAbsent(file, data) {
|
|
|
65
96
|
throw error;
|
|
66
97
|
}
|
|
67
98
|
finally {
|
|
68
|
-
|
|
99
|
+
// Not silently best-effort (issue #38): after linkSync succeeds the target
|
|
100
|
+
// shares the temp inode, so a swallowed unlink failure (a Windows AV scanner
|
|
101
|
+
// or indexer briefly holding tmp) leaves the published file with nlink=2 —
|
|
102
|
+
// which validateExistingFile rejects as hard-linked — plus a stray .tmp.
|
|
103
|
+
cleanupTmp(tmp);
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
|
-
|
|
106
|
+
/** Write + fsync a fresh temp file (shared by both atomic writers). */
|
|
107
|
+
function writeFileAtomicTmp(tmp, data) {
|
|
108
|
+
const fd = openSync(tmp, "w");
|
|
109
|
+
try {
|
|
110
|
+
writeSync(fd, data);
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Remove a temp file, riding out a transient external hold (AV/indexer) with one
|
|
118
|
+
* short retry; a persistent failure is REPORTED, never swallowed — a leaked tmp
|
|
119
|
+
* beside a hard-link-published target keeps that target at nlink=2. */
|
|
120
|
+
function cleanupTmp(p) {
|
|
72
121
|
try {
|
|
73
122
|
rmSync(p, { force: true });
|
|
123
|
+
return;
|
|
74
124
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
125
|
+
catch { /* transient hold — retry once below */ }
|
|
126
|
+
Atomics.wait(renameRetryWaiter, 0, 0, 50);
|
|
127
|
+
try {
|
|
128
|
+
rmSync(p, { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.warn(`[hunch] temp file left behind (its published target keeps nlink=2 until it is removed): ${p} (${e.message})`);
|
|
77
132
|
}
|
|
78
133
|
}
|
|
79
134
|
//# sourceMappingURL=io.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -218,8 +218,31 @@ export const RunbookSchema = z.object({
|
|
|
218
218
|
provenance: ProvenanceSchema,
|
|
219
219
|
date: z.string(),
|
|
220
220
|
});
|
|
221
|
+
/** An OBSERVATION — audited knowledge with no diff (the anchor is a date + evidence,
|
|
222
|
+
* not a commit). Fills the gap between Bug (broke and got fixed) and Decision (chose
|
|
223
|
+
* and changed code): "we looked, we found, we haven't acted yet". Examples: an audit
|
|
224
|
+
* that surfaced unscoped tenant queries, a measured perf number, a vendor limit, an
|
|
225
|
+
* incident with no code fix. ADVISORY retrieval context (pre-edit grounding + MCP);
|
|
226
|
+
* never enters any block path. Lifecycle is `triage`, not valid-time: a finding is
|
|
227
|
+
* resolved/stale-marked, never superseded. */
|
|
228
|
+
export const FindingSchema = z.object({
|
|
229
|
+
id: z.string().describe("fnd_*"),
|
|
230
|
+
title: z.string(),
|
|
231
|
+
observation: z.string().default("").describe("what was observed, in plain words"),
|
|
232
|
+
evidence: z.array(z.string()).default([]).describe("the query/command run + representative output — a finding without evidence is an opinion"),
|
|
233
|
+
method: z.string().nullable().default(null).describe("rb_* runbook that re-runs the audit (makes the finding re-verifiable)"),
|
|
234
|
+
severity: z.enum(["low", "medium", "high", "critical"]).default("medium"),
|
|
235
|
+
triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).default("open"),
|
|
236
|
+
affected_files: z.array(z.string()).default([]).describe("concrete paths or globs the observation concerns"),
|
|
237
|
+
affected_symbols: z.array(z.string()).default([]).describe("symbols/objects concerned (e.g. dbo.GetOrders)"),
|
|
238
|
+
violates_constraint: z.string().nullable().default(null).describe("con_* this finding is a known violation of"),
|
|
239
|
+
spawned_decision: z.string().nullable().default(null).describe("dec_* recorded in response to this finding"),
|
|
240
|
+
observed_at: z.string().describe("ISO instant the observation was made — the anchor (findings have no commit)"),
|
|
241
|
+
resolved_commit: z.string().nullable().default(null).describe("the commit that fixed it (set when triage becomes resolved)"),
|
|
242
|
+
provenance: ProvenanceSchema,
|
|
243
|
+
});
|
|
221
244
|
/** The entity collections, keyed by their on-disk directory name. */
|
|
222
|
-
export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks"];
|
|
245
|
+
export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
|
|
223
246
|
export const SCHEMAS = {
|
|
224
247
|
components: ComponentSchema,
|
|
225
248
|
edges: EdgeSchema,
|
|
@@ -228,6 +251,7 @@ export const SCHEMAS = {
|
|
|
228
251
|
bugs: BugSchema,
|
|
229
252
|
constraints: ConstraintSchema,
|
|
230
253
|
runbooks: RunbookSchema,
|
|
254
|
+
findings: FindingSchema,
|
|
231
255
|
};
|
|
232
256
|
/** Default provenance helper for deterministic (extracted) records. */
|
|
233
257
|
export function extracted(confidence, evidence = []) {
|
package/dist/extractors/git.js
CHANGED
|
@@ -166,6 +166,39 @@ function canonicalPath(path) {
|
|
|
166
166
|
export function gitNullDevice() {
|
|
167
167
|
return process.platform === "win32" ? "NUL" : devNull;
|
|
168
168
|
}
|
|
169
|
+
/** How long a live gitindex.lock can plausibly be held: every Hunch git spawn
|
|
170
|
+
* carries a timeout well under this, so an OLDER lock provably has no living
|
|
171
|
+
* owner in any Hunch flow. */
|
|
172
|
+
const STALE_INDEX_LOCK_MS = 30_000;
|
|
173
|
+
/** Heal a stranded `.git/index.lock` (issue #53). Two ways one appears:
|
|
174
|
+
* (a) THIS call's git was timeout-killed — TerminateProcess on Windows skips
|
|
175
|
+
* git's cleanup, so a lock created at/after this attempt started is ours;
|
|
176
|
+
* (b) a PREVIOUS run crashed/was killed — git then fails FAST forever after,
|
|
177
|
+
* and the best-effort flush paths swallow it, so captures keep "succeeding"
|
|
178
|
+
* while nothing commits. A pre-existing lock older than any live git's
|
|
179
|
+
* possible hold time has no living owner and is safe to remove.
|
|
180
|
+
* Returns true when a lock was removed (a retry is then sensible). */
|
|
181
|
+
function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
|
|
182
|
+
const killed = error?.code === "ETIMEDOUT" || !!error?.signal;
|
|
183
|
+
try {
|
|
184
|
+
const rel = execFileSync("git", ["-C", repoDir, "rev-parse", "--git-path", "index.lock"], {
|
|
185
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 5_000,
|
|
186
|
+
}).trim();
|
|
187
|
+
const lockPath = isAbsolute(rel) ? rel : join(repoDir, rel);
|
|
188
|
+
const mtimeMs = statSync(lockPath).mtimeMs;
|
|
189
|
+
const stranded = killed
|
|
190
|
+
? mtimeMs >= sinceMs // created by the git we just killed
|
|
191
|
+
: mtimeMs <= Date.now() - STALE_INDEX_LOCK_MS; // left behind long before this attempt
|
|
192
|
+
if (!stranded)
|
|
193
|
+
return false;
|
|
194
|
+
rmSync(lockPath, { force: true });
|
|
195
|
+
console.error(`hunch: removed a stranded index.lock at "${repoDir}" (${killed ? "this git operation timed out" : "left by an earlier interrupted git"}); retrying.`);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false; // no lock present, or git itself unavailable — nothing to heal
|
|
200
|
+
}
|
|
201
|
+
}
|
|
169
202
|
/** Compare physical directory identity before path text. Git for Windows can
|
|
170
203
|
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
171
204
|
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
@@ -515,13 +548,21 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
515
548
|
GIT_ATTR_NOSYSTEM: "1",
|
|
516
549
|
});
|
|
517
550
|
const run = (args) => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
551
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
552
|
+
const startedAt = Date.now();
|
|
553
|
+
try {
|
|
554
|
+
execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
// best-effort: nothing staged / not a repo / offline — EXCEPT a
|
|
559
|
+
// stranded index.lock, which would otherwise fail every future
|
|
560
|
+
// flush silently (issue #53); heal it and retry once.
|
|
561
|
+
if (!clearStrandedIndexLock(hunchDir, env, startedAt, error))
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
524
564
|
}
|
|
565
|
+
return false;
|
|
525
566
|
};
|
|
526
567
|
if (opts.push !== false) {
|
|
527
568
|
if (!overlayAttributeSourcesAreSafe(hunchDir, env)) {
|
|
@@ -535,7 +576,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
535
576
|
// remote .gitignore, local info/exclude, or ambient excludesFile must not
|
|
536
577
|
// be able to silently stop the shared graph's heartbeat.
|
|
537
578
|
for (let index = 0; index < paths.length; index += 128) {
|
|
538
|
-
if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "add", "-f", "--", ...paths.slice(index, index + 128)])) {
|
|
579
|
+
if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "-f", "--", ...paths.slice(index, index + 128)])) {
|
|
539
580
|
run(["reset", "-q", "--", "."]);
|
|
540
581
|
return null;
|
|
541
582
|
}
|
|
@@ -573,9 +614,12 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
573
614
|
// docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
|
|
574
615
|
// bug_overlay_clobber detection above nor sweep user edits.
|
|
575
616
|
for (const file of opts.alsoStage ?? []) {
|
|
617
|
+
// core.autocrlf=false on every memory add/checkout: the Git-for-Windows
|
|
618
|
+
// installer default (system gitconfig autocrlf=true) would re-encode the
|
|
619
|
+
// graph's JSON bytes in transit, breaking byte-exact content hashes.
|
|
576
620
|
run(opts.push === false
|
|
577
|
-
? ["add", "--", file]
|
|
578
|
-
: ["-c", `core.attributesFile=${gitNullDevice()}`, "add", "--", file]);
|
|
621
|
+
? ["-c", "core.autocrlf=false", "add", "--", file]
|
|
622
|
+
: ["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "--", file]);
|
|
579
623
|
}
|
|
580
624
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
581
625
|
// enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
|
|
@@ -595,17 +639,29 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
595
639
|
if (!hooksDir)
|
|
596
640
|
return null;
|
|
597
641
|
const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
"
|
|
605
|
-
|
|
606
|
-
|
|
642
|
+
// One retry after healing a stranded index.lock (issue #53): a lock left by
|
|
643
|
+
// a timeout-killed or crashed git otherwise fails EVERY later flush fast and
|
|
644
|
+
// silently — captures keep reporting success while nothing commits.
|
|
645
|
+
for (let attempt = 0; attempt < 2 && !committed; attempt++) {
|
|
646
|
+
const commitStartedAt = Date.now();
|
|
647
|
+
try {
|
|
648
|
+
execFileSync("git", [
|
|
649
|
+
"-C", hunchDir,
|
|
650
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
651
|
+
...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
|
|
652
|
+
"-c", "core.autocrlf=false",
|
|
653
|
+
"-c", "commit.gpgsign=false",
|
|
654
|
+
"commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
|
|
655
|
+
], { stdio: "ignore", env, timeout: 15_000 });
|
|
656
|
+
committed = true;
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
// Nothing staged / not a repo stays quiet, as before; only a healed
|
|
660
|
+
// stranded lock earns the single retry.
|
|
661
|
+
if (!clearStrandedIndexLock(hunchDir, env, commitStartedAt, error))
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
607
664
|
}
|
|
608
|
-
catch { /* nothing staged / not a repo */ }
|
|
609
665
|
if (!committed)
|
|
610
666
|
return null;
|
|
611
667
|
if (opts.push !== false) {
|
|
@@ -1072,6 +1128,7 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
|
|
|
1072
1128
|
"-C", hunchDir,
|
|
1073
1129
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1074
1130
|
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1131
|
+
"-c", "core.autocrlf=false",
|
|
1075
1132
|
"reset", "--hard", fetchedHead,
|
|
1076
1133
|
], {
|
|
1077
1134
|
stdio: "ignore", env, timeout: 5_000,
|
|
@@ -1118,11 +1175,13 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1118
1175
|
if (!hooksDir)
|
|
1119
1176
|
return "failed";
|
|
1120
1177
|
const tryGit = (args, timeout = timeoutMs) => {
|
|
1178
|
+
const startedAt = Date.now();
|
|
1121
1179
|
try {
|
|
1122
1180
|
execFileSync("git", [
|
|
1123
1181
|
"-C", hunchDir,
|
|
1124
1182
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1125
1183
|
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1184
|
+
"-c", "core.autocrlf=false",
|
|
1126
1185
|
"-c", "commit.gpgsign=false",
|
|
1127
1186
|
...args,
|
|
1128
1187
|
], {
|
|
@@ -1130,7 +1189,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1130
1189
|
});
|
|
1131
1190
|
return true;
|
|
1132
1191
|
}
|
|
1133
|
-
catch {
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
// Same stranding class as the commit path: a timeout-killed merge/fetch
|
|
1194
|
+
// leaves index.lock behind and wedges every later sync (issue #53).
|
|
1195
|
+
clearStrandedIndexLock(hunchDir, env, startedAt, error);
|
|
1134
1196
|
return false;
|
|
1135
1197
|
}
|
|
1136
1198
|
};
|
|
@@ -1516,6 +1578,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
|
1516
1578
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1517
1579
|
attempt = acquireCommitLock(lock);
|
|
1518
1580
|
}
|
|
1581
|
+
// The deadline can expire DURING the final wait+acquire; without this check an
|
|
1582
|
+
// acquire that succeeded on that last iteration returned false while this
|
|
1583
|
+
// process's owner directory held the lock — never released (the caller bails
|
|
1584
|
+
// before its try/finally), wedging every flush in every process until this one
|
|
1585
|
+
// exited (issue #48).
|
|
1586
|
+
if (attempt.state === "acquired")
|
|
1587
|
+
return true;
|
|
1519
1588
|
return false;
|
|
1520
1589
|
}
|
|
1521
1590
|
export function headSha(cwd) {
|
|
@@ -1589,7 +1658,7 @@ export function currentBranch(cwd) {
|
|
|
1589
1658
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1590
1659
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1591
1660
|
export function commitFiles(sha, cwd) {
|
|
1592
|
-
const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1661
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1593
1662
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
1594
1663
|
}
|
|
1595
1664
|
/** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
|
|
@@ -2005,7 +2074,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2005
2074
|
return out;
|
|
2006
2075
|
// churn — one windowed log; tally each wanted path's appearances (= commits).
|
|
2007
2076
|
if (days > 0) {
|
|
2008
|
-
const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2077
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2009
2078
|
if (raw) {
|
|
2010
2079
|
for (const line of raw.split("\n")) {
|
|
2011
2080
|
const e = line && out.get(line);
|
|
@@ -2017,7 +2086,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2017
2086
|
// last commit — one newest-first log; the FIRST time a path appears is its most
|
|
2018
2087
|
// recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
|
|
2019
2088
|
// 256MB buffer for the all-history name-only stream on large repos.
|
|
2020
|
-
const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2089
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2021
2090
|
if (raw) {
|
|
2022
2091
|
let remaining = out.size;
|
|
2023
2092
|
let sha = "";
|
|
@@ -2038,17 +2107,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2038
2107
|
}
|
|
2039
2108
|
return out;
|
|
2040
2109
|
}
|
|
2041
|
-
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2110
|
+
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2111
|
+
*
|
|
2112
|
+
* Every path enumerator here pins `core.quotePath=false` (issue #50): with
|
|
2113
|
+
* git's default quotePath, any path holding bytes > 0x7F comes back
|
|
2114
|
+
* octal-quoted (`"src/caf\303\251.ts"`), which matches neither the store's
|
|
2115
|
+
* POSIX paths nor constraint scope globs — a blocking constraint over such a
|
|
2116
|
+
* file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
|
|
2042
2117
|
export function stagedFiles(cwd) {
|
|
2043
|
-
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2118
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2044
2119
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2045
2120
|
}
|
|
2046
2121
|
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
2047
2122
|
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
2048
2123
|
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
2049
2124
|
export function workingFiles(cwd) {
|
|
2050
|
-
const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2051
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2125
|
+
const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2126
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2052
2127
|
return [...new Set([...changed, ...untracked])].sort();
|
|
2053
2128
|
}
|
|
2054
2129
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
@@ -2060,7 +2135,7 @@ export function revExists(ref, cwd) {
|
|
|
2060
2135
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
2061
2136
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
2062
2137
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
2063
|
-
const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2138
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2064
2139
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2065
2140
|
}
|
|
2066
2141
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
@@ -2089,8 +2164,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
2089
2164
|
* intentionally contribute no synthetic content to regression analysis. */
|
|
2090
2165
|
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
2091
2166
|
let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2092
|
-
const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2093
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2167
|
+
const tracked = new Set(gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2168
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2094
2169
|
const readWorkingFile = createRepoFileReader(cwd);
|
|
2095
2170
|
for (const file of untracked) {
|
|
2096
2171
|
try {
|
|
@@ -2140,7 +2215,7 @@ export function fixCommits(spec, cwd, max = 200) {
|
|
|
2140
2215
|
}
|
|
2141
2216
|
/** All tracked files matching the given extensions. */
|
|
2142
2217
|
export function trackedFiles(cwd, exts) {
|
|
2143
|
-
const out = gitSafe(["ls-files"], cwd);
|
|
2218
|
+
const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
|
|
2144
2219
|
const all = out ? out.split("\n").filter(Boolean) : [];
|
|
2145
2220
|
return all.filter((f) => exts.some((e) => f.endsWith(e)));
|
|
2146
2221
|
}
|
|
@@ -64,7 +64,12 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
|
64
64
|
export function loadNativeTreeSitter() {
|
|
65
65
|
if (runtime)
|
|
66
66
|
return runtime;
|
|
67
|
-
|
|
67
|
+
// Both binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node,
|
|
68
|
+
// while from-source builds are named after the binding.gyp target with
|
|
69
|
+
// underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
|
|
70
|
+
// …). Missing the underscore names let an already-loaded source-built addon slip
|
|
71
|
+
// past this guard and defeat the file-lock isolation entirely (issue #52).
|
|
72
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
|
|
68
73
|
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
69
74
|
if (preloaded.length) {
|
|
70
75
|
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|