@davesheffer/hunch 0.1.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -3
- package/dist/cli/index.js +168 -30
- package/dist/extractors/testreport.js +87 -0
- package/dist/mcp/server.js +35 -0
- package/dist/store/embedder.js +6 -1
- package/dist/store/hunchStore.js +20 -0
- package/dist/synthesis/synthesize.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,7 +98,8 @@ normally and Claude consults Hunch, or invoke the slash commands:
|
|
|
98
98
|
| `/hunch-fragile` | a fragility report (the riskiest code, with evidence) |
|
|
99
99
|
|
|
100
100
|
The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
101
|
-
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `
|
|
101
|
+
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
|
|
102
|
+
(dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
|
|
102
103
|
`hunch_context` (surgical minimal slice for a task), `hunch_record_decision` (write-back).
|
|
103
104
|
|
|
104
105
|
**Through the CLI** — the same graph, from your terminal:
|
|
@@ -110,13 +111,14 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
|
110
111
|
| `hunch backfill --since 90d` | replay git history → seed decisions |
|
|
111
112
|
| `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
|
|
112
113
|
| `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
|
|
114
|
+
| `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
|
|
113
115
|
| `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
|
|
114
116
|
| `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
|
|
115
117
|
| `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
|
|
116
118
|
| `hunch context <path\|symbol>` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius |
|
|
117
119
|
| `hunch fragile` | ranked fragility report with evidence |
|
|
118
|
-
| `hunch check [--staged\|--commit <sha>] [--strict]` | guardrail: flag changes touching a do-not-break invariant |
|
|
119
|
-
| `hunch stale` | drift: records whose files changed after
|
|
120
|
+
| `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed); `--blast` prints the dependency fan-out |
|
|
121
|
+
| `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
|
|
120
122
|
| `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
|
|
121
123
|
| `hunch migrate` | upgrade `.hunch/` records to the current schema version |
|
|
122
124
|
| `hunch compact [--apply]` | prune low-value drafts to bound growth (dry-run by default) |
|
|
@@ -135,6 +137,12 @@ hunch embed # embed your records (first run
|
|
|
135
137
|
hunch query --semantic "auth token expiry" # hybrid keyword + semantic recall
|
|
136
138
|
```
|
|
137
139
|
|
|
140
|
+
> **Install it where `hunch` runs.** The runtime is resolved from `hunch`'s own
|
|
141
|
+
> `node_modules`, so match the install scope: a globally-installed `hunch` needs the
|
|
142
|
+
> global (`-g`) install above; running from a source checkout needs it in the repo
|
|
143
|
+
> (`npm i @huggingface/transformers` there). If `hunch embed` reports the model "present
|
|
144
|
+
> but failed to load," the scopes don't match. `hunch doctor` shows the active mode.
|
|
145
|
+
|
|
138
146
|
Embeddings are **local and free** (no API — consistent with the subscription-only synthesis
|
|
139
147
|
rule) and **opt-in** (the base install stays lean). The long-lived MCP server picks them up
|
|
140
148
|
automatically once present. Vectors live in the derived SQLite index and are reconciled by
|
|
@@ -160,6 +168,33 @@ concurrent edits to the graph merge **by record id** instead of throwing conflic
|
|
|
160
168
|
committed `.gitattributes`; the per-clone driver definition is set up by each teammate's
|
|
161
169
|
`hunch init`.
|
|
162
170
|
|
|
171
|
+
## Continuous learning (CI)
|
|
172
|
+
|
|
173
|
+
The decision half of the loop is automatic (the post-commit hook). Light up the **bug /
|
|
174
|
+
constraint half** by wrapping your test run with `hunch test`:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
hunch test # runs `npm test`; capture failures → Bugs, resolve fixed ones
|
|
178
|
+
hunch test -- pytest -q # any runner: pass the command after `--`
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
It parses TAP and the `node:test` spec reporter, captures each failing test as a **Bug**
|
|
182
|
+
(ranked suspects; a recurrence or substantiated high-severity failure auto-promotes a
|
|
183
|
+
do-not-break **Constraint**), and marks a previously-open bug **fixed** once its test passes
|
|
184
|
+
again. It preserves the runner's exit code, so it's a drop-in CI step:
|
|
185
|
+
|
|
186
|
+
```yaml
|
|
187
|
+
# .github/workflows/ci.yml
|
|
188
|
+
- run: npm ci
|
|
189
|
+
- run: npx hunch test # exits non-zero on failure, just like the suite
|
|
190
|
+
- run: | # persist what was learned (optional)
|
|
191
|
+
git add .hunch && git commit -m "chore(hunch): capture test run" || true
|
|
192
|
+
git push || true
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Repair drift after refactors with **`hunch stale --resync`** (re-synthesizes stale decisions
|
|
196
|
+
from their commits via the LLM).
|
|
197
|
+
|
|
163
198
|
## Maintenance
|
|
164
199
|
|
|
165
200
|
- **`hunch doctor`** — is git healthy? are you on the subscription path or the offline
|
package/dist/cli/index.js
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
* why decisions/bugs/constraints explaining a file/symbol
|
|
10
10
|
* fragile ranked fragility report with evidence
|
|
11
11
|
* record-bug capture a Bug from a (failing) test
|
|
12
|
+
* test run the suite, auto-capture failures as Bugs, resolve fixed ones
|
|
12
13
|
* mcp start the MCP server (Claude Code connects here)
|
|
13
14
|
* doctor environment diagnostics
|
|
14
15
|
*/
|
|
15
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
18
|
import { Command } from "commander";
|
|
18
19
|
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
19
20
|
import { HunchStore } from "../store/hunchStore.js";
|
|
20
21
|
import { selectEmbedder } from "../store/embedder.js";
|
|
21
22
|
import { indexRepo } from "../extractors/indexer.js";
|
|
22
|
-
import { syncCommit, recordFailure } from "../synthesis/synthesize.js";
|
|
23
|
+
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
24
|
+
import { parseTestReport } from "../extractors/testreport.js";
|
|
23
25
|
import { selectProvider } from "../synthesis/provider.js";
|
|
24
26
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles } from "../extractors/git.js";
|
|
25
27
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
@@ -45,8 +47,8 @@ program
|
|
|
45
47
|
.command("init")
|
|
46
48
|
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
|
|
47
49
|
.option("--no-index", "skip the initial repo index")
|
|
48
|
-
.option("--enforce", "install
|
|
49
|
-
.option("--enforce-strict", "
|
|
50
|
+
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
51
|
+
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a blocking invariant (direct or near)")
|
|
50
52
|
.action((opts) => {
|
|
51
53
|
const root = findRoot();
|
|
52
54
|
const paths = hunchPaths(root);
|
|
@@ -68,9 +70,13 @@ program
|
|
|
68
70
|
console.log(` ✓ post-commit hook ${h.action} (learning loop)`);
|
|
69
71
|
const m = installMergeDriver(root, inv.shell);
|
|
70
72
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
74
|
+
// touched directly OR via blast radius, never blocks). Opt out with
|
|
75
|
+
// --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
|
|
76
|
+
if (opts.enforce !== false || opts.enforceStrict) {
|
|
77
|
+
const strict = !!opts.enforceStrict;
|
|
78
|
+
const p = installPreCommitHook(root, inv.shell, strict);
|
|
79
|
+
console.log(` ✓ pre-commit constraint guard ${p.action} (${strict ? "strict — blocks on blocking invariants, direct or near" : "advisory — flags invariants in scope or blast radius"})`);
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
else {
|
|
@@ -228,13 +234,25 @@ program
|
|
|
228
234
|
return;
|
|
229
235
|
}
|
|
230
236
|
process.stdout.write(`Embedding ${todo} doc(s) with ${embedder.id} (first run downloads the model ~90MB, one time)…\n`);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
try {
|
|
238
|
+
const res = await store.embedAll(embedder, {
|
|
239
|
+
batch: Number(opts.batch),
|
|
240
|
+
onProgress: (done, total) => process.stdout.write(`\r ${done}/${total} embedded `),
|
|
241
|
+
});
|
|
242
|
+
process.stdout.write("\n");
|
|
243
|
+
console.log(`✓ embedded ${res.embedded} doc(s) (${res.skipped} already current). Model: ${embedder.id}.`);
|
|
244
|
+
console.log(` Try: hunch query --semantic "<question>" — the MCP server uses it automatically.`);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
// The availability probe (createRequire.resolve) can succeed while the
|
|
248
|
+
// runtime ESM import/model load fails — e.g. the package is only
|
|
249
|
+
// resolvable via NODE_PATH, or a native backend is missing. Degrade with
|
|
250
|
+
// an actionable hint instead of a stack trace; queries stay on keyword search.
|
|
251
|
+
process.stdout.write("\n");
|
|
252
|
+
console.log("Semantic model is present but failed to load — staying on keyword (FTS) search.");
|
|
253
|
+
console.log(` reason: ${e.message.split("\n")[0]}`);
|
|
254
|
+
console.log(" Install @huggingface/transformers where hunch actually runs (a global `hunch` needs a global install; running from source needs it in the repo's node_modules).");
|
|
255
|
+
}
|
|
238
256
|
store.close();
|
|
239
257
|
});
|
|
240
258
|
// ---- why ------------------------------------------------------------------
|
|
@@ -306,24 +324,108 @@ program
|
|
|
306
324
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
307
325
|
store.close();
|
|
308
326
|
});
|
|
327
|
+
// ---- test (failure-learning loop) -----------------------------------------
|
|
328
|
+
program
|
|
329
|
+
.command("test")
|
|
330
|
+
.description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
|
|
331
|
+
.argument("[cmd...]", "test command to run (default: `npm test`)")
|
|
332
|
+
.option("--dry-run", "show what would be captured without writing")
|
|
333
|
+
.action(async (cmd, opts) => {
|
|
334
|
+
const { store, root } = storeFor();
|
|
335
|
+
store.json.ensureDirs();
|
|
336
|
+
// Run as a shell string (not argv) so the npm/test-runner shim resolves on
|
|
337
|
+
// Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
|
|
338
|
+
// claude CLI fix. The command is operator-supplied, so a shell is expected.
|
|
339
|
+
const cmdStr = cmd.length ? cmd.join(" ") : "npm test";
|
|
340
|
+
console.log(`▶ running: ${cmdStr}\n`);
|
|
341
|
+
const run = spawnSync(cmdStr, {
|
|
342
|
+
cwd: root,
|
|
343
|
+
shell: true,
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
346
|
+
windowsHide: true,
|
|
347
|
+
});
|
|
348
|
+
const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
|
|
349
|
+
const report = parseTestReport(output);
|
|
350
|
+
if (opts.dryRun) {
|
|
351
|
+
const willFallback = !report.recognized && run.status !== 0;
|
|
352
|
+
const n = willFallback ? 1 : report.failures.length;
|
|
353
|
+
console.log(`DRY RUN — exit ${run.status}, ${report.passed.length} passed, ${n} failure(s)${willFallback ? " (fallback: output not TAP/spec)" : ""}:`);
|
|
354
|
+
for (const f of report.failures)
|
|
355
|
+
console.log(` ✗ ${f.test}`);
|
|
356
|
+
if (willFallback)
|
|
357
|
+
console.log(` ✗ ${cmdStr} (whole-suite)`);
|
|
358
|
+
store.close();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
|
|
362
|
+
for (const { bug, constraint } of cap.results) {
|
|
363
|
+
if (constraint)
|
|
364
|
+
console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
|
|
365
|
+
else
|
|
366
|
+
console.log(` ✗ ${bug.id} "${bug.title}" [${bug.severity}]${bug.lineage.recurrence_of ? ` ↳ recurrence of ${bug.lineage.recurrence_of}` : ""}`);
|
|
367
|
+
}
|
|
368
|
+
for (const b of cap.fixed)
|
|
369
|
+
console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
|
|
370
|
+
store.reindex();
|
|
371
|
+
store.close();
|
|
372
|
+
const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
|
|
373
|
+
const promoted = cap.results.filter((r) => r.constraint).length;
|
|
374
|
+
console.log(`\n${run.status === 0 ? "✓ suite passed" : "✗ suite failed"} — ` +
|
|
375
|
+
`${cap.results.length} bug(s) captured (${recurrences} recurrence, ${promoted} constraint), ${cap.fixed.length} resolved.`);
|
|
376
|
+
if (run.status !== 0)
|
|
377
|
+
process.exitCode = 1; // preserve CI semantics
|
|
378
|
+
});
|
|
309
379
|
// ---- stale (drift detection) ----------------------------------------------
|
|
310
380
|
program
|
|
311
381
|
.command("stale")
|
|
312
382
|
.description("List decisions/constraints whose files changed after they were last verified (drift).")
|
|
313
|
-
.
|
|
383
|
+
.option("--resync", "re-synthesize stale decisions from their commit via the LLM (drift repair)")
|
|
384
|
+
.action(async (opts) => {
|
|
314
385
|
const { store, root } = storeFor();
|
|
315
386
|
const stale = store.staleness((f) => lastChangeDate(f, root));
|
|
316
387
|
if (!stale.length) {
|
|
317
388
|
console.log("✓ No drift detected — every verified decision/constraint is current.");
|
|
389
|
+
store.close();
|
|
390
|
+
return;
|
|
318
391
|
}
|
|
319
|
-
|
|
392
|
+
if (!opts.resync) {
|
|
320
393
|
console.log(`⚠ ${stale.length} record(s) may be stale (a file in scope changed after last verification):\n`);
|
|
321
394
|
for (const s of stale) {
|
|
322
395
|
console.log(` ${s.kind} ${s.id}\n verified ${s.last_verified.slice(0, 10)} · changed ${s.changed_at.slice(0, 10)} · ${s.files.join(", ")}`);
|
|
323
396
|
}
|
|
324
|
-
console.log(`\
|
|
397
|
+
console.log(`\nRepair: hunch stale --resync (re-synthesize from commits) · or hunch review --accept <id>`);
|
|
398
|
+
store.close();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// --resync: regenerate each stale DECISION from its commit. Constraints have no
|
|
402
|
+
// commit to replay, so they're reported as needing manual review instead.
|
|
403
|
+
let resynced = 0, skipped = 0;
|
|
404
|
+
for (const s of stale) {
|
|
405
|
+
if (!s.kind.startsWith("decision")) {
|
|
406
|
+
skipped++;
|
|
407
|
+
console.log(` · ${s.kind} ${s.id} — manual (no commit to replay)`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const d = store.json.get("decisions", s.id);
|
|
411
|
+
if (!d?.commit) {
|
|
412
|
+
skipped++;
|
|
413
|
+
console.log(` · ${s.id} — skipped (no source commit)`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const r = await syncCommit(store, root, d.commit, { force: true });
|
|
417
|
+
if (r.status === "written") {
|
|
418
|
+
resynced++;
|
|
419
|
+
console.log(` ↻ ${s.id} ← ${d.commit.slice(0, 8)} (${r.provider})`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
skipped++;
|
|
423
|
+
console.log(` · ${s.id} — skipped: ${r.reason}`);
|
|
424
|
+
}
|
|
325
425
|
}
|
|
426
|
+
store.reindex();
|
|
326
427
|
store.close();
|
|
428
|
+
console.log(`\n✓ re-synthesized ${resynced} stale decision(s), ${skipped} left for manual review.`);
|
|
327
429
|
});
|
|
328
430
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
329
431
|
program
|
|
@@ -331,40 +433,76 @@ program
|
|
|
331
433
|
.description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
|
|
332
434
|
.option("--staged", "check git staged files (default)")
|
|
333
435
|
.option("--commit <sha>", "check a specific commit's files")
|
|
334
|
-
.option("--strict", "exit non-zero if a blocking constraint is in scope")
|
|
436
|
+
.option("--strict", "exit non-zero if a blocking constraint is in scope (direct OR near)")
|
|
437
|
+
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
335
438
|
.action((opts) => {
|
|
336
439
|
if (opts.commit && opts.staged)
|
|
337
440
|
return fail("--staged and --commit are mutually exclusive");
|
|
338
441
|
const { store, root } = storeFor();
|
|
442
|
+
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
339
443
|
const files = opts.commit ? commitFiles(opts.commit, root) : stagedFiles(root);
|
|
340
444
|
if (!files.length) {
|
|
341
445
|
console.log("No changed files to check.");
|
|
342
446
|
store.close();
|
|
343
447
|
return;
|
|
344
448
|
}
|
|
345
|
-
const
|
|
449
|
+
const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
|
|
450
|
+
// 1) DIRECT — a changed file matches a constraint's scope.
|
|
451
|
+
const direct = new Map();
|
|
346
452
|
for (const f of files) {
|
|
347
453
|
for (const c of store.checkConstraints(f)) {
|
|
348
|
-
const e =
|
|
454
|
+
const e = direct.get(c.id) ?? { c, files: [] };
|
|
349
455
|
e.files.push(f);
|
|
350
|
-
|
|
456
|
+
direct.set(c.id, e);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// 2) NEAR — a changed file's blast radius reaches a file an invariant guards:
|
|
460
|
+
// you didn't touch the invariant, but you touched something it depends on.
|
|
461
|
+
const near = new Map();
|
|
462
|
+
for (const f of files) {
|
|
463
|
+
for (const b of store.blastRadiusFiles(f)) {
|
|
464
|
+
for (const c of store.checkConstraints(b.file)) {
|
|
465
|
+
if (direct.has(c.id))
|
|
466
|
+
continue; // already reported as a direct hit
|
|
467
|
+
const e = near.get(c.id) ?? { c, via: [] };
|
|
468
|
+
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
469
|
+
near.set(c.id, e);
|
|
470
|
+
}
|
|
351
471
|
}
|
|
352
472
|
}
|
|
353
|
-
if (
|
|
354
|
-
console.log(
|
|
473
|
+
if (opts.blast) {
|
|
474
|
+
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
475
|
+
for (const f of files) {
|
|
476
|
+
const b = store.blastRadiusFiles(f);
|
|
477
|
+
const list = b.length ? `: ${b.slice(0, 8).map((x) => x.file).join(", ")}${b.length > 8 ? " …" : ""}` : "";
|
|
478
|
+
console.log(` ${f} → ${b.length} dependent file(s)${list}`);
|
|
479
|
+
}
|
|
480
|
+
console.log("");
|
|
481
|
+
}
|
|
482
|
+
if (!direct.size && !near.size) {
|
|
483
|
+
console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius).`);
|
|
355
484
|
store.close();
|
|
356
485
|
return;
|
|
357
486
|
}
|
|
358
487
|
let blocking = 0;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
blocking
|
|
363
|
-
|
|
364
|
-
|
|
488
|
+
if (direct.size) {
|
|
489
|
+
console.log(`Directly touches ${direct.size} invariant(s):\n`);
|
|
490
|
+
for (const { c, files: fs } of direct.values()) {
|
|
491
|
+
if (c.severity === "blocking")
|
|
492
|
+
blocking++;
|
|
493
|
+
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (near.size) {
|
|
497
|
+
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review):\n`);
|
|
498
|
+
for (const { c, via } of near.values()) {
|
|
499
|
+
if (c.severity === "blocking")
|
|
500
|
+
blocking++;
|
|
501
|
+
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
|
|
502
|
+
}
|
|
365
503
|
}
|
|
366
504
|
if (opts.strict && blocking) {
|
|
367
|
-
console.log(`\n✗ ${blocking} blocking invariant(s) in scope — review before committing.`);
|
|
505
|
+
console.log(`\n✗ ${blocking} blocking invariant(s) in scope (direct or near) — review before committing.`);
|
|
368
506
|
process.exitCode = 1;
|
|
369
507
|
}
|
|
370
508
|
else {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a test run's output into pass/fail signals for the failure-learning loop
|
|
3
|
+
* (`hunch test`). Test-framework-agnostic: it recognizes both common shapes that
|
|
4
|
+
* `node:test` (this repo), mocha, ava, vitest, jest, and `prove` emit:
|
|
5
|
+
*
|
|
6
|
+
* - TAP: `ok 12 - name` / `not ok 3 - name`, with an optional indented YAML
|
|
7
|
+
* diagnostic block (error + stack) under a failure.
|
|
8
|
+
* - spec: node:test's default non-TTY reporter — `✔ name (1.2ms)` /
|
|
9
|
+
* `✖ name (1.2ms)`, failures followed by an indented error block.
|
|
10
|
+
* (Also accepts ✓/✗.)
|
|
11
|
+
*
|
|
12
|
+
* Design choices:
|
|
13
|
+
* - A failure's `message` is the test name plus its indented diagnostic block —
|
|
14
|
+
* exactly the context recordFailure() feeds the synthesizer for root-cause and
|
|
15
|
+
* suspect ranking.
|
|
16
|
+
* - If NEITHER shape is recognized, we return empty lists + recognized=false so
|
|
17
|
+
* the caller falls back to a coarse "the suite failed" bug from the raw tail —
|
|
18
|
+
* never silently reports success.
|
|
19
|
+
* - Results are deduped by name (a spec reporter can echo a failing test in its
|
|
20
|
+
* end-of-run recap).
|
|
21
|
+
*/
|
|
22
|
+
// TAP: `ok 12 - desc` / `not ok 3 - desc` (the "- " separator is optional).
|
|
23
|
+
const TAP = /^(not ok|ok)\s+\d+\s*-?\s*(.*)$/;
|
|
24
|
+
// spec: `✔ desc (1.2ms)` (pass) or `✖ desc (1.2ms)` (fail). ✓/✗ accepted too.
|
|
25
|
+
const SPEC = /^([✔✓✖✗])\s+(.*)$/;
|
|
26
|
+
const DURATION = /\s+\(\d+(?:\.\d+)?ms\)\s*$/; // trailing " (1.2ms)" spec suffix
|
|
27
|
+
const DIRECTIVE = /#\s*(SKIP|TODO)\b/i;
|
|
28
|
+
/** Parse TAP-or-spec text. Pure + synchronous so it's trivially unit-testable. */
|
|
29
|
+
export function parseTestReport(output) {
|
|
30
|
+
const lines = output.split(/\r?\n/);
|
|
31
|
+
const failMap = new Map();
|
|
32
|
+
const passSet = new Set();
|
|
33
|
+
let recognized = false;
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
const raw = lines[i];
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
const tap = TAP.exec(trimmed);
|
|
38
|
+
const spec = tap ? null : SPEC.exec(trimmed);
|
|
39
|
+
if (!tap && !spec)
|
|
40
|
+
continue;
|
|
41
|
+
recognized = true;
|
|
42
|
+
const isFail = tap ? tap[1] === "not ok" : (spec[1] === "✖" || spec[1] === "✗");
|
|
43
|
+
let name = (tap ? tap[2] : spec[2]) ?? "";
|
|
44
|
+
name = name.replace(DURATION, "").trim();
|
|
45
|
+
if (!name)
|
|
46
|
+
name = `test #${i + 1}`;
|
|
47
|
+
if (DIRECTIVE.test(name))
|
|
48
|
+
continue; // skipped/todo — neither pass nor fail
|
|
49
|
+
name = stripDirective(name);
|
|
50
|
+
if (!isFail) {
|
|
51
|
+
passSet.add(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (failMap.has(name))
|
|
55
|
+
continue; // dedupe recap echoes
|
|
56
|
+
// Collect the following more-indented diagnostic block as the message.
|
|
57
|
+
const baseIndent = leadingSpaces(raw);
|
|
58
|
+
const block = [];
|
|
59
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
60
|
+
const ln = lines[j];
|
|
61
|
+
if (ln.trim() === "") {
|
|
62
|
+
block.push("");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (leadingSpaces(ln) <= baseIndent)
|
|
66
|
+
break;
|
|
67
|
+
block.push(ln.slice(baseIndent + 1));
|
|
68
|
+
}
|
|
69
|
+
const diag = block.join("\n").trim();
|
|
70
|
+
failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
|
|
71
|
+
}
|
|
72
|
+
// A test can legitimately appear as both (flaky retry) — trust the failure.
|
|
73
|
+
for (const name of failMap.keys())
|
|
74
|
+
passSet.delete(name);
|
|
75
|
+
return { failures: [...failMap.values()], passed: [...passSet], recognized };
|
|
76
|
+
}
|
|
77
|
+
function leadingSpaces(s) {
|
|
78
|
+
let n = 0;
|
|
79
|
+
while (n < s.length && s[n] === " ")
|
|
80
|
+
n++;
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
function stripDirective(name) {
|
|
84
|
+
const hash = name.indexOf("#");
|
|
85
|
+
return (hash === -1 ? name : name.slice(0, hash)).trim();
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=testreport.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -38,6 +38,12 @@ function resolveSymbols(store, target) {
|
|
|
38
38
|
return byName;
|
|
39
39
|
return syms.filter((s) => s.file === target || s.file.endsWith(target));
|
|
40
40
|
}
|
|
41
|
+
/** Resolve a target to canonical indexed file path(s) (for file-granular blast
|
|
42
|
+
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
43
|
+
function resolveFiles(store, target) {
|
|
44
|
+
const files = new Set(resolveSymbols(store, target).map((s) => s.file));
|
|
45
|
+
return files.size ? [...files] : [target];
|
|
46
|
+
}
|
|
41
47
|
export function buildServer(root) {
|
|
42
48
|
const store = new HunchStore(hunchPaths(root));
|
|
43
49
|
// Ensure the SQLite index reflects the JSON source of truth on startup.
|
|
@@ -143,6 +149,35 @@ export function buildServer(root) {
|
|
|
143
149
|
const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
|
|
144
150
|
return ok(`Blast radius of "${symbol}" — ${deps.length} dependent(s):\n${lines.join("\n")}${more(deps.length, DEP_CAP, "closest shown first")}`);
|
|
145
151
|
});
|
|
152
|
+
// -- hunch_blast_radius (dependents + near-violations) --------------------
|
|
153
|
+
server.registerTool("hunch_blast_radius", {
|
|
154
|
+
title: "Blast radius + near-violations for a file",
|
|
155
|
+
description: "Given a file you're about to change, return its dependency blast radius (files whose code depends on it) AND any invariants reached THROUGH that radius — 'near-violations' you could break indirectly without touching their own scope. Call before editing a widely-depended-on file. Mirrors `hunch check --blast`.",
|
|
156
|
+
inputSchema: { target: z.string().describe("A file path (e.g. src/auth/jwt.ts) or symbol.") },
|
|
157
|
+
}, async ({ target }) => {
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const file of resolveFiles(store, target)) {
|
|
160
|
+
const blast = store.blastRadiusFiles(file);
|
|
161
|
+
const directIds = new Set(store.checkConstraints(file).map((c) => c.id));
|
|
162
|
+
const near = new Map();
|
|
163
|
+
for (const b of blast) {
|
|
164
|
+
for (const c of store.checkConstraints(b.file)) {
|
|
165
|
+
if (directIds.has(c.id) || near.has(c.id))
|
|
166
|
+
continue;
|
|
167
|
+
near.set(c.id, { c, via: `${b.file} (${b.via}, depth ${b.depth})` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const blastBody = blast.length
|
|
171
|
+
? `:\n${blast.slice(0, DEP_CAP).map((b) => ` • [depth ${b.depth}] ${b.file} (via ${b.via})`).join("\n")}${more(blast.length, DEP_CAP, "closest first")}`
|
|
172
|
+
: "";
|
|
173
|
+
const nearArr = [...near.values()];
|
|
174
|
+
const nearBody = nearArr.length
|
|
175
|
+
? `\n NEAR-VIOLATIONS (invariants reachable via this radius — review before editing):\n${nearArr.map((n) => ` ⚠ ${n.c.id} [${n.c.severity}] ${n.c.statement}\n via ${n.via}`).join("\n")}`
|
|
176
|
+
: "\n No invariants in the blast radius.";
|
|
177
|
+
parts.push(`${file} → ${blast.length} dependent file(s)${blastBody}${nearBody}`);
|
|
178
|
+
}
|
|
179
|
+
return ok(`Blast radius for "${target}":\n\n${parts.join("\n\n")}`);
|
|
180
|
+
});
|
|
146
181
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
147
182
|
server.registerTool("hunch_context", {
|
|
148
183
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
package/dist/store/embedder.js
CHANGED
|
@@ -48,7 +48,12 @@ export class TransformersEmbedder {
|
|
|
48
48
|
// pass) and onnxruntime warns to stderr, so the model load never writes to
|
|
49
49
|
// stdout — safe for the MCP JSON-RPC stdio channel without redirecting it.
|
|
50
50
|
const mod = (await import(pkg));
|
|
51
|
-
|
|
51
|
+
// Pin dtype to fp32 (the lib's default for this model): silences the
|
|
52
|
+
// "dtype not specified … using the default dtype (fp32)" line the lib
|
|
53
|
+
// prints to stderr on every load (it leaked into `hunch query` output),
|
|
54
|
+
// and guarantees the numbers stay consistent with vectors already
|
|
55
|
+
// persisted under this model id — changing dtype would shift them.
|
|
56
|
+
return (await mod.pipeline("feature-extraction", HF_MODEL, { dtype: "fp32" }));
|
|
52
57
|
})();
|
|
53
58
|
this.extractor = p;
|
|
54
59
|
// Never cache a REJECTED load: a transient failure (network blip during the
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -337,6 +337,26 @@ export class HunchStore {
|
|
|
337
337
|
}
|
|
338
338
|
return id;
|
|
339
339
|
}
|
|
340
|
+
/** Files whose symbols (in)directly DEPEND ON a symbol defined in `file` — the
|
|
341
|
+
* blast radius of editing `file`, collapsed to file granularity (nearest depth
|
|
342
|
+
* wins per file). Powers `hunch check` near-violation detection and `--blast`. */
|
|
343
|
+
blastRadiusFiles(file, maxDepth = 4) {
|
|
344
|
+
const syms = this.db.prepare(`SELECT id FROM symbols WHERE file = ?`).all(file);
|
|
345
|
+
const out = new Map();
|
|
346
|
+
for (const s of syms) {
|
|
347
|
+
for (const dep of this.getDependents(s.id, maxDepth)) {
|
|
348
|
+
if (!dep.id.startsWith("sym_"))
|
|
349
|
+
continue;
|
|
350
|
+
const r = this.db.prepare(`SELECT name, file FROM symbols WHERE id=?`).get(dep.id);
|
|
351
|
+
if (!r || r.file === file)
|
|
352
|
+
continue; // ignore self-file dependents
|
|
353
|
+
const prev = out.get(r.file);
|
|
354
|
+
if (!prev || dep.depth < prev.depth)
|
|
355
|
+
out.set(r.file, { file: r.file, via: r.name, depth: dep.depth });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return [...out.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
359
|
+
}
|
|
340
360
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints). */
|
|
341
361
|
checkConstraints(scope) {
|
|
342
362
|
const all = this.json.loadAll("constraints");
|
|
@@ -165,6 +165,43 @@ export async function recordFailure(store, root, failure) {
|
|
|
165
165
|
raiseFragility(store, affectedFiles);
|
|
166
166
|
return { status: "written", bug, constraint, provider: provider.name };
|
|
167
167
|
}
|
|
168
|
+
/** Orchestrate one `hunch test` run into graph writes: capture each failing test
|
|
169
|
+
* as a Bug (recordFailure → suspects / recurrence / Constraint promotion), and
|
|
170
|
+
* resolve any open Bug whose test now passes. Kept free of console I/O so the
|
|
171
|
+
* whole capture→resolve→promote loop is unit-testable. Does NOT reindex — the
|
|
172
|
+
* caller does, once. `status` is the runner's exit code (null if unknown). */
|
|
173
|
+
export async function captureTestRun(store, root, input) {
|
|
174
|
+
const { report, status } = input;
|
|
175
|
+
// Unrecognized output that still failed → one coarse bug from the tail, rather
|
|
176
|
+
// than silently reporting success (the worst failure mode for a learning loop).
|
|
177
|
+
let failures = report.failures;
|
|
178
|
+
let fallback = false;
|
|
179
|
+
if (!report.recognized && status !== 0) {
|
|
180
|
+
const tail = input.output.trim().split(/\r?\n/).slice(-40).join("\n");
|
|
181
|
+
failures = [{ test: input.cmd, message: `Test run failed (exit ${status}); output not TAP/spec.\n${tail}` }];
|
|
182
|
+
fallback = true;
|
|
183
|
+
}
|
|
184
|
+
const results = [];
|
|
185
|
+
for (const f of failures) {
|
|
186
|
+
const r = await recordFailure(store, root, f);
|
|
187
|
+
results.push({ bug: r.bug, constraint: r.constraint });
|
|
188
|
+
}
|
|
189
|
+
let sha = null;
|
|
190
|
+
try {
|
|
191
|
+
sha = headSha(root);
|
|
192
|
+
}
|
|
193
|
+
catch { /* not a git repo / no HEAD — leave null */ }
|
|
194
|
+
const fixed = [];
|
|
195
|
+
for (const name of report.passed) {
|
|
196
|
+
const b = store.json.get("bugs", bugId(name));
|
|
197
|
+
if (b && b.status === "open") {
|
|
198
|
+
const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
|
|
199
|
+
store.json.put("bugs", resolved);
|
|
200
|
+
fixed.push(resolved);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { results, fixed, fallback };
|
|
204
|
+
}
|
|
168
205
|
/** Whether a bug should auto-promote a regression Constraint (a do-not-break
|
|
169
206
|
* invariant). A recurrence always does. Otherwise it must be high/critical AND
|
|
170
207
|
* substantiated by a real root cause — a bare severity label with no analysis
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|