@abelxiaoxing/cadence 1.0.0 → 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/README.md +50 -22
- package/package.json +3 -1
- package/prompts/abel-design.md +1 -0
- package/prompts/abel-diagnose.md +1 -0
- package/prompts/abel-implement.md +14 -2
- package/skills/abel-workflow/SKILL.md +7 -1
- package/src/candidate-preflight.ts +1163 -0
- package/src/child-session.ts +98 -10
- package/src/contracts.ts +379 -48
- package/src/file-snapshot.ts +62 -8
- package/src/index.ts +191 -14
- package/src/parent-payload-bridge.ts +442 -0
- package/src/parent-provider.ts +124 -16
- package/src/patch.ts +73 -3
- package/src/result-store.ts +40 -3
- package/src/runtime.ts +1013 -60
- package/src/scheduler.ts +47 -10
- package/src/scoped-tools.ts +27 -6
- package/src/subagent-activity.ts +747 -0
- package/src/submit-tool.ts +3 -1
- package/src/worker.ts +136 -26
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
|
}
|
package/src/file-snapshot.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// File-level snapshots: content hashes for observed files, deterministic
|
|
2
|
-
//
|
|
2
|
+
// recursive content manifests for observed directories, and explicit absent
|
|
3
3
|
// markers for proposed new paths. Currency is decided per bound path; no
|
|
4
4
|
// global workspace revision participates.
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readlinkSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
8
15
|
|
|
9
16
|
export interface FileBound {
|
|
10
17
|
kind: "file";
|
|
@@ -37,7 +44,57 @@ export function snapshotFile(root: string, relPath: string): FileBound | null {
|
|
|
37
44
|
return { sha256: sha256(bytes), bytes: bytes.length, kind: "file" };
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
function updateManifest(
|
|
48
|
+
hash: ReturnType<typeof createHash>,
|
|
49
|
+
...fields: Array<string | Buffer>
|
|
50
|
+
): void {
|
|
51
|
+
for (const field of fields) {
|
|
52
|
+
const bytes = typeof field === "string" ? Buffer.from(field) : field;
|
|
53
|
+
hash.update(`${bytes.length}:`);
|
|
54
|
+
hash.update(bytes);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recursiveDirectoryManifest(abs: string): string {
|
|
59
|
+
const hash = createHash("sha256");
|
|
60
|
+
updateManifest(hash, "cadence-directory-manifest-v2");
|
|
61
|
+
const visit = (directory: string, prefix: string): void => {
|
|
62
|
+
const names = readdirSync(directory).sort();
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
65
|
+
const current = join(directory, name);
|
|
66
|
+
const stat = lstatSync(current);
|
|
67
|
+
if (stat.isDirectory()) {
|
|
68
|
+
updateManifest(hash, "dir", relative, String(stat.mode & 0o777));
|
|
69
|
+
visit(current, relative);
|
|
70
|
+
} else if (stat.isFile()) {
|
|
71
|
+
const bytes = readFileSync(current);
|
|
72
|
+
updateManifest(
|
|
73
|
+
hash,
|
|
74
|
+
"file",
|
|
75
|
+
relative,
|
|
76
|
+
String(stat.mode & 0o777),
|
|
77
|
+
String(bytes.length),
|
|
78
|
+
sha256(bytes),
|
|
79
|
+
);
|
|
80
|
+
} else if (stat.isSymbolicLink()) {
|
|
81
|
+
updateManifest(hash, "symlink", relative, readlinkSync(current));
|
|
82
|
+
} else {
|
|
83
|
+
updateManifest(
|
|
84
|
+
hash,
|
|
85
|
+
"special",
|
|
86
|
+
relative,
|
|
87
|
+
String(stat.mode),
|
|
88
|
+
String(stat.size),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
visit(abs, "");
|
|
94
|
+
return hash.digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Deterministic recursive directory manifest bound to installed content. */
|
|
41
98
|
export function snapshotDirManifest(
|
|
42
99
|
root: string,
|
|
43
100
|
relDir: string,
|
|
@@ -45,10 +102,7 @@ export function snapshotDirManifest(
|
|
|
45
102
|
const abs = resolve(root, relDir);
|
|
46
103
|
const st = statSync(abs, { throwIfNoEntry: false });
|
|
47
104
|
if (!st?.isDirectory()) return null;
|
|
48
|
-
|
|
49
|
-
.map((e) => `${e.name}:${e.isDirectory() ? "dir" : "file"}`)
|
|
50
|
-
.sort();
|
|
51
|
-
return { manifest: sha256(Buffer.from(entries.join("\n"))), kind: "dir" };
|
|
105
|
+
return { manifest: recursiveDirectoryManifest(abs), kind: "dir" };
|
|
52
106
|
}
|
|
53
107
|
|
|
54
108
|
/** Build a bound map for the given file paths, observed directories, and absent markers. */
|