@davesheffer/hunch 0.10.1 → 0.11.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/dist/cli/index.js CHANGED
@@ -26,6 +26,7 @@ import { parseTestReport } from "../extractors/testreport.js";
26
26
  import { selectProvider } from "../synthesis/provider.js";
27
27
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
28
28
  import { analyzeDiff } from "../extractors/diff.js";
29
+ import { isStrictBlocker } from "../core/strictgate.js";
29
30
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
30
31
  import { installMergeDriver } from "../integrations/mergeDriver.js";
31
32
  import { updateClaudeMd } from "../integrations/claudemd.js";
@@ -509,7 +510,7 @@ program
509
510
  .description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
510
511
  .option("--staged", "check git staged files (default)")
511
512
  .option("--commit <sha>", "check a specific commit's files")
512
- .option("--strict", "exit non-zero if a blocking constraint is in scope (direct OR near)")
513
+ .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
513
514
  .option("--blast", "also print the dependency blast radius of the changed files")
514
515
  .action((opts) => {
515
516
  if (opts.commit && opts.staged)
@@ -567,20 +568,29 @@ program
567
568
  store.close();
568
569
  return;
569
570
  }
570
- let blocking = 0;
571
+ // --strict may FAIL a commit ONLY on a DIRECT, high-confidence, non-stale
572
+ // blocking invariant (see strictgate.ts) — never on a blast-radius ("near")
573
+ // guess or a stale/low-confidence record. Those weaker hits still print, as
574
+ // advisory, so strict mode is safe to enable on a shared repo.
575
+ const staleConstraintIds = opts.strict
576
+ ? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
577
+ : new Set();
578
+ let strictBlockers = 0;
571
579
  if (direct.size) {
572
580
  console.log(`Directly touches ${direct.size} invariant(s):\n`);
573
581
  for (const { c, files: fs } of direct.values()) {
574
- if (c.severity === "blocking")
575
- blocking++;
576
- console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
582
+ const blocks = isStrictBlocker(c, staleConstraintIds.has(c.id));
583
+ if (blocks)
584
+ strictBlockers++;
585
+ const note = opts.strict && c.severity === "blocking" && !blocks
586
+ ? staleConstraintIds.has(c.id) ? " (advisory: stale)" : " (advisory: low confidence)"
587
+ : "";
588
+ console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
577
589
  }
578
590
  }
579
591
  if (near.size) {
580
- console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review):\n`);
592
+ console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review; never blocks):\n`);
581
593
  for (const { c, via } of near.values()) {
582
- if (c.severity === "blocking")
583
- blocking++;
584
594
  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)` : ""}`);
585
595
  }
586
596
  }
@@ -590,16 +600,19 @@ program
590
600
  console.log(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
591
601
  }
592
602
  }
593
- if (opts.strict && (blocking || regBlocking)) {
603
+ if (opts.strict && (strictBlockers || regBlocking)) {
594
604
  const reasons = [
595
- blocking ? `${blocking} blocking invariant(s) in scope` : "",
605
+ strictBlockers ? `${strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
596
606
  regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
597
607
  ].filter(Boolean).join(" + ");
598
608
  console.log(`\n✗ ${reasons} — review before committing.`);
599
609
  process.exitCode = 1;
600
610
  }
611
+ else if (opts.strict) {
612
+ console.log(`\nReview these — none are a direct, high-confidence, non-stale blocking invariant, so the commit is NOT blocked.`);
613
+ }
601
614
  else {
602
- console.log(`\nReview that these invariants still hold. (Advisory — run with --strict to fail on blocking.)`);
615
+ console.log(`\nReview that these invariants still hold. (Advisory — run with --strict to fail on direct, high-confidence, non-stale blocking invariants.)`);
603
616
  }
604
617
  store.close();
605
618
  });
@@ -0,0 +1,24 @@
1
+ /** The hardened gate for `hunch check --strict` (and the strict pre-commit hook):
2
+ * which invariants may actually FAIL a commit. Extracted + pure so the rule lives
3
+ * in one audited, unit-tested place (mirrors hookpolicy.ts).
4
+ *
5
+ * A commit is only ever blocked by a DIRECTLY-scoped, high-confidence, NON-STALE
6
+ * blocking invariant — never by a blast-radius ("near") guess, nor by a record the
7
+ * graph may have gone stale on, nor by a low-confidence auto-derived guess. Those
8
+ * weaker signals still print, as advisory. This makes strict mode safe to enable
9
+ * on a shared repo: a false positive downgrades to a warning instead of wrongly
10
+ * failing a teammate's commit. */
11
+ export const STRICT_MIN_CONFIDENCE = 0.8;
12
+ /** May this invariant FAIL a commit under --strict? Requires blocking severity,
13
+ * a fresh (non-stale) record, and either high provenance confidence or a
14
+ * human-confirmed source (a person vouched for it). Near/blast-radius hits never
15
+ * reach here — the caller passes only directly-scoped invariants. */
16
+ export function isStrictBlocker(c, stale) {
17
+ if (c.severity !== "blocking")
18
+ return false;
19
+ if (stale)
20
+ return false;
21
+ const confidence = c.provenance?.confidence ?? 0;
22
+ return confidence >= STRICT_MIN_CONFIDENCE || c.provenance?.source === "human_confirmed";
23
+ }
24
+ //# sourceMappingURL=strictgate.js.map
@@ -1,16 +1,16 @@
1
1
  /** Deterministic git introspection for the extractor + learning loop.
2
2
  * No LLM here — just parsing what git already knows. */
3
3
  import { execFileSync } from "node:child_process";
4
- function git(args, cwd) {
4
+ function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
5
5
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
6
6
  return execFileSync("git", args, {
7
- cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
7
+ cwd, encoding: "utf8", maxBuffer,
8
8
  stdio: ["ignore", "pipe", "ignore"],
9
9
  }).trim();
10
10
  }
11
- function gitSafe(args, cwd) {
11
+ function gitSafe(args, cwd, maxBuffer) {
12
12
  try {
13
- return git(args, cwd);
13
+ return git(args, cwd, maxBuffer);
14
14
  }
15
15
  catch {
16
16
  return "";
@@ -100,6 +100,54 @@ export function lastCommitForFile(file, cwd) {
100
100
  export function lastChangeDate(file, cwd) {
101
101
  return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
102
102
  }
103
+ /** Batched per-file git metrics for indexing: churn (commits touching the file in
104
+ * the last `days`; pass 0 to skip) and the most-recent commit (`commit:<sha>`).
105
+ *
106
+ * Replaces the indexer's O(files) × 2 `git log` spawns — which dominate
107
+ * `hunch index` wall-time on a large repo, especially on Windows where process
108
+ * creation is costly — with ONE `git log` pass each. Only paths present in `want`
109
+ * are returned (every requested path gets an entry, defaulting to 0 / ""). */
110
+ export function fileGitMetrics(cwd, want, days = 90) {
111
+ const out = new Map();
112
+ for (const f of want)
113
+ out.set(f, { churn: 0, lastCommit: "" });
114
+ if (out.size === 0)
115
+ return out;
116
+ // churn — one windowed log; tally each wanted path's appearances (= commits).
117
+ if (days > 0) {
118
+ const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
119
+ if (raw) {
120
+ for (const line of raw.split("\n")) {
121
+ const e = line && out.get(line);
122
+ if (e)
123
+ e.churn++;
124
+ }
125
+ }
126
+ }
127
+ // last commit — one newest-first log; the FIRST time a path appears is its most
128
+ // recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
129
+ // 256MB buffer for the all-history name-only stream on large repos.
130
+ const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
131
+ if (raw) {
132
+ let remaining = out.size;
133
+ let sha = "";
134
+ for (const line of raw.split("\n")) {
135
+ if (line.charCodeAt(0) === 0) {
136
+ sha = line.slice(1);
137
+ continue;
138
+ }
139
+ if (!line)
140
+ continue;
141
+ const e = out.get(line);
142
+ if (e && !e.lastCommit && sha) {
143
+ e.lastCommit = `commit:${sha}`;
144
+ if (--remaining === 0)
145
+ break; // every wanted path resolved — stop scanning
146
+ }
147
+ }
148
+ }
149
+ return out;
150
+ }
103
151
  /** Files staged for commit (for `hunch check` pre-commit enforcement). */
104
152
  export function stagedFiles(cwd) {
105
153
  const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
@@ -12,7 +12,7 @@ import { join, relative, dirname, posix } from "node:path";
12
12
  import { parseSource, attributeCalls } from "./parse.js";
13
13
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
14
  import { extracted, inferred } from "../core/types.js";
15
- import { isGitRepo, trackedFiles, fileChurn, lastCommitForFile } from "./git.js";
15
+ import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
16
16
  const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
17
17
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
18
18
  export function indexRepo(store, root, opts = {}) {
@@ -25,7 +25,10 @@ export function indexRepo(store, root, opts = {}) {
25
25
  const fileStartByteId = new Map(); // file -> (symbol startByte -> id)
26
26
  const perFileCalls = [];
27
27
  const perFileImports = [];
28
- const churnCache = new Map();
28
+ // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
29
+ // total, instead of two per file — the dominant cost of indexing a large repo.
30
+ const rels = files.map((abs) => toPosix(relative(root, abs)));
31
+ const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
29
32
  let skipped = 0;
30
33
  for (const abs of files) {
31
34
  const rel = toPosix(relative(root, abs));
@@ -50,9 +53,9 @@ export function indexRepo(store, root, opts = {}) {
50
53
  skipped++;
51
54
  continue;
52
55
  }
53
- const churn = opts.churn !== false && useGit ? (churnCache.get(rel) ?? fileChurn(rel, root)) : 0;
54
- churnCache.set(rel, churn);
55
- const last = useGit ? lastCommitForFile(rel, root) : "";
56
+ const m = gitMeta?.get(rel);
57
+ const churn = m?.churn ?? 0;
58
+ const last = m?.lastCommit ?? "";
56
59
  const idsInFile = [];
57
60
  const startByteId = new Map();
58
61
  const idCounts = new Map(); // disambiguate same (file,name,kind)
@@ -52,7 +52,9 @@ const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
52
52
  const PRE_END = "# <<< hunch pre-commit <<<";
53
53
  /** Install a pre-commit constraint guard (DESIGN §4 enforcement). Advisory by
54
54
  * default (prints invariants in scope, never blocks); pass strict to fail the
55
- * commit on a blocking invariant. Preserves any existing pre-commit hook. */
55
+ * commit but even strict only fails on a DIRECT, high-confidence, non-stale
56
+ * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
57
+ * Preserves any existing pre-commit hook. */
56
58
  export function installPreCommitHook(root, invocation, strict = false) {
57
59
  const dir = hooksDir(root);
58
60
  const abs = dir.startsWith("/") ? dir : join(root, dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.10.1",
3
+ "version": "0.11.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.",