@davesheffer/hunch 1.45.2 → 1.47.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 +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic repair
|
|
2
|
+
* Deterministic repair of a decision's commit provenance after a squash-merge.
|
|
3
3
|
*
|
|
4
|
-
* A squash-merge
|
|
5
|
-
*
|
|
6
|
-
* This module matches
|
|
4
|
+
* A squash-merge lands ONE new commit on the target branch; the original
|
|
5
|
+
* per-commit SHAs are never its ancestors, so a decision citing one of them
|
|
6
|
+
* becomes orphaned. This module matches each orphaned decision to exactly one
|
|
7
|
+
* newly merged commit: the candidate
|
|
7
8
|
* with the same patch-id, or failing that the ONE whose changed files are a
|
|
8
9
|
* superset of the decision's related_files. Zero or multiple qualifying
|
|
9
10
|
* candidates means "don't guess" — same discipline as repair.ts's
|
|
@@ -17,22 +18,13 @@ export function commitRepairReviewHash(queued, withheld = new Set()) {
|
|
|
17
18
|
const snapshot = queued.map((r) => ({ id: r.id, from: r.from, to: r.to, withheld: withheld.has(r) }));
|
|
18
19
|
return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
|
|
19
20
|
}
|
|
20
|
-
/**
|
|
21
|
-
function
|
|
22
|
-
return
|
|
21
|
+
/** True when a scope entry can serve as an exact match key (non-empty, no glob metacharacters). */
|
|
22
|
+
function isExactScope(entry) {
|
|
23
|
+
return entry.length > 0 && !/[*?[\]{}]/.test(entry);
|
|
23
24
|
}
|
|
24
|
-
/** Live decisions whose cited commit
|
|
25
|
-
* ancestor of the current tip. Only "orphaned" qualifies: a commit that
|
|
26
|
-
* doesn't resolve at all ("unresolvable") is out of repair scope, and a git
|
|
27
|
-
* failure ("unknown") must never be mistaken for repair-eligible. */
|
|
25
|
+
/** Live decisions whose cited commit is orphaned (unresolvable/unknown/current are excluded). */
|
|
28
26
|
export function orphanedCommitDecisions(decisions, status) {
|
|
29
|
-
return decisions.filter((d) =>
|
|
30
|
-
if (d.status === "superseded" || d.status === "rejected")
|
|
31
|
-
return false;
|
|
32
|
-
if (!d.commit)
|
|
33
|
-
return false;
|
|
34
|
-
return status(d.commit) === "orphaned";
|
|
35
|
-
});
|
|
27
|
+
return decisions.filter((d) => d.status !== "superseded" && d.status !== "rejected" && !!d.commit && status(d.commit) === "orphaned");
|
|
36
28
|
}
|
|
37
29
|
/** Match each orphaned decision to exactly one candidate. A candidate with the
|
|
38
30
|
* same `git patch-id --stable` as the orphaned commit (a rebase- or
|
|
@@ -55,29 +47,16 @@ export function planCommitRepair(orphaned, candidates, patchIdOf = () => null) {
|
|
|
55
47
|
if (same.length > 1)
|
|
56
48
|
continue; // ambiguous: never guess
|
|
57
49
|
}
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
50
|
+
const scope = (d.related_files ?? []).filter(isExactScope);
|
|
51
|
+
if (scope.length === 0)
|
|
60
52
|
continue;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
rewrites.push({ id: d.id, from: d.commit, to: matches[0].sha });
|
|
53
|
+
const supersets = candidates.filter((c) => scope.every((f) => c.files.includes(f)));
|
|
54
|
+
if (supersets.length === 1)
|
|
55
|
+
rewrites.push({ id: d.id, from: d.commit, to: supersets[0].sha });
|
|
65
56
|
}
|
|
66
57
|
return { rewrites, records: [...new Set(rewrites.map((r) => r.id))] };
|
|
67
58
|
}
|
|
68
|
-
/**
|
|
69
|
-
* names still exists, still cites the commit the entry expects to replace (a
|
|
70
|
-
* decision that already moved on would make repairDecisionCommit's own
|
|
71
|
-
* stale-plan bail refuse it), and hasn't since been superseded/rejected —
|
|
72
|
-
* same exclusion as orphanedCommitDecisions, since repairing dead history's
|
|
73
|
-
* provenance isn't worth asking about. Backs the read-only escalation
|
|
74
|
-
* surface only (commitRepairEscalations) — absence-means-not-askable is
|
|
75
|
-
* safe there because it self-heals the moment visibility returns.
|
|
76
|
-
* repair-provenance itself does NOT use this: it reads the full store and
|
|
77
|
-
* may legitimately list (dry-run) or leave queued (apply) an entry this
|
|
78
|
-
* predicate would call not-live, so the escalation surface and the CLI can
|
|
79
|
-
* disagree about the same entry — see deadRewrites and resolvedRewriteIds
|
|
80
|
-
* for the CLI's own, looser-on-purpose reasoning. */
|
|
59
|
+
/** Queued entries that are still answerable: the decision exists, still cites `from`, and is live. */
|
|
81
60
|
export function liveRewrites(queued, decisions) {
|
|
82
61
|
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
83
62
|
return queued.filter((r) => {
|
|
@@ -85,128 +64,67 @@ export function liveRewrites(queued, decisions) {
|
|
|
85
64
|
return !!d && d.commit === r.from && d.status !== "superseded" && d.status !== "rejected";
|
|
86
65
|
});
|
|
87
66
|
}
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
* docstring is why that matters — the queue is the one durable record of a
|
|
96
|
-
* match once ORIG_HEAD moves on and the matched-away commit can be gc'd, so
|
|
97
|
-
* deleting an entry on a merely-absent id would destroy it unrecoverably.
|
|
98
|
-
* Contrast liveRewrites, whose absence-means-not-askable rule is safe on
|
|
99
|
-
* the read-only escalation surface, which self-heals once visibility
|
|
100
|
-
* returns.
|
|
101
|
-
*
|
|
102
|
-
* Caveat: "demonstrably" is only as strong as this run's view of
|
|
103
|
-
* `decisions`, which comes from the same git-tracked, branch-dependent
|
|
104
|
-
* files that motivate treating ABSENCE as inconclusive. A decision whose
|
|
105
|
-
* `commit`/`status` genuinely differ between branches (e.g. a repair
|
|
106
|
-
* already applied and committed on one branch, checked out here from a
|
|
107
|
-
* branch that predates it) could still read as "moved on" when it hasn't,
|
|
108
|
-
* from this branch's perspective. Far narrower than the absence case this
|
|
109
|
-
* function exists to fix, and not addressed here. */
|
|
67
|
+
/**
|
|
68
|
+
* Queued entries whose decision IS visible but moved on (different or null
|
|
69
|
+
* commit) or was superseded/rejected. An id absent from `decisions` is never
|
|
70
|
+
* dead: absence comes from the reader's view (a branch checkout, an unmounted
|
|
71
|
+
* private overlay) and the CLI deletes whatever this returns. Caveat: a decision
|
|
72
|
+
* that differs between branches could still read as "moved on".
|
|
73
|
+
*/
|
|
110
74
|
export function deadRewrites(queued, decisions) {
|
|
111
75
|
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
112
76
|
return queued.filter((r) => {
|
|
113
77
|
const d = byId.get(r.id);
|
|
114
|
-
|
|
78
|
+
if (!d)
|
|
79
|
+
return false;
|
|
80
|
+
return d.commit !== r.from || d.status === "superseded" || d.status === "rejected";
|
|
115
81
|
});
|
|
116
82
|
}
|
|
117
|
-
/**
|
|
118
|
-
* on), the ids repair-provenance may treat as RESOLVED once it's done: the
|
|
119
|
-
* decision is present in `decisions` this run, whether or not
|
|
120
|
-
* repairDecisionCommit actually changed it (a present decision that already
|
|
121
|
-
* moved on is "resolved" too — repairDecisionCommit's own bail refused it,
|
|
122
|
-
* and there is nothing more this run can do about it). An id whose decision
|
|
123
|
-
* is NOT present is never resolved — same reasoning as deadRewrites:
|
|
124
|
-
* absence is the reader's transient view, not proof the match is settled,
|
|
125
|
-
* so it must stay queued for a run where the decision is visible again. */
|
|
83
|
+
/** Ids of `toApply` whose decision is visible, whether or not the rewrite changed it. */
|
|
126
84
|
export function resolvedRewriteIds(toApply, decisions) {
|
|
127
|
-
const
|
|
128
|
-
return new Set(toApply.filter((r) =>
|
|
85
|
+
const visible = new Set(decisions.map((d) => d.id));
|
|
86
|
+
return new Set(toApply.filter((r) => visible.has(r.id)).map((r) => r.id));
|
|
129
87
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
* identical candidate would re-queue exactly what the human rejected. A
|
|
133
|
-
* match sharing {id, from} but proposing a different `to` is unaffected: it
|
|
134
|
-
* is a different proposal, not the rejected one.
|
|
135
|
-
*
|
|
136
|
-
* Preserves object identity for every surviving entry (a plain filter, never
|
|
137
|
-
* a clone or a rebuild) — repair-provenance's own action relies on this to
|
|
138
|
-
* diff its swept-vs-active queue by reference (`queue.filter(r =>
|
|
139
|
-
* !active.includes(r))`) rather than re-spelling the {id, from, to} key a
|
|
140
|
-
* third time. Do not change this to normalize or reconstruct entries
|
|
141
|
-
* without updating that call site too. */
|
|
88
|
+
const tripleKey = (r) => JSON.stringify([r.id, r.from, r.to]);
|
|
89
|
+
/** Drop rewrites whose exact {id, from, to} triple is tombstoned. A plain filter: survivors keep identity. */
|
|
142
90
|
export function withoutDropped(rewrites, dropped) {
|
|
143
|
-
if (
|
|
91
|
+
if (dropped.length === 0)
|
|
144
92
|
return [...rewrites];
|
|
145
|
-
const
|
|
146
|
-
return rewrites.filter((r) => !
|
|
93
|
+
const keys = new Set(dropped.map(tripleKey));
|
|
94
|
+
return rewrites.filter((r) => !keys.has(tripleKey(r)));
|
|
147
95
|
}
|
|
148
|
-
/**
|
|
149
|
-
* deduped — same append-only, dedupe-by-key shape as mergeRewrites. */
|
|
96
|
+
/** `existing` followed by the entries of `newly` whose exact triple is not already tombstoned. */
|
|
150
97
|
export function addDropped(newly, existing) {
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
98
|
+
const keys = new Set(existing.map(tripleKey));
|
|
99
|
+
const out = [...existing];
|
|
100
|
+
for (const n of newly) {
|
|
101
|
+
const k = tripleKey(n);
|
|
102
|
+
if (keys.has(k))
|
|
103
|
+
continue;
|
|
104
|
+
keys.add(k);
|
|
105
|
+
out.push(n);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
154
108
|
}
|
|
155
|
-
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
160
|
-
*
|
|
161
|
-
* Overrides by object identity, targeting exactly the one queued entry
|
|
162
|
-
* firstFor (id) would pick — the same first-match rule --drop applies, and
|
|
163
|
-
* the same identity-not-id targeting the dead-entry prune applies (#53; the
|
|
164
|
-
* prune itself removes every provably-dead entry, not just a first match).
|
|
165
|
-
* A corrupted queue file, a hand edit, or a bug upstream could otherwise
|
|
166
|
-
* carry a SECOND entry sharing that id; overriding by id alone would
|
|
167
|
-
* silently destroy that still-queued sibling too, with no tombstone and no
|
|
168
|
-
* way to recover it, even though it was never the entry that would have
|
|
169
|
-
* been applied. Any such sibling is left queued and untouched.
|
|
170
|
-
*
|
|
171
|
-
* How a survivor resolves: the dead-entry prune (deadRewrites), once
|
|
172
|
-
* --apply has moved the decision past the sibling's `from`; or a --drop
|
|
173
|
-
* whose tombstone matches the sibling's exact {id, from, to} — the same
|
|
174
|
-
* --drop that dropped the fresh entry, when the sibling happens to share
|
|
175
|
-
* its triple (the drop's own re-sweep, withoutDropped, catches it too), a
|
|
176
|
-
* second explicit --drop otherwise. NOT a later fresh detection: by this
|
|
177
|
-
* same first-match rule it always overrides the fresh entry sitting ahead
|
|
178
|
-
* of it, never the sibling. */
|
|
109
|
+
/**
|
|
110
|
+
* Fresh detections first, then the queued entries they did not override. A fresh
|
|
111
|
+
* entry overrides only the FIRST queued entry with its id (see `firstFor`);
|
|
112
|
+
* later duplicate-id siblings survive untouched.
|
|
113
|
+
*/
|
|
179
114
|
export function mergeRewrites(fresh, queued) {
|
|
180
115
|
const overridden = new Set();
|
|
181
|
-
for (const
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
overridden.add(
|
|
116
|
+
for (const f of fresh) {
|
|
117
|
+
const hit = firstFor(queued, f.id);
|
|
118
|
+
if (hit)
|
|
119
|
+
overridden.add(hit);
|
|
185
120
|
}
|
|
186
|
-
return [...fresh, ...queued.filter((
|
|
121
|
+
return [...fresh, ...queued.filter((q) => !overridden.has(q))];
|
|
187
122
|
}
|
|
188
|
-
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
* commitsExist — nothing is withheld just because the check couldn't run.
|
|
194
|
-
*
|
|
195
|
-
* A corrupted queue file, a hand-edited entry, or a bug upstream could
|
|
196
|
-
* otherwise get a `to` that never resolves to a real commit written
|
|
197
|
-
* straight into a decision's `commit` field and provenance evidence, then
|
|
198
|
-
* auto-committed into shared team memory. `withheld` entries must stay
|
|
199
|
-
* queued (never deleted, never passed to repairDecisionCommit) — the queue
|
|
200
|
-
* file is the one durable record of the match, same reasoning as
|
|
201
|
-
* deadRewrites/resolvedRewriteIds.
|
|
202
|
-
*
|
|
203
|
-
* Both returned arrays preserve object identity (never clones or rebuilds
|
|
204
|
-
* an entry) — the CLI's queue sweep and its "still queued" reporting both
|
|
205
|
-
* key a `Set` off the exact objects in `withheld`, by reference, precisely
|
|
206
|
-
* because a corrupted queue file can carry two entries sharing an id (one
|
|
207
|
-
* resolvable, one not): an id alone can't tell them apart. Do not change
|
|
208
|
-
* this to normalize or reconstruct entries without updating those call
|
|
209
|
-
* sites too — same caveat withoutDropped's own docstring carries. */
|
|
123
|
+
/**
|
|
124
|
+
* Split entries by whether their proposed `to` resolves here. `existing === null`
|
|
125
|
+
* means the check could not run, so it fails open (everything applicable).
|
|
126
|
+
* Order and object references are preserved.
|
|
127
|
+
*/
|
|
210
128
|
export function withheldForUnresolvableTo(toApply, existing) {
|
|
211
129
|
if (existing === null)
|
|
212
130
|
return { applicable: [...toApply], withheld: [] };
|
|
@@ -216,40 +134,31 @@ export function withheldForUnresolvableTo(toApply, existing) {
|
|
|
216
134
|
(existing.has(r.to) ? applicable : withheld).push(r);
|
|
217
135
|
return { applicable, withheld };
|
|
218
136
|
}
|
|
219
|
-
/** The
|
|
220
|
-
* (a corrupted queue file, a hand edit, or a bug upstream): first match, by
|
|
221
|
-
* construction. Every caller goes through here so no two can independently
|
|
222
|
-
* drift on which entry "won" — this file's own duplicate-id queue handling
|
|
223
|
-
* has drifted between call sites three times already (see withoutDropped's,
|
|
224
|
-
* withheldForUnresolvableTo's, and mergeRewrites' own docstrings). */
|
|
137
|
+
/** The single first-match rule for duplicate decision ids. */
|
|
225
138
|
export function firstFor(entries, id) {
|
|
226
|
-
return entries.find((
|
|
139
|
+
return entries.find((e) => e.id === id);
|
|
227
140
|
}
|
|
228
|
-
/**
|
|
229
|
-
* itself never produces same-id duplicates, but a plan carrying them isn't
|
|
230
|
-
* ruled out here — see firstFor). repairDecisionCommit uses this to decide
|
|
231
|
-
* what to WRITE; a caller that needs to know which entry WOULD be picked
|
|
232
|
-
* without writing anything (the CLI's --apply summary and queue sweep)
|
|
233
|
-
* calls this same function, so the two can never independently drift on
|
|
234
|
-
* which entry "won". */
|
|
141
|
+
/** The plan's rewrite for `id` (first match). */
|
|
235
142
|
export function pickRewrite(plan, id) {
|
|
236
143
|
return firstFor(plan.rewrites, id);
|
|
237
144
|
}
|
|
238
|
-
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Pure: apply the plan's rewrite to a decision. Returns the same reference when
|
|
147
|
+
* there is no entry or the record moved on (`commit` no longer equals `from`);
|
|
148
|
+
* otherwise a copy with `commit` and the `commit:<from>` evidence moved to `to`
|
|
149
|
+
* (deduped if `to` was already cited).
|
|
150
|
+
*/
|
|
244
151
|
export function repairDecisionCommit(d, plan) {
|
|
245
|
-
const
|
|
246
|
-
if (!
|
|
152
|
+
const entry = pickRewrite(plan, d.id);
|
|
153
|
+
if (!entry || d.commit !== entry.from)
|
|
247
154
|
return d;
|
|
248
|
-
const evidence = replaceExact(d.provenance.evidence, `commit:${mine.from}`, `commit:${mine.to}`);
|
|
249
155
|
return {
|
|
250
156
|
...d,
|
|
251
|
-
commit:
|
|
252
|
-
provenance: {
|
|
157
|
+
commit: entry.to,
|
|
158
|
+
provenance: {
|
|
159
|
+
...d.provenance,
|
|
160
|
+
evidence: replaceExact(d.provenance.evidence, `commit:${entry.from}`, `commit:${entry.to}`).values,
|
|
161
|
+
},
|
|
253
162
|
};
|
|
254
163
|
}
|
|
255
164
|
//# sourceMappingURL=commitrepair.js.map
|
package/dist/core/config.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface HunchConfig {
|
|
|
28
28
|
* (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
|
|
29
29
|
mcp_tools?: string;
|
|
30
30
|
workspaces?: Partial<WorkspacesConfig>;
|
|
31
|
+
/** `hunch verify` knobs: `tests` discovery glob, `command` runner template
|
|
32
|
+
* (`{files}` is replaced by the selected test paths). */
|
|
33
|
+
verify?: {
|
|
34
|
+
tests?: string;
|
|
35
|
+
command?: string;
|
|
36
|
+
};
|
|
31
37
|
}
|
|
32
38
|
/** The effective workspace config: every field present, unknown values ignored. */
|
|
33
39
|
export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
|
package/dist/core/config.js
CHANGED
|
@@ -35,6 +35,7 @@ export function readConfig(paths) {
|
|
|
35
35
|
firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
|
|
36
36
|
...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
|
|
37
37
|
...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
|
|
38
|
+
...(raw.verify && typeof raw.verify === "object" && !Array.isArray(raw.verify) ? { verify: raw.verify } : {}),
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
catch {
|
package/dist/core/correction.js
CHANGED
|
@@ -57,13 +57,12 @@ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correc
|
|
|
57
57
|
* Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
|
|
58
58
|
* repo). The caller then falls back to "**", where the existing severity guard
|
|
59
59
|
* down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
|
|
60
|
-
* than a blocking rule enforced nowhere.
|
|
60
|
+
* than a blocking rule that is enforced nowhere.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* converts that "still absolute" signal to "" — the fail-safe this caller needs. */
|
|
62
|
+
* This is a thin adapter over the shared `repoRelativeTarget`, which hands an
|
|
63
|
+
* unresolvable absolute hint back unchanged instead of signalling failure (other
|
|
64
|
+
* callers want the original target back). Here a result that is still absolute is
|
|
65
|
+
* turned into the empty string. */
|
|
67
66
|
function repoRelativeHint(rawHint, root) {
|
|
68
67
|
if (!rawHint)
|
|
69
68
|
return "";
|
|
@@ -72,9 +71,10 @@ function repoRelativeHint(rawHint, root) {
|
|
|
72
71
|
return rawHint;
|
|
73
72
|
if (!root)
|
|
74
73
|
return "";
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
const rel = repoRelativeTarget(rawHint, root);
|
|
75
|
+
if (isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
76
|
+
return "";
|
|
77
|
+
return rel;
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
80
|
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
package/dist/core/delivery.d.ts
CHANGED
|
@@ -128,7 +128,12 @@ export declare function lexicalTokens(value: string): Set<string>;
|
|
|
128
128
|
export declare function resetCommitReachabilityMemo(): void;
|
|
129
129
|
/** Test hook: number of git spawns issued by delivery provenance probes so far. */
|
|
130
130
|
export declare function commitReachabilityProbeCount(): number;
|
|
131
|
-
|
|
131
|
+
/** A per-build reachability probe. `prime` registers the commits the build may
|
|
132
|
+
* ask about; the first lookup then judges all of them in two batched spawns. */
|
|
133
|
+
export type LocalCommitReachability = ((commit: string) => CommitReachability) & {
|
|
134
|
+
prime: (commits: readonly string[]) => void;
|
|
135
|
+
};
|
|
136
|
+
export declare function localCommitReachability(root: string): LocalCommitReachability;
|
|
132
137
|
/** Validate the public receipt without trusting a caller-supplied identity. */
|
|
133
138
|
export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
|
|
134
139
|
/** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
|
package/dist/core/delivery.js
CHANGED
|
@@ -312,12 +312,14 @@ function componentResolves(id, target, options) {
|
|
|
312
312
|
return !!component && component.paths.some((path) => anchorResolves(path, target, options));
|
|
313
313
|
}
|
|
314
314
|
let gitProbeCount = 0;
|
|
315
|
-
function gitProbe(root, args) {
|
|
315
|
+
function gitProbe(root, args, input) {
|
|
316
316
|
gitProbeCount++;
|
|
317
317
|
const result = spawnSync("git", ["-C", root, ...args], {
|
|
318
318
|
encoding: "utf8",
|
|
319
319
|
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
|
|
320
|
-
|
|
320
|
+
input,
|
|
321
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
322
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
|
|
321
323
|
timeout: 2_000,
|
|
322
324
|
windowsHide: true,
|
|
323
325
|
});
|
|
@@ -363,7 +365,60 @@ export function localCommitReachability(root) {
|
|
|
363
365
|
let initialized = false;
|
|
364
366
|
let branch = null;
|
|
365
367
|
let tip = null;
|
|
366
|
-
|
|
368
|
+
// #485: each hook call is a fresh process, so the memo alone still cost two
|
|
369
|
+
// spawns per capture commit. Primed commits are judged together on the first
|
|
370
|
+
// lookup — one `cat-file --batch-check` resolves them, one `rev-list` tests
|
|
371
|
+
// ancestry — and kept for this build only. A spawn failure (timeout, ENOBUFS)
|
|
372
|
+
// primes every pending commit "unknown", the verdict a failed per-commit probe
|
|
373
|
+
// gives, so a slow git costs one timeout rather than 2N more spawns. Unexpected
|
|
374
|
+
// output leaves a commit unprimed and the per-commit path below decides it, so
|
|
375
|
+
// within a build the verdicts match that path.
|
|
376
|
+
let expected = [];
|
|
377
|
+
const primed = new Map();
|
|
378
|
+
const batch = (commits) => {
|
|
379
|
+
const pending = [...new Set(commits)].filter((commit) => /^[0-9a-f]{7,64}$/i.test(commit) && !primed.has(commit) && !(tip && reachabilityMemo.has(`${root}\0${tip}\0${commit}`)));
|
|
380
|
+
if (!pending.length || !branch)
|
|
381
|
+
return;
|
|
382
|
+
const resolved = gitProbe(root, ["cat-file", "--batch-check=%(objectname)"], pending.map((commit) => `${commit}^{commit}\n`).join(""));
|
|
383
|
+
if (!resolved.available) {
|
|
384
|
+
for (const commit of pending)
|
|
385
|
+
primed.set(commit, "unknown");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const lines = resolved.stdout.split(/\r?\n/);
|
|
389
|
+
if (resolved.status !== 0 || lines.length !== pending.length)
|
|
390
|
+
return;
|
|
391
|
+
const shas = new Map();
|
|
392
|
+
for (const [i, commit] of pending.entries()) {
|
|
393
|
+
const line = lines[i];
|
|
394
|
+
if (/^[0-9a-f]{40,64}$/i.test(line))
|
|
395
|
+
shas.set(commit, line);
|
|
396
|
+
// A missing object is not memoized, as on the per-commit path.
|
|
397
|
+
else if (line === `${commit}^{commit} missing` || line === `${commit}^{commit} ambiguous`)
|
|
398
|
+
primed.set(commit, "unreachable");
|
|
399
|
+
else
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!shas.size)
|
|
403
|
+
return;
|
|
404
|
+
// `rev-list <shas> ^<tip>` lists exactly the commits NOT reachable from the tip.
|
|
405
|
+
// It prints the whole history behind an unreachable capture commit (e.g.
|
|
406
|
+
// orphaned by a rewrite), so this probe costs O(|unreachable history|); a
|
|
407
|
+
// history big enough to time out primes "unknown" below.
|
|
408
|
+
const listed = gitProbe(root, ["rev-list", "--stdin"], `${[...new Set(shas.values())].join("\n")}\n^${tip ?? branch}\n`);
|
|
409
|
+
// A failed spawn or a nonzero exit is a merge-base answer outside 0/1: "unknown", never memoized.
|
|
410
|
+
if (!listed.available || listed.status !== 0) {
|
|
411
|
+
for (const commit of shas.keys())
|
|
412
|
+
primed.set(commit, "unknown");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const unreachable = new Set(listed.stdout.split(/\r?\n/).filter(Boolean));
|
|
416
|
+
for (const [commit, sha] of shas) {
|
|
417
|
+
const verdict = unreachable.has(sha) ? "unreachable" : "reachable";
|
|
418
|
+
primed.set(commit, tip ? memoizeReachability(`${root}\0${tip}\0${commit}`, verdict) : verdict);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const lookup = (commit) => {
|
|
367
422
|
if (!initialized) {
|
|
368
423
|
// The default ref is looked up every build (origin/HEAD can be retargeted
|
|
369
424
|
// while the old ref still exists); only verdicts are shared across builds.
|
|
@@ -388,6 +443,14 @@ export function localCommitReachability(root) {
|
|
|
388
443
|
const memo = key ? reachabilityMemo.get(key) : undefined;
|
|
389
444
|
if (memo)
|
|
390
445
|
return memo;
|
|
446
|
+
if (expected.length && !primed.has(commit)) {
|
|
447
|
+
const commits = [...expected, commit];
|
|
448
|
+
expected = [];
|
|
449
|
+
batch(commits);
|
|
450
|
+
}
|
|
451
|
+
const batched = primed.get(commit);
|
|
452
|
+
if (batched)
|
|
453
|
+
return batched;
|
|
391
454
|
const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
|
|
392
455
|
const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
|
|
393
456
|
if (!exact.available)
|
|
@@ -404,6 +467,7 @@ export function localCommitReachability(root) {
|
|
|
404
467
|
return remember("unreachable");
|
|
405
468
|
return "unknown";
|
|
406
469
|
};
|
|
470
|
+
return Object.assign(lookup, { prime: (commits) => { expected.push(...commits); } });
|
|
407
471
|
}
|
|
408
472
|
function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
|
|
409
473
|
if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
|
|
@@ -563,8 +627,10 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
|
|
|
563
627
|
const profile = options.profile ?? "builder";
|
|
564
628
|
const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
|
|
565
629
|
const cap = budget * 4;
|
|
566
|
-
const
|
|
567
|
-
|
|
630
|
+
const local = !options.commitReachability && options.root ? localCommitReachability(options.root) : null;
|
|
631
|
+
// Only decisions carry a capture commit; register them so the first lookup batches them all (#485).
|
|
632
|
+
local?.prime(ctx.decisions.flatMap((decision) => decision.commit ? [decision.commit] : []));
|
|
633
|
+
const reachability = options.commitReachability ?? local ?? (() => "unknown");
|
|
568
634
|
const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
|
|
569
635
|
const candidates = [];
|
|
570
636
|
const query = queryProfile(ctx.target, options);
|
package/dist/core/docanchors.js
CHANGED
|
@@ -360,11 +360,11 @@ function htmlBlockEnd(s) {
|
|
|
360
360
|
/** Character ranges covered by inline code spans (`…`), same rationale as
|
|
361
361
|
* codeRanges: prose quoting a marker in backticks is showing an example.
|
|
362
362
|
* CommonMark-lite: an opener run pairs with the next run of the SAME length
|
|
363
|
-
* on the same line; unpaired
|
|
364
|
-
* Expects LF-normalized
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
|
|
363
|
+
* on the same line; an unpaired backtick run never opens a span, and pairing
|
|
364
|
+
* is per line. Expects LF-normalized input (see scanDocAnchors): the text is
|
|
365
|
+
* split on LF only, so a CR-only document would read as one long line and
|
|
366
|
+
* stray backticks on different lines would falsely pair.
|
|
367
|
+
*/
|
|
368
368
|
function inlineSpanRanges(text) {
|
|
369
369
|
const ranges = [];
|
|
370
370
|
let offset = 0;
|
|
@@ -394,10 +394,10 @@ export function parseDocAnchors(text) {
|
|
|
394
394
|
* never closes hides every marker below it, almost always by mistake (#381). */
|
|
395
395
|
export function scanDocAnchors(text) {
|
|
396
396
|
// codeRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// docblock). Either way an example marker inside a fence
|
|
400
|
-
// a live
|
|
397
|
+
// trailing CR means a CRLF fence line was never recognized as a fence), and the
|
|
398
|
+
// inline-span scan is sensitive to a bare CR (it splits on LF only, see its
|
|
399
|
+
// docblock). Either way an example marker inside a fence or span got registered
|
|
400
|
+
// as a live pinned anchor. Normalizing CRLF and lone CR once, here, keeps
|
|
401
401
|
// codeRanges/inlineSpanRanges/MARKER offsets consistent with each other
|
|
402
402
|
// and with the line numbers reported below. A leading BOM is dropped too, so
|
|
403
403
|
// line 1 starts at column 0 (#381); it is one char on line 1, so line numbers hold.
|
package/dist/core/drift.js
CHANGED
|
@@ -48,25 +48,17 @@ export function computeDrift(store, root, deps = {}) {
|
|
|
48
48
|
return liveFiles.has(p) || liveDirs.some((dir) => p.startsWith(dir));
|
|
49
49
|
};
|
|
50
50
|
const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const defaultCommitResolvable = (sha) => {
|
|
63
|
-
if (!gitRepo)
|
|
64
|
-
return true; // never flag outside a git repo
|
|
65
|
-
if (resolvableCommits === null)
|
|
66
|
-
return true; // the batch check itself failed, or was skipped — fail open
|
|
67
|
-
return resolvableCommits.has(sha);
|
|
68
|
-
};
|
|
69
|
-
const commitResolvable = deps.commitResolvable ?? defaultCommitResolvable;
|
|
51
|
+
// Commit-provenance resolver. Injectable so tests/callers avoid spawning git. The
|
|
52
|
+
// default runs ONE batched existence check over every decision's commit (not a
|
|
53
|
+
// process per decision), and outside a git repo the check is meaningless, so it
|
|
54
|
+
// never flags anything there.
|
|
55
|
+
let commitResolvable = deps.commitResolvable;
|
|
56
|
+
if (!commitResolvable) {
|
|
57
|
+
const inRepo = isGitRepo(root);
|
|
58
|
+
const shas = inRepo ? [...new Set(decisions.map((d) => d.commit).filter((c) => !!c))] : [];
|
|
59
|
+
const resolvable = inRepo ? commitsExist(shas, root) : null;
|
|
60
|
+
commitResolvable = (sha) => !inRepo || resolvable === null || resolvable.has(sha);
|
|
61
|
+
}
|
|
70
62
|
for (const d of decisions) {
|
|
71
63
|
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
72
64
|
// a since-deleted file is legitimate history, not drift.
|
|
@@ -79,15 +71,18 @@ export function computeDrift(store, root, deps = {}) {
|
|
|
79
71
|
findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
|
|
80
72
|
}
|
|
81
73
|
}
|
|
82
|
-
// 8. COMMIT-UNRESOLVABLE —
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// those too), so exclude it explicitly here.
|
|
74
|
+
// 8. COMMIT-UNRESOLVABLE — the commit provenance no longer resolves here (its
|
|
75
|
+
// source branch was deleted and garbage-collected). The post-merge repair
|
|
76
|
+
// flow is the fix path; this is only the deterministic "nothing caught it"
|
|
77
|
+
// signal and is never auto-fixed. Same live-records-only rule as the repair
|
|
78
|
+
// module, hence the explicit rejected exclusion (a never-adopted decision
|
|
79
|
+
// also has superseded_by null).
|
|
89
80
|
if (d.commit && d.status !== "rejected" && !commitResolvable(d.commit)) {
|
|
90
|
-
findings.push({
|
|
81
|
+
findings.push({
|
|
82
|
+
kind: "commit-unresolvable",
|
|
83
|
+
id: d.id,
|
|
84
|
+
detail: `commit ${d.commit} no longer resolves in this repository — provenance may need manual repair`,
|
|
85
|
+
});
|
|
91
86
|
}
|
|
92
87
|
}
|
|
93
88
|
// 2. SUPERSEDE-INTEGRITY — a contradiction class: A.supersedes = B, but B is
|