@davesheffer/hunch 1.39.0 → 1.39.1
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 +126 -60
- package/dist/cli/integrations.js +10 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +1 -0
- package/dist/core/workspace.d.ts +23 -1
- package/dist/core/workspace.js +31 -7
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/workspaces.d.ts +10 -0
- package/dist/extractors/workspaces.js +92 -15
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +61 -7
- package/dist/integrations/hooks.js +330 -43
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +5 -5
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -40,7 +40,7 @@ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/index
|
|
|
40
40
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
41
41
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
42
42
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
43
|
-
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate,
|
|
43
|
+
import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
|
|
44
44
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
45
45
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
46
46
|
import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
|
|
@@ -52,11 +52,11 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
|
52
52
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
53
53
|
import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
54
54
|
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
55
|
-
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus } from "../integrations/hooks.js";
|
|
55
|
+
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
|
|
56
56
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
57
57
|
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
|
|
58
58
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
59
|
-
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
|
|
59
|
+
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
|
|
60
60
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
61
61
|
import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
|
|
62
62
|
import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
|
|
@@ -81,7 +81,7 @@ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
|
81
81
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
|
|
82
82
|
import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
|
|
83
83
|
import { worktreeRows, branchRows } from "../core/workspace.js";
|
|
84
|
-
import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune } from "../integrations/workspaceLedger.js";
|
|
84
|
+
import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
|
|
85
85
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
86
86
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
87
87
|
import { appendEvent, readEvents } from "../core/events.js";
|
|
@@ -341,6 +341,10 @@ program
|
|
|
341
341
|
const gi = ensureGitignore(root);
|
|
342
342
|
if (gi.action !== "unchanged")
|
|
343
343
|
console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
|
|
344
|
+
// A repo migrated to a private overlay by an older release: bring its private-only
|
|
345
|
+
// block up to date (and stop publishing newly ignored memory dirs).
|
|
346
|
+
for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
|
|
347
|
+
console.log(` ✓ ${line}`);
|
|
344
348
|
if (opts.index !== false) {
|
|
345
349
|
if (isGitRepo(root) && revExists("HEAD", root)) {
|
|
346
350
|
const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
|
|
@@ -393,11 +397,14 @@ program
|
|
|
393
397
|
if (isGitRepo(root)) {
|
|
394
398
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
395
399
|
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
|
|
396
|
-
|
|
400
|
+
for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
|
|
401
|
+
console.log(line);
|
|
397
402
|
const pm = installPostMergeHook(root, inv.shell);
|
|
398
|
-
|
|
403
|
+
for (const line of formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"))
|
|
404
|
+
console.log(line);
|
|
399
405
|
const pc = installPostCheckoutHook(root, inv.shell);
|
|
400
|
-
|
|
406
|
+
for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
|
|
407
|
+
console.log(line);
|
|
401
408
|
const m = installMergeDriver(root, inv.shell);
|
|
402
409
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
403
410
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -406,7 +413,8 @@ program
|
|
|
406
413
|
if (opts.enforce !== false || opts.enforceStrict) {
|
|
407
414
|
const strict = !!opts.enforceStrict;
|
|
408
415
|
const p = installPreCommitHook(root, inv.shell, strict);
|
|
409
|
-
|
|
416
|
+
for (const line of formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`))
|
|
417
|
+
console.log(line);
|
|
410
418
|
}
|
|
411
419
|
}
|
|
412
420
|
else {
|
|
@@ -478,7 +486,10 @@ program
|
|
|
478
486
|
.action((opts) => {
|
|
479
487
|
const { store, root } = storeFor();
|
|
480
488
|
store.json.ensureDirs();
|
|
481
|
-
|
|
489
|
+
// Keep the derived SQLite index out of git (idempotent). Adds a missing block but
|
|
490
|
+
// never rewrites an existing one: `index` runs in CI/release gates on a clean
|
|
491
|
+
// checkout. Older blocks are upgraded by `hunch update` / init / private setup.
|
|
492
|
+
ensureGitignore(root, { upgradeExisting: false });
|
|
482
493
|
// The post-merge hook only ever got installed by `hunch init`/`hunch
|
|
483
494
|
// private`/`hunch shared` — a repo that already ran init before this hook
|
|
484
495
|
// existed never receives it. `hunch index` already self-heals gitignore
|
|
@@ -1250,11 +1261,12 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1250
1261
|
if (opts.hook && isGitRepo(root)) {
|
|
1251
1262
|
freshSetup?.markHookWrite();
|
|
1252
1263
|
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
|
|
1253
|
-
|
|
1264
|
+
const note = (lines) => lines.map((l) => `${l}\n`).join("");
|
|
1265
|
+
hookNote = note(formatHookInstall(root, "post-commit hook", h, ` — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}`));
|
|
1254
1266
|
const pm = installPostMergeHook(root, inv.shell);
|
|
1255
|
-
hookNote +=
|
|
1267
|
+
hookNote += note(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"));
|
|
1256
1268
|
const pc = installPostCheckoutHook(root, inv.shell);
|
|
1257
|
-
hookNote +=
|
|
1269
|
+
hookNote += note(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: this machine's branches + worktrees sync through the overlay)"));
|
|
1258
1270
|
}
|
|
1259
1271
|
// 5) one-time migration: MOVE existing public memory INTO the overlay, then make
|
|
1260
1272
|
// THIS repo code-only. Records are absorbed (union by id) BEFORE the public
|
|
@@ -1314,6 +1326,13 @@ function configureOverlay(dir, opts, mode) {
|
|
|
1314
1326
|
` next: review, then commit the PUBLIC repo:\n` +
|
|
1315
1327
|
` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
|
|
1316
1328
|
}
|
|
1329
|
+
else {
|
|
1330
|
+
// Re-running setup on a repo migrated by an older release upgrades its private-only
|
|
1331
|
+
// block in place and stops publishing memory dirs that block did not yet list.
|
|
1332
|
+
const upgraded = describeGitignoreUpgrade(upgradeManagedGitignore(root));
|
|
1333
|
+
if (upgraded.length)
|
|
1334
|
+
migrateNote = upgraded.map((line) => ` ✓ ${line}\n`).join("");
|
|
1335
|
+
}
|
|
1317
1336
|
const lead = mode === "private"
|
|
1318
1337
|
? `✓ private overlay enabled → ${hunchDir}\n`
|
|
1319
1338
|
: `✓ shared overlay enabled → ${hunchDir}\n`;
|
|
@@ -1520,7 +1539,7 @@ workspacesCmd
|
|
|
1520
1539
|
if (!isGitRepo(root))
|
|
1521
1540
|
return fail("`hunch workspaces prune` needs a git repo");
|
|
1522
1541
|
const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
|
|
1523
|
-
const plan = prunePlanFor(view);
|
|
1542
|
+
const plan = prunePlanFor(view, root);
|
|
1524
1543
|
if (!opts.apply) {
|
|
1525
1544
|
if (opts.json)
|
|
1526
1545
|
return console.log(JSON.stringify({ machine: view.machine.label, plan }, null, 2));
|
|
@@ -1536,8 +1555,7 @@ workspacesCmd
|
|
|
1536
1555
|
if (!opts.json)
|
|
1537
1556
|
console.log(renderPrunePlan(view, plan));
|
|
1538
1557
|
if (!opts.yes) {
|
|
1539
|
-
const
|
|
1540
|
-
const ok = await confirmPrune(`Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`);
|
|
1558
|
+
const ok = await confirmPrune(pruneConfirmQuestion(view, plan));
|
|
1541
1559
|
if (!ok)
|
|
1542
1560
|
return fail(process.stdin.isTTY ? "not confirmed — nothing was deleted" : "refusing to apply without confirmation: stdin is not a terminal; pass --yes to confirm explicitly");
|
|
1543
1561
|
}
|
|
@@ -1555,11 +1573,12 @@ workspacesCmd
|
|
|
1555
1573
|
return console.log(JSON.stringify({ machine: view.machine.label, plan, results, recorded }, null, 2));
|
|
1556
1574
|
console.log("");
|
|
1557
1575
|
for (const r of results)
|
|
1558
|
-
console.log(` ${r.outcome === "deleted" ? "✓" : "✗"} ${r.step.branch}: ${r.detail}`);
|
|
1576
|
+
console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
|
|
1559
1577
|
const failed = results.filter((r) => r.outcome === "failed").length;
|
|
1578
|
+
const skipped = results.filter((r) => r.outcome === "skipped").length;
|
|
1560
1579
|
const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
|
|
1561
|
-
console.log(`\n${results.length - failed} deleted, ${failed} refused by git${ledger}`);
|
|
1562
|
-
if (failed)
|
|
1580
|
+
console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
|
|
1581
|
+
if (failed || skipped)
|
|
1563
1582
|
process.exitCode = 1;
|
|
1564
1583
|
}
|
|
1565
1584
|
finally {
|
|
@@ -4025,14 +4044,17 @@ program
|
|
|
4025
4044
|
// vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
|
|
4026
4045
|
// such a PR enumerates zero files) — including a deletion of the very symbol a
|
|
4027
4046
|
// blocking conformance predicate or an active policy guards.
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4047
|
+
// The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
|
|
4048
|
+
// content-matched blocking invariants fail closed instead of passing unseen.
|
|
4049
|
+
const gate = files.length
|
|
4050
|
+
? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
|
|
4051
|
+
: { diff: "" };
|
|
4031
4052
|
const report = files.length
|
|
4032
|
-
? store.buildCheckReport(files, diff, {
|
|
4053
|
+
? store.buildCheckReport(files, gate.diff, {
|
|
4033
4054
|
strict: !!opts.strict,
|
|
4034
4055
|
lastChange: (f) => lastChangeDate(f, root),
|
|
4035
4056
|
publicOnly: !!opts.publicOnly,
|
|
4057
|
+
diffStatus: gate,
|
|
4036
4058
|
})
|
|
4037
4059
|
: emptyReport;
|
|
4038
4060
|
if (opts.blast && !markdown && !sarif && files.length) {
|
|
@@ -4170,8 +4192,8 @@ const vetoCmd = program
|
|
|
4170
4192
|
store.close();
|
|
4171
4193
|
return;
|
|
4172
4194
|
}
|
|
4173
|
-
const
|
|
4174
|
-
const full = store.buildCheckReport(files, diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root) });
|
|
4195
|
+
const gate = opts.commit ? commitGateDiff(opts.commit, root) : opts.base ? rangeGateDiff(opts.base, root) : stagedGateDiff(root);
|
|
4196
|
+
const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
|
|
4175
4197
|
if (!full.vetoes.length) {
|
|
4176
4198
|
console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
|
|
4177
4199
|
store.close();
|
|
@@ -4732,11 +4754,14 @@ program
|
|
|
4732
4754
|
const before = st;
|
|
4733
4755
|
let activity = null;
|
|
4734
4756
|
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
4735
|
-
|
|
4736
|
-
|
|
4757
|
+
// A Codex apply_patch touches every file it lists (and each Move-to
|
|
4758
|
+
// destination); the Stop gate must see all of them, not only the first.
|
|
4759
|
+
const patchPaths = evt.tool_input?.patch_files?.flatMap((f) => f.moved_to ? [f.path, f.moved_to] : [f.path]);
|
|
4760
|
+
const edited = patchPaths?.length ? patchPaths : evt.tool_input?.file_path ? [evt.tool_input.file_path] : [];
|
|
4761
|
+
for (const p of edited)
|
|
4737
4762
|
st = onEdit(st, toRepoRel(root, p));
|
|
4763
|
+
if (edited.length)
|
|
4738
4764
|
activity = { kind: "edit" };
|
|
4739
|
-
}
|
|
4740
4765
|
}
|
|
4741
4766
|
else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
|
|
4742
4767
|
const command = String(evt.tool_input?.command ?? "");
|
|
@@ -5046,18 +5071,16 @@ program
|
|
|
5046
5071
|
}
|
|
5047
5072
|
if (evt.hook_event_name !== "PreToolUse")
|
|
5048
5073
|
return;
|
|
5049
|
-
const
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
const target = toRepoRel(root, abs);
|
|
5053
|
-
// Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
|
|
5054
|
-
// → nothing for Hunch to say.
|
|
5055
|
-
if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
|
|
5074
|
+
const targets = editTargets(root, evt.tool_input);
|
|
5075
|
+
// Nothing inside the repo → nothing for Hunch to say.
|
|
5076
|
+
if (!targets.length)
|
|
5056
5077
|
return;
|
|
5078
|
+
// Grounding below stays about the first in-repo file; the strict gate checks every target.
|
|
5079
|
+
const { abs, target } = targets[0];
|
|
5057
5080
|
// A compiled red→green probe is only meaningful if its red receipt exists
|
|
5058
5081
|
// before implementation. Firm/strict may deny two edits per prompt, then
|
|
5059
5082
|
// fail open so a malformed or unavailable probe can never deadlock work.
|
|
5060
|
-
if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && isProductPath(target)) {
|
|
5083
|
+
if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
|
|
5061
5084
|
const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
|
|
5062
5085
|
if (baseline.block) {
|
|
5063
5086
|
savePipelineState(evt.session_id, baseline.state);
|
|
@@ -5089,23 +5112,28 @@ program
|
|
|
5089
5112
|
store.reindex();
|
|
5090
5113
|
// The lines this edit would ADD — so a content-matched invariant denies only
|
|
5091
5114
|
// when the edit actually trips it (not on every edit in scope), and the Veto
|
|
5092
|
-
// Guard can test the proposed text. Covers Edit/Write/MultiEdit
|
|
5093
|
-
|
|
5094
|
-
const
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5115
|
+
// Guard can test the proposed text. Covers Edit/Write/MultiEdit, and each
|
|
5116
|
+
// file of a Codex apply_patch with that file's own added lines.
|
|
5117
|
+
const denials = [];
|
|
5118
|
+
for (const t of targets) {
|
|
5119
|
+
// Veto Guard (live): the proposed edit text re-introduces an approach an
|
|
5120
|
+
// in-force decision REJECTED. The agent self-corrects before staging;
|
|
5121
|
+
// only human-confirmed tripwires deny.
|
|
5122
|
+
const hit = blockingInScope(store, t.target, t.lines) ?? (t.lines.length ? vetoInScope(store, t.target, t.lines) : null);
|
|
5123
|
+
if (hit)
|
|
5124
|
+
denials.push({ target: t.target, hit });
|
|
5100
5125
|
}
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5126
|
+
if (denials.length) {
|
|
5127
|
+
for (const d of denials)
|
|
5128
|
+
appendEvent(paths, { at: new Date().toISOString(), file: d.target, ...d.hit.event });
|
|
5129
|
+
// One denied file keeps the single-file reason verbatim; several are
|
|
5130
|
+
// listed so the agent knows every file it must reconsider.
|
|
5131
|
+
const reason = denials.length === 1
|
|
5132
|
+
? denials[0].hit.reason
|
|
5133
|
+
: `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
|
|
5134
|
+
emitDeny(provider, reason);
|
|
5135
|
+
for (const d of denials)
|
|
5136
|
+
observeHookDenial(root, provider, evt, d.target, d.hit);
|
|
5109
5137
|
return;
|
|
5110
5138
|
}
|
|
5111
5139
|
}
|
|
@@ -6562,8 +6590,8 @@ program
|
|
|
6562
6590
|
console.log(`No changed files in ${scope}.`);
|
|
6563
6591
|
return;
|
|
6564
6592
|
}
|
|
6565
|
-
const
|
|
6566
|
-
console.log(renderImpact(store.prImpact(files, diff), scope));
|
|
6593
|
+
const gate = opts.commit ? commitGateDiff(opts.commit, root) : base ? rangeGateDiff(base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root);
|
|
6594
|
+
console.log(renderImpact(store.prImpact(files, gate.diff, gate), scope));
|
|
6567
6595
|
}
|
|
6568
6596
|
finally {
|
|
6569
6597
|
store.close();
|
|
@@ -6983,16 +7011,32 @@ program
|
|
|
6983
7011
|
// squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
|
|
6984
7012
|
// so its absence is informational only, never a warning.
|
|
6985
7013
|
if (isGitRepo(root)) {
|
|
7014
|
+
const report = hookReport(root);
|
|
6986
7015
|
const hooks = hookStatus(root);
|
|
6987
|
-
const missing = [
|
|
7016
|
+
const missing = [report.postCommit.state === "missing" && "post-commit", report.postMerge.state === "missing" && "post-merge"].filter((h) => !!h);
|
|
7017
|
+
// A block that is present but never runs (after an exec/exit, or inside a
|
|
7018
|
+
// hook manager's regenerated file — issue #311) is NOT installed.
|
|
7019
|
+
const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
7020
|
+
.filter(([, e]) => e.state === "unreachable");
|
|
6988
7021
|
// `hunch index` only ever installs post-merge onto an existing
|
|
6989
7022
|
// post-commit install (it never hooks an un-hooked repo — see
|
|
6990
|
-
// isGitRepo(root) && hookStatus(root).postCommit above)
|
|
6991
|
-
// hint must not point there when
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
7023
|
+
// isGitRepo(root) && hookStatus(root).postCommit above) and never writes
|
|
7024
|
+
// into a manager-owned hook — so the fix hint must not point there when
|
|
7025
|
+
// post-commit itself is missing or a hook manager is in play.
|
|
7026
|
+
const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
|
|
7027
|
+
const fix = managedMissing
|
|
7028
|
+
? `${managedMissing.manager === "exec-exit" ? "the existing hook exits before an appended block would run" : `a hook manager (${managedMissing.manager}) owns these hooks`}; run \`hunch init\` to print the snippet to add to ${rel(root, managedMissing.path)}`
|
|
7029
|
+
: !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
|
|
7030
|
+
const problems = [
|
|
7031
|
+
missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
|
|
7032
|
+
unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
|
|
7033
|
+
].filter(Boolean);
|
|
7034
|
+
console.log(`hooks: ${problems.length
|
|
7035
|
+
? problems.join("\n ")
|
|
6995
7036
|
: `post-commit, post-merge installed${hooks.preCommit ? " (+ pre-commit)" : ""}`}`);
|
|
7037
|
+
for (const [name, e] of unreachable) {
|
|
7038
|
+
console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
|
|
7039
|
+
}
|
|
6996
7040
|
// Workspace ledger: what this machine is called, whether its record is in memory,
|
|
6997
7041
|
// and whether the checkout hook that keeps it fresh is installed.
|
|
6998
7042
|
try {
|
|
@@ -7001,7 +7045,7 @@ program
|
|
|
7001
7045
|
const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
|
|
7002
7046
|
const leak = labelLeaksIdentity(machine.label);
|
|
7003
7047
|
console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
|
|
7004
|
-
`${hooks.postCheckout ? " · post-checkout hook installed" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7048
|
+
`${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7005
7049
|
}
|
|
7006
7050
|
catch { /* no machine file writable: nothing to report */ }
|
|
7007
7051
|
}
|
|
@@ -7169,6 +7213,28 @@ function realpathNorm(p) {
|
|
|
7169
7213
|
function toRepoRel(root, abs) {
|
|
7170
7214
|
return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
|
|
7171
7215
|
}
|
|
7216
|
+
/** The in-repo files a pre-edit event would change, each with the lines the edit
|
|
7217
|
+
* ADDS to that file. A Codex apply_patch yields one entry per touched path (a
|
|
7218
|
+
* Move-to destination is its own entry, carrying the section's added lines);
|
|
7219
|
+
* every other edit tool yields its single `file_path`. Paths outside the repo
|
|
7220
|
+
* ("..") or on another drive ("D:/…") are skipped. */
|
|
7221
|
+
function editTargets(root, input) {
|
|
7222
|
+
const raw = input?.patch_files?.length
|
|
7223
|
+
? input.patch_files.flatMap((f) => (f.moved_to ? [f.path, f.moved_to] : [f.path]).map((abs) => ({ abs, lines: f.added_lines })))
|
|
7224
|
+
: input?.file_path ? [{ abs: input.file_path, lines: proposedEditLines(input) }] : [];
|
|
7225
|
+
const byTarget = new Map();
|
|
7226
|
+
for (const { abs, lines } of raw) {
|
|
7227
|
+
const target = toRepoRel(root, abs);
|
|
7228
|
+
if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
|
|
7229
|
+
continue;
|
|
7230
|
+
const seen = byTarget.get(target);
|
|
7231
|
+
if (seen)
|
|
7232
|
+
seen.lines = [...seen.lines, ...lines];
|
|
7233
|
+
else
|
|
7234
|
+
byTarget.set(target, { abs, target, lines });
|
|
7235
|
+
}
|
|
7236
|
+
return [...byTarget.values()];
|
|
7237
|
+
}
|
|
7172
7238
|
function emitContext(provider, event, text,
|
|
7173
7239
|
/** One user-facing line where the host shows hook messages (Claude Code's
|
|
7174
7240
|
* `systemMessage`); never a block, never a second model turn. */
|
package/dist/cli/integrations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { findRoot } from "../core/paths.js";
|
|
2
2
|
import { CAPABILITIES, HARNESSES, inspectIntegrations, integrationHealthFails, formatIntegrationHealth, repairIntegrationPins } from "../integrations/health.js";
|
|
3
3
|
import { probeIntegration } from "../integrations/probe.js";
|
|
4
|
+
import { describeGitignoreUpgrade, upgradeManagedGitignore } from "../integrations/gitignore.js";
|
|
4
5
|
export function registerIntegrationCommands(program, refreshGrounding) {
|
|
5
6
|
const integrations = program.command("integrations").description("Check harness coverage and repair stale repository Hunch pins");
|
|
6
7
|
integrations.command("check")
|
|
@@ -34,6 +35,15 @@ export function registerIntegrationCommands(program, refreshGrounding) {
|
|
|
34
35
|
if (files.includes(".codex/hooks.json")) {
|
|
35
36
|
console.log("Codex: open /hooks to review and trust the changed commands, then start a new session. Command changes require renewed trust; Hunch does not grant it automatically.");
|
|
36
37
|
}
|
|
38
|
+
// `hunch update` runs this command with the NEW release, so existing repos pick up
|
|
39
|
+
// managed .gitignore entries added since they were set up (no re-init needed).
|
|
40
|
+
try {
|
|
41
|
+
for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
|
|
42
|
+
console.log(line);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.log(`.gitignore not upgraded: ${error.message}`);
|
|
46
|
+
}
|
|
37
47
|
const grounding = refreshGrounding();
|
|
38
48
|
if (grounding.length)
|
|
39
49
|
console.log(`Updated Hunch instructions: ${grounding.join(", ")}. Reconnect the agent to load task reporting instructions.`);
|
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
9
|
export type HookProvider = (typeof HOOK_PROVIDERS)[number];
|
|
10
10
|
export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
|
|
11
|
+
/** One file section of a Codex `apply_patch` payload. */
|
|
12
|
+
export interface HunchPatchFile {
|
|
13
|
+
/** The path as written in the patch (may be absolute). */
|
|
14
|
+
path: string;
|
|
15
|
+
action: "update" | "add" | "delete";
|
|
16
|
+
/** `*** Move to:` destination, when the section renames the file. */
|
|
17
|
+
moved_to?: string;
|
|
18
|
+
/** Only the lines this section adds (`+` prefix stripped). */
|
|
19
|
+
added_lines: string[];
|
|
20
|
+
}
|
|
11
21
|
export interface HunchToolInput {
|
|
12
22
|
file_path?: string;
|
|
13
23
|
new_string?: string;
|
|
@@ -17,6 +27,9 @@ export interface HunchToolInput {
|
|
|
17
27
|
}>;
|
|
18
28
|
command?: string;
|
|
19
29
|
skill?: string;
|
|
30
|
+
/** Codex `apply_patch` only: every file the patch touches. `file_path` is the
|
|
31
|
+
* first entry's path and `content` the raw patch, for single-file consumers. */
|
|
32
|
+
patch_files?: HunchPatchFile[];
|
|
20
33
|
}
|
|
21
34
|
/** A provider-neutral observation of the tool result. Output is ephemeral: the
|
|
22
35
|
* pipeline compares it with bounded expectations but never persists it. */
|
|
@@ -41,6 +54,7 @@ export interface HunchHookInput {
|
|
|
41
54
|
/** SubagentStart: the delegated agent's type (e.g. "Explore", "Plan"). */
|
|
42
55
|
agent_type?: string;
|
|
43
56
|
}
|
|
57
|
+
export declare function parseApplyPatch(patch: string): HunchPatchFile[];
|
|
44
58
|
/** Parse a provider name supplied by a hook config. Unknown values intentionally
|
|
45
59
|
* return null so a bad config cannot make an edit fail. */
|
|
46
60
|
export declare function hookProvider(value: unknown): HookProvider | null;
|
package/dist/core/agenthook.js
CHANGED
|
@@ -49,16 +49,59 @@ function edits(value) {
|
|
|
49
49
|
.map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
|
|
50
50
|
return normalized.length ? normalized : undefined;
|
|
51
51
|
}
|
|
52
|
+
/** Parse Codex `apply_patch` text into one entry per touched file. Only `+` lines
|
|
53
|
+
* inside a file section count as added: removed (`-`) and context (` `) lines are
|
|
54
|
+
* text the edit takes away or leaves alone, so a content-matched gate must not read
|
|
55
|
+
* them as proposed content. Paths are returned exactly as written in the patch;
|
|
56
|
+
* the CLI normalizes them against the repository root. */
|
|
57
|
+
const PATCH_HEADER = /^\*\*\* (Update|Add|Delete) File: (.+?)\s*$/;
|
|
58
|
+
const PATCH_MOVE = /^\*\*\* Move to: (.+?)\s*$/;
|
|
59
|
+
export function parseApplyPatch(patch) {
|
|
60
|
+
const files = [];
|
|
61
|
+
let current;
|
|
62
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
63
|
+
const header = PATCH_HEADER.exec(line);
|
|
64
|
+
if (header) {
|
|
65
|
+
current = { path: header[2], action: header[1].toLowerCase(), added_lines: [] };
|
|
66
|
+
files.push(current);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!current)
|
|
70
|
+
continue;
|
|
71
|
+
const move = PATCH_MOVE.exec(line);
|
|
72
|
+
if (move) {
|
|
73
|
+
current.moved_to = move[1];
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// `*** End Patch` / `*** End of File` close sections; they carry no content.
|
|
77
|
+
if (line.startsWith("*** "))
|
|
78
|
+
continue;
|
|
79
|
+
// A unified-diff style file header is not content; apply_patch has none, but
|
|
80
|
+
// a model can still emit one.
|
|
81
|
+
if (/^\+\+\+ (?:[ab]\/|\/dev\/null)/.test(line))
|
|
82
|
+
continue;
|
|
83
|
+
if (line.startsWith("+"))
|
|
84
|
+
current.added_lines.push(line.slice(1));
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
52
88
|
/** Codex edits files through `apply_patch`, whose input is the patch text itself
|
|
53
|
-
* (`*** Update File: path`).
|
|
54
|
-
*
|
|
55
|
-
|
|
89
|
+
* (`*** Update File: path`). Every touched file is listed in `patch_files` so the
|
|
90
|
+
* pre-edit gate runs per file; the first path stays `file_path` and the whole patch
|
|
91
|
+
* stays `content` for consumers that read the single-file shape.
|
|
92
|
+
*
|
|
93
|
+
* TODO(codex-exec-patch): a Codex code-mode `exec` tool call that embeds an
|
|
94
|
+
* apply_patch does NOT reach this parser: `.codex/hooks.json` (integrations/
|
|
95
|
+
* providers.ts) matches PreToolUse on `apply_patch` only, and `normalizeHookEvent`
|
|
96
|
+
* enables patch parsing only for tool names `apply_patch`/`patch`. No captured
|
|
97
|
+
* payload of such a call exists in the repo, so its shape is not guessed here;
|
|
98
|
+
* capture a real hook payload before extending the matcher or this parser. */
|
|
56
99
|
function applyPatchInput(raw) {
|
|
57
100
|
const patch = [raw.input, raw.patch, raw.content].find((v) => typeof v === "string" && /\*\*\* Begin Patch/.test(v));
|
|
58
101
|
if (!patch)
|
|
59
102
|
return undefined;
|
|
60
|
-
const
|
|
61
|
-
return
|
|
103
|
+
const files = parseApplyPatch(patch);
|
|
104
|
+
return files.length ? { file_path: files[0].path, content: patch, patch_files: files } : undefined;
|
|
62
105
|
}
|
|
63
106
|
function normalizeToolInput(value, allowPatch = false) {
|
|
64
107
|
const raw = obj(value);
|
package/dist/core/changeProof.js
CHANGED
|
@@ -94,7 +94,8 @@ function exactChangedPaths(root, baseRevision, resultRevision) {
|
|
|
94
94
|
}
|
|
95
95
|
function exactDiff(root, baseRevision, resultRevision) {
|
|
96
96
|
const raw = gitBytes(root, [
|
|
97
|
-
"
|
|
97
|
+
"-c", "core.quotePath=false",
|
|
98
|
+
"diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/",
|
|
98
99
|
baseRevision, resultRevision, "--",
|
|
99
100
|
]);
|
|
100
101
|
if (raw.byteLength <= MAX_DIFF_BYTES)
|
|
@@ -309,6 +310,9 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
|
|
|
309
310
|
const guardReport = store.buildCheckReport(changed.paths, diff.diff, {
|
|
310
311
|
strict: true,
|
|
311
312
|
publicOnly,
|
|
313
|
+
// A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
|
|
314
|
+
// files it omits fail closed rather than read as compliant (dec_20db57c576).
|
|
315
|
+
diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
|
|
312
316
|
lastChange: (path) => lastChangeAt(root, change.head_revision, path),
|
|
313
317
|
});
|
|
314
318
|
const allStrictBlockerIds = sortedUnique([
|
|
@@ -29,6 +29,13 @@ export interface CheckDirect {
|
|
|
29
29
|
strictBlocks: boolean;
|
|
30
30
|
/** If a blocking invariant is downgraded to advisory under strict, why. */
|
|
31
31
|
downgrade?: "stale" | "low-confidence";
|
|
32
|
+
/** Set when a content-matched blocking invariant could NOT be evaluated because the
|
|
33
|
+
* added lines of some scoped files are missing from the diff (truncated diff, git
|
|
34
|
+
* failure, unreadable file). Reported as a hit, never as compliance: it fails closed. */
|
|
35
|
+
unevaluable?: {
|
|
36
|
+
reason: string;
|
|
37
|
+
files: string[];
|
|
38
|
+
};
|
|
32
39
|
/** The causal citation (the "why this guard exists") — present when the graph links it. */
|
|
33
40
|
why?: CausalWhy;
|
|
34
41
|
}
|
package/dist/core/checkreport.js
CHANGED
|
@@ -44,6 +44,16 @@ export function renderImpact(im, scope) {
|
|
|
44
44
|
}
|
|
45
45
|
return out.join("\n");
|
|
46
46
|
}
|
|
47
|
+
/** Strict-failure reason fragments for direct invariants: proven hits and the
|
|
48
|
+
* content-matched invariants that could not be evaluated are named separately. */
|
|
49
|
+
function strictBlockerReasons(r) {
|
|
50
|
+
const unevaluable = r.direct.filter((d) => d.strictBlocks && d.unevaluable).length;
|
|
51
|
+
const proven = r.strictBlockers - unevaluable;
|
|
52
|
+
return [
|
|
53
|
+
proven > 0 ? `${proven} high-confidence blocking invariant(s) directly in scope` : "",
|
|
54
|
+
unevaluable > 0 ? `${unevaluable} blocking invariant(s) that could not be evaluated against the complete diff` : "",
|
|
55
|
+
];
|
|
56
|
+
}
|
|
47
57
|
/** True when --strict should FAIL the commit/PR. */
|
|
48
58
|
export function reportFailsStrict(r) {
|
|
49
59
|
return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
|
|
@@ -93,7 +103,10 @@ export function renderText(r) {
|
|
|
93
103
|
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
94
104
|
? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
95
105
|
: "";
|
|
96
|
-
|
|
106
|
+
const unevaluable = c.unevaluable
|
|
107
|
+
? `\n ‼ NOT EVALUATED — ${c.unevaluable.reason}; added lines unavailable for: ${c.unevaluable.files.join(", ")} (fails closed)`
|
|
108
|
+
: "";
|
|
109
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${unevaluable}${whyText(c.why)}`);
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
if (r.near.length) {
|
|
@@ -122,7 +135,7 @@ export function renderText(r) {
|
|
|
122
135
|
}
|
|
123
136
|
if (reportFailsStrict(r)) {
|
|
124
137
|
const reasons = [
|
|
125
|
-
r
|
|
138
|
+
...strictBlockerReasons(r),
|
|
126
139
|
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
127
140
|
r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
|
|
128
141
|
].filter(Boolean).join(" + ");
|
|
@@ -153,6 +166,8 @@ export function renderMarkdown(r) {
|
|
|
153
166
|
: "";
|
|
154
167
|
out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\`${note}`);
|
|
155
168
|
out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
|
|
169
|
+
if (c.unevaluable)
|
|
170
|
+
out.push(` - ‼ **Not evaluated** — ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.map((f) => `\`${f}\``).join(", ")} _(fails closed)_`);
|
|
156
171
|
if (c.rationale)
|
|
157
172
|
out.push(` - _${c.rationale}_`);
|
|
158
173
|
for (const line of whyMd(c.why))
|
|
@@ -196,7 +211,7 @@ export function renderMarkdown(r) {
|
|
|
196
211
|
out.push("---");
|
|
197
212
|
if (reportFailsStrict(r)) {
|
|
198
213
|
const reasons = [
|
|
199
|
-
r
|
|
214
|
+
...strictBlockerReasons(r),
|
|
200
215
|
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
201
216
|
r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
|
|
202
217
|
].filter(Boolean).join(" + ");
|
|
@@ -233,6 +248,8 @@ export function renderSarif(r, version, extras = {}) {
|
|
|
233
248
|
text += ` — ${c.rationale}`;
|
|
234
249
|
if (c.downgrade)
|
|
235
250
|
text += ` (advisory under strict: ${c.downgrade})`;
|
|
251
|
+
if (c.unevaluable)
|
|
252
|
+
text += `\nNOT EVALUATED (fails closed): ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.join(", ")}`;
|
|
236
253
|
if (c.why?.decision)
|
|
237
254
|
text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
|
|
238
255
|
if (c.why?.bug)
|
package/dist/core/compare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { verdict } from "./checkreport.js";
|
|
2
|
-
import { revExists, rangeFiles,
|
|
2
|
+
import { revExists, rangeFiles, rangeGateDiff } from "../extractors/git.js";
|
|
3
3
|
/** A lower fit score is better. Verdict dominates (pass < warn < block), then the
|
|
4
4
|
* blocking count, then total advisory hits. Errored candidates sort last. */
|
|
5
5
|
function fitKey(c) {
|
|
@@ -14,7 +14,8 @@ export function compareCandidates(store, root, base, candidates) {
|
|
|
14
14
|
const files = rangeFiles(base, root, ref);
|
|
15
15
|
if (!files.length)
|
|
16
16
|
return { ...zero, verdict: "pass", error: `no changes vs ${base}` };
|
|
17
|
-
const
|
|
17
|
+
const gate = rangeGateDiff(base, root, ref);
|
|
18
|
+
const r = store.buildCheckReport(files, gate.diff, { strict: true, diffStatus: gate });
|
|
18
19
|
return {
|
|
19
20
|
ref,
|
|
20
21
|
verdict: verdict(r),
|