@davesheffer/hunch 0.10.2 → 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 +24 -11
- package/dist/core/strictgate.js +24 -0
- package/dist/integrations/hooks.js +3 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 && (
|
|
603
|
+
if (opts.strict && (strictBlockers || regBlocking)) {
|
|
594
604
|
const reasons = [
|
|
595
|
-
|
|
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
|
|
@@ -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
|
|
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.
|
|
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.",
|