@davesheffer/hunch 1.10.0 → 1.10.2
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 +90 -32
- package/dist/core/conformance.js +19 -5
- package/dist/core/constraintmatch.js +11 -3
- package/dist/core/docscan.js +10 -0
- package/dist/core/drift.js +5 -2
- package/dist/core/io.js +64 -9
- package/dist/extractors/diff.js +13 -5
- package/dist/extractors/git.js +114 -29
- package/dist/extractors/languages.js +41 -28
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +18 -4
- package/dist/integrations/providers.js +55 -17
- package/dist/integrations/scaffold.js +21 -5
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +26 -8
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +8 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/synthesis/provider.js +29 -3
- package/dist/wiki/wiki.js +89 -9
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -222,12 +222,26 @@ program
|
|
|
222
222
|
if (opts.autoCommit === false) {
|
|
223
223
|
const localFile = join(paths.hunch, "local.json");
|
|
224
224
|
let existing = {};
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
if (existsSync(localFile)) {
|
|
226
|
+
// Same contract as every other writer of this file (issue #40): an
|
|
227
|
+
// unparseable or non-object local.json may hold the private-overlay
|
|
228
|
+
// pointer — rewriting it from a swallowed parse failure silently
|
|
229
|
+
// re-routes private captures into the committed public store.
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(readFileSync(localFile, "utf8"));
|
|
232
|
+
existing = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
existing = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (existing === null) {
|
|
239
|
+
console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
243
|
+
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
227
244
|
}
|
|
228
|
-
catch { /* absent/invalid → fresh */ }
|
|
229
|
-
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
230
|
-
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
231
245
|
}
|
|
232
246
|
if (isGitRepo(root)) {
|
|
233
247
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
@@ -259,7 +273,10 @@ program
|
|
|
259
273
|
console.log(` ⚠ skipped .mcp.json: ${e.message}`);
|
|
260
274
|
}
|
|
261
275
|
const cmds = writeSlashCommands(root);
|
|
262
|
-
console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
276
|
+
console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
|
|
277
|
+
for (const s of cmds.skipped) {
|
|
278
|
+
console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
|
|
279
|
+
}
|
|
263
280
|
const cmd = updateClaudeMd(root, store);
|
|
264
281
|
console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
|
|
265
282
|
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
@@ -374,7 +391,11 @@ program
|
|
|
374
391
|
// commit is drafted — not per-commit, and not under --deep (an ensemble may
|
|
375
392
|
// fan out to several distinct workers, each with its own configuration).
|
|
376
393
|
if (!opts.deep && commits.length > 0) {
|
|
377
|
-
|
|
394
|
+
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
395
|
+
// inside syncCommit resolves (persisted local.json preference included) —
|
|
396
|
+
// rootless resolution could grade a different provider and swallow the
|
|
397
|
+
// num_ctx warning for an actual Ollama run (issue #46).
|
|
398
|
+
const ctxProvider = await selectProvider({ root });
|
|
378
399
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
379
400
|
if (ctxWarning)
|
|
380
401
|
console.log(ctxWarning);
|
|
@@ -3238,26 +3259,29 @@ program
|
|
|
3238
3259
|
console.log(" Strict mode fails closed because an omitted source file could hide a semantic violation.");
|
|
3239
3260
|
}
|
|
3240
3261
|
}
|
|
3241
|
-
if (!files.length) {
|
|
3242
|
-
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
3243
|
-
if (teamFreshnessFailure)
|
|
3244
|
-
fail(teamFreshnessFailure);
|
|
3245
|
-
if (opts.strict && semanticIssues.length)
|
|
3246
|
-
process.exitCode = 1;
|
|
3247
|
-
store.close();
|
|
3248
|
-
return;
|
|
3249
|
-
}
|
|
3250
3262
|
// DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
|
|
3251
3263
|
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
3252
3264
|
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
3253
3265
|
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3266
|
+
//
|
|
3267
|
+
// A ZERO-FILE diff must NOT short-circuit the run: the per-file report has nothing
|
|
3268
|
+
// to say, but the GRAPH-WIDE gates below (Architectural Conformance and Constitution
|
|
3269
|
+
// policy) ask "does the code, right now, still satisfy recorded intent?" — a question
|
|
3270
|
+
// independent of what this diff touched. Returning early here made a delete-only PR a
|
|
3271
|
+
// vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
|
|
3272
|
+
// such a PR enumerates zero files) — including a deletion of the very symbol a
|
|
3273
|
+
// blocking conformance predicate or an active policy guards.
|
|
3274
|
+
const diff = files.length
|
|
3275
|
+
? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
|
|
3276
|
+
: "";
|
|
3277
|
+
const report = files.length
|
|
3278
|
+
? store.buildCheckReport(files, diff, {
|
|
3279
|
+
strict: !!opts.strict,
|
|
3280
|
+
lastChange: (f) => lastChangeDate(f, root),
|
|
3281
|
+
publicOnly: !!opts.publicOnly,
|
|
3282
|
+
})
|
|
3283
|
+
: emptyReport;
|
|
3284
|
+
if (opts.blast && !markdown && files.length) {
|
|
3261
3285
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
3262
3286
|
for (const f of files) {
|
|
3263
3287
|
const b = store.blastRadiusFiles(f);
|
|
@@ -3266,7 +3290,9 @@ program
|
|
|
3266
3290
|
}
|
|
3267
3291
|
console.log("");
|
|
3268
3292
|
}
|
|
3269
|
-
console.log(
|
|
3293
|
+
console.log(files.length
|
|
3294
|
+
? (markdown ? renderMarkdown(report) : renderText(report))
|
|
3295
|
+
: (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
|
|
3270
3296
|
// ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
|
|
3271
3297
|
// architectural invariant? This is graph-reachability, not a diff — so it catches semantic
|
|
3272
3298
|
// violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
|
|
@@ -3354,6 +3380,13 @@ const vetoCmd = program
|
|
|
3354
3380
|
store.close();
|
|
3355
3381
|
return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
|
|
3356
3382
|
}
|
|
3383
|
+
// Same guard for --commit: an unfetched/mistyped sha would enumerate zero
|
|
3384
|
+
// files and exit 0 — a vacuous pass where CI expects the Decision Guard
|
|
3385
|
+
// to have actually looked (issue #45).
|
|
3386
|
+
if (opts.commit && !revExists(opts.commit, root)) {
|
|
3387
|
+
store.close();
|
|
3388
|
+
return fail(`--commit sha "${opts.commit}" does not resolve. In CI, ensure the commit is fetched (git fetch --depth=... or fetch-depth: 0).`);
|
|
3389
|
+
}
|
|
3357
3390
|
store.reindex();
|
|
3358
3391
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
3359
3392
|
: opts.base ? rangeFiles(opts.base, root)
|
|
@@ -3593,7 +3626,7 @@ program
|
|
|
3593
3626
|
firm: "surfaces + warns on a violating edit",
|
|
3594
3627
|
strict: "edit-time DENY + CI guard — the teeth are on",
|
|
3595
3628
|
};
|
|
3596
|
-
console.log(`\nHunch — enforcement status (${root
|
|
3629
|
+
console.log(`\nHunch — enforcement status (${basename(root)})\n`);
|
|
3597
3630
|
console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
|
|
3598
3631
|
console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
|
|
3599
3632
|
if (blocking.length) {
|
|
@@ -3713,19 +3746,29 @@ program
|
|
|
3713
3746
|
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
3714
3747
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
3715
3748
|
// not just obey it this once and forget it next session.
|
|
3716
|
-
|
|
3749
|
+
const isCorrection = looksLikeCorrection(evt.prompt);
|
|
3750
|
+
let text = isCorrection ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
|
|
3751
|
+
// Payloads that must NEVER be deduped away. The dedup key hashes CONTENT, and
|
|
3752
|
+
// this content is assembled from constants — so two back-to-back corrections
|
|
3753
|
+
// produce byte-identical text and the second (usually the escalating one) was
|
|
3754
|
+
// silently swallowed, never becoming an enforced rule. Same for the unverified
|
|
3755
|
+
// nag, which is documented as the one nag that must repeat but rode the same
|
|
3756
|
+
// deduped payload and so fired once per streak.
|
|
3757
|
+
let mustDeliver = isCorrection;
|
|
3717
3758
|
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
3718
3759
|
// repeat: edits from an earlier turn still unverified.
|
|
3719
3760
|
if (evt.session_id && pipelineEnabled()) {
|
|
3720
3761
|
const st = onPrompt(loadPipelineState(evt.session_id));
|
|
3721
3762
|
savePipelineState(evt.session_id, st);
|
|
3722
|
-
if (!st.verifyAfterEdit)
|
|
3763
|
+
if (!st.verifyAfterEdit) {
|
|
3723
3764
|
text += `\n\n${UNVERIFIED_NAG}`;
|
|
3765
|
+
mustDeliver = true;
|
|
3766
|
+
}
|
|
3724
3767
|
}
|
|
3725
|
-
// Once per session is enough for the availability reminder — repeating it
|
|
3726
|
-
// every prompt burns context for zero information.
|
|
3727
|
-
//
|
|
3728
|
-
if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
3768
|
+
// Once per session is enough for the bare availability reminder — repeating it
|
|
3769
|
+
// every prompt burns context for zero information (dec_244397d920). Only that
|
|
3770
|
+
// ambient case is deduped.
|
|
3771
|
+
if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
3729
3772
|
return;
|
|
3730
3773
|
emitContext(provider, "UserPromptSubmit", text);
|
|
3731
3774
|
return;
|
|
@@ -4659,6 +4702,7 @@ program
|
|
|
4659
4702
|
.option("--no-llm", "skip LLM prose; deterministic template pages only")
|
|
4660
4703
|
.option("--prose-heal", "also LLM-rewrite each adopted copy's reconciled overview (subscription; the deterministic corrections always remain)")
|
|
4661
4704
|
.option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
|
|
4705
|
+
.option("--private-prose", "opt in to LLM prose for a PRIVATE-split overlay wiki — this SENDS overlay records (decision text, private constraints, private bug root causes) to the configured provider. Off by default.")
|
|
4662
4706
|
.action(async (opts) => {
|
|
4663
4707
|
const { store, root } = storeFor();
|
|
4664
4708
|
try {
|
|
@@ -4724,7 +4768,21 @@ program
|
|
|
4724
4768
|
return fail("--prose-heal needs the LLM — drop --no-llm.");
|
|
4725
4769
|
let prose;
|
|
4726
4770
|
let adoptionProse;
|
|
4727
|
-
|
|
4771
|
+
// A PRIVATE-SPLIT overlay's packs carry the full union (source: "all") — overlay
|
|
4772
|
+
// decision context/rationale/rejected alternatives, private constraint statements,
|
|
4773
|
+
// private bug root causes. Sending that to an external subscription CLI would
|
|
4774
|
+
// silently break the storage-private promise every other path here enforces
|
|
4775
|
+
// (public-only CI comments, public-only grounding, public-only wiki manifests), in
|
|
4776
|
+
// the very command documented as the way to build the private wiki. So prose is OFF
|
|
4777
|
+
// by default for that home and needs an explicit --private-prose. A SHARED overlay
|
|
4778
|
+
// is deliberately excluded: there the team already routes captures through the
|
|
4779
|
+
// configured provider by recorded policy.
|
|
4780
|
+
const privateSplit = home.kind === "private" && store.mode === "private";
|
|
4781
|
+
const proseBlockedForPrivacy = privateSplit && !opts.privateProse;
|
|
4782
|
+
if (opts.llm !== false && proseBlockedForPrivacy) {
|
|
4783
|
+
console.log("Private overlay: LLM prose is OFF (pages would send overlay records to the configured provider). Deterministic template pages; pass --private-prose to opt in.");
|
|
4784
|
+
}
|
|
4785
|
+
if (opts.llm !== false && !proseBlockedForPrivacy) {
|
|
4728
4786
|
const provider = await selectProvider({ root });
|
|
4729
4787
|
if (provider.draftProse) {
|
|
4730
4788
|
console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
|
package/dist/core/conformance.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { externalImportNodeId } from "./externalImports.js";
|
|
1
2
|
function resolveSymbols(graph, ref) {
|
|
2
3
|
const syms = graph.symbols;
|
|
3
4
|
if (ref.startsWith("sym_"))
|
|
@@ -48,17 +49,30 @@ function evalPredicate(graph, d, p) {
|
|
|
48
49
|
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
|
|
49
50
|
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
50
51
|
const objects = p.object ? resolveSymbols(graph, p.object) : [];
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// An external package is a VIRTUAL graph node (`ext_<hash>`, see core/externalImports.ts),
|
|
53
|
+
// never a Symbol record — so resolveSymbols can NEVER find one. Without this, every
|
|
54
|
+
// `not-imports <symbol> <package>` predicate fell straight into the "target not found ⇒
|
|
55
|
+
// a forbidden relation trivially holds" branch below and reported ✅ forever, while the
|
|
56
|
+
// graph right beside it recorded the violating `imports` edge. Resolve the package from
|
|
57
|
+
// the edge set instead, which is where the indexer actually puts it.
|
|
58
|
+
const externalId = p.object ? externalImportNodeId(p.object) : null;
|
|
59
|
+
const objectIds = objects.length
|
|
60
|
+
? objects.map((o) => o.id)
|
|
61
|
+
: externalId && graph.edges.some((e) => e.to === externalId)
|
|
62
|
+
? [externalId]
|
|
63
|
+
: [];
|
|
64
|
+
if (!objectIds.length) {
|
|
65
|
+
// a required target gone ⇒ the link can't hold (violated); a forbidden one trivially
|
|
66
|
+
// holds — nothing can reach a node the graph does not contain.
|
|
53
67
|
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
54
68
|
}
|
|
55
69
|
// A required relation cannot guess which same-name symbol carries the intent.
|
|
56
70
|
// Force qualification instead of accidentally proving a different binding.
|
|
57
|
-
if (wantReach && (subjects.length !== 1 ||
|
|
71
|
+
if (wantReach && (subjects.length !== 1 || objectIds.length !== 1)) {
|
|
58
72
|
return {
|
|
59
73
|
...base,
|
|
60
74
|
satisfied: false,
|
|
61
|
-
detail: `ambiguous required binding (${subjects.length} subject, ${
|
|
75
|
+
detail: `ambiguous required binding (${subjects.length} subject, ${objectIds.length} target matches) — qualify as file:symbol; intent VIOLATED`,
|
|
62
76
|
};
|
|
63
77
|
}
|
|
64
78
|
// A forbidden relation is conservative in the other direction: ANY matching
|
|
@@ -66,7 +80,7 @@ function evalPredicate(graph, d, p) {
|
|
|
66
80
|
// only the first target lets a duplicate symbol hide a violation.
|
|
67
81
|
const linked = subjects.some((subject) => {
|
|
68
82
|
const reached = reaches(graph, subject.id, p.transitive);
|
|
69
|
-
return
|
|
83
|
+
return objectIds.some((id) => reached.has(id));
|
|
70
84
|
});
|
|
71
85
|
const satisfied = wantReach ? linked : !linked;
|
|
72
86
|
const via = p.transitive ? " (transitively)" : "";
|
|
@@ -25,9 +25,17 @@ export function matchForbids(f, addedDeps, scopedAdded) {
|
|
|
25
25
|
// tripping an in-scope edit.
|
|
26
26
|
const hitDeps = [];
|
|
27
27
|
for (const dep of f.deps) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// EVERY added specifier that satisfies the forbid, not just the first: one commit
|
|
29
|
+
// can add `lodash` in an out-of-scope file and `lodash/groupBy` in the scoped one.
|
|
30
|
+
// Taking only `.find`'s first match meant the out-of-scope spelling shadowed the
|
|
31
|
+
// real in-scope violation and the blocking rule failed OPEN — with the outcome
|
|
32
|
+
// depending on diff order, i.e. on an unrelated file's name.
|
|
33
|
+
for (const added of addedDeps) {
|
|
34
|
+
if (added !== dep && !added.startsWith(`${dep}/`))
|
|
35
|
+
continue;
|
|
36
|
+
if (codeLines.some((l) => importsDep(l, added)))
|
|
37
|
+
hitDeps.push(added);
|
|
38
|
+
}
|
|
31
39
|
}
|
|
32
40
|
if (hitDeps.length)
|
|
33
41
|
return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
|
package/dist/core/docscan.js
CHANGED
|
@@ -89,6 +89,16 @@ export function scanRepoDocs(decisions, root) {
|
|
|
89
89
|
else if (superseded && current && current.id !== a.pin) {
|
|
90
90
|
issues.push(`line ${a.line}: pinned to superseded ${a.pin}; current for "${a.topic}" is ${current.id}`);
|
|
91
91
|
}
|
|
92
|
+
else if (pinned.status === "rejected") {
|
|
93
|
+
// The ledger tells readers to "Trust ✅". A doc pinned to an approach the team
|
|
94
|
+
// explicitly REJECTED is the opposite of grounded — and the pre-edit hook, which
|
|
95
|
+
// only injects live decisions, already disagrees with the ✅ this used to award.
|
|
96
|
+
issues.push(`line ${a.line}: pinned to ${a.pin}, a REJECTED decision (topic "${a.topic}")${current ? `; current is ${current.id}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
else if (pinned.status === "proposed") {
|
|
99
|
+
// Roadmap intent, not an in-force answer: it neither grounds the doc nor makes
|
|
100
|
+
// it stale, so the doc falls to the honest "unverified" tier.
|
|
101
|
+
}
|
|
92
102
|
else if (!superseded) {
|
|
93
103
|
groundedPins++;
|
|
94
104
|
}
|
package/dist/core/drift.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
19
|
import { parseDocAnchors } from "./docanchors.js";
|
|
@@ -163,7 +163,10 @@ function referenceExists(store, root, decisionId, ref) {
|
|
|
163
163
|
// A private-scoped reference is an overlay-repo-relative path, not an escape
|
|
164
164
|
// hatch into arbitrary local files.
|
|
165
165
|
const rel = relative(privateRoot, candidate);
|
|
166
|
-
|
|
166
|
+
// NOTE: sep, not an escaped literal — `"\\\\"` in a template is the TWO-char
|
|
167
|
+
// string `\\`, which `relative()` never produces, silently disabling the
|
|
168
|
+
// containment check on Windows (issue #31).
|
|
169
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
167
170
|
return false;
|
|
168
171
|
return existsSync(candidate);
|
|
169
172
|
}
|
package/dist/core/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { linkSync,
|
|
2
|
+
import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
let counter = 0;
|
|
4
5
|
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
5
6
|
const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
@@ -7,6 +8,13 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
7
8
|
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
8
9
|
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
9
10
|
*
|
|
11
|
+
* Durability (issue #34): the temp file is fsync'd BEFORE the rename, and the
|
|
12
|
+
* parent directory best-effort after it. A process kill was always safe (page
|
|
13
|
+
* cache preserves ordering), but on power loss / OS crash the rename's metadata
|
|
14
|
+
* could reach disk before the temp file's data blocks — leaving the target
|
|
15
|
+
* present but truncated or garbage, the exact state the atomic-write invariant
|
|
16
|
+
* (con_902759b3dc) exists to prevent.
|
|
17
|
+
*
|
|
10
18
|
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
11
19
|
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
12
20
|
* while a CLI writes. Retry that atomic replacement with bounded backoff. If the
|
|
@@ -17,19 +25,42 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
17
25
|
export function writeFileAtomic(file, data) {
|
|
18
26
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
19
27
|
try {
|
|
20
|
-
|
|
28
|
+
const fd = openSync(tmp, "w");
|
|
29
|
+
try {
|
|
30
|
+
writeSync(fd, data);
|
|
31
|
+
fsyncSync(fd); // data blocks reach disk before the rename's metadata can
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
21
36
|
}
|
|
22
37
|
catch (e) {
|
|
23
|
-
|
|
38
|
+
cleanupTmp(tmp);
|
|
24
39
|
throw e;
|
|
25
40
|
}
|
|
26
41
|
try {
|
|
27
42
|
renameWithContentionRetry(tmp, file);
|
|
28
43
|
}
|
|
29
44
|
catch (e) {
|
|
30
|
-
|
|
45
|
+
cleanupTmp(tmp);
|
|
31
46
|
throw e;
|
|
32
47
|
}
|
|
48
|
+
fsyncDirBestEffort(dirname(file));
|
|
49
|
+
}
|
|
50
|
+
/** Persist the rename itself (the directory entry). POSIX semantics; Windows
|
|
51
|
+
* cannot open directories for fsync, so this is a silent no-op there — NTFS
|
|
52
|
+
* journals the metadata on its own schedule. */
|
|
53
|
+
function fsyncDirBestEffort(dir) {
|
|
54
|
+
try {
|
|
55
|
+
const fd = openSync(dir, "r");
|
|
56
|
+
try {
|
|
57
|
+
fsyncSync(fd);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* platform without directory fsync — best effort by contract */ }
|
|
33
64
|
}
|
|
34
65
|
function renameWithContentionRetry(from, to) {
|
|
35
66
|
for (let attempt = 0;; attempt++) {
|
|
@@ -55,7 +86,7 @@ function isRenameContention(error) {
|
|
|
55
86
|
export function writeFileAtomicIfAbsent(file, data) {
|
|
56
87
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
57
88
|
try {
|
|
58
|
-
|
|
89
|
+
writeFileAtomicTmp(tmp, data);
|
|
59
90
|
linkSync(tmp, file);
|
|
60
91
|
return true;
|
|
61
92
|
}
|
|
@@ -65,15 +96,39 @@ export function writeFileAtomicIfAbsent(file, data) {
|
|
|
65
96
|
throw error;
|
|
66
97
|
}
|
|
67
98
|
finally {
|
|
68
|
-
|
|
99
|
+
// Not silently best-effort (issue #38): after linkSync succeeds the target
|
|
100
|
+
// shares the temp inode, so a swallowed unlink failure (a Windows AV scanner
|
|
101
|
+
// or indexer briefly holding tmp) leaves the published file with nlink=2 —
|
|
102
|
+
// which validateExistingFile rejects as hard-linked — plus a stray .tmp.
|
|
103
|
+
cleanupTmp(tmp);
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
|
-
|
|
106
|
+
/** Write + fsync a fresh temp file (shared by both atomic writers). */
|
|
107
|
+
function writeFileAtomicTmp(tmp, data) {
|
|
108
|
+
const fd = openSync(tmp, "w");
|
|
109
|
+
try {
|
|
110
|
+
writeSync(fd, data);
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Remove a temp file, riding out a transient external hold (AV/indexer) with one
|
|
118
|
+
* short retry; a persistent failure is REPORTED, never swallowed — a leaked tmp
|
|
119
|
+
* beside a hard-link-published target keeps that target at nlink=2. */
|
|
120
|
+
function cleanupTmp(p) {
|
|
72
121
|
try {
|
|
73
122
|
rmSync(p, { force: true });
|
|
123
|
+
return;
|
|
74
124
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
125
|
+
catch { /* transient hold — retry once below */ }
|
|
126
|
+
Atomics.wait(renameRetryWaiter, 0, 0, 50);
|
|
127
|
+
try {
|
|
128
|
+
rmSync(p, { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.warn(`[hunch] temp file left behind (its published target keeps nlink=2 until it is removed): ${p} (${e.message})`);
|
|
77
132
|
}
|
|
78
133
|
}
|
|
79
134
|
//# sourceMappingURL=io.js.map
|
package/dist/extractors/diff.js
CHANGED
|
@@ -131,18 +131,26 @@ export function analyzeDiff(diff) {
|
|
|
131
131
|
}
|
|
132
132
|
// ---- inside a hunk: content lines ----
|
|
133
133
|
if (raw.startsWith("+")) {
|
|
134
|
-
if (!isCode(curFile))
|
|
135
|
-
continue;
|
|
136
|
-
addedLines++;
|
|
137
|
-
if (!curAdded && !curDeleted)
|
|
138
|
-
filesModified.add(curFile);
|
|
139
134
|
const body = raw.slice(1);
|
|
135
|
+
// Raw added lines are captured for EVERY file, before the code-only gate:
|
|
136
|
+
// content-matched constraints and Veto tripwires are not code-only rules
|
|
137
|
+
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
138
|
+
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
139
|
+
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
140
|
+
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
141
|
+
// very commit that landed it. Symbol/import extraction and the churn
|
|
142
|
+
// counters below stay code-only, unchanged.
|
|
140
143
|
let lines = addedLinesBy.get(curFile);
|
|
141
144
|
if (!lines) {
|
|
142
145
|
lines = [];
|
|
143
146
|
addedLinesBy.set(curFile, lines);
|
|
144
147
|
}
|
|
145
148
|
lines.push(body);
|
|
149
|
+
if (!isCode(curFile))
|
|
150
|
+
continue;
|
|
151
|
+
addedLines++;
|
|
152
|
+
if (!curAdded && !curDeleted)
|
|
153
|
+
filesModified.add(curFile);
|
|
146
154
|
const d = declOf(body);
|
|
147
155
|
if (d)
|
|
148
156
|
declsFor(curFile)?.added.set(d.name, d);
|