@davesheffer/hunch 0.1.1 → 0.4.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 CHANGED
@@ -110,13 +110,14 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
110
110
  | `hunch backfill --since 90d` | replay git history → seed decisions |
111
111
  | `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
112
112
  | `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
113
+ | `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
113
114
  | `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
114
115
  | `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
115
116
  | `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
116
117
  | `hunch context <path\|symbol>` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius |
117
118
  | `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 they were last verified |
119
+ | `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 |
120
+ | `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
120
121
  | `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
121
122
  | `hunch migrate` | upgrade `.hunch/` records to the current schema version |
122
123
  | `hunch compact [--apply]` | prune low-value drafts to bound growth (dry-run by default) |
@@ -135,6 +136,12 @@ hunch embed # embed your records (first run
135
136
  hunch query --semantic "auth token expiry" # hybrid keyword + semantic recall
136
137
  ```
137
138
 
139
+ > **Install it where `hunch` runs.** The runtime is resolved from `hunch`'s own
140
+ > `node_modules`, so match the install scope: a globally-installed `hunch` needs the
141
+ > global (`-g`) install above; running from a source checkout needs it in the repo
142
+ > (`npm i @huggingface/transformers` there). If `hunch embed` reports the model "present
143
+ > but failed to load," the scopes don't match. `hunch doctor` shows the active mode.
144
+
138
145
  Embeddings are **local and free** (no API — consistent with the subscription-only synthesis
139
146
  rule) and **opt-in** (the base install stays lean). The long-lived MCP server picks them up
140
147
  automatically once present. Vectors live in the derived SQLite index and are reconciled by
@@ -160,6 +167,33 @@ concurrent edits to the graph merge **by record id** instead of throwing conflic
160
167
  committed `.gitattributes`; the per-clone driver definition is set up by each teammate's
161
168
  `hunch init`.
162
169
 
170
+ ## Continuous learning (CI)
171
+
172
+ The decision half of the loop is automatic (the post-commit hook). Light up the **bug /
173
+ constraint half** by wrapping your test run with `hunch test`:
174
+
175
+ ```bash
176
+ hunch test # runs `npm test`; capture failures → Bugs, resolve fixed ones
177
+ hunch test -- pytest -q # any runner: pass the command after `--`
178
+ ```
179
+
180
+ It parses TAP and the `node:test` spec reporter, captures each failing test as a **Bug**
181
+ (ranked suspects; a recurrence or substantiated high-severity failure auto-promotes a
182
+ do-not-break **Constraint**), and marks a previously-open bug **fixed** once its test passes
183
+ again. It preserves the runner's exit code, so it's a drop-in CI step:
184
+
185
+ ```yaml
186
+ # .github/workflows/ci.yml
187
+ - run: npm ci
188
+ - run: npx hunch test # exits non-zero on failure, just like the suite
189
+ - run: | # persist what was learned (optional)
190
+ git add .hunch && git commit -m "chore(hunch): capture test run" || true
191
+ git push || true
192
+ ```
193
+
194
+ Repair drift after refactors with **`hunch stale --resync`** (re-synthesizes stale decisions
195
+ from their commits via the LLM).
196
+
163
197
  ## Maintenance
164
198
 
165
199
  - **`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";
@@ -228,13 +230,25 @@ program
228
230
  return;
229
231
  }
230
232
  process.stdout.write(`Embedding ${todo} doc(s) with ${embedder.id} (first run downloads the model ~90MB, one time)…\n`);
231
- const res = await store.embedAll(embedder, {
232
- batch: Number(opts.batch),
233
- onProgress: (done, total) => process.stdout.write(`\r ${done}/${total} embedded `),
234
- });
235
- process.stdout.write("\n");
236
- console.log(`✓ embedded ${res.embedded} doc(s) (${res.skipped} already current). Model: ${embedder.id}.`);
237
- console.log(` Try: hunch query --semantic "<question>" — the MCP server uses it automatically.`);
233
+ try {
234
+ const res = await store.embedAll(embedder, {
235
+ batch: Number(opts.batch),
236
+ onProgress: (done, total) => process.stdout.write(`\r ${done}/${total} embedded `),
237
+ });
238
+ process.stdout.write("\n");
239
+ console.log(`✓ embedded ${res.embedded} doc(s) (${res.skipped} already current). Model: ${embedder.id}.`);
240
+ console.log(` Try: hunch query --semantic "<question>" — the MCP server uses it automatically.`);
241
+ }
242
+ catch (e) {
243
+ // The availability probe (createRequire.resolve) can succeed while the
244
+ // runtime ESM import/model load fails — e.g. the package is only
245
+ // resolvable via NODE_PATH, or a native backend is missing. Degrade with
246
+ // an actionable hint instead of a stack trace; queries stay on keyword search.
247
+ process.stdout.write("\n");
248
+ console.log("Semantic model is present but failed to load — staying on keyword (FTS) search.");
249
+ console.log(` reason: ${e.message.split("\n")[0]}`);
250
+ 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).");
251
+ }
238
252
  store.close();
239
253
  });
240
254
  // ---- why ------------------------------------------------------------------
@@ -306,24 +320,108 @@ program
306
320
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
307
321
  store.close();
308
322
  });
323
+ // ---- test (failure-learning loop) -----------------------------------------
324
+ program
325
+ .command("test")
326
+ .description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
327
+ .argument("[cmd...]", "test command to run (default: `npm test`)")
328
+ .option("--dry-run", "show what would be captured without writing")
329
+ .action(async (cmd, opts) => {
330
+ const { store, root } = storeFor();
331
+ store.json.ensureDirs();
332
+ // Run as a shell string (not argv) so the npm/test-runner shim resolves on
333
+ // Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
334
+ // claude CLI fix. The command is operator-supplied, so a shell is expected.
335
+ const cmdStr = cmd.length ? cmd.join(" ") : "npm test";
336
+ console.log(`▶ running: ${cmdStr}\n`);
337
+ const run = spawnSync(cmdStr, {
338
+ cwd: root,
339
+ shell: true,
340
+ encoding: "utf8",
341
+ maxBuffer: 64 * 1024 * 1024,
342
+ windowsHide: true,
343
+ });
344
+ const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
345
+ const report = parseTestReport(output);
346
+ if (opts.dryRun) {
347
+ const willFallback = !report.recognized && run.status !== 0;
348
+ const n = willFallback ? 1 : report.failures.length;
349
+ console.log(`DRY RUN — exit ${run.status}, ${report.passed.length} passed, ${n} failure(s)${willFallback ? " (fallback: output not TAP/spec)" : ""}:`);
350
+ for (const f of report.failures)
351
+ console.log(` ✗ ${f.test}`);
352
+ if (willFallback)
353
+ console.log(` ✗ ${cmdStr} (whole-suite)`);
354
+ store.close();
355
+ return;
356
+ }
357
+ const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
358
+ for (const { bug, constraint } of cap.results) {
359
+ if (constraint)
360
+ console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
361
+ else
362
+ console.log(` ✗ ${bug.id} "${bug.title}" [${bug.severity}]${bug.lineage.recurrence_of ? ` ↳ recurrence of ${bug.lineage.recurrence_of}` : ""}`);
363
+ }
364
+ for (const b of cap.fixed)
365
+ console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
366
+ store.reindex();
367
+ store.close();
368
+ const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
369
+ const promoted = cap.results.filter((r) => r.constraint).length;
370
+ console.log(`\n${run.status === 0 ? "✓ suite passed" : "✗ suite failed"} — ` +
371
+ `${cap.results.length} bug(s) captured (${recurrences} recurrence, ${promoted} constraint), ${cap.fixed.length} resolved.`);
372
+ if (run.status !== 0)
373
+ process.exitCode = 1; // preserve CI semantics
374
+ });
309
375
  // ---- stale (drift detection) ----------------------------------------------
310
376
  program
311
377
  .command("stale")
312
378
  .description("List decisions/constraints whose files changed after they were last verified (drift).")
313
- .action(() => {
379
+ .option("--resync", "re-synthesize stale decisions from their commit via the LLM (drift repair)")
380
+ .action(async (opts) => {
314
381
  const { store, root } = storeFor();
315
382
  const stale = store.staleness((f) => lastChangeDate(f, root));
316
383
  if (!stale.length) {
317
384
  console.log("✓ No drift detected — every verified decision/constraint is current.");
385
+ store.close();
386
+ return;
318
387
  }
319
- else {
388
+ if (!opts.resync) {
320
389
  console.log(`⚠ ${stale.length} record(s) may be stale (a file in scope changed after last verification):\n`);
321
390
  for (const s of stale) {
322
391
  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
392
  }
324
- console.log(`\nRe-validate with: hunch review --accept <id> (or edit the record).`);
393
+ console.log(`\nRepair: hunch stale --resync (re-synthesize from commits) · or hunch review --accept <id>`);
394
+ store.close();
395
+ return;
396
+ }
397
+ // --resync: regenerate each stale DECISION from its commit. Constraints have no
398
+ // commit to replay, so they're reported as needing manual review instead.
399
+ let resynced = 0, skipped = 0;
400
+ for (const s of stale) {
401
+ if (!s.kind.startsWith("decision")) {
402
+ skipped++;
403
+ console.log(` · ${s.kind} ${s.id} — manual (no commit to replay)`);
404
+ continue;
405
+ }
406
+ const d = store.json.get("decisions", s.id);
407
+ if (!d?.commit) {
408
+ skipped++;
409
+ console.log(` · ${s.id} — skipped (no source commit)`);
410
+ continue;
411
+ }
412
+ const r = await syncCommit(store, root, d.commit, { force: true });
413
+ if (r.status === "written") {
414
+ resynced++;
415
+ console.log(` ↻ ${s.id} ← ${d.commit.slice(0, 8)} (${r.provider})`);
416
+ }
417
+ else {
418
+ skipped++;
419
+ console.log(` · ${s.id} — skipped: ${r.reason}`);
420
+ }
325
421
  }
422
+ store.reindex();
326
423
  store.close();
424
+ console.log(`\n✓ re-synthesized ${resynced} stale decision(s), ${skipped} left for manual review.`);
327
425
  });
328
426
  // ---- check (constraint enforcement) ---------------------------------------
329
427
  program
@@ -331,40 +429,76 @@ program
331
429
  .description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
332
430
  .option("--staged", "check git staged files (default)")
333
431
  .option("--commit <sha>", "check a specific commit's files")
334
- .option("--strict", "exit non-zero if a blocking constraint is in scope")
432
+ .option("--strict", "exit non-zero if a blocking constraint is in scope (direct OR near)")
433
+ .option("--blast", "also print the dependency blast radius of the changed files")
335
434
  .action((opts) => {
336
435
  if (opts.commit && opts.staged)
337
436
  return fail("--staged and --commit are mutually exclusive");
338
437
  const { store, root } = storeFor();
438
+ store.reindex(); // blast radius walks the edge graph — make the index current
339
439
  const files = opts.commit ? commitFiles(opts.commit, root) : stagedFiles(root);
340
440
  if (!files.length) {
341
441
  console.log("No changed files to check.");
342
442
  store.close();
343
443
  return;
344
444
  }
345
- const hits = new Map();
445
+ const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
446
+ // 1) DIRECT — a changed file matches a constraint's scope.
447
+ const direct = new Map();
346
448
  for (const f of files) {
347
449
  for (const c of store.checkConstraints(f)) {
348
- const e = hits.get(c.id) ?? { constraint: c, files: [] };
450
+ const e = direct.get(c.id) ?? { c, files: [] };
349
451
  e.files.push(f);
350
- hits.set(c.id, e);
452
+ direct.set(c.id, e);
453
+ }
454
+ }
455
+ // 2) NEAR — a changed file's blast radius reaches a file an invariant guards:
456
+ // you didn't touch the invariant, but you touched something it depends on.
457
+ const near = new Map();
458
+ for (const f of files) {
459
+ for (const b of store.blastRadiusFiles(f)) {
460
+ for (const c of store.checkConstraints(b.file)) {
461
+ if (direct.has(c.id))
462
+ continue; // already reported as a direct hit
463
+ const e = near.get(c.id) ?? { c, via: [] };
464
+ e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
465
+ near.set(c.id, e);
466
+ }
351
467
  }
352
468
  }
353
- if (!hits.size) {
354
- console.log(`✓ ${files.length} changed file(s) touch no recorded invariants.`);
469
+ if (opts.blast) {
470
+ console.log(`Blast radius of ${files.length} changed file(s):`);
471
+ for (const f of files) {
472
+ const b = store.blastRadiusFiles(f);
473
+ const list = b.length ? `: ${b.slice(0, 8).map((x) => x.file).join(", ")}${b.length > 8 ? " …" : ""}` : "";
474
+ console.log(` ${f} → ${b.length} dependent file(s)${list}`);
475
+ }
476
+ console.log("");
477
+ }
478
+ if (!direct.size && !near.size) {
479
+ console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius).`);
355
480
  store.close();
356
481
  return;
357
482
  }
358
483
  let blocking = 0;
359
- console.log(`Changes touch ${hits.size} invariant(s):\n`);
360
- for (const { constraint: c, files: fs } of hits.values()) {
361
- if (c.severity === "blocking")
362
- blocking++;
363
- const mark = c.severity === "blocking" ? "⛔" : c.severity === "warning" ? "⚠" : "·";
364
- console.log(` ${mark} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
484
+ if (direct.size) {
485
+ console.log(`Directly touches ${direct.size} invariant(s):\n`);
486
+ for (const { c, files: fs } of direct.values()) {
487
+ if (c.severity === "blocking")
488
+ blocking++;
489
+ console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
490
+ }
491
+ }
492
+ if (near.size) {
493
+ console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review):\n`);
494
+ for (const { c, via } of near.values()) {
495
+ if (c.severity === "blocking")
496
+ blocking++;
497
+ 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)` : ""}`);
498
+ }
365
499
  }
366
500
  if (opts.strict && blocking) {
367
- console.log(`\n✗ ${blocking} blocking invariant(s) in scope — review before committing.`);
501
+ console.log(`\n✗ ${blocking} blocking invariant(s) in scope (direct or near) — review before committing.`);
368
502
  process.exitCode = 1;
369
503
  }
370
504
  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
@@ -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
- return (await mod.pipeline("feature-extraction", HF_MODEL));
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
@@ -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.1.1",
3
+ "version": "0.4.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.",