@abelxiaoxing/cadence 1.0.1 → 1.0.3

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/patch.ts CHANGED
@@ -1,113 +1,411 @@
1
1
  import { spawn } from "node:child_process";
2
- import { diffWritePaths } from "./contracts.ts";
3
- import { isCurrent } from "./file-snapshot.ts";
2
+ import {
3
+ copyFileSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+ import { preflightCandidate } from "./candidate-preflight.ts";
13
+ import {
14
+ type AgentsCheckpointRequest,
15
+ type ApplyCandidateResult,
16
+ type CandidateFailure,
17
+ diffWritePaths,
18
+ validateAgentsCheckpointRequest,
19
+ } from "./contracts.ts";
20
+ import { type Bound, isCurrent } from "./file-snapshot.ts";
4
21
  import type { ResultStore } from "./result-store.ts";
5
22
 
6
23
  interface CommandResult {
7
24
  code: number;
8
25
  stdout: Buffer;
9
26
  stderr: Buffer;
27
+ cancelled: boolean;
10
28
  }
11
29
 
12
30
  async function git(
13
31
  root: string,
14
32
  args: string[],
15
33
  input: Buffer,
34
+ signal?: AbortSignal,
16
35
  ): Promise<CommandResult> {
17
36
  return new Promise((resolve, reject) => {
18
37
  const child = spawn("git", args, {
19
38
  cwd: root,
20
39
  shell: false,
21
40
  stdio: ["pipe", "pipe", "pipe"],
41
+ ...(signal ? { signal } : {}),
22
42
  });
23
43
  const stdout: Buffer[] = [];
24
44
  const stderr: Buffer[] = [];
45
+ let settled = false;
46
+ const finish = (result: CommandResult) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ resolve(result);
50
+ };
25
51
  child.stdout.on("data", (b: Buffer) => stdout.push(b));
26
52
  child.stderr.on("data", (b: Buffer) => stderr.push(b));
27
- child.on("error", reject);
53
+ child.on("error", (error) => {
54
+ if (signal?.aborted || error.name === "AbortError") {
55
+ finish({
56
+ code: 1,
57
+ stdout: Buffer.concat(stdout),
58
+ stderr: Buffer.concat(stderr),
59
+ cancelled: true,
60
+ });
61
+ } else {
62
+ reject(error);
63
+ }
64
+ });
28
65
  child.on("close", (code) =>
29
- resolve({
66
+ finish({
30
67
  code: code ?? 1,
31
68
  stdout: Buffer.concat(stdout),
32
69
  stderr: Buffer.concat(stderr),
70
+ cancelled: signal?.aborted ?? false,
33
71
  }),
34
72
  );
35
73
  child.stdin.end(input);
36
74
  });
37
75
  }
38
76
 
77
+ function failure(value: CandidateFailure): {
78
+ ok: false;
79
+ failure: CandidateFailure;
80
+ } {
81
+ return { ok: false, failure: value };
82
+ }
83
+
84
+ function cancellationFailure(): {
85
+ ok: false;
86
+ failure: CandidateFailure;
87
+ } {
88
+ return failure({ kind: "cancelled", code: "cancelled" });
89
+ }
90
+
91
+ type AgentsCheckpointResult =
92
+ | {
93
+ ok: true;
94
+ target: string;
95
+ agentsImpact: AgentsCheckpointRequest["agentsImpact"];
96
+ checkExitCode: 0;
97
+ applyExitCode: 0;
98
+ }
99
+ | {
100
+ ok: false;
101
+ failure: CandidateFailure;
102
+ };
103
+
104
+ const AGENTS_START = "<!-- ABEL:AGENTS-INDEX:START -->";
105
+ const AGENTS_END = "<!-- ABEL:AGENTS-INDEX:END -->";
106
+ const NONREGULAR_AGENTS_TARGET = Symbol("nonregular AGENTS target");
107
+
108
+ function managedParts(text: string): { prefix: string; suffix: string } | null {
109
+ const lines = text.split("\n");
110
+ const starts: Array<{ line: number; start: number; end: number }> = [];
111
+ const ends: Array<{ line: number; start: number; end: number }> = [];
112
+ let offset = 0;
113
+ for (const [line, value] of lines.entries()) {
114
+ const range = { line, start: offset, end: offset + value.length };
115
+ if (value === AGENTS_START) starts.push(range);
116
+ if (value === AGENTS_END) ends.push(range);
117
+ offset = range.end + 1;
118
+ }
119
+ if (
120
+ starts.length !== 1 ||
121
+ ends.length !== 1 ||
122
+ starts[0].line >= ends[0].line
123
+ ) {
124
+ return null;
125
+ }
126
+ return {
127
+ prefix: text.slice(0, starts[0].start),
128
+ suffix: text.slice(ends[0].end),
129
+ };
130
+ }
131
+
132
+ function checkpointContentIsScoped(
133
+ impact: AgentsCheckpointRequest["agentsImpact"],
134
+ before: string | null,
135
+ after: string | null,
136
+ ): boolean {
137
+ const beforeParts = before === null ? null : managedParts(before);
138
+ const afterParts = after === null ? null : managedParts(after);
139
+ if (impact === "update-existing") {
140
+ return (
141
+ beforeParts !== null &&
142
+ afterParts !== null &&
143
+ beforeParts.prefix === afterParts.prefix &&
144
+ beforeParts.suffix === afterParts.suffix
145
+ );
146
+ }
147
+ if (impact === "create-index") {
148
+ return (
149
+ before === null &&
150
+ afterParts !== null &&
151
+ afterParts.prefix.trim() === "" &&
152
+ afterParts.suffix.trim() === ""
153
+ );
154
+ }
155
+ if (beforeParts === null) return false;
156
+ if (after === null) {
157
+ return beforeParts.prefix.trim() === "" && beforeParts.suffix.trim() === "";
158
+ }
159
+ return (
160
+ managedParts(after) === null &&
161
+ after === `${beforeParts.prefix}${beforeParts.suffix}`
162
+ );
163
+ }
164
+
165
+ function regularText(
166
+ root: string,
167
+ target: string,
168
+ ): string | null | typeof NONREGULAR_AGENTS_TARGET {
169
+ const resolvedRoot = path.resolve(root);
170
+ const absolute = path.resolve(resolvedRoot, target);
171
+ if (
172
+ absolute === resolvedRoot ||
173
+ !absolute.startsWith(`${resolvedRoot}${path.sep}`)
174
+ ) {
175
+ throw new Error("AGENTS target escapes the root");
176
+ }
177
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
178
+ if (!stat) return null;
179
+ if (!stat.isFile() || stat.isSymbolicLink()) {
180
+ return NONREGULAR_AGENTS_TARGET;
181
+ }
182
+ return readFileSync(absolute, "utf8");
183
+ }
184
+
185
+ export async function applyAgentsCheckpoint(
186
+ root: string,
187
+ value: unknown,
188
+ signal?: AbortSignal,
189
+ ): Promise<AgentsCheckpointResult> {
190
+ const validation = validateAgentsCheckpointRequest(value);
191
+ if (!validation.ok) {
192
+ return failure({ kind: "artifact", code: "invalid-checkpoint-contract" });
193
+ }
194
+ const request = validation.value;
195
+ if (signal?.aborted) {
196
+ return cancellationFailure();
197
+ }
198
+ const before = regularText(root, request.agentsTarget);
199
+ if (before === NONREGULAR_AGENTS_TARGET) {
200
+ return failure({ kind: "artifact", code: "nonregular-mode" });
201
+ }
202
+ if (!isCurrent(root, request.snapshot as Bound)) {
203
+ return failure({ kind: "stale", code: "stale-snapshot" });
204
+ }
205
+ let targets: string[];
206
+ try {
207
+ targets = diffWritePaths(request.diff).paths;
208
+ } catch {
209
+ return failure({ kind: "artifact", code: "invalid-diff" });
210
+ }
211
+ if (targets.length !== 1 || targets[0] !== request.agentsTarget) {
212
+ return failure({ kind: "artifact", code: "agents-target-mismatch" });
213
+ }
214
+
215
+ let temp: string | undefined;
216
+ try {
217
+ if (
218
+ (request.agentsImpact === "create-index" && before !== null) ||
219
+ (request.agentsImpact !== "create-index" && before === null)
220
+ ) {
221
+ return failure({ kind: "artifact", code: "agents-impact-mismatch" });
222
+ }
223
+ temp = mkdtempSync(path.join(tmpdir(), "cadence-agents-checkpoint-"));
224
+ if (before !== null) {
225
+ const temporaryTarget = path.join(temp, request.agentsTarget);
226
+ mkdirSync(path.dirname(temporaryTarget), { recursive: true });
227
+ copyFileSync(path.join(root, request.agentsTarget), temporaryTarget);
228
+ }
229
+ const bytes = Buffer.from(request.diff, "utf8");
230
+ const candidateCheck = await git(
231
+ temp,
232
+ ["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
233
+ bytes,
234
+ signal,
235
+ );
236
+ if (candidateCheck.cancelled || signal?.aborted) {
237
+ return cancellationFailure();
238
+ }
239
+ if (candidateCheck.code !== 0) {
240
+ return failure({ kind: "artifact", code: "git-apply-check-failed" });
241
+ }
242
+ const candidateApply = await git(
243
+ temp,
244
+ ["apply", "--recount", "--whitespace=nowarn", "-"],
245
+ bytes,
246
+ signal,
247
+ );
248
+ if (candidateApply.cancelled || signal?.aborted) {
249
+ return cancellationFailure();
250
+ }
251
+ if (candidateApply.code !== 0) {
252
+ return failure({ kind: "artifact", code: "git-apply-failed" });
253
+ }
254
+ const after = regularText(temp, request.agentsTarget);
255
+ if (after === NONREGULAR_AGENTS_TARGET) {
256
+ return failure({ kind: "artifact", code: "nonregular-mode" });
257
+ }
258
+ if (!checkpointContentIsScoped(request.agentsImpact, before, after)) {
259
+ return failure({ kind: "artifact", code: "outside-managed-region" });
260
+ }
261
+ if (!isCurrent(root, request.snapshot as Bound)) {
262
+ return failure({ kind: "stale", code: "stale-snapshot" });
263
+ }
264
+ const check = await git(
265
+ root,
266
+ ["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
267
+ bytes,
268
+ signal,
269
+ );
270
+ if (check.cancelled || signal?.aborted) {
271
+ return cancellationFailure();
272
+ }
273
+ if (check.code !== 0) {
274
+ return failure({ kind: "stale", code: "git-apply-check-failed" });
275
+ }
276
+ const apply = await git(
277
+ root,
278
+ ["apply", "--recount", "--whitespace=nowarn", "-"],
279
+ bytes,
280
+ );
281
+ if (apply.code !== 0) {
282
+ return failure({ kind: "environment", code: "git-apply-failed" });
283
+ }
284
+ return {
285
+ ok: true,
286
+ target: request.agentsTarget,
287
+ agentsImpact: request.agentsImpact,
288
+ checkExitCode: 0,
289
+ applyExitCode: 0,
290
+ };
291
+ } finally {
292
+ if (temp) rmSync(temp, { recursive: true, force: true });
293
+ }
294
+ }
295
+
39
296
  export async function applyRetainedPatch(input: {
40
297
  root: string;
41
298
  id: string;
42
299
  store: ResultStore;
43
- }): Promise<
44
- | { ok: true; targets: string[]; checkExitCode: 0; applyExitCode: 0 }
45
- | { ok: false; error: string }
46
- > {
47
- const retained = input.store.get(input.id);
48
- if (!retained) return { ok: false, error: "retained result not found" };
300
+ signal?: AbortSignal;
301
+ }): Promise<ApplyCandidateResult> {
302
+ if (input.signal?.aborted) return cancellationFailure();
303
+ const retained = input.store.resolveForApply(input.id);
49
304
  if (retained.root !== input.root)
50
- return { ok: false, error: "retained result root mismatch" };
51
- if (!isCurrent(input.root, retained.snapshot))
52
- return { ok: false, error: "stale file snapshot" };
305
+ throw new Error("retained result root mismatch");
306
+ if (!retained.verification && !isCurrent(input.root, retained.snapshot))
307
+ return failure({ kind: "stale", code: "stale-snapshot" });
53
308
 
54
309
  let targets: string[];
55
310
  try {
56
311
  targets = diffWritePaths(retained.diff.toString("utf8")).paths;
57
- } catch (error) {
58
- return { ok: false, error: (error as Error).message };
312
+ } catch {
313
+ return failure({ kind: "artifact", code: "invalid-diff" });
59
314
  }
60
315
  if (targets.some((path) => !retained.writeSet.includes(path))) {
61
- return {
62
- ok: false,
63
- error: "patch target is outside the declared write set",
64
- };
316
+ return failure({ kind: "artifact", code: "write-set-mismatch" });
65
317
  }
66
318
 
319
+ if (retained.verification) {
320
+ if (
321
+ !retained.packageManifest ||
322
+ !retained.lockfile ||
323
+ !retained.dependencyTarget
324
+ ) {
325
+ throw new Error("retained candidate preflight inputs are incomplete");
326
+ }
327
+ const preflight = await preflightCandidate({
328
+ root: input.root,
329
+ diff: retained.diff,
330
+ writeSet: retained.writeSet,
331
+ approvedDependencies: retained.approvedDependencies,
332
+ snapshot: retained.snapshot,
333
+ baseline: retained.baseline,
334
+ verification: retained.verification,
335
+ packageManifest: retained.packageManifest,
336
+ lockfile: retained.lockfile,
337
+ dependencyTarget: retained.dependencyTarget,
338
+ signal: input.signal,
339
+ });
340
+ if (!preflight.ok) {
341
+ switch (preflight.kind) {
342
+ case "artifact":
343
+ return failure({
344
+ kind: preflight.kind,
345
+ code: preflight.code,
346
+ ...(preflight.excerpt ? { evidence: [preflight.excerpt] } : {}),
347
+ });
348
+ case "stale":
349
+ return failure({ kind: preflight.kind, code: preflight.code });
350
+ case "environment":
351
+ return failure({ kind: preflight.kind, code: preflight.code });
352
+ case "approval-boundary":
353
+ return failure({ kind: preflight.kind, code: preflight.code });
354
+ case "cancelled":
355
+ return failure({ kind: preflight.kind, code: preflight.code });
356
+ }
357
+ }
358
+ if (!isCurrent(input.root, retained.snapshot)) {
359
+ return failure({ kind: "stale", code: "stale-snapshot" });
360
+ }
361
+ }
362
+ if (input.signal?.aborted) return cancellationFailure();
363
+
67
364
  const numstat = await git(
68
365
  input.root,
69
366
  ["apply", "--numstat", "-z", "--recount", "-"],
70
367
  retained.diff,
368
+ input.signal,
71
369
  );
370
+ if (numstat.cancelled || input.signal?.aborted) return cancellationFailure();
72
371
  if (numstat.code !== 0 || numstat.stdout.includes(Buffer.from("-\t-\t"))) {
73
- return {
74
- ok: false,
75
- error: `git numstat screening failed: ${numstat.stderr.toString("utf8")}`,
76
- };
372
+ return failure({ kind: "artifact", code: "invalid-diff" });
77
373
  }
78
374
  const summary = await git(
79
375
  input.root,
80
376
  ["apply", "--summary", "--recount", "-"],
81
377
  retained.diff,
378
+ input.signal,
82
379
  );
380
+ if (summary.cancelled || input.signal?.aborted) return cancellationFailure();
83
381
  if (
84
382
  summary.code !== 0 ||
85
383
  /rename|copy|mode change|create mode 160000/i.test(
86
384
  summary.stdout.toString("utf8"),
87
385
  )
88
386
  ) {
89
- return { ok: false, error: "git summary screening rejected the patch" };
387
+ return failure({ kind: "artifact", code: "invalid-diff" });
90
388
  }
91
389
  const check = await git(
92
390
  input.root,
93
391
  ["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
94
392
  retained.diff,
393
+ input.signal,
95
394
  );
395
+ if (check.cancelled || input.signal?.aborted) return cancellationFailure();
96
396
  if (check.code !== 0)
97
- return {
98
- ok: false,
99
- error: `git apply --check failed: ${check.stderr.toString("utf8")}`,
100
- };
397
+ return failure({ kind: "artifact", code: "git-apply-check-failed" });
398
+ if (input.signal?.aborted) return cancellationFailure();
101
399
  const apply = await git(
102
400
  input.root,
103
401
  ["apply", "--recount", "--whitespace=nowarn", "-"],
104
402
  retained.diff,
105
403
  );
106
404
  if (apply.code !== 0)
107
- return {
108
- ok: false,
109
- error: `git apply failed: ${apply.stderr.toString("utf8")}`,
110
- };
405
+ return failure({ kind: "environment", code: "git-apply-failed" });
111
406
  input.store.discard(input.id);
112
- return { ok: true, targets, checkExitCode: 0, applyExitCode: 0 };
407
+ return {
408
+ ok: true,
409
+ result: { targets, checkExitCode: 0, applyExitCode: 0 },
410
+ };
113
411
  }