@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/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
- if (/^GIT binary patch|^Binary files /.test(diffText)) {
176
- throw new Error("binary diff is not supported");
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 (/^old mode /.test(line) || /^new mode /.test(line)) {
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
- if (/^(new|deleted) file mode /.test(line)) {
193
- const mode = line.split(" ").pop();
194
- if (mode === "160000") {
195
- throw new Error("submodule mode is not supported");
196
- }
197
- if (mode !== "100644" && mode !== "100755") {
198
- throw new Error(`non-regular file mode is not supported: ${mode}`);
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
- continue;
402
+ throw new Error(`unconsumed diff content: ${lines[i]}`);
201
403
  }
202
- if (line.startsWith("--- ") && !line.startsWith("--- /dev/null")) {
203
- pendingOld = parseHeaderPath(line.slice(4));
204
- continue;
404
+
405
+ let gitTarget: string | null = null;
406
+ if (lines[i].startsWith("diff --git ")) {
407
+ gitTarget = parseGitTarget(lines[i]);
408
+ i++;
205
409
  }
206
- if (line.startsWith("+++ ")) {
207
- const newPath = parseHeaderPath(line.slice(4));
208
- let target: string;
209
- if (newPath === null) {
210
- if (pendingOld === null) {
211
- throw new Error("diff header pair is malformed");
212
- }
213
- target = pendingOld;
214
- } else {
215
- target = newPath;
216
- if (pendingOld !== null && pendingOld !== newPath) {
217
- throw new Error("mismatched old/new diff targets");
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
- pendingOld = null;
221
- if (paths.includes(target)) throw new Error("duplicate diff targets");
222
- paths.push(target);
223
- sawHeader = true;
500
+ }
501
+ if (!sawHunk) {
502
+ throw new Error("file section has no valid unified-diff hunk");
224
503
  }
225
504
  }
226
- if (!sawHeader || paths.length === 0) {
227
- throw new Error("diff has no ordinary text headers");
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 parseHeaderPath(header: string): string | null {
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 path = header.replace(/^\s*(a|b)\//, "");
235
- if (!isValidRelativePath(path)) {
236
- throw new Error(`noncanonical or escaping diff path: ${header}`);
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
  }
@@ -1,10 +1,17 @@
1
1
  // File-level snapshots: content hashes for observed files, deterministic
2
- // directory-entry manifests for observed directories, and explicit absent
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 { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
- import { resolve } from "node:path";
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
- /** Deterministic directory-entry manifest: hash of sorted name/type entries. */
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
- const entries = readdirSync(abs, { withFileTypes: true })
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. */