@davesheffer/hunch 1.39.0 → 1.39.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 +193 -65
- package/dist/cli/integrations.js +10 -0
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +55 -8
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- 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/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +31 -11
- package/dist/core/topics.js +1 -1
- 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/scaffold.js +1 -1
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +115 -56
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +111 -42
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/types.d.ts
CHANGED
package/dist/core/workspace.d.ts
CHANGED
|
@@ -23,6 +23,9 @@ export declare const MACHINE_LABEL: RegExp;
|
|
|
23
23
|
* `.lock` suffix, leading/trailing `.` or `/`, and bounded length. Every real branch
|
|
24
24
|
* from `for-each-ref` passes; a crafted record cannot smuggle an argument. */
|
|
25
25
|
export declare function isSafeBranchName(name: string): boolean;
|
|
26
|
+
/** C0/C1 control characters, including newline and ESC: a stored string carrying one could
|
|
27
|
+
* forge extra lines or terminal escapes in output a human reads (a printed command). */
|
|
28
|
+
export declare const CONTROL_CHARS: RegExp;
|
|
26
29
|
export declare const WorkspaceWorktreeSchema: z.ZodObject<{
|
|
27
30
|
id: z.ZodString;
|
|
28
31
|
path: z.ZodNullable<z.ZodString>;
|
|
@@ -35,13 +38,17 @@ export declare const WorkspaceWorktreeSchema: z.ZodObject<{
|
|
|
35
38
|
last_commit_at: z.ZodNullable<z.ZodString>;
|
|
36
39
|
}, z.core.$strict>;
|
|
37
40
|
export type WorkspaceWorktree = z.infer<typeof WorkspaceWorktreeSchema>;
|
|
38
|
-
|
|
41
|
+
/** `no-commits`: the branch head lies on the default branch's first-parent history, so the
|
|
42
|
+
* branch holds no commits of its own (freshly created, or fast-forwarded into the default
|
|
43
|
+
* branch). Ancestry alone would call it merged; it is never offered for deletion. */
|
|
44
|
+
export declare const MERGED_STATUSES: readonly ["merged", "unmerged", "no-commits", "unknown"];
|
|
39
45
|
export declare const MERGED_METHODS: readonly ["ancestry", "squash", "rebase"];
|
|
40
46
|
export declare const MergedVerdictSchema: z.ZodObject<{
|
|
41
47
|
status: z.ZodEnum<{
|
|
42
48
|
unknown: "unknown";
|
|
43
49
|
merged: "merged";
|
|
44
50
|
unmerged: "unmerged";
|
|
51
|
+
"no-commits": "no-commits";
|
|
45
52
|
}>;
|
|
46
53
|
method: z.ZodNullable<z.ZodEnum<{
|
|
47
54
|
ancestry: "ancestry";
|
|
@@ -67,6 +74,7 @@ export declare const WorkspaceBranchSchema: z.ZodObject<{
|
|
|
67
74
|
unknown: "unknown";
|
|
68
75
|
merged: "merged";
|
|
69
76
|
unmerged: "unmerged";
|
|
77
|
+
"no-commits": "no-commits";
|
|
70
78
|
}>;
|
|
71
79
|
method: z.ZodNullable<z.ZodEnum<{
|
|
72
80
|
ancestry: "ancestry";
|
|
@@ -124,6 +132,7 @@ export declare const WorkspaceSchema: z.ZodObject<{
|
|
|
124
132
|
unknown: "unknown";
|
|
125
133
|
merged: "merged";
|
|
126
134
|
unmerged: "unmerged";
|
|
135
|
+
"no-commits": "no-commits";
|
|
127
136
|
}>;
|
|
128
137
|
method: z.ZodNullable<z.ZodEnum<{
|
|
129
138
|
ancestry: "ancestry";
|
|
@@ -216,7 +225,20 @@ export interface PruneStep {
|
|
|
216
225
|
} | null;
|
|
217
226
|
commands: string[];
|
|
218
227
|
why: string;
|
|
228
|
+
/** How the merge was proven; squash and rebase merges are invisible to `git branch -d`. */
|
|
229
|
+
method: MergedVerdict["method"];
|
|
230
|
+
/** Ignored files in the worktree that `git worktree remove` deletes without asking (local
|
|
231
|
+
* plan only, read live; never stored). `shown` is bounded, `total` counts all entries. */
|
|
232
|
+
ignored?: {
|
|
233
|
+
shown: string[];
|
|
234
|
+
total: number;
|
|
235
|
+
};
|
|
219
236
|
}
|
|
237
|
+
/** POSIX shell quoting for one token of a PRINTED command (never executed through a shell
|
|
238
|
+
* here: execution uses argv arrays). Tokens made only of characters no shell treats
|
|
239
|
+
* specially stay bare; anything else is single-quoted with `'` escaped as `'\''`, which is
|
|
240
|
+
* also valid in Git Bash for Windows paths. */
|
|
241
|
+
export declare function shellQuote(token: string): string;
|
|
220
242
|
export interface PrunePlan {
|
|
221
243
|
/** Executable on this machine (live record). */
|
|
222
244
|
local: PruneStep[];
|
package/dist/core/workspace.js
CHANGED
|
@@ -43,7 +43,12 @@ const UpstreamName = z.string().max(320).refine((v) => {
|
|
|
43
43
|
const slash = v.indexOf("/");
|
|
44
44
|
return slash > 0 && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v.slice(0, slash)) && isSafeBranchName(v.slice(slash + 1));
|
|
45
45
|
}, { message: "upstream must be <remote>/<branch>" });
|
|
46
|
-
|
|
46
|
+
/** C0/C1 control characters, including newline and ESC: a stored string carrying one could
|
|
47
|
+
* forge extra lines or terminal escapes in output a human reads (a printed command). */
|
|
48
|
+
export const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
|
|
49
|
+
const credentialFree = (label, max) => z.string().min(1).max(max)
|
|
50
|
+
.refine((v) => !CONTROL_CHARS.test(v), { message: `${label} must not contain control characters or newlines` })
|
|
51
|
+
.refine(isCredentialFreeValue, { message: `${label} must not carry credential material` });
|
|
47
52
|
export const WorkspaceWorktreeSchema = z.object({
|
|
48
53
|
/** Stable, path-free handle (hash of the path) so branches can point at a worktree in
|
|
49
54
|
* `branches` publish mode, where the path itself is omitted. */
|
|
@@ -58,12 +63,17 @@ export const WorkspaceWorktreeSchema = z.object({
|
|
|
58
63
|
prunable: z.boolean(),
|
|
59
64
|
last_commit_at: z.string().regex(ISO).nullable(),
|
|
60
65
|
}).strict();
|
|
61
|
-
|
|
66
|
+
/** `no-commits`: the branch head lies on the default branch's first-parent history, so the
|
|
67
|
+
* branch holds no commits of its own (freshly created, or fast-forwarded into the default
|
|
68
|
+
* branch). Ancestry alone would call it merged; it is never offered for deletion. */
|
|
69
|
+
export const MERGED_STATUSES = ["merged", "unmerged", "no-commits", "unknown"];
|
|
62
70
|
export const MERGED_METHODS = ["ancestry", "squash", "rebase"];
|
|
63
71
|
export const MergedVerdictSchema = z.object({
|
|
64
72
|
status: z.enum(MERGED_STATUSES),
|
|
65
73
|
method: z.enum(MERGED_METHODS).nullable(),
|
|
66
|
-
evidence: z.array(z.string().max(256)
|
|
74
|
+
evidence: z.array(z.string().max(256)
|
|
75
|
+
.refine((v) => !CONTROL_CHARS.test(v), { message: "verdict evidence must not contain control characters or newlines" })
|
|
76
|
+
.refine(isCredentialFreeValue, { message: "verdict evidence must not carry credential material" })).max(8),
|
|
67
77
|
/** The pull request that landed a merged branch, read from the LOCAL merge / squash commit
|
|
68
78
|
* subject ("Merge pull request #N from …", "… (#N)") — never fetched from a forge. */
|
|
69
79
|
pr: z.number().int().min(1).max(100_000_000).optional(),
|
|
@@ -218,6 +228,8 @@ export function recommendAction(row, opts = {}) {
|
|
|
218
228
|
const dirty = row.dirty_on.length ? `; dirty worktree on ${row.dirty_on.join(", ")}` : "";
|
|
219
229
|
if (row.merged.status === "unknown")
|
|
220
230
|
return `review: merge state unknown${dirty}${suffix}`;
|
|
231
|
+
if (row.merged.status === "no-commits")
|
|
232
|
+
return `keep: no commits of its own${dirty}${suffix}`;
|
|
221
233
|
if (row.upstream === null || row.upstream_gone) {
|
|
222
234
|
const idle = daysIdle(row.last_commit_at, now);
|
|
223
235
|
const why = row.upstream_gone ? "upstream deleted, unmerged work" : "unpushed";
|
|
@@ -233,6 +245,7 @@ export function recommendAction(row, opts = {}) {
|
|
|
233
245
|
function bestVerdict(verdicts) {
|
|
234
246
|
return verdicts.find((v) => v.status === "merged")
|
|
235
247
|
?? verdicts.find((v) => v.status === "unmerged")
|
|
248
|
+
?? verdicts.find((v) => v.status === "no-commits")
|
|
236
249
|
?? verdicts[0]
|
|
237
250
|
?? { status: "unknown", method: null, evidence: [] };
|
|
238
251
|
}
|
|
@@ -283,6 +296,15 @@ export function ago(iso, now = new Date()) {
|
|
|
283
296
|
return `${h}h ago`;
|
|
284
297
|
return `${Math.floor(h / 24)}d ago`;
|
|
285
298
|
}
|
|
299
|
+
/** POSIX shell quoting for one token of a PRINTED command (never executed through a shell
|
|
300
|
+
* here: execution uses argv arrays). Tokens made only of characters no shell treats
|
|
301
|
+
* specially stay bare; anything else is single-quoted with `'` escaped as `'\''`, which is
|
|
302
|
+
* also valid in Git Bash for Windows paths. */
|
|
303
|
+
export function shellQuote(token) {
|
|
304
|
+
if (/^[A-Za-z0-9_\-./:@+,]+$/.test(token))
|
|
305
|
+
return token;
|
|
306
|
+
return `'${token.replace(/'/g, `'\\''`)}'`;
|
|
307
|
+
}
|
|
286
308
|
function pruneStepsFor(record, opts) {
|
|
287
309
|
const steps = [];
|
|
288
310
|
const skipped = [];
|
|
@@ -296,11 +318,12 @@ function pruneStepsFor(record, opts) {
|
|
|
296
318
|
}
|
|
297
319
|
const commands = [];
|
|
298
320
|
if (wt)
|
|
299
|
-
commands.push(`git worktree remove -- ${wt.path
|
|
300
|
-
commands.push(`git branch -d -- ${b.name}`);
|
|
321
|
+
commands.push(`git worktree remove -- ${wt.path === null ? "<its worktree>" : shellQuote(wt.path)}`);
|
|
322
|
+
commands.push(`git branch -d -- ${shellQuote(b.name)}`);
|
|
301
323
|
steps.push({
|
|
302
324
|
branch: b.name, head: b.head, worktree: wt ? { id: wt.id, path: wt.path } : null, commands,
|
|
303
325
|
why: `${b.merged.method}${b.merged.pr ? ` (PR #${b.merged.pr})` : ""}: ${b.merged.evidence[0] ?? ""}`,
|
|
326
|
+
method: b.merged.method,
|
|
304
327
|
});
|
|
305
328
|
}
|
|
306
329
|
return { steps, skipped };
|
|
@@ -310,8 +333,9 @@ function pruneStepsFor(record, opts) {
|
|
|
310
333
|
export function pruneRefusal(b, wt) {
|
|
311
334
|
if (b.is_default)
|
|
312
335
|
return "default branch";
|
|
313
|
-
if (b.merged.status !== "merged")
|
|
314
|
-
return b.merged.status === "unknown" ? "merge state unknown" : "not merged";
|
|
336
|
+
if (b.merged.status !== "merged") {
|
|
337
|
+
return b.merged.status === "unknown" ? "merge state unknown" : b.merged.status === "no-commits" ? "no commits of its own" : "not merged";
|
|
338
|
+
}
|
|
315
339
|
if (wt?.is_main)
|
|
316
340
|
return "checked out in the main worktree (switch away first)";
|
|
317
341
|
if (wt?.locked)
|
|
@@ -35,6 +35,40 @@ export interface DiffAnalysis {
|
|
|
35
35
|
* the same (new-path) key as perFile. */
|
|
36
36
|
addedLinesByFile: Map<string, string[]>;
|
|
37
37
|
}
|
|
38
|
+
/** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
|
|
39
|
+
* path holding `"`, `\`, or a control character (`"src/a\"b.ts"`); with the
|
|
40
|
+
* default quotePath it also octal-escapes every byte above 0x7F. Octal escapes
|
|
41
|
+
* are raw bytes, so the unescaped byte string is decoded as UTF-8. An unquoted
|
|
42
|
+
* or malformed value is returned unchanged. */
|
|
43
|
+
export declare function unquoteGitPath(p: string): string;
|
|
44
|
+
/** The final line a budget-capped diff ends with (see SYNTHESIS_DIFF_BUDGET in git.ts). */
|
|
45
|
+
export declare const DIFF_TRUNCATED_LINE = "\u2026(diff truncated)\u2026";
|
|
46
|
+
/** Does this diff end with the truncation marker (i.e. is it a prefix of the change)? */
|
|
47
|
+
export declare function isTruncatedDiff(diff: string): boolean;
|
|
48
|
+
/** The (new-side) path of every file block in a unified diff, in order — including
|
|
49
|
+
* blocks with no hunks (binary, mode-only). Paths use the same keys as
|
|
50
|
+
* DiffAnalysis.addedLinesByFile. */
|
|
51
|
+
export declare function diffBlockFiles(diff: string): string[];
|
|
52
|
+
/** How complete a gate's diff is (git.ts GateDiff satisfies this shape). */
|
|
53
|
+
export interface DiffStatus {
|
|
54
|
+
/** Why the diff as a whole cannot be trusted as complete. */
|
|
55
|
+
incomplete?: string;
|
|
56
|
+
/** The diff is a byte prefix of the change, so its last block may be cut. */
|
|
57
|
+
truncated?: boolean;
|
|
58
|
+
/** Files whose content could not be read into the diff. */
|
|
59
|
+
unreadFiles?: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface DiffContentGaps {
|
|
62
|
+
/** True when this file's added lines may be missing from the diff. */
|
|
63
|
+
missing(file: string): boolean;
|
|
64
|
+
/** A human-readable reason for the given missing files. */
|
|
65
|
+
reason(files: string[]): string;
|
|
66
|
+
}
|
|
67
|
+
/** Which files a content check cannot trust "no added lines" for. Null when the
|
|
68
|
+
* diff is complete, so callers keep the exact small-diff behavior. A truncated
|
|
69
|
+
* diff (ends with DIFF_TRUNCATED_LINE) covers only the blocks before its last,
|
|
70
|
+
* possibly cut, block; an `incomplete` status covers only the blocks present. */
|
|
71
|
+
export declare function diffContentGaps(diff: string, status?: DiffStatus): DiffContentGaps | null;
|
|
38
72
|
export declare function analyzeDiff(diff: string): DiffAnalysis;
|
|
39
73
|
/** A compact human-readable summary of a DiffAnalysis (used in decision text). */
|
|
40
74
|
export declare function summarizeDiff(a: DiffAnalysis): string;
|
package/dist/extractors/diff.js
CHANGED
|
@@ -59,6 +59,148 @@ function importOf(line) {
|
|
|
59
59
|
function stripAB(p) {
|
|
60
60
|
return p.replace(/^[ab]\//, "");
|
|
61
61
|
}
|
|
62
|
+
const C_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
|
|
63
|
+
/** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
|
|
64
|
+
* path holding `"`, `\`, or a control character (`"src/a\"b.ts"`); with the
|
|
65
|
+
* default quotePath it also octal-escapes every byte above 0x7F. Octal escapes
|
|
66
|
+
* are raw bytes, so the unescaped byte string is decoded as UTF-8. An unquoted
|
|
67
|
+
* or malformed value is returned unchanged. */
|
|
68
|
+
export function unquoteGitPath(p) {
|
|
69
|
+
if (p.length < 2 || !p.startsWith('"') || !p.endsWith('"'))
|
|
70
|
+
return p;
|
|
71
|
+
const body = p.slice(1, -1);
|
|
72
|
+
const bytes = [];
|
|
73
|
+
for (let i = 0; i < body.length; i++) {
|
|
74
|
+
const ch = body[i];
|
|
75
|
+
if (ch !== "\\") {
|
|
76
|
+
for (const b of Buffer.from(ch, "utf8"))
|
|
77
|
+
bytes.push(b);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const next = body[i + 1];
|
|
81
|
+
if (next === undefined)
|
|
82
|
+
return p;
|
|
83
|
+
if (/[0-7]/.test(next)) {
|
|
84
|
+
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))[0];
|
|
85
|
+
bytes.push(parseInt(oct, 8) & 0xff);
|
|
86
|
+
i += oct.length;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const code = C_ESCAPES[next];
|
|
90
|
+
if (code === undefined)
|
|
91
|
+
return p;
|
|
92
|
+
bytes.push(code);
|
|
93
|
+
i += 1;
|
|
94
|
+
}
|
|
95
|
+
return Buffer.from(bytes).toString("utf8");
|
|
96
|
+
}
|
|
97
|
+
/** A `---`/`+++` header path: strip git's trailing tab (added after names with
|
|
98
|
+
* spaces), unquote, then drop the a/ b/ prefix. */
|
|
99
|
+
function headerPath(raw) {
|
|
100
|
+
const value = raw.replace(/\r$/, "").replace(/\t$/, "").trim();
|
|
101
|
+
return value.startsWith('"') ? unquoteGitPath(value) : value;
|
|
102
|
+
}
|
|
103
|
+
/** The final line a budget-capped diff ends with (see SYNTHESIS_DIFF_BUDGET in git.ts). */
|
|
104
|
+
export const DIFF_TRUNCATED_LINE = "…(diff truncated)…";
|
|
105
|
+
/** Does this diff end with the truncation marker (i.e. is it a prefix of the change)? */
|
|
106
|
+
export function isTruncatedDiff(diff) {
|
|
107
|
+
return diff === DIFF_TRUNCATED_LINE || diff.endsWith(`\n${DIFF_TRUNCATED_LINE}`);
|
|
108
|
+
}
|
|
109
|
+
/** The (new-side) path of every file block in a unified diff, in order — including
|
|
110
|
+
* blocks with no hunks (binary, mode-only). Paths use the same keys as
|
|
111
|
+
* DiffAnalysis.addedLinesByFile. */
|
|
112
|
+
export function diffBlockFiles(diff) {
|
|
113
|
+
const out = [];
|
|
114
|
+
let cur = null;
|
|
115
|
+
const flush = () => { if (cur?.path)
|
|
116
|
+
out.push(cur.path); };
|
|
117
|
+
for (const raw of diff.split("\n")) {
|
|
118
|
+
if (raw.startsWith("diff --git ")) {
|
|
119
|
+
flush();
|
|
120
|
+
cur = { path: gitHeaderNewPath(raw.slice("diff --git ".length)), inHunk: false };
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!cur)
|
|
124
|
+
continue;
|
|
125
|
+
if (raw.startsWith("@@")) {
|
|
126
|
+
cur.inHunk = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (cur.inHunk)
|
|
130
|
+
continue;
|
|
131
|
+
if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
132
|
+
cur.path = headerPath(raw.slice(raw.indexOf(" to ") + 4));
|
|
133
|
+
}
|
|
134
|
+
else if (raw.startsWith("+++ ")) {
|
|
135
|
+
const p = headerPath(raw.slice(4));
|
|
136
|
+
if (p !== "/dev/null")
|
|
137
|
+
cur.path = stripAB(p);
|
|
138
|
+
}
|
|
139
|
+
else if (raw.startsWith("--- ")) {
|
|
140
|
+
const p = headerPath(raw.slice(4));
|
|
141
|
+
if (p !== "/dev/null")
|
|
142
|
+
cur.path = stripAB(p);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
flush();
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/** Which files a content check cannot trust "no added lines" for. Null when the
|
|
149
|
+
* diff is complete, so callers keep the exact small-diff behavior. A truncated
|
|
150
|
+
* diff (ends with DIFF_TRUNCATED_LINE) covers only the blocks before its last,
|
|
151
|
+
* possibly cut, block; an `incomplete` status covers only the blocks present. */
|
|
152
|
+
export function diffContentGaps(diff, status) {
|
|
153
|
+
const truncated = !!status?.truncated || isTruncatedDiff(diff);
|
|
154
|
+
const unread = new Set(status?.unreadFiles ?? []);
|
|
155
|
+
const wholeReason = status?.incomplete ?? (truncated ? "the diff was truncated before this file's changes" : undefined);
|
|
156
|
+
if (!wholeReason && !unread.size)
|
|
157
|
+
return null;
|
|
158
|
+
let covered = null;
|
|
159
|
+
if (wholeReason) {
|
|
160
|
+
const blocks = diffBlockFiles(diff);
|
|
161
|
+
if (truncated)
|
|
162
|
+
blocks.pop();
|
|
163
|
+
covered = new Set(blocks);
|
|
164
|
+
}
|
|
165
|
+
const outsideDiff = (f) => covered !== null && !covered.has(f);
|
|
166
|
+
return {
|
|
167
|
+
missing: (f) => unread.has(f) || outsideDiff(f),
|
|
168
|
+
reason: (files) => {
|
|
169
|
+
const parts = [];
|
|
170
|
+
if (files.some(outsideDiff))
|
|
171
|
+
parts.push(wholeReason);
|
|
172
|
+
if (files.some((f) => unread.has(f)))
|
|
173
|
+
parts.push("file content could not be read");
|
|
174
|
+
return parts.join("; ");
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Best-effort new path from a `diff --git <a> <b>` line, used only when a block
|
|
179
|
+
* has no ---/+++ header (binary or mode-only). Unambiguous when both sides are
|
|
180
|
+
* the same path, which is the case for every non-rename block. */
|
|
181
|
+
function gitHeaderNewPath(rest) {
|
|
182
|
+
const line = rest.replace(/\r$/, "");
|
|
183
|
+
if (line.startsWith('"')) {
|
|
184
|
+
const close = /^"(?:[^"\\]|\\.)*"/.exec(line);
|
|
185
|
+
if (!close)
|
|
186
|
+
return "";
|
|
187
|
+
const second = line.slice(close[0].length + 1);
|
|
188
|
+
return stripAB(unquoteGitPath(second.startsWith('"') ? second : second.trim()));
|
|
189
|
+
}
|
|
190
|
+
if (line.endsWith('"')) {
|
|
191
|
+
const open = line.lastIndexOf(' "');
|
|
192
|
+
return open >= 0 ? stripAB(unquoteGitPath(line.slice(open + 1))) : "";
|
|
193
|
+
}
|
|
194
|
+
// "a/P b/P": both halves have equal length when old and new path agree.
|
|
195
|
+
if ((line.length - 1) % 2 === 0) {
|
|
196
|
+
const half = (line.length - 1) / 2;
|
|
197
|
+
const a = line.slice(0, half), b = line.slice(half + 1);
|
|
198
|
+
if (stripAB(a) === stripAB(b))
|
|
199
|
+
return stripAB(b);
|
|
200
|
+
}
|
|
201
|
+
const lastB = line.lastIndexOf(" b/");
|
|
202
|
+
return lastB >= 0 ? line.slice(lastB + 3) : "";
|
|
203
|
+
}
|
|
62
204
|
export function analyzeDiff(diff) {
|
|
63
205
|
const filesAdded = new Set();
|
|
64
206
|
const filesDeleted = new Set();
|
|
@@ -106,24 +248,24 @@ export function analyzeDiff(diff) {
|
|
|
106
248
|
curDeleted = true;
|
|
107
249
|
}
|
|
108
250
|
else if (raw.startsWith("rename from ")) {
|
|
109
|
-
renameFrom = raw.slice("rename from ".length)
|
|
251
|
+
renameFrom = headerPath(raw.slice("rename from ".length));
|
|
110
252
|
}
|
|
111
253
|
else if (raw.startsWith("copy from ")) {
|
|
112
|
-
renameFrom = raw.slice("copy from ".length)
|
|
254
|
+
renameFrom = headerPath(raw.slice("copy from ".length));
|
|
113
255
|
}
|
|
114
256
|
else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
115
|
-
const to = raw.slice(raw.indexOf(" to ") + 4)
|
|
257
|
+
const to = headerPath(raw.slice(raw.indexOf(" to ") + 4));
|
|
116
258
|
curFile = to;
|
|
117
259
|
if (isSubstantive(to))
|
|
118
260
|
filesRenamed.push({ from: renameFrom, to });
|
|
119
261
|
}
|
|
120
262
|
else if (raw.startsWith("--- ")) {
|
|
121
|
-
const p = raw.slice(4)
|
|
263
|
+
const p = headerPath(raw.slice(4));
|
|
122
264
|
if (p !== "/dev/null")
|
|
123
265
|
curFile = stripAB(p); // old path (may be replaced by +++)
|
|
124
266
|
}
|
|
125
267
|
else if (raw.startsWith("+++ ")) {
|
|
126
|
-
const p = raw.slice(4)
|
|
268
|
+
const p = headerPath(raw.slice(4));
|
|
127
269
|
if (p !== "/dev/null")
|
|
128
270
|
curFile = stripAB(p); // new path preferred
|
|
129
271
|
if (isSubstantive(curFile)) {
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -143,6 +143,9 @@ export declare function isolatedHeadSha(cwd: string): string;
|
|
|
143
143
|
* without deleting it locally. `--ignore-unmatch` makes an already-untracked path a
|
|
144
144
|
* no-op rather than an error; best-effort (a non-repo dir just no-ops). */
|
|
145
145
|
export declare function gitUntrackCached(cwd: string, paths: string[]): void;
|
|
146
|
+
/** Tracked files under the given pathspecs (repo-relative, POSIX). Best-effort:
|
|
147
|
+
* `[]` when not a repository or nothing is tracked there. */
|
|
148
|
+
export declare function gitTrackedPaths(cwd: string, paths: string[]): string[];
|
|
146
149
|
/** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
|
|
147
150
|
* Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
|
|
148
151
|
export declare function revParse(ref: string, cwd: string): string;
|
|
@@ -222,9 +225,28 @@ export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unkn
|
|
|
222
225
|
* be treated as repair-eligible; collapsing "false" and "error" into one
|
|
223
226
|
* boolean is exactly the bug this type exists to prevent. */
|
|
224
227
|
export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
|
|
225
|
-
/**
|
|
228
|
+
/** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
|
|
229
|
+
* constraint check over a prefix of a change would read every file past the
|
|
230
|
+
* cutoff as "no added lines" and pass it. */
|
|
231
|
+
export declare const SYNTHESIS_DIFF_BUDGET = 60000;
|
|
232
|
+
/** The diff a GATE evaluates (`hunch check`, the CI Constraint Guard, merge verdict,
|
|
233
|
+
* veto, PR impact). Complete — no byte budget and no noise exclusion — or explicitly
|
|
234
|
+
* marked incomplete, so content-matched constraints fail closed instead of reading a
|
|
235
|
+
* missing hunk as "nothing added". */
|
|
236
|
+
export interface GateDiff {
|
|
237
|
+
diff: string;
|
|
238
|
+
/** Why the diff cannot be trusted as complete (git failed, or its output exceeded
|
|
239
|
+
* the capture ceiling). Every file without a diff block is then unevaluable. */
|
|
240
|
+
incomplete?: string;
|
|
241
|
+
/** Files whose content could not be read into the diff; unevaluable individually. */
|
|
242
|
+
unreadFiles?: string[];
|
|
243
|
+
}
|
|
244
|
+
/** Complete gating diff of one commit (see GateDiff). */
|
|
245
|
+
export declare function commitGateDiff(sha: string, cwd: string): GateDiff;
|
|
246
|
+
/** The unified diff for a commit, truncated to keep SYNTHESIS prompts bounded.
|
|
226
247
|
* Machine-generated noise (see DIFF_NOISE) is excluded so the model spends its
|
|
227
|
-
* budget on code that encodes intent, not on regenerated lockfiles/build output.
|
|
248
|
+
* budget on code that encodes intent, not on regenerated lockfiles/build output.
|
|
249
|
+
* Not for gating: use commitGateDiff. */
|
|
228
250
|
export declare function commitDiff(sha: string, cwd: string, maxBytes?: number): string;
|
|
229
251
|
/** Number of commits touching a file in the last `days` (churn). */
|
|
230
252
|
export declare function fileChurn(file: string, cwd: string, days?: number): number;
|
|
@@ -278,18 +300,25 @@ export declare function rangeFiles(base: string, cwd: string, head?: string): st
|
|
|
278
300
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
279
301
|
* oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
|
|
280
302
|
export declare function rangeSubjects(base: string, cwd: string, head?: string, max?: number): string[];
|
|
281
|
-
/**
|
|
282
|
-
|
|
303
|
+
/** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
|
|
304
|
+
export declare function rangeGateDiff(base: string, cwd: string, head?: string): GateDiff;
|
|
305
|
+
/** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the
|
|
306
|
+
* synthesis budget. Bounded-prompt use only; gates use rangeGateDiff. */
|
|
283
307
|
export declare function rangeDiff(base: string, cwd: string, head?: string, maxBytes?: number): string;
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
308
|
+
/** Complete gating diff of the staged changes (pre-commit `hunch check`). */
|
|
309
|
+
export declare function stagedGateDiff(cwd: string): GateDiff;
|
|
310
|
+
/** Unified diff of the staged changes, noise-excluded and capped at the synthesis
|
|
311
|
+
* budget. Bounded-prompt use only; gates use stagedGateDiff. */
|
|
287
312
|
export declare function stagedDiff(cwd: string, maxBytes?: number): string;
|
|
288
|
-
/**
|
|
313
|
+
/** Complete gating diff of the local working tree vs HEAD. Git's normal diff
|
|
289
314
|
* includes both staged and unstaged tracked edits; untracked text files are
|
|
290
|
-
* appended as synthetic additions so guards can also see their added
|
|
291
|
-
* Binary
|
|
292
|
-
*
|
|
315
|
+
* appended as synthetic additions so guards can also see their added lines.
|
|
316
|
+
* Binary files and non-regular paths (symlinks) contribute no content, as in a
|
|
317
|
+
* git diff; an untracked regular file that cannot be read is listed in
|
|
318
|
+
* `unreadFiles`, so content checks over it fail closed. */
|
|
319
|
+
export declare function workingGateDiff(cwd: string): GateDiff;
|
|
320
|
+
/** Working-tree diff vs HEAD capped at the synthesis budget. Bounded-prompt use
|
|
321
|
+
* only; gates and rule verification use workingGateDiff. */
|
|
293
322
|
export declare function workingDiff(cwd: string, maxBytes?: number): string;
|
|
294
323
|
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
295
324
|
* date of that commit — the instant valid-time windows are filtered against.
|