@abelxiaoxing/cadence 1.0.1 → 1.0.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/package.json +1 -1
- package/prompts/abel-implement.md +13 -2
- package/skills/abel-workflow/SKILL.md +7 -1
- package/src/candidate-preflight.ts +1163 -0
- package/src/child-session.ts +46 -5
- package/src/contracts.ts +379 -48
- package/src/file-snapshot.ts +62 -8
- package/src/index.ts +55 -10
- package/src/parent-payload-bridge.ts +442 -0
- package/src/parent-provider.ts +91 -12
- package/src/patch.ts +73 -3
- package/src/result-store.ts +40 -3
- package/src/runtime.ts +775 -66
- package/src/scheduler.ts +1 -0
- package/src/scoped-tools.ts +27 -6
- package/src/submit-tool.ts +3 -1
- package/src/worker.ts +136 -26
package/src/child-session.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import type { Model, Usage } from "@earendil-works/pi-ai";
|
|
2
3
|
import {
|
|
3
4
|
createAgentSession,
|
|
@@ -66,9 +67,22 @@ export class UsageAggregator {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
function wrapScopedTools(
|
|
70
|
+
function wrapScopedTools(
|
|
71
|
+
roots: string[],
|
|
72
|
+
cwd: string,
|
|
73
|
+
allowedPaths?: string[],
|
|
74
|
+
) {
|
|
70
75
|
const order = ["read", "grep", "find", "ls"];
|
|
71
|
-
return createScopedTools({
|
|
76
|
+
return createScopedTools({
|
|
77
|
+
roots,
|
|
78
|
+
...(allowedPaths
|
|
79
|
+
? {
|
|
80
|
+
allowedPaths: allowedPaths.map((relative) =>
|
|
81
|
+
path.resolve(cwd, relative),
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
})
|
|
72
86
|
.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
|
|
73
87
|
.map((scoped) =>
|
|
74
88
|
defineTool({
|
|
@@ -100,10 +114,12 @@ export async function runChildSession(input: {
|
|
|
100
114
|
model: Model<string>;
|
|
101
115
|
systemPrompt: string;
|
|
102
116
|
requestId: string;
|
|
117
|
+
taskId?: string;
|
|
103
118
|
role: string;
|
|
104
119
|
phase?: string;
|
|
105
120
|
output: "evidence" | "diff";
|
|
106
121
|
roots: string[];
|
|
122
|
+
allowedPaths?: string[];
|
|
107
123
|
timeoutMs: number;
|
|
108
124
|
signal?: AbortSignal;
|
|
109
125
|
}): Promise<
|
|
@@ -120,17 +136,19 @@ export async function runChildSession(input: {
|
|
|
120
136
|
ok: false;
|
|
121
137
|
error: string;
|
|
122
138
|
failureKind: ChildFailureKind;
|
|
139
|
+
transportFailure: boolean;
|
|
123
140
|
disposeCount: number;
|
|
124
141
|
classification: SubmitClassification;
|
|
125
142
|
}
|
|
126
143
|
> {
|
|
127
144
|
const submit = createSubmitTool({
|
|
128
145
|
requestId: input.requestId,
|
|
146
|
+
taskId: input.taskId,
|
|
129
147
|
role: input.role,
|
|
130
148
|
phase: input.phase ?? "red",
|
|
131
149
|
output: input.output,
|
|
132
150
|
});
|
|
133
|
-
const readTools = wrapScopedTools(input.roots);
|
|
151
|
+
const readTools = wrapScopedTools(input.roots, input.cwd, input.allowedPaths);
|
|
134
152
|
const customTools = [...readTools, submit.tool];
|
|
135
153
|
const toolNames = customTools.map((tool) => tool.name);
|
|
136
154
|
let disposeCount = 0;
|
|
@@ -185,6 +203,23 @@ export async function runChildSession(input: {
|
|
|
185
203
|
identity: submit.getIdentity(),
|
|
186
204
|
};
|
|
187
205
|
};
|
|
206
|
+
const transportFailed = (): boolean => {
|
|
207
|
+
const final = session?.messages
|
|
208
|
+
.filter((message) => message.role === "assistant")
|
|
209
|
+
.at(-1);
|
|
210
|
+
if (final === undefined) return true;
|
|
211
|
+
if (final.stopReason === "error") {
|
|
212
|
+
return (
|
|
213
|
+
submit.getAttempts() === 0 &&
|
|
214
|
+
!final.content.some(
|
|
215
|
+
(content) =>
|
|
216
|
+
content.type === "toolCall" &&
|
|
217
|
+
content.name === "abel_submit_result",
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return final.stopReason === "aborted";
|
|
222
|
+
};
|
|
188
223
|
try {
|
|
189
224
|
abort.signal.throwIfAborted();
|
|
190
225
|
const creation = createAgentSession({
|
|
@@ -239,11 +274,13 @@ export async function runChildSession(input: {
|
|
|
239
274
|
const attempts = submit.getAttempts();
|
|
240
275
|
const classification = classifySession();
|
|
241
276
|
if (!result || attempts !== 1) {
|
|
277
|
+
const transportFailure = transportFailed();
|
|
242
278
|
disposeOnce();
|
|
243
279
|
return {
|
|
244
280
|
ok: false,
|
|
245
281
|
error: "child did not retain exactly one structural submission",
|
|
246
282
|
failureKind: "failed",
|
|
283
|
+
transportFailure,
|
|
247
284
|
disposeCount,
|
|
248
285
|
classification,
|
|
249
286
|
};
|
|
@@ -262,6 +299,7 @@ export async function runChildSession(input: {
|
|
|
262
299
|
ok: false,
|
|
263
300
|
error: "final assistant message is not one structural submit",
|
|
264
301
|
failureKind: "failed",
|
|
302
|
+
transportFailure: false,
|
|
265
303
|
disposeCount,
|
|
266
304
|
classification,
|
|
267
305
|
};
|
|
@@ -277,19 +315,22 @@ export async function runChildSession(input: {
|
|
|
277
315
|
classification,
|
|
278
316
|
};
|
|
279
317
|
} catch (error) {
|
|
280
|
-
disposeOnce();
|
|
281
318
|
const failureKind: ChildFailureKind = timedOut
|
|
282
319
|
? "timed-out"
|
|
283
320
|
: input.signal !== undefined &&
|
|
284
321
|
(input.signal.aborted || abort.signal.reason === input.signal.reason)
|
|
285
322
|
? "cancelled"
|
|
286
323
|
: "failed";
|
|
324
|
+
const classification = classifySession();
|
|
325
|
+
const transportFailure = transportFailed();
|
|
326
|
+
disposeOnce();
|
|
287
327
|
return {
|
|
288
328
|
ok: false,
|
|
289
329
|
error: (error as Error).message,
|
|
290
330
|
failureKind,
|
|
331
|
+
transportFailure,
|
|
291
332
|
disposeCount,
|
|
292
|
-
classification
|
|
333
|
+
classification,
|
|
293
334
|
};
|
|
294
335
|
} finally {
|
|
295
336
|
clearTimeout(timer);
|
package/src/contracts.ts
CHANGED
|
@@ -21,6 +21,20 @@ export const PHASES = [
|
|
|
21
21
|
"review",
|
|
22
22
|
] as const;
|
|
23
23
|
|
|
24
|
+
export const RECOVERY_CODES = [
|
|
25
|
+
"mechanical-redispatch-exhausted",
|
|
26
|
+
"implementation-artifact-delivery-blocked",
|
|
27
|
+
"environment-blocked",
|
|
28
|
+
"design-required",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type RecoveryCode = (typeof RECOVERY_CODES)[number];
|
|
32
|
+
export type RecoveryNext =
|
|
33
|
+
| "finish-unaffected"
|
|
34
|
+
| "correct-artifact"
|
|
35
|
+
| "repair-environment"
|
|
36
|
+
| "return-to-design";
|
|
37
|
+
|
|
24
38
|
export const LIMITS = {
|
|
25
39
|
maxActiveChildSessions: 4,
|
|
26
40
|
maxRequestsPerBatch: 8,
|
|
@@ -30,10 +44,20 @@ export const LIMITS = {
|
|
|
30
44
|
} as const;
|
|
31
45
|
|
|
32
46
|
const NONCANONICAL = /(^|\/)\.\.(\/|$)|(^|\/)\/|^\//;
|
|
47
|
+
const SNAPSHOT_SHA256 = /^[a-f0-9]{64}$/;
|
|
48
|
+
|
|
49
|
+
export interface VerificationContract {
|
|
50
|
+
id: string;
|
|
51
|
+
argv: string[];
|
|
52
|
+
classification: "expected-red" | "expected-green" | "expected-refactor";
|
|
53
|
+
expectedFailure?: string;
|
|
54
|
+
minTests: number;
|
|
55
|
+
}
|
|
33
56
|
|
|
34
57
|
export interface RequestEnvelope {
|
|
35
58
|
stage: string;
|
|
36
59
|
role: string;
|
|
60
|
+
taskId?: string;
|
|
37
61
|
id: string;
|
|
38
62
|
phase: string;
|
|
39
63
|
objective: string;
|
|
@@ -47,9 +71,23 @@ export interface RequestEnvelope {
|
|
|
47
71
|
verificationLock?: string;
|
|
48
72
|
};
|
|
49
73
|
output: string;
|
|
74
|
+
verification?: VerificationContract;
|
|
50
75
|
snapshot?: unknown;
|
|
51
76
|
}
|
|
52
77
|
|
|
78
|
+
export interface RecoveryRecord {
|
|
79
|
+
code: RecoveryCode;
|
|
80
|
+
taskId: string;
|
|
81
|
+
requestId: string;
|
|
82
|
+
phase: string;
|
|
83
|
+
launchIndex: 0 | 1;
|
|
84
|
+
branchBlocked: true;
|
|
85
|
+
dependentsBlocked: true;
|
|
86
|
+
partialResultUsable: false;
|
|
87
|
+
independentResultsPreserved: true;
|
|
88
|
+
next: RecoveryNext;
|
|
89
|
+
}
|
|
90
|
+
|
|
53
91
|
export function isValidRelativePath(p: unknown): p is string {
|
|
54
92
|
return (
|
|
55
93
|
typeof p === "string" &&
|
|
@@ -63,6 +101,115 @@ export function isValidRelativePath(p: unknown): p is string {
|
|
|
63
101
|
);
|
|
64
102
|
}
|
|
65
103
|
|
|
104
|
+
export function validateVerificationContract(
|
|
105
|
+
value: unknown,
|
|
106
|
+
): { ok: true; value: VerificationContract } | { ok: false; reason: string } {
|
|
107
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
108
|
+
return { ok: false, reason: "invalid verification contract" };
|
|
109
|
+
const contract = value as Record<string, unknown>;
|
|
110
|
+
if (
|
|
111
|
+
typeof contract.id !== "string" ||
|
|
112
|
+
contract.id.length === 0 ||
|
|
113
|
+
contract.id.length > 128 ||
|
|
114
|
+
!Array.isArray(contract.argv) ||
|
|
115
|
+
contract.argv.some(
|
|
116
|
+
(part) =>
|
|
117
|
+
typeof part !== "string" ||
|
|
118
|
+
part.length === 0 ||
|
|
119
|
+
/[;&|`$<>\n\r]/u.test(part) ||
|
|
120
|
+
part.includes("\0"),
|
|
121
|
+
) ||
|
|
122
|
+
!["expected-red", "expected-green", "expected-refactor"].includes(
|
|
123
|
+
String(contract.classification),
|
|
124
|
+
) ||
|
|
125
|
+
!Number.isSafeInteger(contract.minTests) ||
|
|
126
|
+
(contract.minTests as number) < 1
|
|
127
|
+
) {
|
|
128
|
+
return { ok: false, reason: "invalid verification contract" };
|
|
129
|
+
}
|
|
130
|
+
const argv = contract.argv as string[];
|
|
131
|
+
const check =
|
|
132
|
+
argv.length === 3 &&
|
|
133
|
+
argv[0] === "bun" &&
|
|
134
|
+
argv[1] === "run" &&
|
|
135
|
+
argv[2] === "check";
|
|
136
|
+
const target =
|
|
137
|
+
argv.length >= 4 &&
|
|
138
|
+
argv[0] === "bun" &&
|
|
139
|
+
argv[1] === "run" &&
|
|
140
|
+
argv[2] === "test:target" &&
|
|
141
|
+
argv
|
|
142
|
+
.slice(3)
|
|
143
|
+
.every((part) => part.startsWith("test/") && isValidRelativePath(part));
|
|
144
|
+
if (!check && !target)
|
|
145
|
+
return { ok: false, reason: "verification argv is not approved" };
|
|
146
|
+
if (
|
|
147
|
+
contract.classification === "expected-red"
|
|
148
|
+
? typeof contract.expectedFailure !== "string" ||
|
|
149
|
+
contract.expectedFailure.length === 0
|
|
150
|
+
: contract.expectedFailure !== undefined
|
|
151
|
+
) {
|
|
152
|
+
return { ok: false, reason: "invalid expected failure identity" };
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, value: contract as unknown as VerificationContract };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function validateImplementationSnapshot(
|
|
158
|
+
snapshot: unknown,
|
|
159
|
+
paths: string[],
|
|
160
|
+
): string | null {
|
|
161
|
+
if (
|
|
162
|
+
snapshot === undefined ||
|
|
163
|
+
snapshot === null ||
|
|
164
|
+
typeof snapshot !== "object" ||
|
|
165
|
+
Array.isArray(snapshot)
|
|
166
|
+
) {
|
|
167
|
+
return "invalid snapshot";
|
|
168
|
+
}
|
|
169
|
+
const bounds = snapshot as Record<string, unknown>;
|
|
170
|
+
for (const [path, value] of Object.entries(bounds)) {
|
|
171
|
+
if (
|
|
172
|
+
!isValidRelativePath(path) ||
|
|
173
|
+
value === null ||
|
|
174
|
+
typeof value !== "object" ||
|
|
175
|
+
Array.isArray(value)
|
|
176
|
+
) {
|
|
177
|
+
return "invalid snapshot";
|
|
178
|
+
}
|
|
179
|
+
const entry = value as Record<string, unknown>;
|
|
180
|
+
if (entry.kind === "file") {
|
|
181
|
+
if (
|
|
182
|
+
typeof entry.sha256 !== "string" ||
|
|
183
|
+
!SNAPSHOT_SHA256.test(entry.sha256) ||
|
|
184
|
+
typeof entry.bytes !== "number" ||
|
|
185
|
+
!Number.isSafeInteger(entry.bytes) ||
|
|
186
|
+
entry.bytes < 0
|
|
187
|
+
) {
|
|
188
|
+
return "invalid snapshot";
|
|
189
|
+
}
|
|
190
|
+
} else if (entry.kind === "dir") {
|
|
191
|
+
if (
|
|
192
|
+
typeof entry.manifest !== "string" ||
|
|
193
|
+
!SNAPSHOT_SHA256.test(entry.manifest)
|
|
194
|
+
) {
|
|
195
|
+
return "invalid snapshot";
|
|
196
|
+
}
|
|
197
|
+
} else if (entry.kind === "absent") {
|
|
198
|
+
if (entry.absent !== true) return "invalid snapshot";
|
|
199
|
+
} else {
|
|
200
|
+
return "invalid snapshot";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
[...new Set(paths)].some(
|
|
205
|
+
(path) => !Object.prototype.hasOwnProperty.call(bounds, path),
|
|
206
|
+
)
|
|
207
|
+
) {
|
|
208
|
+
return "snapshot does not cover declared paths";
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
66
213
|
export function validateRequestEnvelope(
|
|
67
214
|
value: unknown,
|
|
68
215
|
): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
|
|
@@ -98,6 +245,23 @@ export function validateRequestEnvelope(
|
|
|
98
245
|
) {
|
|
99
246
|
return { ok: false, reason: "invalid request id" };
|
|
100
247
|
}
|
|
248
|
+
if (
|
|
249
|
+
env.taskId !== undefined &&
|
|
250
|
+
(typeof env.taskId !== "string" ||
|
|
251
|
+
env.taskId.length === 0 ||
|
|
252
|
+
env.taskId.length > 128)
|
|
253
|
+
) {
|
|
254
|
+
return { ok: false, reason: "invalid task id" };
|
|
255
|
+
}
|
|
256
|
+
// Implementation phases use a phase-local request id and carry their stable
|
|
257
|
+
// task identity explicitly. Other packets keep id as task id.
|
|
258
|
+
if (
|
|
259
|
+
env.stage === "abel-implement" &&
|
|
260
|
+
env.output === "diff" &&
|
|
261
|
+
env.taskId === undefined
|
|
262
|
+
) {
|
|
263
|
+
return { ok: false, reason: "missing required field: taskId" };
|
|
264
|
+
}
|
|
101
265
|
if (
|
|
102
266
|
typeof env.phase !== "string" ||
|
|
103
267
|
!(PHASES as readonly string[]).includes(env.phase)
|
|
@@ -155,6 +319,45 @@ export function validateRequestEnvelope(
|
|
|
155
319
|
if (!(OUTPUT_KINDS as readonly string[]).includes(env.output as string)) {
|
|
156
320
|
return { ok: false, reason: `invalid output kind: ${String(env.output)}` };
|
|
157
321
|
}
|
|
322
|
+
if (env.verification !== undefined) {
|
|
323
|
+
const verification = validateVerificationContract(env.verification);
|
|
324
|
+
if (!verification.ok) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: verification.reason,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (env.stage === "abel-implement" && env.output === "diff") {
|
|
332
|
+
if (env.verification === undefined) {
|
|
333
|
+
return { ok: false, reason: "missing required field: verification" };
|
|
334
|
+
}
|
|
335
|
+
const expectedClassification = {
|
|
336
|
+
red: "expected-red",
|
|
337
|
+
green: "expected-green",
|
|
338
|
+
refactor: "expected-refactor",
|
|
339
|
+
}[String(env.phase)];
|
|
340
|
+
if (
|
|
341
|
+
expectedClassification === undefined ||
|
|
342
|
+
(env.verification as VerificationContract).classification !==
|
|
343
|
+
expectedClassification
|
|
344
|
+
) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
reason:
|
|
348
|
+
"verification classification does not match implementation phase",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (env.stage === "abel-implement" && env.output === "diff") {
|
|
353
|
+
const snapshotReason = validateImplementationSnapshot(env.snapshot, [
|
|
354
|
+
...(decl.read as string[]),
|
|
355
|
+
...(decl.write as string[]),
|
|
356
|
+
]);
|
|
357
|
+
if (snapshotReason !== null) {
|
|
358
|
+
return { ok: false, reason: snapshotReason };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
158
361
|
if (
|
|
159
362
|
env.snapshot !== undefined &&
|
|
160
363
|
(env.snapshot === null || typeof env.snapshot !== "object")
|
|
@@ -172,68 +375,196 @@ export function diffWritePaths(diffText: string): {
|
|
|
172
375
|
if (typeof diffText !== "string" || diffText.length === 0) {
|
|
173
376
|
throw new Error("empty diff");
|
|
174
377
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const lines = diffText.split("\n");
|
|
179
|
-
if (lines.some((line) => /^copy (from|to) /.test(line))) {
|
|
180
|
-
throw new Error("copy records are not supported");
|
|
181
|
-
}
|
|
182
|
-
if (lines.some((line) => /^rename (from|to) /.test(line))) {
|
|
183
|
-
throw new Error("rename records are not supported");
|
|
184
|
-
}
|
|
185
|
-
const paths: string[] = [];
|
|
186
|
-
let sawHeader = false;
|
|
187
|
-
let pendingOld: string | null = null;
|
|
378
|
+
const hasFinalLf = diffText.endsWith("\n");
|
|
379
|
+
const lines = (hasFinalLf ? diffText.slice(0, -1) : diffText).split("\n");
|
|
380
|
+
|
|
188
381
|
for (const line of lines) {
|
|
189
|
-
if (/^
|
|
382
|
+
if (/^(?:GIT binary patch|Binary files )/.test(line)) {
|
|
383
|
+
throw new Error("binary diff is not supported");
|
|
384
|
+
}
|
|
385
|
+
if (/^(?:copy|rename) (?:from|to) /.test(line)) {
|
|
386
|
+
throw new Error(`${line.split(" ", 1)[0]} records are not supported`);
|
|
387
|
+
}
|
|
388
|
+
if (/^(?:old|new) mode /.test(line)) {
|
|
190
389
|
throw new Error("mode transitions are not supported");
|
|
191
390
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
391
|
+
const mode = /^(?:new file mode|deleted file mode) (.+)$/.exec(line);
|
|
392
|
+
if (mode) requireRegularFileMode(mode[1]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const paths: string[] = [];
|
|
396
|
+
let i = 0;
|
|
397
|
+
while (i < lines.length) {
|
|
398
|
+
if (!isFileSectionStart(lines[i])) {
|
|
399
|
+
if (paths.length === 0) {
|
|
400
|
+
throw new Error("diff has no ordinary text headers");
|
|
199
401
|
}
|
|
200
|
-
|
|
402
|
+
throw new Error(`unconsumed diff content: ${lines[i]}`);
|
|
201
403
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
404
|
+
|
|
405
|
+
let gitTarget: string | null = null;
|
|
406
|
+
if (lines[i].startsWith("diff --git ")) {
|
|
407
|
+
gitTarget = parseGitTarget(lines[i]);
|
|
408
|
+
i++;
|
|
205
409
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
410
|
+
|
|
411
|
+
let operation: "add" | "delete" | null = null;
|
|
412
|
+
const mode = /^(new file mode|deleted file mode) (.+)$/.exec(
|
|
413
|
+
lines[i] ?? "",
|
|
414
|
+
);
|
|
415
|
+
if (mode) {
|
|
416
|
+
operation = mode[1] === "new file mode" ? "add" : "delete";
|
|
417
|
+
i++;
|
|
418
|
+
}
|
|
419
|
+
if (lines[i]?.startsWith("index ")) {
|
|
420
|
+
parseIndexMetadata(lines[i]);
|
|
421
|
+
i++;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!lines[i]?.startsWith("--- ")) {
|
|
425
|
+
throw new Error("file section is missing an ordinary --- header");
|
|
426
|
+
}
|
|
427
|
+
const oldPath = parseHeaderPath(lines[i].slice(4), "a");
|
|
428
|
+
i++;
|
|
429
|
+
if (!lines[i]?.startsWith("+++ ")) {
|
|
430
|
+
throw new Error("diff header pair is malformed");
|
|
431
|
+
}
|
|
432
|
+
const newPath = parseHeaderPath(lines[i].slice(4), "b");
|
|
433
|
+
i++;
|
|
434
|
+
|
|
435
|
+
if (oldPath === null && newPath === null) {
|
|
436
|
+
throw new Error("diff header pair has no target");
|
|
437
|
+
}
|
|
438
|
+
if (oldPath !== null && newPath !== null && oldPath !== newPath) {
|
|
439
|
+
throw new Error("mismatched old/new diff targets");
|
|
440
|
+
}
|
|
441
|
+
const target = oldPath ?? newPath;
|
|
442
|
+
if (target === null) throw new Error("diff header pair has no target");
|
|
443
|
+
if (gitTarget !== null && gitTarget !== target) {
|
|
444
|
+
throw new Error("mismatched diff --git and text targets");
|
|
445
|
+
}
|
|
446
|
+
if (
|
|
447
|
+
(operation === "add" && oldPath !== null) ||
|
|
448
|
+
(operation === "delete" && newPath !== null)
|
|
449
|
+
) {
|
|
450
|
+
throw new Error("create/delete metadata does not match text headers");
|
|
451
|
+
}
|
|
452
|
+
if (paths.includes(target)) throw new Error("duplicate diff targets");
|
|
453
|
+
paths.push(target);
|
|
454
|
+
|
|
455
|
+
let sawHunk = false;
|
|
456
|
+
while (lines[i]?.startsWith("@@")) {
|
|
457
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(
|
|
458
|
+
lines[i],
|
|
459
|
+
);
|
|
460
|
+
if (!hunk) throw new Error(`invalid hunk header: ${lines[i]}`);
|
|
461
|
+
const range = [
|
|
462
|
+
Number(hunk[1]),
|
|
463
|
+
Number(hunk[2] ?? "1"),
|
|
464
|
+
Number(hunk[3]),
|
|
465
|
+
Number(hunk[4] ?? "1"),
|
|
466
|
+
];
|
|
467
|
+
if (!range.every(Number.isSafeInteger)) {
|
|
468
|
+
throw new Error("hunk range exceeds supported integer bounds");
|
|
469
|
+
}
|
|
470
|
+
let oldRemaining = range[1];
|
|
471
|
+
let newRemaining = range[3];
|
|
472
|
+
sawHunk = true;
|
|
473
|
+
i++;
|
|
474
|
+
|
|
475
|
+
while (oldRemaining > 0 || newRemaining > 0) {
|
|
476
|
+
if (i >= lines.length) throw new Error("hunk body underflow");
|
|
477
|
+
const body = lines[i];
|
|
478
|
+
switch (body[0]) {
|
|
479
|
+
case " ":
|
|
480
|
+
if (oldRemaining === 0 || newRemaining === 0) {
|
|
481
|
+
throw new Error("hunk body overflow");
|
|
482
|
+
}
|
|
483
|
+
oldRemaining--;
|
|
484
|
+
newRemaining--;
|
|
485
|
+
break;
|
|
486
|
+
case "-":
|
|
487
|
+
if (oldRemaining === 0) throw new Error("hunk body overflow");
|
|
488
|
+
oldRemaining--;
|
|
489
|
+
break;
|
|
490
|
+
case "+":
|
|
491
|
+
if (newRemaining === 0) throw new Error("hunk body overflow");
|
|
492
|
+
newRemaining--;
|
|
493
|
+
break;
|
|
494
|
+
default:
|
|
495
|
+
throw new Error("hunk body underflow");
|
|
218
496
|
}
|
|
497
|
+
i++;
|
|
498
|
+
if (lines[i] === "\") i++;
|
|
219
499
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
sawHeader = true;
|
|
500
|
+
}
|
|
501
|
+
if (!sawHunk) {
|
|
502
|
+
throw new Error("file section has no valid unified-diff hunk");
|
|
224
503
|
}
|
|
225
504
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
505
|
+
|
|
506
|
+
if (!hasFinalLf) throw new Error("diff must end with LF");
|
|
229
507
|
return { paths, kind: "text" };
|
|
230
508
|
}
|
|
231
509
|
|
|
232
|
-
function
|
|
510
|
+
function isFileSectionStart(line: string | undefined): boolean {
|
|
511
|
+
return (
|
|
512
|
+
line?.startsWith("diff --git ") === true ||
|
|
513
|
+
line?.startsWith("new file mode ") === true ||
|
|
514
|
+
line?.startsWith("deleted file mode ") === true ||
|
|
515
|
+
line?.startsWith("index ") === true ||
|
|
516
|
+
line?.startsWith("--- ") === true
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function parseGitTarget(line: string): string {
|
|
521
|
+
const prefix = "diff --git a/";
|
|
522
|
+
if (!line.startsWith(prefix)) throw new Error("malformed diff --git header");
|
|
523
|
+
const rest = line.slice(prefix.length);
|
|
524
|
+
for (
|
|
525
|
+
let separator = rest.indexOf(" b/");
|
|
526
|
+
separator >= 0;
|
|
527
|
+
separator = rest.indexOf(" b/", separator + 1)
|
|
528
|
+
) {
|
|
529
|
+
const oldPath = rest.slice(0, separator);
|
|
530
|
+
const newPath = rest.slice(separator + 3);
|
|
531
|
+
if (oldPath === newPath) return requireCanonicalDiffPath(oldPath);
|
|
532
|
+
}
|
|
533
|
+
throw new Error("mismatched diff --git targets");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function parseIndexMetadata(line: string): void {
|
|
537
|
+
const index = /^index [0-9a-f]+\.\.[0-9a-f]+(?: ([0-7]{6}))?$/.exec(line);
|
|
538
|
+
if (!index) throw new Error("malformed index metadata");
|
|
539
|
+
if (index[1]) requireRegularFileMode(index[1]);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function requireRegularFileMode(mode: string): void {
|
|
543
|
+
if (mode === "160000") {
|
|
544
|
+
throw new Error("submodule mode is not supported: 160000");
|
|
545
|
+
}
|
|
546
|
+
if (mode !== "100644" && mode !== "100755") {
|
|
547
|
+
throw new Error(`non-regular file mode is not supported: ${mode}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parseHeaderPath(header: string, prefix: "a" | "b"): string | null {
|
|
233
552
|
if (header === "/dev/null") return null;
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
236
|
-
throw new Error(
|
|
553
|
+
const other = prefix === "a" ? "b" : "a";
|
|
554
|
+
if (header.startsWith(`${other}/`)) {
|
|
555
|
+
throw new Error("mismatched old/new diff header prefixes");
|
|
556
|
+
}
|
|
557
|
+
const path = header.startsWith(`${prefix}/`) ? header.slice(2) : header;
|
|
558
|
+
return requireCanonicalDiffPath(path);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function requireCanonicalDiffPath(path: string): string {
|
|
562
|
+
if (
|
|
563
|
+
!isValidRelativePath(path) ||
|
|
564
|
+
path.trim() !== path ||
|
|
565
|
+
/[\r\n\t]/.test(path)
|
|
566
|
+
) {
|
|
567
|
+
throw new Error(`noncanonical or escaping diff path: ${path}`);
|
|
237
568
|
}
|
|
238
569
|
return path;
|
|
239
570
|
}
|