@davesheffer/hunch 0.11.2 โ 0.12.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 +71 -70
- package/dist/core/checkreport.js +113 -0
- package/dist/extractors/git.js +12 -0
- package/dist/integrations/ciAction.js +81 -0
- package/dist/integrations/gitignore.js +37 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -24,11 +24,14 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
24
24
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
25
25
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
26
26
|
import { selectProvider } from "../synthesis/provider.js";
|
|
27
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
|
|
27
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff } from "../extractors/git.js";
|
|
28
28
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
29
29
|
import { isStrictBlocker } from "../core/strictgate.js";
|
|
30
|
+
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
30
31
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
31
32
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
33
|
+
import { ensureGitignore } from "../integrations/gitignore.js";
|
|
34
|
+
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
32
35
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
33
36
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
34
37
|
import { scaffoldProviders } from "../integrations/providers.js";
|
|
@@ -73,6 +76,12 @@ program
|
|
|
73
76
|
console.log(`๐ง Initializing Hunch at ${root}`);
|
|
74
77
|
store.json.ensureDirs(); // stamps the manifest at the current version when fresh
|
|
75
78
|
console.log(` โ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
|
|
79
|
+
// Exclude the derived SQLite index BEFORE it's written, so the working tree
|
|
80
|
+
// never goes dirty on the MCP server's index writes (which blocks branch
|
|
81
|
+
// switches). The .hunch/*.json graph stays tracked.
|
|
82
|
+
const gi = ensureGitignore(root);
|
|
83
|
+
if (gi.action !== "unchanged")
|
|
84
|
+
console.log(` โ .gitignore ${gi.action} (Hunch runtime index excluded)`);
|
|
76
85
|
if (opts.index !== false) {
|
|
77
86
|
const res = indexRepo(store, root);
|
|
78
87
|
store.reindex();
|
|
@@ -134,6 +143,7 @@ program
|
|
|
134
143
|
.action(() => {
|
|
135
144
|
const { store, root } = storeFor();
|
|
136
145
|
store.json.ensureDirs();
|
|
146
|
+
ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
|
|
137
147
|
const res = indexRepo(store, root);
|
|
138
148
|
const { counts } = store.reindex();
|
|
139
149
|
updateClaudeMd(root, store);
|
|
@@ -526,54 +536,76 @@ program
|
|
|
526
536
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
527
537
|
program
|
|
528
538
|
.command("check")
|
|
529
|
-
.description("Flag changes that touch a do-not-break invariant
|
|
539
|
+
.description("Flag changes that touch a do-not-break invariant โ the local guardrail AND the CI/PR Constraint Guard.")
|
|
530
540
|
.option("--staged", "check git staged files (default)")
|
|
531
541
|
.option("--commit <sha>", "check a specific commit's files")
|
|
542
|
+
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) โ for CI")
|
|
532
543
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
544
|
+
.option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
|
|
533
545
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
534
546
|
.action((opts) => {
|
|
535
|
-
|
|
536
|
-
|
|
547
|
+
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
548
|
+
if (sources.length > 1)
|
|
549
|
+
return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
|
|
550
|
+
const markdown = opts.format === "markdown";
|
|
551
|
+
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
|
|
537
552
|
const { store, root } = storeFor();
|
|
538
553
|
store.reindex(); // blast radius walks the edge graph โ make the index current
|
|
539
|
-
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
554
|
+
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
555
|
+
: opts.base ? rangeFiles(opts.base, root)
|
|
556
|
+
: stagedFiles(root);
|
|
540
557
|
if (!files.length) {
|
|
541
|
-
console.log("No changed files to check.");
|
|
558
|
+
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
542
559
|
store.close();
|
|
543
560
|
return;
|
|
544
561
|
}
|
|
545
|
-
const mark = (s) => (s === "blocking" ? "โ" : s === "warning" ? "โ " : "ยท");
|
|
546
562
|
// 1) DIRECT โ a changed file matches a constraint's scope.
|
|
547
563
|
const direct = new Map();
|
|
548
|
-
for (const f of files)
|
|
564
|
+
for (const f of files)
|
|
549
565
|
for (const c of store.checkConstraints(f)) {
|
|
550
566
|
const e = direct.get(c.id) ?? { c, files: [] };
|
|
551
567
|
e.files.push(f);
|
|
552
568
|
direct.set(c.id, e);
|
|
553
569
|
}
|
|
554
|
-
|
|
555
|
-
// 2) NEAR โ a changed file's blast radius reaches a file an invariant guards:
|
|
556
|
-
// you didn't touch the invariant, but you touched something it depends on.
|
|
570
|
+
// 2) NEAR โ reached only through the blast radius (a guarded dependency changed).
|
|
557
571
|
const near = new Map();
|
|
558
|
-
for (const f of files)
|
|
559
|
-
for (const b of store.blastRadiusFiles(f))
|
|
572
|
+
for (const f of files)
|
|
573
|
+
for (const b of store.blastRadiusFiles(f))
|
|
560
574
|
for (const c of store.checkConstraints(b.file)) {
|
|
561
575
|
if (direct.has(c.id))
|
|
562
|
-
continue; // already
|
|
576
|
+
continue; // already a direct hit
|
|
563
577
|
const e = near.get(c.id) ?? { c, via: [] };
|
|
564
578
|
e.via.push(`${f} โ ${b.file} (${b.via}, depth ${b.depth})`);
|
|
565
579
|
near.set(c.id, e);
|
|
566
580
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// 3) REGRESSION โ does the diff RE-ADD something an in-force decision removed?
|
|
570
|
-
// (e.g. re-introducing a symbol/dep that was deliberately deleted). Warn
|
|
571
|
-
// always; only a blocking-linked resurrection fails the commit under strict.
|
|
572
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : stagedDiff(root);
|
|
581
|
+
// 3) REGRESSION โ does the diff RE-ADD something an in-force decision retired?
|
|
582
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
573
583
|
const an = analyzeDiff(diff);
|
|
574
584
|
const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
575
|
-
|
|
576
|
-
|
|
585
|
+
// Hardened strict gate (strictgate.ts): only DIRECT + high-confidence + non-stale
|
|
586
|
+
// can fail. near/stale/low-confidence stay advisory โ safe on a shared repo / PR.
|
|
587
|
+
const staleConstraintIds = opts.strict
|
|
588
|
+
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
589
|
+
: new Set();
|
|
590
|
+
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
591
|
+
const stale = staleConstraintIds.has(c.id);
|
|
592
|
+
const strictBlocks = isStrictBlocker(c, stale);
|
|
593
|
+
return {
|
|
594
|
+
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
595
|
+
files: fs, strictBlocks,
|
|
596
|
+
downgrade: (c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined),
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
const report = {
|
|
600
|
+
fileCount: files.length,
|
|
601
|
+
strict: !!opts.strict,
|
|
602
|
+
direct: directReport,
|
|
603
|
+
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
604
|
+
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
605
|
+
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
606
|
+
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
607
|
+
};
|
|
608
|
+
if (opts.blast && !markdown) {
|
|
577
609
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
578
610
|
for (const f of files) {
|
|
579
611
|
const b = store.blastRadiusFiles(f);
|
|
@@ -582,58 +614,27 @@ program
|
|
|
582
614
|
}
|
|
583
615
|
console.log("");
|
|
584
616
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
store.close();
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
// --strict may FAIL a commit ONLY on a DIRECT, high-confidence, non-stale
|
|
591
|
-
// blocking invariant (see strictgate.ts) โ never on a blast-radius ("near")
|
|
592
|
-
// guess or a stale/low-confidence record. Those weaker hits still print, as
|
|
593
|
-
// advisory, so strict mode is safe to enable on a shared repo.
|
|
594
|
-
const staleConstraintIds = opts.strict
|
|
595
|
-
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
596
|
-
: new Set();
|
|
597
|
-
let strictBlockers = 0;
|
|
598
|
-
if (direct.size) {
|
|
599
|
-
console.log(`Directly touches ${direct.size} invariant(s):\n`);
|
|
600
|
-
for (const { c, files: fs } of direct.values()) {
|
|
601
|
-
const blocks = isStrictBlocker(c, staleConstraintIds.has(c.id));
|
|
602
|
-
if (blocks)
|
|
603
|
-
strictBlockers++;
|
|
604
|
-
const note = opts.strict && c.severity === "blocking" && !blocks
|
|
605
|
-
? staleConstraintIds.has(c.id) ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
606
|
-
: "";
|
|
607
|
-
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} ยท in: ${fs.join(", ")}\n rationale: ${c.rationale || "โ"}`);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
if (near.size) {
|
|
611
|
-
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed โ review; never blocks):\n`);
|
|
612
|
-
for (const { c, via } of near.values()) {
|
|
613
|
-
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)` : ""}`);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
if (regHits.length) {
|
|
617
|
-
console.log(`${direct.size || near.size ? "\n" : ""}Re-introduces ${regHits.length} deliberately-retired item(s):\n`);
|
|
618
|
-
for (const h of regHits) {
|
|
619
|
-
console.log(` ${h.blocking ? "โ" : "โ "} re-adds ${h.kind} \`${h.name}\` โ ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n โ${h.title}โ\n ${h.reason}`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
if (opts.strict && (strictBlockers || regBlocking)) {
|
|
623
|
-
const reasons = [
|
|
624
|
-
strictBlockers ? `${strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
625
|
-
regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
|
|
626
|
-
].filter(Boolean).join(" + ");
|
|
627
|
-
console.log(`\nโ ${reasons} โ review before committing.`);
|
|
617
|
+
console.log(markdown ? renderMarkdown(report) : renderText(report));
|
|
618
|
+
if (reportFailsStrict(report))
|
|
628
619
|
process.exitCode = 1;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
620
|
+
store.close();
|
|
621
|
+
});
|
|
622
|
+
// ---- ci (scaffold the CI Constraint Guard) --------------------------------
|
|
623
|
+
program
|
|
624
|
+
.command("ci")
|
|
625
|
+
.description("Scaffold the CI Constraint Guard: a GitHub Action that runs `hunch check` on PRs, comments the result, and fails on a blocking invariant.")
|
|
626
|
+
.action(() => {
|
|
627
|
+
const root = findRoot();
|
|
628
|
+
const r = writeCiWorkflow(root);
|
|
629
|
+
if (r.action === "created") {
|
|
630
|
+
console.log(`โ wrote ${rel(root, r.path)}`);
|
|
631
|
+
console.log(" Runs on every PR: comments the affected invariants/decisions and fails on a direct,");
|
|
632
|
+
console.log(" high-confidence, non-stale blocking invariant. Commit it, then (optionally) make");
|
|
633
|
+
console.log(' "Hunch Guard" a required status check in branch protection to enforce on merge.');
|
|
632
634
|
}
|
|
633
635
|
else {
|
|
634
|
-
console.log(
|
|
636
|
+
console.log(`ยท ${rel(root, r.path)} already exists โ left untouched. Delete it to regenerate.`);
|
|
635
637
|
}
|
|
636
|
-
store.close();
|
|
637
638
|
});
|
|
638
639
|
// ---- context (surgical retrieval) -----------------------------------------
|
|
639
640
|
program
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Rendering for `hunch check` โ pure, so the terminal output and the CI/PR
|
|
2
|
+
* comment share one source of truth and stay unit-testable. The CLI builds a
|
|
3
|
+
* CheckReport from the store (direct/near/regression + the hardened strict gate),
|
|
4
|
+
* then renders it as text (terminal, unchanged) or markdown (a PR comment posted
|
|
5
|
+
* by the GitHub Action). The exit-code decision lives with the caller. */
|
|
6
|
+
export function reportIsClean(r) {
|
|
7
|
+
return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0;
|
|
8
|
+
}
|
|
9
|
+
/** True when --strict should FAIL the commit/PR. */
|
|
10
|
+
export function reportFailsStrict(r) {
|
|
11
|
+
return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0);
|
|
12
|
+
}
|
|
13
|
+
const mark = (s) => (s === "blocking" ? "โ" : s === "warning" ? "โ " : "ยท");
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Terminal text (unchanged from the inline CLI output it replaces)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
export function renderText(r) {
|
|
18
|
+
if (reportIsClean(r)) {
|
|
19
|
+
return `โ ${r.fileCount} changed file(s) touch no recorded invariants (directly or via blast radius) and re-introduce nothing deliberately retired.`;
|
|
20
|
+
}
|
|
21
|
+
const out = [];
|
|
22
|
+
if (r.direct.length) {
|
|
23
|
+
out.push(`Directly touches ${r.direct.length} invariant(s):\n`);
|
|
24
|
+
for (const c of r.direct) {
|
|
25
|
+
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
26
|
+
? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
27
|
+
: "";
|
|
28
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} ยท in: ${c.files.join(", ")}\n rationale: ${c.rationale || "โ"}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (r.near.length) {
|
|
32
|
+
out.push(`${r.direct.length ? "\n" : ""}Near ${r.near.length} invariant(s) via blast radius (a guarded dependency changed โ review; never blocks):\n`);
|
|
33
|
+
for (const c of r.near) {
|
|
34
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${c.via.slice(0, 4).join("\n ")}${c.via.length > 4 ? `\n โฆ+${c.via.length - 4} more path(s)` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (r.regressions.length) {
|
|
38
|
+
out.push(`${r.direct.length || r.near.length ? "\n" : ""}Re-introduces ${r.regressions.length} deliberately-retired item(s):\n`);
|
|
39
|
+
for (const h of r.regressions) {
|
|
40
|
+
out.push(` ${h.blocking ? "โ" : "โ "} re-adds ${h.kind} \`${h.name}\` โ ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n โ${h.title}โ\n ${h.reason}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (reportFailsStrict(r)) {
|
|
44
|
+
const reasons = [
|
|
45
|
+
r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
46
|
+
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
47
|
+
].filter(Boolean).join(" + ");
|
|
48
|
+
out.push(`\nโ ${reasons} โ review before committing.`);
|
|
49
|
+
}
|
|
50
|
+
else if (r.strict) {
|
|
51
|
+
out.push(`\nReview these โ none are a direct, high-confidence, non-stale blocking invariant, so the commit is NOT blocked.`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out.push(`\nReview that these invariants still hold. (Advisory โ run with --strict to fail on direct, high-confidence, non-stale blocking invariants.)`);
|
|
55
|
+
}
|
|
56
|
+
return out.join("\n");
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Markdown (a PR comment posted by the CI Constraint Guard)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function renderMarkdown(r) {
|
|
62
|
+
const H = "## ๐ง Hunch โ Engineering Memory Guard";
|
|
63
|
+
if (reportIsClean(r)) {
|
|
64
|
+
return `${H}\n\nโ
This PR touches **no recorded invariants** (directly or via blast radius) and re-introduces nothing deliberately retired across ${r.fileCount} changed file(s).`;
|
|
65
|
+
}
|
|
66
|
+
const out = [H, ""];
|
|
67
|
+
if (r.direct.length) {
|
|
68
|
+
out.push(`### โ Invariants directly in scope`);
|
|
69
|
+
for (const c of r.direct) {
|
|
70
|
+
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
71
|
+
? c.downgrade === "stale" ? " _(advisory: record is stale)_" : " _(advisory: low confidence)_"
|
|
72
|
+
: "";
|
|
73
|
+
out.push(`- **[${c.severity}] ${c.statement}** โ \`${c.id}\`${note}`);
|
|
74
|
+
out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
|
|
75
|
+
if (c.rationale)
|
|
76
|
+
out.push(` - _${c.rationale}_`);
|
|
77
|
+
}
|
|
78
|
+
out.push("");
|
|
79
|
+
}
|
|
80
|
+
if (r.near.length) {
|
|
81
|
+
out.push(`### โ Near-invariants (reached via blast radius โ review, never blocks)`);
|
|
82
|
+
for (const c of r.near) {
|
|
83
|
+
out.push(`- **[${c.severity}] ${c.statement}** โ \`${c.id}\``);
|
|
84
|
+
out.push(` - ${c.via.slice(0, 3).join("\n - ")}${c.via.length > 3 ? `\n - โฆ+${c.via.length - 3} more path(s)` : ""}`);
|
|
85
|
+
}
|
|
86
|
+
out.push("");
|
|
87
|
+
}
|
|
88
|
+
if (r.regressions.length) {
|
|
89
|
+
out.push(`### โป๏ธ Re-introduces deliberately-retired code`);
|
|
90
|
+
for (const h of r.regressions) {
|
|
91
|
+
out.push(`- ${h.blocking ? "โ" : "โ "} re-adds ${h.kind} \`${h.name}\` โ \`${h.decision}\` removed it${h.blocking ? " **(blocking-linked)**" : ""}`);
|
|
92
|
+
out.push(` - _${h.title}_`);
|
|
93
|
+
}
|
|
94
|
+
out.push("");
|
|
95
|
+
}
|
|
96
|
+
out.push("---");
|
|
97
|
+
if (reportFailsStrict(r)) {
|
|
98
|
+
const reasons = [
|
|
99
|
+
r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
100
|
+
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
101
|
+
].filter(Boolean).join(" + ");
|
|
102
|
+
out.push(`โ **This PR breaks ${reasons}.** Resolve or supersede the decision before merge.`);
|
|
103
|
+
}
|
|
104
|
+
else if (r.strict) {
|
|
105
|
+
out.push(`โน๏ธ Nothing here is a direct, high-confidence, non-stale blocking invariant โ **not blocking** this PR.`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
out.push(`โน๏ธ Advisory โ review that these invariants still hold.`);
|
|
109
|
+
}
|
|
110
|
+
out.push(`\n<sub>๐ง Hunch ยท engineering memory ยท run \`hunch why <file>\` for the full reasoning.</sub>`);
|
|
111
|
+
return out.join("\n");
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=checkreport.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -153,6 +153,18 @@ export function stagedFiles(cwd) {
|
|
|
153
153
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
154
154
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
155
155
|
}
|
|
156
|
+
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
157
|
+
* i.e. exactly the PR's own commits โ the CI Constraint Guard's surface). */
|
|
158
|
+
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
159
|
+
const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
160
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
161
|
+
}
|
|
162
|
+
/** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
|
|
163
|
+
* analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
|
|
164
|
+
export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
|
|
165
|
+
const out = gitSafe(["diff", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
|
|
166
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\nโฆ(diff truncated)โฆ" : out;
|
|
167
|
+
}
|
|
156
168
|
/** Unified diff of the staged changes (for the Regression Guard's structural
|
|
157
169
|
* analysis). Excludes machine-generated noise and truncates at the SAME budget as
|
|
158
170
|
* commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** The CI Constraint Guard โ Hunch's enforcement edge. A GitHub Actions workflow
|
|
2
|
+
* that runs `hunch check` over a pull request's diff, posts the result as a
|
|
3
|
+
* sticky PR comment (citing the con_/dec_ ids), and FAILS the check on a direct,
|
|
4
|
+
* high-confidence, non-stale blocking invariant (the hardened strict gate). This
|
|
5
|
+
* turns the engineering memory from advisory into a merge gate โ and it's
|
|
6
|
+
* structurally uncopyable: it reasons over the diff PLUS the constraints
|
|
7
|
+
* committed in the same git history. Idempotent: never clobbers a user's edits.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
// `\${{ โฆ }}` keeps GitHub Actions expressions literal inside this template
|
|
12
|
+
// literal (a bare `${` would be JS interpolation).
|
|
13
|
+
export function ciWorkflowYaml() {
|
|
14
|
+
return `# Hunch โ CI Constraint Guard. Blocks a PR that breaks a recorded invariant,
|
|
15
|
+
# re-adds deliberately-retired code, or contradicts an in-force decision, and
|
|
16
|
+
# comments with the why (con_/dec_ ids). Make this a required check to enforce.
|
|
17
|
+
name: Hunch Guard
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
pull_request:
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
pull-requests: write
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
hunch-guard:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 0 # full history: the guard diffs base...head and reads git log
|
|
33
|
+
|
|
34
|
+
- uses: actions/setup-node@v4
|
|
35
|
+
with:
|
|
36
|
+
node-version: 20
|
|
37
|
+
|
|
38
|
+
- name: Install Hunch
|
|
39
|
+
run: npm install -g @davesheffer/hunch
|
|
40
|
+
|
|
41
|
+
- name: Run Constraint Guard
|
|
42
|
+
id: guard
|
|
43
|
+
run: |
|
|
44
|
+
set +e
|
|
45
|
+
hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown > hunch-report.md
|
|
46
|
+
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
|
47
|
+
set -e
|
|
48
|
+
|
|
49
|
+
- name: Comment on PR
|
|
50
|
+
if: always()
|
|
51
|
+
uses: actions/github-script@v7
|
|
52
|
+
with:
|
|
53
|
+
script: |
|
|
54
|
+
const fs = require('fs');
|
|
55
|
+
const body = fs.readFileSync('hunch-report.md', 'utf8').trim();
|
|
56
|
+
const marker = '<!-- hunch-guard -->';
|
|
57
|
+
const { owner, repo } = context.repo;
|
|
58
|
+
const issue_number = context.payload.pull_request.number;
|
|
59
|
+
const all = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
|
60
|
+
const existing = all.find(c => c.body && c.body.includes(marker));
|
|
61
|
+
const out = marker + '\\n' + body;
|
|
62
|
+
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: out });
|
|
63
|
+
else await github.rest.issues.createComment({ owner, repo, issue_number, body: out });
|
|
64
|
+
|
|
65
|
+
- name: Enforce (fail on a blocking invariant)
|
|
66
|
+
if: always()
|
|
67
|
+
run: exit \${{ steps.guard.outputs.exit }}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
/** Write .github/workflows/hunch-guard.yml. Never overwrites an existing file
|
|
71
|
+
* (respects user edits) โ reports "exists" instead. */
|
|
72
|
+
export function writeCiWorkflow(root) {
|
|
73
|
+
const dir = join(root, ".github", "workflows");
|
|
74
|
+
const path = join(dir, "hunch-guard.yml");
|
|
75
|
+
if (existsSync(path))
|
|
76
|
+
return { path, action: "exists" };
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
writeFileSync(path, ciWorkflowYaml());
|
|
79
|
+
return { path, action: "created" };
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=ciAction.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure the project's .gitignore excludes Hunch's DERIVED runtime artifacts: the
|
|
3
|
+
* SQLite index (rebuilt from the committed .hunch/*.json source of truth) and the
|
|
4
|
+
* atomic-write temp files. Without this the MCP server's constant index writes
|
|
5
|
+
* leave the working tree perpetually dirty, which blocks branch switches, pulls,
|
|
6
|
+
* and rebases. The .hunch/*.json graph itself stays TRACKED โ only the regenerable
|
|
7
|
+
* index is ignored.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent + merge-safe (con_8460b6770f): appends a single marked block and
|
|
10
|
+
* never rewrites the user's existing entries; re-running is a no-op.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
const MARK = "# >>> hunch (derived runtime index โ regenerable from .hunch/*.json) >>>";
|
|
15
|
+
const END = "# <<< hunch <<<";
|
|
16
|
+
const ENTRIES = [
|
|
17
|
+
".hunch/*.sqlite",
|
|
18
|
+
".hunch/*.sqlite-shm",
|
|
19
|
+
".hunch/*.sqlite-wal",
|
|
20
|
+
".hunch/*.sqlite-journal",
|
|
21
|
+
".hunch/**/*.tmp*",
|
|
22
|
+
];
|
|
23
|
+
export function ensureGitignore(root) {
|
|
24
|
+
const path = join(root, ".gitignore");
|
|
25
|
+
const block = [MARK, ...ENTRIES, END].join("\n");
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
writeFileSync(path, block + "\n");
|
|
28
|
+
return { path, action: "created" };
|
|
29
|
+
}
|
|
30
|
+
const cur = readFileSync(path, "utf8");
|
|
31
|
+
if (cur.includes(MARK))
|
|
32
|
+
return { path, action: "unchanged" }; // already managed
|
|
33
|
+
const sep = cur.endsWith("\n") || cur.length === 0 ? "" : "\n";
|
|
34
|
+
writeFileSync(path, `${cur}${sep}${block}\n`);
|
|
35
|
+
return { path, action: "appended" };
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=gitignore.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.",
|