pi-pair-programmer 0.1.0

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.
@@ -0,0 +1,840 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ lstat,
5
+ mkdtemp,
6
+ open,
7
+ opendir,
8
+ readFile,
9
+ realpath,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import path from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { z } from "zod";
17
+
18
+ const execute = promisify(execFile);
19
+ const NoMatchSchema = z.object({ code: z.literal(1), stderr: z.string() });
20
+ const MAX_FILE_BYTES = 64_000;
21
+ const MAX_ENTRIES = 10_000;
22
+ const MAX_LISTING_BYTES = 256_000_000;
23
+ const CAPTURE_TIMEOUT_MS = 5000;
24
+ const gitEnvironment = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
25
+ const excludedDirectories: Record<string, true> = {
26
+ ".git": true,
27
+ ".hg": true,
28
+ ".svn": true,
29
+ node_modules: true,
30
+ vendor: true,
31
+ dist: true,
32
+ build: true,
33
+ coverage: true,
34
+ target: true,
35
+ ".next": true,
36
+ ".nuxt": true,
37
+ out: true,
38
+ __pycache__: true,
39
+ ".venv": true,
40
+ venv: true,
41
+ Pods: true,
42
+ ".dart_tool": true,
43
+ };
44
+ const excludedFiles: Record<string, true> = {
45
+ ".npmrc": true,
46
+ ".netrc": true,
47
+ ".pypirc": true,
48
+ id_rsa: true,
49
+ id_ed25519: true,
50
+ "credentials.json": true,
51
+ "package-lock.json": true,
52
+ "yarn.lock": true,
53
+ "pnpm-lock.yaml": true,
54
+ "bun.lock": true,
55
+ "Cargo.lock": true,
56
+ "poetry.lock": true,
57
+ "uv.lock": true,
58
+ };
59
+
60
+ interface SourceFile {
61
+ file: string;
62
+ source: string;
63
+ }
64
+
65
+ interface SourceLine {
66
+ text: string;
67
+ line: number;
68
+ }
69
+
70
+ export type CaptureFailure =
71
+ | "aborted"
72
+ | "timeout"
73
+ | "root-unavailable"
74
+ | "git-failed"
75
+ | "ignore-rules-without-git"
76
+ | "entry-limit"
77
+ | "enumeration-failed"
78
+ | "storage-failed"
79
+ | "unexpected-error";
80
+
81
+ export type BaselineCapture =
82
+ | { status: "available"; baseline: TaskBaseline }
83
+ | { status: "unavailable"; reason: CaptureFailure };
84
+
85
+ type OverlayEntry =
86
+ | { kind: "copy"; name: string }
87
+ | { kind: "absent" }
88
+ | { kind: "excluded" }
89
+ | { kind: "unknown" };
90
+
91
+ interface HeadFile {
92
+ oid: string;
93
+ size: number;
94
+ }
95
+
96
+ interface PinnedHead {
97
+ toplevel: string;
98
+ files: ReadonlyMap<string, HeadFile>;
99
+ tree: string;
100
+ }
101
+
102
+ type BeforeState =
103
+ | { state: "present"; source: string }
104
+ | { state: "absent" }
105
+ | { state: "unknown" };
106
+
107
+ interface OriginSearch {
108
+ origins: SourceFile[];
109
+ complete: boolean;
110
+ }
111
+
112
+ export interface ChangeEvidence {
113
+ status: "available" | "unavailable";
114
+ before: SourceFile | null;
115
+ after: SourceFile;
116
+ diff: string | null;
117
+ origins: readonly SourceFile[];
118
+ reason: string | null;
119
+ }
120
+
121
+ class CaptureError extends Error {
122
+ readonly reason: CaptureFailure;
123
+
124
+ constructor(reason: CaptureFailure, cause?: unknown) {
125
+ super(reason, { cause });
126
+ this.reason = reason;
127
+ }
128
+ }
129
+
130
+ async function attempt<T>(
131
+ reason: CaptureFailure,
132
+ action: () => Promise<T>,
133
+ ): Promise<T> {
134
+ try {
135
+ return await action();
136
+ } catch (error) {
137
+ throw new CaptureError(reason, error);
138
+ }
139
+ }
140
+
141
+ function eligible(file: string): boolean {
142
+ const parts = file.split("/");
143
+ return (
144
+ parts.every((part) => !Object.hasOwn(excludedDirectories, part)) &&
145
+ !Object.hasOwn(excludedFiles, path.posix.basename(file)) &&
146
+ !/(?:^|\/)\.env(?:\.|$)/iu.test(file) &&
147
+ !/\.(?:pem|key|p12|pfx)$/iu.test(file)
148
+ );
149
+ }
150
+
151
+ function relativeFile(root: string, file: string): string | undefined {
152
+ const relative = path.relative(root, path.resolve(root, file));
153
+ return relative.length === 0 ||
154
+ relative === ".." ||
155
+ relative.startsWith(`..${path.sep}`) ||
156
+ path.isAbsolute(relative) ||
157
+ file.includes("\\") ||
158
+ /\p{Cc}/u.test(file)
159
+ ? undefined
160
+ : relative.split(path.sep).join("/");
161
+ }
162
+
163
+ function safeFiles(root: string, files: Iterable<string>): string[] {
164
+ const result: string[] = [];
165
+ for (const requested of files) {
166
+ const file = relativeFile(root, requested);
167
+ if (file !== undefined && eligible(file)) result.push(file);
168
+ }
169
+ return result;
170
+ }
171
+
172
+ function sourceLines(source: string): SourceLine[] {
173
+ return source.split("\n").flatMap((text, index) => {
174
+ const normalized = text.trim();
175
+ return normalized.length >= 6 && /[\p{L}_$]/u.test(normalized)
176
+ ? [{ text: normalized, line: index + 1 }]
177
+ : [];
178
+ });
179
+ }
180
+
181
+ function decodeText(bytes: Uint8Array): string | null {
182
+ return bytes.includes(0)
183
+ ? null
184
+ : new TextDecoder("utf-8", { fatal: true }).decode(bytes);
185
+ }
186
+
187
+ async function git(
188
+ cwd: string,
189
+ args: string[],
190
+ signal: AbortSignal,
191
+ ): Promise<string> {
192
+ const { stdout } = await execute("git", args, {
193
+ cwd,
194
+ env: gitEnvironment,
195
+ signal,
196
+ maxBuffer: MAX_LISTING_BYTES,
197
+ });
198
+ return stdout;
199
+ }
200
+
201
+ function nulSeparated(output: string): string[] {
202
+ return output.split("\0").filter((entry) => entry.length > 0);
203
+ }
204
+
205
+ async function readBlobs(
206
+ toplevel: string,
207
+ oids: readonly string[],
208
+ signal: AbortSignal | undefined,
209
+ ): Promise<Buffer[]> {
210
+ const pending = execute("git", ["cat-file", "--batch"], {
211
+ cwd: toplevel,
212
+ env: gitEnvironment,
213
+ encoding: "buffer",
214
+ maxBuffer: oids.length * (MAX_FILE_BYTES + 128),
215
+ ...(signal === undefined ? {} : { signal }),
216
+ });
217
+ pending.child.stdin?.end(`${oids.join("\n")}\n`);
218
+ const { stdout } = await pending;
219
+ const blobs: Buffer[] = [];
220
+ let offset = 0;
221
+ while (blobs.length < oids.length) {
222
+ const end = stdout.indexOf(10, offset);
223
+ const [, type, size] = stdout
224
+ .subarray(offset, end)
225
+ .toString("utf8")
226
+ .split(" ", 3);
227
+ if (type !== "blob") throw new Error("Git object is unavailable");
228
+ offset = end + 1 + Number(size) + 1;
229
+ blobs.push(stdout.subarray(end + 1, offset - 1));
230
+ }
231
+ return blobs;
232
+ }
233
+
234
+ function rejectUnsupported(entries: number, name: string): void {
235
+ if (entries > MAX_ENTRIES) throw new CaptureError("entry-limit");
236
+ if (name === ".gitignore") throw new CaptureError("ignore-rules-without-git");
237
+ }
238
+
239
+ async function directoryFiles(
240
+ root: string,
241
+ signal: AbortSignal,
242
+ ): Promise<string[]> {
243
+ const pending = [""];
244
+ const files: string[] = [];
245
+ let entries = 0;
246
+ for (const directory of pending) {
247
+ const contents = await attempt("enumeration-failed", () =>
248
+ opendir(path.join(root, directory)),
249
+ );
250
+ for await (const entry of contents) {
251
+ signal.throwIfAborted();
252
+ entries += 1;
253
+ rejectUnsupported(entries, entry.name);
254
+ const file = path.posix.join(directory, entry.name);
255
+ if (!eligible(file)) continue;
256
+ if (entry.isDirectory()) pending.push(file);
257
+ else files.push(file);
258
+ }
259
+ }
260
+ return files;
261
+ }
262
+
263
+ async function isWorkTree(root: string, signal: AbortSignal): Promise<boolean> {
264
+ try {
265
+ await git(root, ["rev-parse", "--is-inside-work-tree"], signal);
266
+ return true;
267
+ } catch {
268
+ signal.throwIfAborted();
269
+ return false;
270
+ }
271
+ }
272
+
273
+ async function optionalRevision(
274
+ root: string,
275
+ revision: string,
276
+ signal: AbortSignal,
277
+ ): Promise<string | undefined> {
278
+ try {
279
+ return (
280
+ await git(root, ["rev-parse", "--verify", "--quiet", revision], signal)
281
+ ).trim();
282
+ } catch {
283
+ signal.throwIfAborted();
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ interface PinnedGit {
289
+ head: PinnedHead | undefined;
290
+ dirty: Set<string>;
291
+ excluded: string[];
292
+ }
293
+
294
+ async function pinGit(root: string, signal: AbortSignal): Promise<PinnedGit> {
295
+ const toplevel = (
296
+ await git(root, ["rev-parse", "--show-toplevel"], signal)
297
+ ).trim();
298
+ const commit = await optionalRevision(root, "HEAD^{commit}", signal);
299
+ const tree =
300
+ commit === undefined
301
+ ? undefined
302
+ : await optionalRevision(root, `${commit}:./`, signal);
303
+ const dirty = new Set<string>();
304
+ for (const entry of nulSeparated(
305
+ await git(
306
+ root,
307
+ ["ls-files", "-v", "-z", "--cached", "--others", "--exclude-standard"],
308
+ signal,
309
+ ),
310
+ )) {
311
+ if (tree === undefined || !entry.startsWith("H "))
312
+ dirty.add(entry.slice(2));
313
+ }
314
+ if (commit === undefined || tree === undefined)
315
+ return { head: undefined, dirty, excluded: [] };
316
+ for (const file of nulSeparated(
317
+ await git(
318
+ root,
319
+ [
320
+ "diff",
321
+ "--name-only",
322
+ "-z",
323
+ "--no-renames",
324
+ "--ignore-submodules=all",
325
+ "--relative",
326
+ commit,
327
+ "--",
328
+ ],
329
+ signal,
330
+ ),
331
+ ))
332
+ dirty.add(file);
333
+ const files = new Map<string, HeadFile>();
334
+ const excluded: string[] = [];
335
+ for (const entry of nulSeparated(
336
+ await git(toplevel, ["ls-tree", "-r", "-l", "-z", tree], signal),
337
+ )) {
338
+ const tab = entry.indexOf("\t");
339
+ const [mode, , oid = "", size] = entry.slice(0, tab).split(/ +/u, 4);
340
+ const [file] = safeFiles(root, [entry.slice(tab + 1)]);
341
+ if (file === undefined) continue;
342
+ if (mode !== "100644" && mode !== "100755") {
343
+ excluded.push(file);
344
+ continue;
345
+ }
346
+ files.set(file, { oid, size: Number(size) });
347
+ }
348
+ return { head: { toplevel, files, tree }, dirty, excluded };
349
+ }
350
+
351
+ async function captureFile(
352
+ root: string,
353
+ file: string,
354
+ buffer: Buffer,
355
+ ): Promise<string | null | undefined> {
356
+ const absolute = path.join(root, file);
357
+ if ((await realpath(absolute)) !== absolute) {
358
+ return null;
359
+ }
360
+ const handle = await open(
361
+ absolute,
362
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
363
+ );
364
+ try {
365
+ const before = await handle.stat();
366
+ if (!before.isFile()) {
367
+ return null;
368
+ }
369
+ if (before.size > MAX_FILE_BYTES) {
370
+ return undefined;
371
+ }
372
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
373
+ const after = await handle.stat();
374
+ const stable =
375
+ bytesRead === before.size &&
376
+ after.size === before.size &&
377
+ after.mtimeMs === before.mtimeMs &&
378
+ after.ctimeMs === before.ctimeMs;
379
+ return stable ? decodeText(buffer.subarray(0, bytesRead)) : undefined;
380
+ } finally {
381
+ await handle.close();
382
+ }
383
+ }
384
+
385
+ async function exists(file: string): Promise<boolean> {
386
+ try {
387
+ await lstat(file);
388
+ return true;
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ export class TaskBaseline {
395
+ readonly root: string;
396
+ readonly requestedRoot: string;
397
+ readonly complete: boolean;
398
+ private readonly head: PinnedHead | undefined;
399
+ private readonly overlay: ReadonlyMap<string, OverlayEntry>;
400
+ private readonly excluded: ReadonlySet<string>;
401
+ private readonly storage: string;
402
+
403
+ constructor(options: {
404
+ root: string;
405
+ requestedRoot: string;
406
+ head: PinnedHead | undefined;
407
+ overlay: ReadonlyMap<string, OverlayEntry>;
408
+ excluded: ReadonlySet<string>;
409
+ storage: string;
410
+ }) {
411
+ this.root = options.root;
412
+ this.requestedRoot = options.requestedRoot;
413
+ this.head = options.head;
414
+ this.overlay = options.overlay;
415
+ this.excluded = options.excluded;
416
+ this.storage = options.storage;
417
+ this.complete = true;
418
+ for (const entry of options.overlay.values())
419
+ if (entry.kind === "unknown") this.complete = false;
420
+ }
421
+
422
+ get fileCount(): number {
423
+ let count = 0;
424
+ for (const file of this.head?.files.keys() ?? [])
425
+ if (!this.overlay.has(file)) count += 1;
426
+ for (const entry of this.overlay.values())
427
+ if (entry.kind === "copy") count += 1;
428
+ return count;
429
+ }
430
+
431
+ get overlayCount(): number {
432
+ return this.overlay.size;
433
+ }
434
+
435
+ async dispose(): Promise<void> {
436
+ try {
437
+ await rm(this.storage, { recursive: true, force: true });
438
+ } catch {
439
+ return;
440
+ }
441
+ }
442
+
443
+ unattributable(file: string): boolean {
444
+ const parts = file.split("/");
445
+ return parts.some((_, index) => {
446
+ const prefix = parts.slice(0, index + 1).join("/");
447
+ const kind = this.overlay.get(prefix)?.kind;
448
+ return kind === undefined
449
+ ? this.excluded.has(prefix)
450
+ : kind === "excluded" || kind === "unknown";
451
+ });
452
+ }
453
+
454
+ async before(file: string, signal?: AbortSignal): Promise<BeforeState> {
455
+ let source: string | null | undefined;
456
+ const entry = this.overlay.get(file);
457
+ const blob = this.head?.files.get(file);
458
+ if (entry !== undefined) {
459
+ if (entry.kind !== "copy") return { state: "absent" };
460
+ source = await this.readCopy(entry.name);
461
+ } else if (this.head === undefined || blob === undefined) {
462
+ return { state: "absent" };
463
+ } else if (blob.size <= MAX_FILE_BYTES) {
464
+ [source] = await headTexts(this.head, [blob], signal);
465
+ }
466
+ return typeof source === "string"
467
+ ? { state: "present", source }
468
+ : { state: "unknown" };
469
+ }
470
+
471
+ async origins(
472
+ file: string,
473
+ lines: readonly SourceLine[],
474
+ line: number,
475
+ signal?: AbortSignal,
476
+ ): Promise<OriginSearch> {
477
+ const blocks = [...findingBlocks(lines, line)];
478
+ const anchor = lines.find((entry) => entry.line === line)?.text;
479
+ if (blocks.length === 0 || anchor === undefined)
480
+ return { origins: [], complete: this.complete };
481
+ const candidates: SourceFile[] = [];
482
+ let complete = this.complete;
483
+ for (const [origin, entry] of this.overlay) {
484
+ if (origin === file || entry.kind !== "copy") continue;
485
+ const source = await this.readCopy(entry.name);
486
+ if (source === undefined) complete = false;
487
+ else if (source.includes(anchor))
488
+ candidates.push({ file: origin, source });
489
+ }
490
+ const head = await this.headCandidates(file, anchor, signal);
491
+ complete &&= head.complete;
492
+ candidates.push(...head.origins);
493
+ candidates.sort((left, right) => left.file.localeCompare(right.file));
494
+ return { origins: matchingOrigins(candidates, blocks), complete };
495
+ }
496
+
497
+ private async headCandidates(
498
+ file: string,
499
+ anchor: string,
500
+ signal: AbortSignal | undefined,
501
+ ): Promise<OriginSearch> {
502
+ const head = this.head;
503
+ if (head === undefined) return { origins: [], complete: true };
504
+ const hits: { file: string; blob: HeadFile }[] = [];
505
+ const search = await grepHead(head, anchor, signal);
506
+ let complete = search.complete;
507
+ for (const hit of search.hits) {
508
+ const blob = head.files.get(hit);
509
+ if (hit === file || blob === undefined || this.overlay.has(hit)) continue;
510
+ if (blob.size > MAX_FILE_BYTES) complete = false;
511
+ else hits.push({ file: hit, blob });
512
+ }
513
+ const sources = await headTexts(
514
+ head,
515
+ hits.map(({ blob }) => blob),
516
+ signal,
517
+ );
518
+ const origins: SourceFile[] = [];
519
+ for (const [index, { file: origin }] of hits.entries()) {
520
+ const source = sources.at(index);
521
+ if (typeof source === "string") origins.push({ file: origin, source });
522
+ else complete = false;
523
+ }
524
+ return { origins, complete };
525
+ }
526
+
527
+ private async readCopy(name: string): Promise<string | undefined> {
528
+ try {
529
+ return await readFile(path.join(this.storage, name), "utf8");
530
+ } catch {
531
+ return undefined;
532
+ }
533
+ }
534
+ }
535
+
536
+ async function headTexts(
537
+ head: PinnedHead,
538
+ blobs: readonly HeadFile[],
539
+ signal: AbortSignal | undefined,
540
+ ): Promise<(string | null)[]> {
541
+ if (blobs.length === 0) return [];
542
+ const contents = await readBlobs(
543
+ head.toplevel,
544
+ blobs.map((blob) => blob.oid),
545
+ signal,
546
+ );
547
+ return contents.map((bytes) => {
548
+ try {
549
+ return decodeText(bytes);
550
+ } catch {
551
+ return null;
552
+ }
553
+ });
554
+ }
555
+
556
+ async function grepHead(
557
+ head: PinnedHead,
558
+ anchor: string,
559
+ signal: AbortSignal | undefined,
560
+ ): Promise<{ hits: string[]; complete: boolean }> {
561
+ let output: { stdout: string; stderr: string };
562
+ try {
563
+ output = await execute(
564
+ "git",
565
+ ["grep", "-z", "-l", "-F", "-I", "--no-color", "-e", anchor, head.tree],
566
+ {
567
+ cwd: head.toplevel,
568
+ env: gitEnvironment,
569
+ maxBuffer: MAX_LISTING_BYTES,
570
+ ...(signal === undefined ? {} : { signal }),
571
+ },
572
+ );
573
+ } catch (error) {
574
+ const parsed = NoMatchSchema.safeParse(error);
575
+ if (!parsed.success) throw error;
576
+ output = { stdout: "", stderr: parsed.data.stderr };
577
+ }
578
+ return {
579
+ hits: nulSeparated(output.stdout).map((hit) =>
580
+ hit.slice(head.tree.length + 1),
581
+ ),
582
+ complete: output.stderr.length === 0,
583
+ };
584
+ }
585
+
586
+ async function copyOverlay(
587
+ root: string,
588
+ files: Iterable<string>,
589
+ signal: AbortSignal,
590
+ ): Promise<{
591
+ overlay: Map<string, OverlayEntry>;
592
+ storage: string;
593
+ }> {
594
+ const storage = await attempt("storage-failed", () =>
595
+ mkdtemp(path.join(tmpdir(), "pi-pair-programmer-baseline-")),
596
+ );
597
+ const overlay = new Map<string, OverlayEntry>();
598
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
599
+ try {
600
+ for (const file of files) {
601
+ signal.throwIfAborted();
602
+ if (!(await exists(path.join(root, file)))) {
603
+ overlay.set(file, { kind: "absent" });
604
+ continue;
605
+ }
606
+ let source: string | null | undefined;
607
+ try {
608
+ source = await captureFile(root, file, buffer);
609
+ } catch {
610
+ source = undefined;
611
+ }
612
+ if (typeof source !== "string") {
613
+ overlay.set(file, { kind: source === null ? "excluded" : "unknown" });
614
+ continue;
615
+ }
616
+ const name = String(overlay.size);
617
+ await attempt("storage-failed", () =>
618
+ writeFile(path.join(storage, name), source, {
619
+ mode: 0o600,
620
+ flag: "wx",
621
+ }),
622
+ );
623
+ overlay.set(file, { kind: "copy", name });
624
+ }
625
+ } catch (error) {
626
+ await rm(storage, { recursive: true, force: true });
627
+ throw error;
628
+ }
629
+ return { overlay, storage };
630
+ }
631
+
632
+ export async function captureBaseline(
633
+ root: string,
634
+ signal?: AbortSignal,
635
+ ): Promise<BaselineCapture> {
636
+ const deadline = AbortSignal.timeout(CAPTURE_TIMEOUT_MS);
637
+ const captureSignal =
638
+ signal === undefined ? deadline : AbortSignal.any([signal, deadline]);
639
+ try {
640
+ captureSignal.throwIfAborted();
641
+ const canonical = await attempt("root-unavailable", () => realpath(root));
642
+ const pinned: PinnedGit = (await isWorkTree(canonical, captureSignal))
643
+ ? await attempt("git-failed", () => pinGit(canonical, captureSignal))
644
+ : {
645
+ head: undefined,
646
+ dirty: new Set(await directoryFiles(canonical, captureSignal)),
647
+ excluded: [],
648
+ };
649
+ const { overlay, storage } = await copyOverlay(
650
+ canonical,
651
+ safeFiles(canonical, pinned.dirty),
652
+ captureSignal,
653
+ );
654
+ return {
655
+ status: "available",
656
+ baseline: new TaskBaseline({
657
+ root: canonical,
658
+ requestedRoot: path.resolve(root),
659
+ head: pinned.head,
660
+ overlay,
661
+ excluded: new Set(pinned.excluded),
662
+ storage,
663
+ }),
664
+ };
665
+ } catch (error) {
666
+ if (signal?.aborted === true)
667
+ return { status: "unavailable", reason: "aborted" };
668
+ if (deadline.aborted) return { status: "unavailable", reason: "timeout" };
669
+ return {
670
+ status: "unavailable",
671
+ reason: error instanceof CaptureError ? error.reason : "unexpected-error",
672
+ };
673
+ }
674
+ }
675
+
676
+ function* findingBlocks(
677
+ lines: readonly SourceLine[],
678
+ line: number,
679
+ ): Generator<{ lines: readonly SourceLine[]; anchor: number }> {
680
+ const anchor = lines.findIndex((entry) => entry.line === line);
681
+ if (anchor === -1) {
682
+ return;
683
+ }
684
+ for (let length = 3; length <= 5; length += 1) {
685
+ const first = Math.max(0, anchor - length + 1);
686
+ for (let start = first; start <= anchor; start += 1) {
687
+ const block = lines.slice(start, start + length);
688
+ if (
689
+ block.length === length &&
690
+ block.reduce((size, entry) => size + entry.text.length, 0) >= 96
691
+ ) {
692
+ yield { lines: block, anchor: anchor - start };
693
+ }
694
+ }
695
+ }
696
+ }
697
+
698
+ function* matchingOffsets(
699
+ source: readonly SourceLine[],
700
+ block: readonly SourceLine[],
701
+ ): Generator<number> {
702
+ for (let offset = 0; offset <= source.length - block.length; offset += 1) {
703
+ if (
704
+ block.every(
705
+ (entry, index) => entry.text === source.at(offset + index)?.text,
706
+ )
707
+ ) {
708
+ yield offset;
709
+ }
710
+ }
711
+ }
712
+
713
+ function matchingOrigins(
714
+ candidates: readonly SourceFile[],
715
+ blocks: readonly { lines: readonly SourceLine[]; anchor: number }[],
716
+ ): SourceFile[] {
717
+ const indexed = candidates.map((origin) => ({
718
+ origin,
719
+ lines: sourceLines(origin.source),
720
+ }));
721
+ const matches = new Map<string, SourceFile>();
722
+ for (const block of blocks) {
723
+ for (const { origin, lines } of indexed) {
724
+ for (const offset of matchingOffsets(lines, block.lines)) {
725
+ const key = `${origin.file}:${String(offset + block.anchor)}`;
726
+ matches.set(key, origin);
727
+ if (matches.size > 1) {
728
+ return [...matches.values()];
729
+ }
730
+ }
731
+ }
732
+ }
733
+ return [...matches.values()];
734
+ }
735
+
736
+ function sourceDiff(before: SourceFile, after: SourceFile): string {
737
+ const oldLines = before.source.split("\n");
738
+ const newLines = after.source.split("\n");
739
+ let start = 0;
740
+ while (start < oldLines.length && oldLines.at(start) === newLines.at(start)) {
741
+ start += 1;
742
+ }
743
+ let oldEnd = oldLines.length;
744
+ let newEnd = newLines.length;
745
+ while (
746
+ oldEnd > start &&
747
+ newEnd > start &&
748
+ oldLines[oldEnd - 1] === newLines[newEnd - 1]
749
+ ) {
750
+ oldEnd -= 1;
751
+ newEnd -= 1;
752
+ }
753
+ const contextStart = Math.max(0, start - 3);
754
+ const contextEnd = Math.min(oldLines.length - oldEnd, 3);
755
+ const changes = [
756
+ ...oldLines.slice(contextStart, start).map((text) => ` ${text}`),
757
+ ...oldLines.slice(start, oldEnd).map((text) => `-${text}`),
758
+ ...newLines.slice(start, newEnd).map((text) => `+${text}`),
759
+ ...oldLines.slice(oldEnd, oldEnd + contextEnd).map((text) => ` ${text}`),
760
+ ];
761
+ return `--- ${before.file}\n+++ ${after.file}\n@@ -${String(contextStart + 1)},${String(oldEnd - contextStart + contextEnd)} +${String(contextStart + 1)},${String(newEnd - contextStart + contextEnd)} @@\n${changes.join("\n")}`;
762
+ }
763
+
764
+ export async function buildChangeEvidence(
765
+ baseline: TaskBaseline | undefined,
766
+ file: string,
767
+ source: string,
768
+ line: number,
769
+ quote: string,
770
+ signal?: AbortSignal,
771
+ ): Promise<ChangeEvidence> {
772
+ const relative =
773
+ baseline === undefined
774
+ ? undefined
775
+ : (relativeFile(baseline.root, file) ??
776
+ relativeFile(baseline.requestedRoot, file));
777
+ const evidence: ChangeEvidence = {
778
+ status: "unavailable",
779
+ before: null,
780
+ after: { file: relative ?? file, source },
781
+ diff: null,
782
+ origins: [],
783
+ reason: "Task baseline or eligible file is unavailable",
784
+ };
785
+ if (
786
+ baseline === undefined ||
787
+ relative === undefined ||
788
+ !eligible(relative) ||
789
+ baseline.unattributable(relative)
790
+ ) {
791
+ return evidence;
792
+ }
793
+ if (
794
+ Buffer.byteLength(source) > MAX_FILE_BYTES ||
795
+ !Number.isSafeInteger(line) ||
796
+ line < 1 ||
797
+ quote.trim().length === 0 ||
798
+ source.split("\n")[line - 1]?.includes(quote) !== true
799
+ ) {
800
+ evidence.reason = "Current source is oversized or finding is not anchored";
801
+ return evidence;
802
+ }
803
+ let before: BeforeState;
804
+ let search: OriginSearch;
805
+ try {
806
+ [before, search] = await Promise.all([
807
+ baseline.before(relative, signal),
808
+ baseline.origins(relative, sourceLines(source), line, signal),
809
+ ]);
810
+ } catch {
811
+ evidence.reason = "Task baseline could not be read";
812
+ return evidence;
813
+ }
814
+ if (before.state === "unknown") {
815
+ evidence.reason = "Task-start state of this file is unknown";
816
+ return evidence;
817
+ }
818
+ if (before.state === "present")
819
+ evidence.before = { file: relative, source: before.source };
820
+ evidence.origins = search.origins;
821
+ if (search.origins.length > 1) {
822
+ evidence.reason = "Multiple baseline blocks match; origin is ambiguous";
823
+ return evidence;
824
+ }
825
+ if (evidence.before === null && !search.complete) {
826
+ evidence.reason =
827
+ "Incomplete baseline cannot prove a unique cross-file origin";
828
+ return evidence;
829
+ }
830
+ const original = evidence.before ?? search.origins[0];
831
+ if (original === undefined) {
832
+ evidence.reason =
833
+ "No substantial matching baseline block or same-file source";
834
+ return evidence;
835
+ }
836
+ evidence.diff = sourceDiff(original, evidence.after);
837
+ evidence.status = "available";
838
+ evidence.reason = null;
839
+ return evidence;
840
+ }