@davesheffer/hunch 1.10.0 → 1.10.2
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 +90 -32
- package/dist/core/conformance.js +19 -5
- package/dist/core/constraintmatch.js +11 -3
- package/dist/core/docscan.js +10 -0
- package/dist/core/drift.js +5 -2
- package/dist/core/io.js +64 -9
- package/dist/extractors/diff.js +13 -5
- package/dist/extractors/git.js +114 -29
- package/dist/extractors/languages.js +41 -28
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +18 -4
- package/dist/integrations/providers.js +55 -17
- package/dist/integrations/scaffold.js +21 -5
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +26 -8
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +8 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/synthesis/provider.js +29 -3
- package/dist/wiki/wiki.js +89 -9
- package/package.json +1 -1
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)) {
|
|
@@ -598,18 +639,29 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
598
639
|
if (!hooksDir)
|
|
599
640
|
return null;
|
|
600
641
|
const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
"
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
+
}
|
|
611
664
|
}
|
|
612
|
-
catch { /* nothing staged / not a repo */ }
|
|
613
665
|
if (!committed)
|
|
614
666
|
return null;
|
|
615
667
|
if (opts.push !== false) {
|
|
@@ -659,6 +711,22 @@ export function isGitCleanPath(root, rel) {
|
|
|
659
711
|
return false;
|
|
660
712
|
}
|
|
661
713
|
}
|
|
714
|
+
/** The committed (HEAD) content of a tracked file, or null when the path is
|
|
715
|
+
* untracked/absent at HEAD or git is unavailable. Used to decide whether a
|
|
716
|
+
* dirty grounding doc differs from HEAD ONLY inside its generated section
|
|
717
|
+
* (the stranded-grounding heal, fnd_b269d5c422). */
|
|
718
|
+
export function headFileContent(root, rel) {
|
|
719
|
+
try {
|
|
720
|
+
return execFileSync("git", ["-C", root, "show", `HEAD:${rel.replace(/\\/g, "/")}`], {
|
|
721
|
+
encoding: "utf8",
|
|
722
|
+
env: foreignRepoEnv(process.env),
|
|
723
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
662
730
|
/** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
|
|
663
731
|
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
664
732
|
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
@@ -1123,6 +1191,7 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1123
1191
|
if (!hooksDir)
|
|
1124
1192
|
return "failed";
|
|
1125
1193
|
const tryGit = (args, timeout = timeoutMs) => {
|
|
1194
|
+
const startedAt = Date.now();
|
|
1126
1195
|
try {
|
|
1127
1196
|
execFileSync("git", [
|
|
1128
1197
|
"-C", hunchDir,
|
|
@@ -1136,7 +1205,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1136
1205
|
});
|
|
1137
1206
|
return true;
|
|
1138
1207
|
}
|
|
1139
|
-
catch {
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
// Same stranding class as the commit path: a timeout-killed merge/fetch
|
|
1210
|
+
// leaves index.lock behind and wedges every later sync (issue #53).
|
|
1211
|
+
clearStrandedIndexLock(hunchDir, env, startedAt, error);
|
|
1140
1212
|
return false;
|
|
1141
1213
|
}
|
|
1142
1214
|
};
|
|
@@ -1522,6 +1594,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
|
1522
1594
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1523
1595
|
attempt = acquireCommitLock(lock);
|
|
1524
1596
|
}
|
|
1597
|
+
// The deadline can expire DURING the final wait+acquire; without this check an
|
|
1598
|
+
// acquire that succeeded on that last iteration returned false while this
|
|
1599
|
+
// process's owner directory held the lock — never released (the caller bails
|
|
1600
|
+
// before its try/finally), wedging every flush in every process until this one
|
|
1601
|
+
// exited (issue #48).
|
|
1602
|
+
if (attempt.state === "acquired")
|
|
1603
|
+
return true;
|
|
1525
1604
|
return false;
|
|
1526
1605
|
}
|
|
1527
1606
|
export function headSha(cwd) {
|
|
@@ -1595,7 +1674,7 @@ export function currentBranch(cwd) {
|
|
|
1595
1674
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1596
1675
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1597
1676
|
export function commitFiles(sha, cwd) {
|
|
1598
|
-
const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1677
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1599
1678
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
1600
1679
|
}
|
|
1601
1680
|
/** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
|
|
@@ -2011,7 +2090,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2011
2090
|
return out;
|
|
2012
2091
|
// churn — one windowed log; tally each wanted path's appearances (= commits).
|
|
2013
2092
|
if (days > 0) {
|
|
2014
|
-
const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2093
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2015
2094
|
if (raw) {
|
|
2016
2095
|
for (const line of raw.split("\n")) {
|
|
2017
2096
|
const e = line && out.get(line);
|
|
@@ -2023,7 +2102,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2023
2102
|
// last commit — one newest-first log; the FIRST time a path appears is its most
|
|
2024
2103
|
// recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
|
|
2025
2104
|
// 256MB buffer for the all-history name-only stream on large repos.
|
|
2026
|
-
const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2105
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2027
2106
|
if (raw) {
|
|
2028
2107
|
let remaining = out.size;
|
|
2029
2108
|
let sha = "";
|
|
@@ -2044,17 +2123,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2044
2123
|
}
|
|
2045
2124
|
return out;
|
|
2046
2125
|
}
|
|
2047
|
-
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2126
|
+
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2127
|
+
*
|
|
2128
|
+
* Every path enumerator here pins `core.quotePath=false` (issue #50): with
|
|
2129
|
+
* git's default quotePath, any path holding bytes > 0x7F comes back
|
|
2130
|
+
* octal-quoted (`"src/caf\303\251.ts"`), which matches neither the store's
|
|
2131
|
+
* POSIX paths nor constraint scope globs — a blocking constraint over such a
|
|
2132
|
+
* file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
|
|
2048
2133
|
export function stagedFiles(cwd) {
|
|
2049
|
-
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2134
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2050
2135
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2051
2136
|
}
|
|
2052
2137
|
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
2053
2138
|
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
2054
2139
|
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
2055
2140
|
export function workingFiles(cwd) {
|
|
2056
|
-
const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2057
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2141
|
+
const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2142
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2058
2143
|
return [...new Set([...changed, ...untracked])].sort();
|
|
2059
2144
|
}
|
|
2060
2145
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
@@ -2066,7 +2151,7 @@ export function revExists(ref, cwd) {
|
|
|
2066
2151
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
2067
2152
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
2068
2153
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
2069
|
-
const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2154
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2070
2155
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2071
2156
|
}
|
|
2072
2157
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
@@ -2095,8 +2180,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
2095
2180
|
* intentionally contribute no synthetic content to regression analysis. */
|
|
2096
2181
|
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
2097
2182
|
let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2098
|
-
const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2099
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2183
|
+
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));
|
|
2184
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2100
2185
|
const readWorkingFile = createRepoFileReader(cwd);
|
|
2101
2186
|
for (const file of untracked) {
|
|
2102
2187
|
try {
|
|
@@ -2146,7 +2231,7 @@ export function fixCommits(spec, cwd, max = 200) {
|
|
|
2146
2231
|
}
|
|
2147
2232
|
/** All tracked files matching the given extensions. */
|
|
2148
2233
|
export function trackedFiles(cwd, exts) {
|
|
2149
|
-
const out = gitSafe(["ls-files"], cwd);
|
|
2234
|
+
const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
|
|
2150
2235
|
const all = out ? out.split("\n").filter(Boolean) : [];
|
|
2151
2236
|
return all.filter((f) => exts.some((e) => f.endsWith(e)));
|
|
2152
2237
|
}
|
|
@@ -6,19 +6,25 @@
|
|
|
6
6
|
* four files.
|
|
7
7
|
*/
|
|
8
8
|
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
9
|
-
const TS_QUERY = `
|
|
10
|
-
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
11
|
-
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
12
|
-
(method_definition name: (property_identifier) @method.name) @method.def
|
|
13
|
-
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
14
|
-
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
15
|
-
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
16
|
-
(variable_declarator
|
|
17
|
-
name: (identifier) @arrow.name
|
|
18
|
-
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
19
|
-
(import_statement source: (string) @import.src)
|
|
20
|
-
(call_expression function: (identifier) @call.id)
|
|
21
|
-
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
9
|
+
const TS_QUERY = `
|
|
10
|
+
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
11
|
+
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
12
|
+
(method_definition name: (property_identifier) @method.name) @method.def
|
|
13
|
+
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
14
|
+
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
15
|
+
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
16
|
+
(variable_declarator
|
|
17
|
+
name: (identifier) @arrow.name
|
|
18
|
+
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
19
|
+
(import_statement source: (string) @import.src)
|
|
20
|
+
(call_expression function: (identifier) @call.id)
|
|
21
|
+
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
22
|
+
;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
|
|
23
|
+
;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
|
|
24
|
+
;; change came back empty, and a \`not-calls\` conformance predicate over a class
|
|
25
|
+
;; could never see its own counterexample.
|
|
26
|
+
(new_expression constructor: (identifier) @call.id)
|
|
27
|
+
(new_expression constructor: (member_expression property: (property_identifier) @call.member))
|
|
22
28
|
`;
|
|
23
29
|
const TS_BUILTIN_METHODS = new Set([
|
|
24
30
|
"map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
|
|
@@ -65,21 +71,28 @@ const TSX = {
|
|
|
65
71
|
grammarKey: "tsx",
|
|
66
72
|
loadGrammar: () => loadNativeTreeSitter().tsx,
|
|
67
73
|
};
|
|
68
|
-
const PY_QUERY = `
|
|
69
|
-
(class_definition
|
|
70
|
-
name: (identifier) @class.name
|
|
71
|
-
body: (block
|
|
72
|
-
[
|
|
73
|
-
(function_definition name: (identifier) @method.name) @method.def
|
|
74
|
-
(decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
|
|
75
|
-
])) @class.def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(
|
|
74
|
+
const PY_QUERY = `
|
|
75
|
+
(class_definition
|
|
76
|
+
name: (identifier) @class.name
|
|
77
|
+
body: (block
|
|
78
|
+
[
|
|
79
|
+
(function_definition name: (identifier) @method.name) @method.def
|
|
80
|
+
(decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
|
|
81
|
+
])) @class.def
|
|
82
|
+
;; Every class, including one with no directly-nested def: dataclasses, Exception
|
|
83
|
+
;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
|
|
84
|
+
;; were invisible to the entire graph (no symbol, no component, no edges), so
|
|
85
|
+
;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
|
|
86
|
+
;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
|
|
87
|
+
;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
|
|
88
|
+
(class_definition name: (identifier) @class.name) @class.def
|
|
89
|
+
(function_definition name: (identifier) @fn.name) @fn.def
|
|
90
|
+
(import_statement name: (dotted_name) @import.src)
|
|
91
|
+
(import_statement name: (aliased_import name: (dotted_name) @import.src))
|
|
92
|
+
(import_from_statement module_name: (dotted_name) @import.src)
|
|
93
|
+
(import_from_statement module_name: (relative_import) @import.src)
|
|
94
|
+
(call function: (identifier) @call.id)
|
|
95
|
+
(call function: (attribute attribute: (identifier) @call.member))
|
|
83
96
|
`;
|
|
84
97
|
const PY_BUILTIN_METHODS = new Set([
|
|
85
98
|
"get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
|
|
@@ -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(", ")}`);
|
|
@@ -56,7 +56,8 @@ export function parseTestReport(output) {
|
|
|
56
56
|
// Collect the following more-indented diagnostic block as the message.
|
|
57
57
|
const baseIndent = leadingSpaces(raw);
|
|
58
58
|
const block = [];
|
|
59
|
-
|
|
59
|
+
let j = i + 1;
|
|
60
|
+
for (; j < lines.length; j++) {
|
|
60
61
|
const ln = lines[j];
|
|
61
62
|
if (ln.trim() === "") {
|
|
62
63
|
block.push("");
|
|
@@ -68,6 +69,11 @@ export function parseTestReport(output) {
|
|
|
68
69
|
}
|
|
69
70
|
const diag = block.join("\n").trim();
|
|
70
71
|
failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
|
|
72
|
+
// Skip the consumed diagnostic block (issue #51): re-visiting it let
|
|
73
|
+
// TAP-looking text QUOTED INSIDE an error message (assertion diffs in this
|
|
74
|
+
// very repo quote "ok N - …" lines) parse as real results — a phantom pass
|
|
75
|
+
// can mark a previously-open bug fixed without any test having re-run.
|
|
76
|
+
i = j - 1;
|
|
71
77
|
}
|
|
72
78
|
// A test can legitimately appear as both (flaky retry) — trust the failure.
|
|
73
79
|
for (const name of failMap.keys())
|
|
@@ -3,12 +3,24 @@
|
|
|
3
3
|
* context loaded every session for free"). We own ONLY the region between the
|
|
4
4
|
* HUNCH markers — any user-authored content outside it is preserved verbatim.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync,
|
|
7
|
-
import {
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
8
|
+
import { basename, join, dirname } from "node:path";
|
|
8
9
|
import { wikiSummary } from "../wiki/wiki.js";
|
|
9
10
|
import { PolicyRepository } from "../constitution/repository.js";
|
|
10
11
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
11
12
|
const END = "<!-- HUNCH:END -->";
|
|
13
|
+
/** Remove the managed HUNCH section (markers inclusive), leaving only the
|
|
14
|
+
* user-authored surroundings. Lets a caller decide whether two versions of a
|
|
15
|
+
* doc differ ONLY in generated content (the stranded-grounding heal,
|
|
16
|
+
* fnd_b269d5c422): equal outside the block ⇒ regenerating cannot lose prose. */
|
|
17
|
+
export function stripManagedSection(text) {
|
|
18
|
+
const iStart = text.indexOf(START);
|
|
19
|
+
const iEnd = text.indexOf(END);
|
|
20
|
+
if (iStart < 0 || iEnd <= iStart)
|
|
21
|
+
return text;
|
|
22
|
+
return text.slice(0, iStart) + text.slice(iEnd + END.length);
|
|
23
|
+
}
|
|
12
24
|
export function renderHunchSection(store, root) {
|
|
13
25
|
const constraints = store.json
|
|
14
26
|
.loadAll("constraints")
|
|
@@ -103,12 +115,14 @@ export function upsertSection(file, section, fallbackTitle) {
|
|
|
103
115
|
content = `${fallbackTitle}\n\n${section}\n`;
|
|
104
116
|
}
|
|
105
117
|
mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
|
|
106
|
-
|
|
118
|
+
// Atomic: this file carries the USER'S prose around the managed block — a torn
|
|
119
|
+
// write must not be able to truncate it (issue #43).
|
|
120
|
+
writeFileAtomic(file, content);
|
|
107
121
|
return file;
|
|
108
122
|
}
|
|
109
123
|
/** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
|
|
110
124
|
export function updateClaudeMd(root, store) {
|
|
111
|
-
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root
|
|
125
|
+
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
|
|
112
126
|
}
|
|
113
127
|
function sev(s) {
|
|
114
128
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
* Every writer MERGES into existing files (preserving other servers / user prose)
|
|
18
18
|
* and is idempotent, so re-running `hunch init` is safe.
|
|
19
19
|
*/
|
|
20
|
-
import { readFileSync,
|
|
20
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
21
22
|
import { homedir } from "node:os";
|
|
22
23
|
import { join, dirname } from "node:path";
|
|
23
|
-
import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
24
|
-
import { isGitCleanPath } from "../extractors/git.js";
|
|
24
|
+
import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
25
|
+
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
25
26
|
/** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
|
|
26
27
|
* (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
|
|
27
28
|
* .vscode/mcp.json is JSONC, so we must tolerate comments. */
|
|
@@ -135,7 +136,9 @@ function tomlStr(s) {
|
|
|
135
136
|
}
|
|
136
137
|
function writeJson(file, obj) {
|
|
137
138
|
mkdirSync(dirname(file), { recursive: true });
|
|
138
|
-
|
|
139
|
+
// Atomic: these files hold the USER'S merged servers/hooks — a torn write would
|
|
140
|
+
// leave them unparseable, which every writer here then refuses to touch (#43).
|
|
141
|
+
writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
|
|
139
142
|
return file;
|
|
140
143
|
}
|
|
141
144
|
/** Provider hook commands live in tracked config files, so use the structured
|
|
@@ -148,7 +151,14 @@ function hookCommand(inv, provider) {
|
|
|
148
151
|
function isHunchProviderHook(entry) {
|
|
149
152
|
const e = entry && typeof entry === "object" ? entry : null;
|
|
150
153
|
const command = typeof e?.command === "string" ? e.command : "";
|
|
151
|
-
|
|
154
|
+
// Anchored to the exact shape hookCommand() writes — JSON-quoted parts ending
|
|
155
|
+
// in "hook" "--provider" "<name>" — plus a Hunch launcher (the pinned npm
|
|
156
|
+
// package spec, or a quoted …/index.js|ts path for source installs). The old
|
|
157
|
+
// unanchored /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries
|
|
158
|
+
// like `node ./hook/index.js` as ours and silently deleted them, violating
|
|
159
|
+
// the leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
|
|
160
|
+
return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts)")/.test(command)
|
|
161
|
+
&& /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
152
162
|
}
|
|
153
163
|
/** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
|
|
154
164
|
* We replace only old Hunch commands and leave every foreign hook in place. */
|
|
@@ -255,7 +265,7 @@ export function writeCodexConfig(root, inv) {
|
|
|
255
265
|
}
|
|
256
266
|
base = base.trimEnd();
|
|
257
267
|
mkdirSync(dirname(file), { recursive: true });
|
|
258
|
-
|
|
268
|
+
writeFileAtomic(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
|
|
259
269
|
return file;
|
|
260
270
|
}
|
|
261
271
|
/** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
|
|
@@ -273,7 +283,7 @@ export function writeCursorRule(root, store) {
|
|
|
273
283
|
const file = join(root, ".cursor", "rules", "hunch.mdc");
|
|
274
284
|
const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
|
|
275
285
|
mkdirSync(dirname(file), { recursive: true });
|
|
276
|
-
|
|
286
|
+
writeFileAtomic(file, body);
|
|
277
287
|
return file;
|
|
278
288
|
}
|
|
279
289
|
/** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
|
|
@@ -308,7 +318,7 @@ export function writeWindsurfRule(root, store) {
|
|
|
308
318
|
const file = join(root, ".windsurf", "rules", "hunch.md");
|
|
309
319
|
const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
|
|
310
320
|
mkdirSync(dirname(file), { recursive: true });
|
|
311
|
-
|
|
321
|
+
writeFileAtomic(file, body);
|
|
312
322
|
return file;
|
|
313
323
|
}
|
|
314
324
|
/** Cursor's hook API is beta, but its project-level config accepts this standard
|
|
@@ -434,22 +444,50 @@ export function refreshExistingGrounding(root, store) {
|
|
|
434
444
|
}
|
|
435
445
|
return changed;
|
|
436
446
|
}
|
|
437
|
-
/**
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
447
|
+
/** Wholly-Hunch-owned grounding docs (namespaced rule files the generators emit
|
|
448
|
+
* in full). Any dirt in these is generated dirt by contract — there is no user
|
|
449
|
+
* prose to protect, so a stale copy is always safe to regenerate and stage. */
|
|
450
|
+
const WHOLLY_OWNED_GROUNDING = new Set([
|
|
451
|
+
join(".cursor", "rules", "hunch.mdc"),
|
|
452
|
+
join(".windsurf", "rules", "hunch.md"),
|
|
453
|
+
]);
|
|
454
|
+
/** Is a DIRTY grounding doc's divergence from HEAD confined to generated content?
|
|
455
|
+
* Marker-managed docs (CLAUDE.md, AGENTS.md, copilot-instructions): compare
|
|
456
|
+
* worktree vs HEAD with the managed section stripped from both — equal outside
|
|
457
|
+
* the block means regenerating cannot lose user prose. Wholly-owned rule files
|
|
458
|
+
* need no comparison. Untracked files return false: a doc the user hasn't
|
|
459
|
+
* committed is theirs to stage. */
|
|
460
|
+
function generatedDirtOnly(root, rel, current) {
|
|
461
|
+
if (WHOLLY_OWNED_GROUNDING.has(rel))
|
|
462
|
+
return headFileContent(root, rel) !== null;
|
|
463
|
+
const head = headFileContent(root, rel);
|
|
464
|
+
if (head === null)
|
|
465
|
+
return false;
|
|
466
|
+
return stripManagedSection(head) === stripManagedSection(current);
|
|
467
|
+
}
|
|
468
|
+
/** Capture-commit refresh: rewrite grounding docs that are git-clean OR whose only
|
|
469
|
+
* divergence from HEAD is generated content, and return the absolute paths to fold
|
|
470
|
+
* into the memory commit (commitAndPushHunch alsoStage). This keeps committed record
|
|
471
|
+
* counts permanently true — every capture used to bump the count and re-stale the
|
|
472
|
+
* committed docs, failing the release gate's clean-tree check on the next CI index
|
|
473
|
+
* (the refresh-counts treadmill). The generated-dirt branch closes the second half
|
|
474
|
+
* (fnd_b269d5c422): once a doc went stale-dirty, the clean-only rule skipped it on
|
|
475
|
+
* every later flush FOREVER, and each release needed a manual chore commit. A doc
|
|
476
|
+
* whose USER PROSE differs from HEAD is still left completely untouched. */
|
|
444
477
|
export function refreshCommittableGrounding(root, store) {
|
|
445
478
|
const changed = [];
|
|
446
479
|
for (const [rel, write] of groundingTargets(root, store)) {
|
|
447
480
|
const file = join(root, rel);
|
|
448
|
-
if (!existsSync(file)
|
|
481
|
+
if (!existsSync(file))
|
|
449
482
|
continue;
|
|
450
483
|
const before = readFileSync(file, "utf8");
|
|
484
|
+
const clean = isGitCleanPath(root, rel);
|
|
485
|
+
if (!clean && !generatedDirtOnly(root, rel, before))
|
|
486
|
+
continue;
|
|
451
487
|
write();
|
|
452
|
-
|
|
488
|
+
// A doc that was stale-DIRTY must be staged even when the regeneration is a
|
|
489
|
+
// byte no-op — the commit is what re-syncs HEAD with the worktree.
|
|
490
|
+
if (readFileSync(file, "utf8") !== before || !clean)
|
|
453
491
|
changed.push(file);
|
|
454
492
|
}
|
|
455
493
|
return changed;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* - .mcp.json → registers the `hunch` MCP server with Claude Code
|
|
4
4
|
* - .claude/commands/* → user-triggered slash commands for the §5 workflows
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync,
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
8
9
|
/** Merge a `hunch` server entry into .mcp.json, preserving other servers.
|
|
9
10
|
* A non-empty file we cannot parse THROWS instead of being silently replaced
|
|
@@ -28,7 +29,9 @@ export function writeMcpJson(root, inv) {
|
|
|
28
29
|
}
|
|
29
30
|
json.mcpServers = json.mcpServers ?? {};
|
|
30
31
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
31
|
-
|
|
32
|
+
// Atomic: .mcp.json holds the user's other servers — a torn write would leave
|
|
33
|
+
// it unparseable, which this writer then refuses to touch (issue #43).
|
|
34
|
+
writeFileAtomic(file, JSON.stringify(json, null, 2) + "\n");
|
|
32
35
|
return file;
|
|
33
36
|
}
|
|
34
37
|
const WHY_CMD = `---
|
|
@@ -168,13 +171,17 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
168
171
|
if (existed && before === next)
|
|
169
172
|
return { path: file, action: "unchanged" };
|
|
170
173
|
mkdirSync(dirname(file), { recursive: true });
|
|
171
|
-
|
|
174
|
+
writeFileAtomic(file, next);
|
|
172
175
|
return { path: file, action: existed ? "updated" : "created" };
|
|
173
176
|
}
|
|
177
|
+
/** Ownership marker for generated slash commands: its presence means Hunch may
|
|
178
|
+
* refresh the file; deleting the line hands the file to the user for good. */
|
|
179
|
+
const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
|
|
174
180
|
export function writeSlashCommands(root) {
|
|
175
181
|
const dir = join(root, ".claude", "commands");
|
|
176
182
|
mkdirSync(dir, { recursive: true });
|
|
177
183
|
const written = [];
|
|
184
|
+
const skipped = [];
|
|
178
185
|
const files = [
|
|
179
186
|
["hunch-why.md", WHY_CMD],
|
|
180
187
|
["hunch-fix.md", FIX_CMD],
|
|
@@ -185,9 +192,18 @@ export function writeSlashCommands(root) {
|
|
|
185
192
|
];
|
|
186
193
|
for (const [name, body] of files) {
|
|
187
194
|
const p = join(dir, name);
|
|
188
|
-
|
|
195
|
+
// Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;
|
|
196
|
+
// hunch-prefixed names are namespaced ours. Overwrite an existing file only
|
|
197
|
+
// when it carries the ownership marker or the hunch- namespace — never
|
|
198
|
+
// silently replace user content (issue #42). Pre-marker Hunch installs skip
|
|
199
|
+
// once and report; re-adopt by deleting the file and re-running init.
|
|
200
|
+
if (existsSync(p) && !name.startsWith("hunch-") && !readFileSync(p, "utf8").includes("hunch:generated")) {
|
|
201
|
+
skipped.push(p);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
writeFileAtomic(p, `${body}\n${CMD_MARKER}\n`);
|
|
189
205
|
written.push(p);
|
|
190
206
|
}
|
|
191
|
-
return written;
|
|
207
|
+
return { written, skipped };
|
|
192
208
|
}
|
|
193
209
|
//# sourceMappingURL=scaffold.js.map
|