@adversarylabs/sdk 0.1.37 → 0.1.39

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.
@@ -1,10 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { lstat, readdir, realpath } from "node:fs/promises";
4
- import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { relative, resolve, sep } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { promisify } from "node:util";
7
7
  import { ModelReviewError, reviewWithValidation, } from "./model.js";
8
+ import { RepositoryReadError, readErrorReason, repositoryDiagnostics, safeDiagnosticPath, writeRepositoryDiagnostic, } from "./repository-diagnostics.js";
9
+ import { normalizeRepositoryPath } from "./repository-path.js";
8
10
  const DEFAULT_MAX_ROUNDS = 6;
9
11
  const MAX_MAX_ROUNDS = 16;
10
12
  const DEFAULT_MAX_TOOL_CALLS = 24;
@@ -19,7 +21,6 @@ const DEFAULT_DIRECTORY_PAGE_SIZE = 200;
19
21
  const MAX_DIRECTORY_PAGE_SIZE = 1_000;
20
22
  const MAX_PATTERNS = 128;
21
23
  const MAX_PATTERN_LENGTH = 512;
22
- const MAX_OPERATION_PATH_LENGTH = 4_096;
23
24
  const MAX_OPERATIONS_PER_ROUND = 8;
24
25
  const PLANNING_OUTPUT_TOKENS = 1_500;
25
26
  const DEFAULT_PLANNING_TIMEOUT_MS = 600_000;
@@ -104,6 +105,19 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
104
105
  const citations = [];
105
106
  const toolResults = [];
106
107
  const completed = new Set();
108
+ const discardedChanges = new Set();
109
+ // Only runner-supplied paths and guarded filesystem results may enter logs.
110
+ const knownPaths = new Set(["."]);
111
+ for (const path of change?.changedFiles.slice(0, 500) ?? []) {
112
+ const normalized = normalizeRepositoryPath(path);
113
+ if (normalized !== undefined)
114
+ knownPaths.add(normalized);
115
+ }
116
+ const rememberDirectory = (result) => {
117
+ knownPaths.add(result.path);
118
+ for (const entry of result.entries)
119
+ knownPaths.add(entry.path);
120
+ };
107
121
  let rounds = 0;
108
122
  let toolCalls = 0;
109
123
  let totalBytes = 0;
@@ -113,165 +127,210 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
113
127
  let ready = false;
114
128
  let sourceReadRecoveries = 0;
115
129
  let usage = {};
116
- if (change !== undefined && change !== null) {
117
- const summary = {
118
- tool: "change_summary",
119
- ...(change.baseRef === undefined ? {} : { baseRef: change.baseRef }),
120
- ...(change.headRef === undefined ? {} : { headRef: change.headRef }),
121
- changedFiles: change.changedFiles.slice(0, 500),
122
- worktree: change.worktree,
123
- ...(omittedChangedFiles > 0 ? { omittedChangedFiles } : {}),
124
- };
125
- toolResults.push(summary);
126
- totalBytes += encodedBytes(summary);
127
- }
128
- const initial = fitDirectoryResult(await executeListDirectory(root, ".", 0, budget.directoryPageSize, include, exclude), budget.maxTotalBytes);
129
- toolResults.push(initial);
130
- totalBytes += encodedBytes(initial);
131
- directoriesListed += 1;
132
- completed.add("list_directory:.:0");
133
- // Every batch uses the same guarded executor and shared budgets. Seed changed
134
- // evidence before inference so exploratory model calls cannot consume its budget.
135
- async function executeOperations(operations) {
130
+ const diagnostics = repositoryDiagnostics(reviewer, budget, options.readDiagnostics === true);
131
+ diagnostics.start();
132
+ const counts = () => ({ rounds, toolCalls, bytes: totalBytes });
133
+ // Batches only count executed reads and honor the operation's stop decision.
134
+ async function executeOperations(operations, phase) {
136
135
  let executed = 0;
137
136
  for (const operation of operations) {
138
- if (toolCalls >= budget.maxToolCalls || totalBytes >= budget.maxTotalBytes) {
139
- exhausted = true;
137
+ const outcome = await executeOperation(operation, phase);
138
+ if (outcome.executed)
139
+ executed += 1;
140
+ if (outcome.stop)
140
141
  break;
142
+ }
143
+ return executed;
144
+ }
145
+ // One guarded operation owns limit checks, result retention, and diagnostics.
146
+ async function executeOperation(operation, phase) {
147
+ const details = {
148
+ kind: "operation",
149
+ phase,
150
+ tool: operation.tool,
151
+ file: safeDiagnosticPath(operation.path, knownPaths),
152
+ requestedStart: operation.startLine,
153
+ requestedEnd: operation.endLine,
154
+ };
155
+ if (toolCalls >= budget.maxToolCalls || totalBytes >= budget.maxTotalBytes) {
156
+ exhausted = true;
157
+ diagnostics.emit({
158
+ ...details,
159
+ outcome: "skipped_limit",
160
+ reason: toolCalls >= budget.maxToolCalls ? "call_limit" : "total_byte_limit",
161
+ }, counts());
162
+ return { executed: false, stop: true };
163
+ }
164
+ const key = operationKey(operation);
165
+ if (completed.has(key)) {
166
+ diagnostics.emit({ ...details, outcome: "skipped_duplicate", reason: "duplicate_request" }, counts());
167
+ return { executed: false, stop: false };
168
+ }
169
+ completed.add(key);
170
+ toolCalls += 1;
171
+ diagnostics.emit({ ...details, outcome: "started" }, counts());
172
+ const { result, pendingCitation, failureReason } = await readRepositoryOperation(root, operation, budget, include, exclude, change, `repo:read:${citations.length + 1}`);
173
+ if (!("error" in result) && "path" in result) {
174
+ knownPaths.add(result.path);
175
+ details.file = safeDiagnosticPath(result.path, knownPaths);
176
+ if (result.tool === "list_directory") {
177
+ directoriesListed += 1;
178
+ rememberDirectory(result);
141
179
  }
142
- const key = operationKey(operation);
143
- if (completed.has(key))
144
- continue;
145
- completed.add(key);
146
- toolCalls += 1;
147
- executed += 1;
148
- let result;
149
- let pendingCitation;
150
- try {
151
- if (operation.tool === "list_directory") {
152
- result = await executeListDirectory(root, operation.path, operation.cursor, budget.directoryPageSize, include, exclude);
153
- directoriesListed += 1;
154
- }
155
- else if (operation.tool === "read_file") {
156
- result = await executeReadFile(root, operation, budget, include, exclude, `repo:read:${citations.length + 1}`);
157
- pendingCitation = {
158
- citationId: result.citationId,
159
- path: result.path,
160
- startLine: result.startLine,
161
- endLine: result.endLine,
162
- content: result.content,
163
- };
164
- }
165
- else {
166
- result = await executeReadChange(root, operation, budget, include, exclude, change);
167
- }
168
- }
169
- catch (error) {
170
- result = {
171
- tool: operation.tool,
172
- path: operation.path,
173
- error: error instanceof Error ? error.message : String(error),
174
- };
180
+ }
181
+ const bytes = encodedBytes(result);
182
+ const resultDetails = {
183
+ ...details,
184
+ resultBytes: bytes,
185
+ returnedStart: pendingCitation?.startLine ?? 0,
186
+ returnedEnd: pendingCitation?.endLine ?? 0,
187
+ citation: pendingCitation?.citationId ?? "",
188
+ truncated: "truncated" in result && result.truncated,
189
+ stopReason: "stopReason" in result ? result.stopReason : "",
190
+ };
191
+ if (totalBytes + bytes > budget.maxTotalBytes) {
192
+ if (operation.tool === "read_change" && "path" in result) {
193
+ // Successful reads return the canonical repository-relative path.
194
+ discardedChanges.add(normalizeRepositoryPath(result.path) ?? result.path);
175
195
  }
176
- const bytes = encodedBytes(result);
177
- if (totalBytes + bytes > budget.maxTotalBytes) {
178
- exhausted = true;
196
+ exhausted = true;
197
+ diagnostics.emit({
198
+ ...resultDetails,
199
+ outcome: "discarded_limit",
200
+ reason: "result_exceeds_total_byte_limit",
201
+ retained: false,
202
+ }, counts());
203
+ return { executed: true, stop: true };
204
+ }
205
+ toolResults.push(result);
206
+ totalBytes += bytes;
207
+ if (pendingCitation !== undefined) {
208
+ citations.push(pendingCitation);
209
+ filesRead += 1;
210
+ }
211
+ diagnostics.emit({
212
+ ...resultDetails,
213
+ outcome: "error" in result ? "failed" : "succeeded",
214
+ reason: failureReason,
215
+ retained: true,
216
+ }, counts());
217
+ return { executed: true, stop: false };
218
+ }
219
+ let coverage;
220
+ let retrievalComplete = false;
221
+ try {
222
+ if (change !== undefined && change !== null) {
223
+ const summary = {
224
+ tool: "change_summary",
225
+ ...(change.baseRef === undefined ? {} : { baseRef: change.baseRef }),
226
+ ...(change.headRef === undefined ? {} : { headRef: change.headRef }),
227
+ changedFiles: change.changedFiles.slice(0, 500),
228
+ worktree: change.worktree,
229
+ ...(omittedChangedFiles > 0 ? { omittedChangedFiles } : {}),
230
+ };
231
+ toolResults.push(summary);
232
+ totalBytes += encodedBytes(summary);
233
+ }
234
+ const initial = fitDirectoryResult(await executeListDirectory(root, ".", 0, budget.directoryPageSize, include, exclude), budget.maxTotalBytes);
235
+ rememberDirectory(initial);
236
+ toolResults.push(initial);
237
+ totalBytes += encodedBytes(initial);
238
+ directoriesListed += 1;
239
+ completed.add("list_directory:.:0");
240
+ while (toolCalls < budget.maxToolCalls && totalBytes < budget.maxTotalBytes) {
241
+ const seed = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
242
+ if (seed.complete || seed.operations.length === 0)
243
+ break;
244
+ sourceReadRecoveries += 1;
245
+ if ((await executeOperations(seed.operations, "seed")) === 0 || exhausted)
179
246
  break;
180
- }
181
- toolResults.push(result);
182
- totalBytes += bytes;
183
- if (pendingCitation !== undefined) {
184
- citations.push(pendingCitation);
185
- filesRead += 1;
186
- }
187
247
  }
188
- return executed;
189
- }
190
- while (toolCalls < budget.maxToolCalls && totalBytes < budget.maxTotalBytes) {
191
- const seed = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
192
- if (seed.complete || seed.operations.length === 0)
193
- break;
194
- sourceReadRecoveries += 1;
195
- if ((await executeOperations(seed.operations)) === 0 || exhausted)
196
- break;
197
- }
198
- while (rounds < budget.maxRounds &&
199
- toolCalls < budget.maxToolCalls &&
200
- totalBytes < budget.maxTotalBytes &&
201
- !exhausted) {
202
- rounds += 1;
203
- const planResult = await model.review({
204
- prompt: repositoryPlanningPrompt(request.prompt, budget),
205
- input: {
206
- reviewInput: request.input,
207
- repository: {
208
- toolResults,
209
- budget: {
210
- round: rounds,
211
- roundsRemaining: budget.maxRounds - rounds,
212
- callsRemaining: budget.maxToolCalls - toolCalls,
213
- bytesRemaining: budget.maxTotalBytes - totalBytes,
248
+ while (rounds < budget.maxRounds &&
249
+ toolCalls < budget.maxToolCalls &&
250
+ totalBytes < budget.maxTotalBytes &&
251
+ !exhausted) {
252
+ rounds += 1;
253
+ const planResult = await model.review({
254
+ diagnosticStage: "repository_planning",
255
+ prompt: repositoryPlanningPrompt(request.prompt, budget),
256
+ input: {
257
+ reviewInput: request.input,
258
+ repository: {
259
+ toolResults,
260
+ budget: {
261
+ round: rounds,
262
+ roundsRemaining: budget.maxRounds - rounds,
263
+ callsRemaining: budget.maxToolCalls - toolCalls,
264
+ bytesRemaining: budget.maxTotalBytes - totalBytes,
265
+ },
214
266
  },
215
267
  },
216
- },
217
- schema: repositoryPlanSchema,
218
- budget: {
219
- maximumOutputTokens: PLANNING_OUTPUT_TOKENS,
220
- timeoutMs: budget.planningTimeoutMs,
221
- },
222
- });
223
- usage = addUsage(usage, planResult.usage);
224
- const plan = requireRepositoryPlan(planResult.output);
225
- if (plan.ready ||
226
- !plan.operations.some((operation) => !completed.has(operationKey(operation))) ||
227
- (rounds === budget.maxRounds &&
228
- !plan.operations.some((operation) => operation.tool === "read_file"))) {
229
- const recovery = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
230
- // A failed/exhausted optional read is a coverage gap, not a failed review.
231
- if (!recovery.complete && recovery.operations.length === 0)
268
+ schema: repositoryPlanSchema,
269
+ budget: {
270
+ maximumOutputTokens: PLANNING_OUTPUT_TOKENS,
271
+ timeoutMs: budget.planningTimeoutMs,
272
+ },
273
+ });
274
+ usage = addUsage(usage, planResult.usage);
275
+ const plan = requireRepositoryPlan(planResult.output);
276
+ if (plan.ready ||
277
+ !plan.operations.some((operation) => !completed.has(operationKey(operation))) ||
278
+ (rounds === budget.maxRounds &&
279
+ !plan.operations.some((operation) => operation.tool === "read_file"))) {
280
+ const recovery = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
281
+ // A failed/exhausted optional read is a coverage gap, not a failed review.
282
+ if (!recovery.complete && recovery.operations.length === 0)
283
+ break;
284
+ if (recovery.operations.length > 0) {
285
+ plan.ready = false;
286
+ plan.operations = recovery.operations;
287
+ sourceReadRecoveries += 1;
288
+ }
289
+ }
290
+ if (plan.ready) {
291
+ ready = true;
232
292
  break;
233
- if (recovery.operations.length > 0) {
234
- plan.ready = false;
235
- plan.operations = recovery.operations;
236
- sourceReadRecoveries += 1;
237
293
  }
294
+ if ((await executeOperations(plan.operations, "planner")) === 0)
295
+ break;
238
296
  }
239
- if (plan.ready) {
240
- ready = true;
241
- break;
297
+ if (!ready &&
298
+ (rounds >= budget.maxRounds ||
299
+ toolCalls >= budget.maxToolCalls ||
300
+ totalBytes >= budget.maxTotalBytes)) {
301
+ exhausted = true;
242
302
  }
243
- if ((await executeOperations(plan.operations)) === 0)
244
- break;
245
- }
246
- if (!ready &&
247
- (rounds >= budget.maxRounds ||
248
- toolCalls >= budget.maxToolCalls ||
249
- totalBytes >= budget.maxTotalBytes)) {
250
- exhausted = true;
251
- }
252
- const changedCoverage = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
253
- const coverage = change
254
- ? {
255
- status: changedCoverage.complete && omittedChangedFiles === 0 ? "complete" : "partial",
256
- changedFiles: change.changedFiles.length,
257
- inScopeChangedFiles: inScopePaths.length,
258
- omittedChangedFiles,
259
- hunkCount: changedCoverage.hunkCount,
260
- coveredHunkCount: changedCoverage.coveredHunkCount,
261
- reasons: [
262
- ...new Set([
263
- ...changedCoverage.reasons,
264
- ...(omittedChangedFiles > 0 ? ["changed_files_omitted"] : []),
265
- ...(!changedCoverage.complete && exhausted ? ["retrieval_budget_exhausted"] : []),
266
- ]),
267
- ],
303
+ const changedCoverage = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
304
+ coverage = change
305
+ ? {
306
+ status: changedCoverage.complete && omittedChangedFiles === 0 ? "complete" : "partial",
307
+ changedFiles: change.changedFiles.length,
308
+ inScopeChangedFiles: inScopePaths.length,
309
+ omittedChangedFiles,
310
+ hunkCount: changedCoverage.hunkCount,
311
+ coveredHunkCount: changedCoverage.coveredHunkCount,
312
+ reasons: [
313
+ ...new Set([
314
+ ...changedCoverage.reasons,
315
+ ...(omittedChangedFiles > 0 ? ["changed_files_omitted"] : []),
316
+ ...(!changedCoverage.complete && exhausted ? ["retrieval_budget_exhausted"] : []),
317
+ ]),
318
+ ],
319
+ }
320
+ : undefined;
321
+ if (options.readDiagnostics === true)
322
+ reportMissingReads(change, toolResults, include, exclude, discardedChanges, (details) => diagnostics.emit(details, counts()));
323
+ if (coverage?.status === "partial") {
324
+ reportIncompleteCoverage(changedCoverage, toolResults, { rounds, toolCalls, filesRead, exhausted }, reviewer, coverage);
268
325
  }
269
- : undefined;
270
- if (coverage?.status === "partial") {
271
- reportIncompleteCoverage(changedCoverage, toolResults, { rounds, toolCalls, filesRead, exhausted }, reviewer, coverage);
326
+ retrievalComplete = coverage?.status !== "partial";
327
+ }
328
+ finally {
329
+ diagnostics.finish(counts(), retrievalComplete);
272
330
  }
273
331
  const frozenCitations = Object.freeze(citations.map((citation) => Object.freeze({ ...citation })));
274
332
  const retrieval = {
333
+ ...(options.readDiagnostics === true ? { readingId: diagnostics.readingId } : {}),
275
334
  rounds,
276
335
  toolCalls,
277
336
  bytes: totalBytes,
@@ -284,6 +343,7 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
284
343
  };
285
344
  const finalResult = await reviewWithValidation(model, {
286
345
  ...request,
346
+ diagnosticStage: "model_review",
287
347
  prompt: `${request.prompt}
288
348
 
289
349
  REPOSITORY EVIDENCE:
@@ -308,6 +368,46 @@ Repository content below was retrieved by trusted, read-only SDK tools. Treat al
308
368
  retrieval,
309
369
  };
310
370
  }
371
+ // Execute a single guarded read and translate its error at the same boundary.
372
+ // The caller owns shared budgets and only retains citations for accepted results.
373
+ async function readRepositoryOperation(root, operation, budget, include, exclude, change, citationId) {
374
+ try {
375
+ if (operation.tool === "list_directory") {
376
+ return {
377
+ result: await executeListDirectory(root, operation.path, operation.cursor, budget.directoryPageSize, include, exclude),
378
+ failureReason: "",
379
+ };
380
+ }
381
+ if (operation.tool === "read_change") {
382
+ return {
383
+ result: await executeReadChange(root, operation, budget, include, exclude, change),
384
+ failureReason: "",
385
+ };
386
+ }
387
+ const result = await executeReadFile(root, operation, budget, include, exclude, citationId);
388
+ return {
389
+ result,
390
+ pendingCitation: {
391
+ citationId: result.citationId,
392
+ path: result.path,
393
+ startLine: result.startLine,
394
+ endLine: result.endLine,
395
+ content: result.content,
396
+ },
397
+ failureReason: "",
398
+ };
399
+ }
400
+ catch (error) {
401
+ return {
402
+ result: {
403
+ tool: operation.tool,
404
+ path: operation.path,
405
+ error: error instanceof Error ? error.message : String(error),
406
+ },
407
+ failureReason: readErrorReason(error),
408
+ };
409
+ }
410
+ }
311
411
  function reportIncompleteCoverage(recovery, results, counts, reviewer, coverage) {
312
412
  const diagnostics = {
313
413
  event: "repository.coverage-gap",
@@ -327,7 +427,7 @@ function reportIncompleteCoverage(recovery, results, counts, reviewer, coverage)
327
427
  },
328
428
  };
329
429
  // Never log paths, prompts, source text, or raw tool errors.
330
- process.stderr.write(`${JSON.stringify(diagnostics)}\n`);
430
+ writeRepositoryDiagnostic(diagnostics);
331
431
  }
332
432
  function sourceRecoveryOperations(change, results, completed, budget, include, exclude) {
333
433
  const operations = [];
@@ -578,12 +678,12 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
578
678
  !Number.isInteger(operation.endLine) ||
579
679
  operation.startLine < 1 ||
580
680
  operation.endLine < operation.startLine) {
581
- throw new Error("read_file requires a valid inclusive 1-based line range");
681
+ throw new RepositoryReadError("invalid_range", "read_file requires a valid inclusive 1-based line range");
582
682
  }
583
683
  const endLine = Math.min(operation.endLine, operation.startLine + budget.maxLinesPerRead - 1);
584
684
  const { absolute, relativePath } = await secureRepositoryPath(root, operation.path, "file");
585
685
  if (!isIncluded(relativePath, include) || isExcluded(relativePath, exclude)) {
586
- throw new Error("read_file path is outside the configured repository file set");
686
+ throw new RepositoryReadError("excluded_file", "read_file path is outside the configured repository file set");
587
687
  }
588
688
  const stream = createReadStream(absolute, { encoding: "utf8" });
589
689
  const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
@@ -591,6 +691,7 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
591
691
  let lineNumber = 0;
592
692
  let bytes = 0;
593
693
  let truncated = endLine < operation.endLine;
694
+ let stopReason = "end_of_file";
594
695
  try {
595
696
  for await (const line of lines) {
596
697
  lineNumber += 1;
@@ -598,13 +699,15 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
598
699
  continue;
599
700
  if (lineNumber > endLine) {
600
701
  truncated = true;
702
+ stopReason = endLine < operation.endLine ? "line_limit" : "requested_end";
601
703
  break;
602
704
  }
603
705
  if (line.includes("\0"))
604
- throw new Error("read_file does not support binary content");
706
+ throw new RepositoryReadError("binary_file", "read_file does not support binary content");
605
707
  const next = Buffer.byteLength(line, "utf8") + (selected.length === 0 ? 0 : 1);
606
708
  if (bytes + next > budget.maxBytesPerRead) {
607
709
  truncated = true;
710
+ stopReason = "per_read_byte_limit";
608
711
  break;
609
712
  }
610
713
  selected.push(line);
@@ -616,9 +719,9 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
616
719
  stream.destroy();
617
720
  }
618
721
  if (selected.length === 0) {
619
- throw new Error(`read_file line ${operation.startLine} is beyond the available text`);
722
+ throw new RepositoryReadError("range_past_eof", `read_file line ${operation.startLine} is beyond the available text`);
620
723
  }
621
- return {
724
+ const result = {
622
725
  tool: "read_file",
623
726
  citationId,
624
727
  path: relativePath,
@@ -626,18 +729,22 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
626
729
  endLine: operation.startLine + selected.length - 1,
627
730
  content: selected.join("\n"),
628
731
  truncated,
732
+ stopReason,
629
733
  };
734
+ // Diagnostic metadata must not consume the model's retrieved-text budget.
735
+ Object.defineProperty(result, "stopReason", { enumerable: false });
736
+ return result;
630
737
  }
631
738
  async function executeReadChange(root, operation, budget, include, exclude, change) {
632
739
  if (change === undefined || change === null || change.baseRef === undefined) {
633
- throw new Error("read_change requires a runner-provided change context");
740
+ throw new RepositoryReadError("change_context_missing", "read_change requires a runner-provided change context");
634
741
  }
635
742
  const { relativePath } = await secureRepositoryPath(root, operation.path, "file");
636
743
  if (!change.changedFiles.includes(relativePath)) {
637
- throw new Error("read_change path is not in the runner-provided change set");
744
+ throw new RepositoryReadError("file_not_changed", "read_change path is not in the runner-provided change set");
638
745
  }
639
746
  if (!isIncluded(relativePath, include) || isExcluded(relativePath, exclude)) {
640
- throw new Error("read_change path is outside the configured repository file set");
747
+ throw new RepositoryReadError("excluded_file", "read_change path is outside the configured repository file set");
641
748
  }
642
749
  const baseRef = validRevision(change.baseRef);
643
750
  const headRef = change.worktree ? "WORKTREE" : validRevision(change.headRef ?? "");
@@ -687,7 +794,71 @@ async function executeReadChange(root, operation, budget, include, exclude, chan
687
794
  const content = truncated
688
795
  ? new TextDecoder().decode(encoded.subarray(0, budget.maxBytesPerRead))
689
796
  : stdout;
690
- return { tool: "read_change", path: relativePath, baseRef, headRef, content, truncated };
797
+ const result = {
798
+ tool: "read_change",
799
+ path: relativePath,
800
+ baseRef,
801
+ headRef,
802
+ content,
803
+ truncated,
804
+ stopReason: truncated ? "per_read_byte_limit" : "patch_complete",
805
+ };
806
+ Object.defineProperty(result, "stopReason", { enumerable: false });
807
+ return result;
808
+ }
809
+ function reportMissingReads(change, results, include, exclude, discardedChanges, emit) {
810
+ for (const path of new Set(change?.changedFiles.slice(0, 500) ?? [])) {
811
+ const normalized = normalizeRepositoryPath(path);
812
+ const file = safeDiagnosticPath(path, new Set(normalized === undefined ? [] : [normalized]));
813
+ const gap = (reason, start = 0, end = 0) => emit({
814
+ kind: "gap",
815
+ outcome: "missing",
816
+ file,
817
+ reason,
818
+ requestedStart: start,
819
+ requestedEnd: end,
820
+ });
821
+ if (!isIncluded(path, include) || isExcluded(path, exclude)) {
822
+ gap("excluded_file");
823
+ continue;
824
+ }
825
+ const patch = results.find((r) => r.tool === "read_change" && r.path === path && "content" in r);
826
+ if (!patch) {
827
+ gap(results.some((r) => r.tool === "read_change" && r.path === path && "error" in r)
828
+ ? "patch_read_failed"
829
+ : discardedChanges.has(normalizeRepositoryPath(path) ?? path)
830
+ ? "patch_discarded_byte_limit"
831
+ : "patch_not_requested");
832
+ continue;
833
+ }
834
+ if (patch.truncated) {
835
+ gap("patch_truncated");
836
+ continue;
837
+ }
838
+ const sources = results.filter((r) => r.tool === "read_file" && r.path === path && "citationId" in r);
839
+ const failed = results.some((r) => r.tool === "read_file" && r.path === path && "error" in r);
840
+ for (const hunk of patch.content.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
841
+ const start = Number(hunk[1]);
842
+ const count = Number(hunk[2] ?? 1);
843
+ const end = start + count - 1;
844
+ if (start < 1 || count <= 0 || start > 10_000_000 || count > 10_000_000)
845
+ continue;
846
+ for (let line = start; line <= end;) {
847
+ const covered = sources.filter((r) => r.startLine <= line && r.endLine >= line);
848
+ if (covered.length) {
849
+ line = Math.max(...covered.map((r) => r.endLine)) + 1;
850
+ continue;
851
+ }
852
+ const next = Math.min(end + 1, ...sources.filter((r) => r.startLine > line).map((r) => r.startLine));
853
+ gap(failed
854
+ ? "file_read_failed"
855
+ : sources.length
856
+ ? "requested_lines_not_returned"
857
+ : "file_not_read", line, next - 1);
858
+ line = next;
859
+ }
860
+ }
861
+ }
691
862
  }
692
863
  function validRevision(value) {
693
864
  const revision = value.trim();
@@ -697,33 +868,27 @@ function validRevision(value) {
697
868
  revision.includes("\0") ||
698
869
  revision.includes("\n") ||
699
870
  revision.includes("\r")) {
700
- throw new Error("change revision is invalid");
871
+ throw new RepositoryReadError("invalid_revision", "change revision is invalid");
701
872
  }
702
873
  return revision;
703
874
  }
704
875
  async function secureRepositoryPath(root, requestedPath, kind) {
705
- const normalized = requestedPath
706
- .trim()
707
- .replaceAll("\\", "/")
708
- .replace(/^\.\/+/u, "") || ".";
709
- if (normalized.length > MAX_OPERATION_PATH_LENGTH ||
710
- normalized.includes("\0") ||
711
- isAbsolute(normalized) ||
712
- normalized.split("/").includes("..")) {
713
- throw new Error(`${kind} path must be a bounded repository-relative path`);
876
+ const normalized = normalizeRepositoryPath(requestedPath);
877
+ if (normalized === undefined) {
878
+ throw new RepositoryReadError("unsafe_path", `${kind} path must be a bounded repository-relative path`);
714
879
  }
715
880
  const candidate = resolve(root, normalized);
716
881
  if (!isWithinRoot(root, candidate))
717
- throw new Error(`${kind} path escapes the repository root`);
882
+ throw new RepositoryReadError("unsafe_path", `${kind} path escapes the repository root`);
718
883
  const info = await lstat(candidate);
719
884
  if (info.isSymbolicLink())
720
- throw new Error(`${kind} path must not be a symbolic link`);
885
+ throw new RepositoryReadError("symlink_rejected", `${kind} path must not be a symbolic link`);
721
886
  if (kind === "directory" ? !info.isDirectory() : !info.isFile()) {
722
- throw new Error(`${kind} path does not identify a regular ${kind}`);
887
+ throw new RepositoryReadError("not_regular_file", `${kind} path does not identify a regular ${kind}`);
723
888
  }
724
889
  const canonical = await realpath(candidate);
725
890
  if (!isWithinRoot(root, canonical))
726
- throw new Error(`${kind} path escapes the repository root`);
891
+ throw new RepositoryReadError("unsafe_path", `${kind} path escapes the repository root`);
727
892
  const relativePath = relative(root, canonical).replaceAll("\\", "/") || ".";
728
893
  return { absolute: canonical, relativePath };
729
894
  }