iterate-plugin 3.5.7 → 3.7.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.
Files changed (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -14,30 +14,69 @@ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'nod
14
14
  import { basename, dirname, join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
16
  import { writeTextAtomic } from './atomic-fs.ts'
17
+ import {
18
+ MAX_ATOMIC_MAX_LINES,
19
+ MAX_MAX_ADJACENT_METHODS,
20
+ MAX_MAX_ROUNDS,
21
+ MAX_SCOPE_CHUNK_SIZE,
22
+ SUPPORTED_CONFIG_KEYS,
23
+ } from './config-loader.ts'
24
+
25
+ export {
26
+ MAX_ATOMIC_MAX_LINES,
27
+ MAX_MAX_ADJACENT_METHODS,
28
+ MAX_MAX_ROUNDS,
29
+ MAX_SCOPE_CHUNK_SIZE,
30
+ }
17
31
 
18
32
  /** Config file name (must match config-loader). */
19
33
  export const CONFIG_FILE = 'iterate.config.yaml'
20
34
 
21
- /** Backup suffix helper (filesystem-safe timestamp). */
35
+ /**
36
+ * Monotonic collision guard for {@link configBackupSuffix}: two writes in the
37
+ * SAME millisecond used to build the identical `config.bak-<iso>` path, so the
38
+ * second backup overwrote the first and one snapshot was silently lost.
39
+ * Only ever moves forward (a clock stepping backwards also counts as a
40
+ * collision), so every suffix issued by this process is unique.
41
+ */
42
+ let lastSuffixMs = -1
43
+ let suffixSequence = 0
44
+
45
+ /**
46
+ * Backup suffix helper (filesystem-safe timestamp + monotonic disambiguator).
47
+ * The ISO timestamp is kept verbatim when the millisecond is fresh (existing
48
+ * `config.bak-…` names sort chronologically); repeats within the same
49
+ * millisecond get `-1`, `-2`, … appended so the backup path never collides.
50
+ */
22
51
  export function configBackupSuffix(now = new Date()): string {
23
- return now.toISOString().replace(/[:.]/g, '-')
52
+ const ms = now.getTime()
53
+ if (ms > lastSuffixMs) {
54
+ lastSuffixMs = ms
55
+ suffixSequence = 0
56
+ } else {
57
+ suffixSequence += 1
58
+ }
59
+ const base = now.toISOString().replace(/[:.]/g, '-')
60
+ return suffixSequence === 0 ? base : `${base}-${suffixSequence}`
24
61
  }
25
62
 
26
- /** Upper bound on configurable iteration rounds (config bomb guard). */
27
- export const MAX_MAX_ROUNDS = 100
28
-
29
- /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
30
- export const MAX_ATOMIC_MAX_LINES = 10_000
31
-
32
- /** Upper bound on `atomic.max_adjacent_methods`. */
33
- export const MAX_MAX_ADJACENT_METHODS = 200
34
-
35
- /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
36
- export const MAX_SCOPE_CHUNK_SIZE = 1000
37
-
38
63
  /** Keep at most this many timestamped config backups (older ones are removed). */
39
64
  export const MAX_CONFIG_BACKUPS = 5
40
65
 
66
+ /**
67
+ * Top-level keys `iterate_config` accepts in `updates`.
68
+ *
69
+ * The write path MERGES updates into the file, so an unrecognized key used to
70
+ * be persisted verbatim: a typo (`maxRounds`) or a hallucinated field
71
+ * (`task_modes`) silently became part of the project's config, survived every
72
+ * later read, and was never consumed by anything. Rejecting unknown keys turns
73
+ * that silent no-op into a structured error the model can correct.
74
+ *
75
+ * Defined once in config-loader (shared with `validateConfig`) and re-exported
76
+ * here under its write-path name.
77
+ */
78
+ export const SUPPORTED_UPDATE_KEYS = SUPPORTED_CONFIG_KEYS
79
+
41
80
  /**
42
81
  * Bound the timestamped config backups: after a fresh one is written, delete
43
82
  * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
@@ -76,6 +115,16 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
76
115
  return ['updates must be a JSON object']
77
116
  }
78
117
 
118
+ // Unknown top-level keys are refused rather than merged into the file
119
+ // (see SUPPORTED_UPDATE_KEYS for why a typo used to persist forever).
120
+ for (const key of Object.keys(updates)) {
121
+ if (!(SUPPORTED_UPDATE_KEYS as readonly string[]).includes(key)) {
122
+ errors.push(
123
+ `updates.${key} is not a supported config key (supported: ${SUPPORTED_UPDATE_KEYS.join(', ')})`,
124
+ )
125
+ }
126
+ }
127
+
79
128
  if ('goal' in updates && typeof updates.goal !== 'string') {
80
129
  errors.push('updates.goal must be a string')
81
130
  }
@@ -83,8 +132,14 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
83
132
  errors.push('updates.language must be "zh" or "en"')
84
133
  }
85
134
  if ('dimensions' in updates) {
86
- if (!Array.isArray(updates.dimensions) || updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
87
- errors.push('updates.dimensions must be an array of non-empty strings')
135
+ // `.some` on an EMPTY array is false, so `dimensions: []` used to validate
136
+ // clean and then replace the whole dimension list with "review nothing".
137
+ if (
138
+ !Array.isArray(updates.dimensions) ||
139
+ updates.dimensions.length === 0 ||
140
+ updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)
141
+ ) {
142
+ errors.push('updates.dimensions must be a non-empty array of non-empty strings')
88
143
  }
89
144
  }
90
145
  if ('max_rounds' in updates) {
@@ -107,7 +162,7 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
107
162
  }
108
163
  if ('reviewer' in updates) {
109
164
  const rv = updates.reviewer as Record<string, unknown> | undefined
110
- if (!rv || typeof rv !== 'object') {
165
+ if (!rv || typeof rv !== 'object' || Array.isArray(rv)) {
111
166
  errors.push('updates.reviewer must be an object')
112
167
  } else {
113
168
  for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
@@ -126,7 +181,7 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
126
181
  }
127
182
  if ('review' in updates) {
128
183
  const r = updates.review as Record<string, unknown> | undefined
129
- if (!r || typeof r !== 'object') {
184
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
130
185
  errors.push('updates.review must be an object')
131
186
  } else if (r.scope !== undefined && r.scope !== 'full' && r.scope !== 'changed-only') {
132
187
  errors.push('updates.review.scope must be "full" or "changed-only"')
@@ -134,7 +189,7 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
134
189
  }
135
190
  if ('atomic' in updates) {
136
191
  const a = updates.atomic as Record<string, unknown> | undefined
137
- if (!a || typeof a !== 'object') {
192
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
138
193
  errors.push('updates.atomic must be an object')
139
194
  } else {
140
195
  if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
@@ -147,7 +202,7 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
147
202
  }
148
203
  if ('git' in updates) {
149
204
  const g = updates.git as Record<string, unknown> | undefined
150
- if (!g || typeof g !== 'object') {
205
+ if (!g || typeof g !== 'object' || Array.isArray(g)) {
151
206
  errors.push('updates.git must be an object')
152
207
  } else {
153
208
  if (g.target_branch !== undefined && typeof g.target_branch !== 'string') {
@@ -162,15 +217,36 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
162
217
  }
163
218
  if ('validation' in updates) {
164
219
  const v = updates.validation as Record<string, unknown> | undefined
165
- if (!v || typeof v !== 'object') {
220
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
166
221
  errors.push('updates.validation must be an object')
167
- } else if ('commands' in v && v.commands !== undefined && typeof v.commands !== 'object') {
168
- errors.push('updates.validation.commands must be an object of command arrays')
222
+ } else {
223
+ if ('command_whitelist' in v && v.command_whitelist !== undefined) {
224
+ if (!Array.isArray(v.command_whitelist) || !v.command_whitelist.every((x) => typeof x === 'string')) {
225
+ errors.push('updates.validation.command_whitelist must be an array of strings')
226
+ }
227
+ }
228
+ // `typeof [] === 'object'` let `commands: ["npm t"]` validate clean, and
229
+ // `flattenCommands` drops every non-object entry — the allow-list would
230
+ // read as configured while matching NOTHING. Require the real shape:
231
+ // a mapping of module → string[] (string keys, string-array values).
232
+ if ('commands' in v && v.commands !== undefined) {
233
+ const c = v.commands
234
+ const shapeOk =
235
+ typeof c === 'object' &&
236
+ c !== null &&
237
+ !Array.isArray(c) &&
238
+ Object.entries(c as Record<string, unknown>).every(
239
+ ([, value]) => Array.isArray(value) && value.every((x) => typeof x === 'string'),
240
+ )
241
+ if (!shapeOk) {
242
+ errors.push('updates.validation.commands must be a mapping of module → string[] (not an array)')
243
+ }
244
+ }
169
245
  }
170
246
  }
171
247
  if ('observatory' in updates) {
172
248
  const o = updates.observatory as Record<string, unknown> | undefined
173
- if (!o || typeof o !== 'object') {
249
+ if (!o || typeof o !== 'object' || Array.isArray(o)) {
174
250
  errors.push('updates.observatory must be an object')
175
251
  } else {
176
252
  if (o.capture !== undefined && typeof o.capture !== 'boolean') {
@@ -190,10 +266,16 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
190
266
  }
191
267
  }
192
268
  }
193
- if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
269
+ if (
270
+ 'personalization' in updates &&
271
+ (!updates.personalization || typeof updates.personalization !== 'object' || Array.isArray(updates.personalization))
272
+ ) {
194
273
  errors.push('updates.personalization must be an object')
195
274
  }
196
- if ('onboarding' in updates && (!updates.onboarding || typeof updates.onboarding !== 'object')) {
275
+ if (
276
+ 'onboarding' in updates &&
277
+ (!updates.onboarding || typeof updates.onboarding !== 'object' || Array.isArray(updates.onboarding))
278
+ ) {
197
279
  errors.push('updates.onboarding must be an object')
198
280
  }
199
281
  return errors
package/src/evidence.ts CHANGED
@@ -8,8 +8,15 @@
8
8
  *
9
9
  * - a finding's `file` must resolve to an existing file under the project root
10
10
  * (traversal-safe), otherwise evidence is poisoned (`file_not_found`);
11
- * - a finding with an explicit line must reference a line that actually exists
12
- * in that file (`line_out_of_range`);
11
+ * - a finding with an explicit line must reference an INTEGER line that
12
+ * actually exists in that file (`line_out_of_range`); a fractional line
13
+ * (e.g. 42.5) can never anchor to real code and fails the gate, and so
14
+ * does a PRESENT-but-non-numeric line (`"42"`, `true`) — only an absent or
15
+ * null line keeps the whole-file semantics;
16
+ * - files that exist but cannot be line-addressed report a DISTINCT error:
17
+ * `file_too_large` (over MAX_EVIDENCE_BYTES) or `binary_file` (NUL bytes),
18
+ * so the meta-review can describe the actual reason instead of lumping
19
+ * every non-addressable file into `line_out_of_range`;
13
20
  * - a whole-file finding (line 0 / undefined) must still reference an existing
14
21
  * file, so even structural findings cannot point at nothing;
15
22
  * - `readVerified` is a best-effort, NON-gating hint: the plugin's reviewers are
@@ -38,7 +45,20 @@ export const WHOLE_FILE_LINE = 0
38
45
  */
39
46
  const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024
40
47
 
41
- export type EvidenceError = 'file_not_found' | 'line_out_of_range'
48
+ /**
49
+ * Why a finding's location could not be verified.
50
+ *
51
+ * `file_too_large` / `binary_file` are intentionally distinct from
52
+ * `line_out_of_range`: the finding still fails the evidence gate (any error
53
+ * does), but consumers (meta-review detail text, tool payloads) can describe
54
+ * the real reason. `line_out_of_range` keeps covering "line anchor does not
55
+ * exist in this file" and "target is not a regular file (dir/device)".
56
+ */
57
+ export type EvidenceError =
58
+ | 'file_not_found'
59
+ | 'line_out_of_range'
60
+ | 'file_too_large'
61
+ | 'binary_file'
42
62
 
43
63
  /** Per-finding attestation result. */
44
64
  export interface FindingEvidence {
@@ -58,6 +78,36 @@ export interface EvidenceAudit {
58
78
  results: FindingEvidence[]
59
79
  }
60
80
 
81
+ /**
82
+ * Cached outcome of the per-file filesystem probe (containment + stat + size
83
+ * cap + full read + NUL check + line count). Computed once per resolved path
84
+ * per audit and reused for every finding anchored to that file; the
85
+ * per-finding line-bounds check still runs against each finding's own `line`.
86
+ */
87
+ export interface FileProbe {
88
+ /** Lexical resolved path (null only when the path escapes the root). */
89
+ resolvedPath: string | null
90
+ /** `'text'` = line-addressable; anything else is the EvidenceError to report. */
91
+ outcome: 'text' | EvidenceError
92
+ /** Physical line count when `outcome === 'text'`; null otherwise. */
93
+ lineTotal: number | null
94
+ }
95
+
96
+ /**
97
+ * Point-in-time per-file probe cache keyed by resolved (lexical) path.
98
+ *
99
+ * `verifyFindings` creates one per call by default, so results always reflect
100
+ * the filesystem at audit time. Supplying your own via `opts.cache` shares it
101
+ * across calls — later calls then answer from the FIRST probe (a deliberate
102
+ * snapshot; they do not re-stat/re-read).
103
+ */
104
+ export type EvidenceFileCache = Map<string, FileProbe>
105
+
106
+ /** Create an empty probe cache for `opts.cache`. */
107
+ export function createEvidenceCache(): EvidenceFileCache {
108
+ return new Map()
109
+ }
110
+
61
111
  /** A single finding object that exposes `file` / `line` (for verification). */
62
112
  interface Locatable {
63
113
  file?: string
@@ -103,31 +153,107 @@ function safeRealpath(p: string): string {
103
153
  }
104
154
  }
105
155
 
156
+ /**
157
+ * Pure decision: does `line` anchor to an existing line in a file with
158
+ * `lineTotal` lines? Shared by `verifyLineBounds` and the cached probe path
159
+ * (which has the line count but not the text).
160
+ */
161
+ function lineInBounds(line: number | null | undefined, lineTotal: number): boolean {
162
+ // Whole-file findings (undefined/null/0 = WHOLE_FILE_LINE) are always
163
+ // bounds-valid — semantics preserved from the original gate.
164
+ if (line === undefined || line === null || line === WHOLE_FILE_LINE) return true
165
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
166
+ // 42.5 from a bypassed schema), NaN, ±Infinity or a negative value is out of
167
+ // bounds — a fractional line "within" the file would otherwise be accepted
168
+ // and poison the evidence trail with a location nothing can jump to.
169
+ if (line < 1 || !Number.isInteger(line)) return false
170
+ return line <= lineTotal
171
+ }
172
+
106
173
  /**
107
174
  * Pure check that `line` (if anchored) exists in `text`.
108
175
  * Whole-file findings (undefined/0) are always bounds-valid.
176
+ *
177
+ * Exported PUBLIC API: the production gate routes through the cached probe
178
+ * path (`lineInBounds` + the per-file line count), while tests and external
179
+ * consumers use this text-based form. Kept — not deleted — for that reason.
109
180
  */
110
181
  export function verifyLineBounds(
111
182
  line: number | null | undefined,
112
183
  text: string,
113
184
  ): { inBounds: boolean; lineTotal: number } {
114
185
  const lineTotal = countLines(text)
115
- if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
116
- return { inBounds: true, lineTotal }
186
+ return { inBounds: lineInBounds(line, lineTotal), lineTotal }
187
+ }
188
+
189
+ /**
190
+ * One filesystem probe for `root`/`resolved`: symlink containment, stat,
191
+ * regular-file + size cap, full read, NUL check, line count. Everything a
192
+ * finding needs beyond its own per-line check is captured in the result so
193
+ * repeated findings on the same file never re-stat or re-read it.
194
+ */
195
+ function probeEvidenceFile(root: string, resolved: string): FileProbe {
196
+ // Symlink containment: resolveWithin is lexical only, but existsSync /
197
+ // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
198
+ // project root so a finding path can never read (or line-count) a file
199
+ // outside the project via a symlinked directory or file.
200
+ const rootReal = safeRealpath(root)
201
+ const real = safeRealpath(resolved)
202
+ if (!isWithin(rootReal, real)) {
203
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null }
117
204
  }
118
- // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
119
- // 3.7 from a bypassed schema) or a negative value is out of bounds — a
120
- // fractional line "within" the file would otherwise be accepted and poison
121
- // the evidence trail with a location nothing can jump to.
122
- if (line < 1 || !Number.isInteger(line)) return { inBounds: false, lineTotal }
123
- return { inBounds: line <= lineTotal, lineTotal }
205
+
206
+ // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
207
+ // multi-GB file is not a line-addressable text target. statSync follows
208
+ // symlinks, so a link to a device still lands here and is rejected.
209
+ let st
210
+ try {
211
+ st = statSync(resolved)
212
+ } catch {
213
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null }
214
+ }
215
+ if (!st.isFile()) {
216
+ // Directory / device / FIFO: exists but is not line-addressable at all.
217
+ return { resolvedPath: resolved, outcome: 'line_out_of_range', lineTotal: null }
218
+ }
219
+ if (st.size > MAX_EVIDENCE_BYTES) {
220
+ return { resolvedPath: resolved, outcome: 'file_too_large', lineTotal: null }
221
+ }
222
+
223
+ let raw: Buffer
224
+ try {
225
+ raw = readFileSync(resolved)
226
+ } catch {
227
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null }
228
+ }
229
+
230
+ // A file containing a NUL byte is a binary payload: anchored line numbers
231
+ // on it cannot be trusted (mirrors the harness `evidence.py` NUL check).
232
+ // Reported as `binary_file` so it is distinguishable from a plain
233
+ // out-of-range line anchor.
234
+ if (raw.includes(0)) {
235
+ return { resolvedPath: resolved, outcome: 'binary_file', lineTotal: null }
236
+ }
237
+ return { resolvedPath: resolved, outcome: 'text', lineTotal: countLines(raw.toString('utf-8')) }
238
+ }
239
+
240
+ /** Shared options for `verifyFinding` / `verifyFindings`. */
241
+ export interface EvidenceVerifyOptions {
242
+ /** Read set backing the non-gating `readVerified` hint. */
243
+ readSet?: Set<string>
244
+ /**
245
+ * Optional shared probe cache. `verifyFindings` allocates one per call when
246
+ * omitted; pass your own to reuse a prior snapshot across calls (see
247
+ * `EvidenceFileCache`).
248
+ */
249
+ cache?: EvidenceFileCache
124
250
  }
125
251
 
126
252
  /** Verify a single finding's location against the real filesystem. */
127
253
  export function verifyFinding(
128
254
  root: string,
129
255
  input: Locatable,
130
- opts: { readSet?: Set<string> } = {},
256
+ opts: EvidenceVerifyOptions = {},
131
257
  ): FindingEvidence {
132
258
  // A null element / non-object finding (hostile or model-authored JSON) must
133
259
  // be reported as poisoned evidence, never crash `path.resolve` downstream.
@@ -144,11 +270,17 @@ export function verifyFinding(
144
270
  // A non-string `file` (number, object, …) would make `resolve` throw
145
271
  // ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
146
272
  const relFile = typeof input.file === 'string' ? input.file : ''
147
- const line = typeof input.line === 'number' ? input.line : null
273
+ const rawLine: unknown = (input as { line?: unknown }).line
274
+ const line = typeof rawLine === 'number' ? rawLine : null
275
+ // A PRESENT `line` must be a number. `line: "42"` / `line: true` (schema-
276
+ // bypassing JSON) is a claimed anchor we cannot resolve — fail closed as
277
+ // poisoned evidence instead of silently degrading it to a whole-file
278
+ // finding. Only an absent / null line keeps the WHOLE_FILE_LINE semantics.
279
+ const badLineType = rawLine !== undefined && rawLine !== null && typeof rawLine !== 'number'
148
280
 
149
281
  // "" resolves to the project ROOT (resolve(root, '') === root), which then
150
- // trips the not-a-regular-file branch as `line_out_of_range` — but there is
151
- // no file reference at all. Report it as `file_not_found` upfront.
282
+ // trips the not-a-regular-file branch — but there is no file reference at
283
+ // all. Report it as `file_not_found` upfront.
152
284
  if (relFile === '') {
153
285
  return {
154
286
  file: '',
@@ -159,100 +291,59 @@ export function verifyFinding(
159
291
  error: 'file_not_found',
160
292
  }
161
293
  }
162
- const resolved = resolveWithin(root, relFile)
163
294
 
164
- if (resolved === null || !existsSync(resolved)) {
165
- return {
166
- file: relFile,
167
- line,
168
- lineTotal: null,
169
- resolvedPath: resolved,
170
- verified: false,
171
- error: 'file_not_found',
172
- }
173
- }
174
-
175
- // Symlink containment: resolveWithin is lexical only, but existsSync /
176
- // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
177
- // project root so a finding path can never read (or line-count) a file
178
- // outside the project via a symlinked directory or file.
179
- const rootReal = safeRealpath(root)
180
- const real = safeRealpath(resolved)
181
- if (!isWithin(rootReal, real)) {
182
- return {
183
- file: relFile,
184
- line,
185
- lineTotal: null,
186
- resolvedPath: resolved,
187
- verified: false,
188
- error: 'file_not_found',
189
- }
190
- }
191
-
192
- // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
193
- // multi-GB file is not a line-addressable text target. statSync follows
194
- // symlinks, so a link to a device still lands here and is rejected.
195
- let st
196
- try {
197
- st = statSync(resolved)
198
- } catch {
199
- return {
200
- file: relFile,
201
- line,
202
- lineTotal: null,
203
- resolvedPath: resolved,
204
- verified: false,
205
- error: 'file_not_found',
206
- }
207
- }
208
- if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
209
- return {
210
- file: relFile,
211
- line,
212
- lineTotal: null,
213
- resolvedPath: resolved,
214
- verified: false,
215
- error: 'line_out_of_range',
295
+ const resolved = resolveWithin(root, relFile)
296
+ let probe: FileProbe
297
+ if (resolved === null) {
298
+ // Path escapes the root: no I/O happens, so nothing to cache.
299
+ probe = { resolvedPath: null, outcome: 'file_not_found', lineTotal: null }
300
+ } else {
301
+ const cached = opts.cache?.get(resolved)
302
+ if (cached) {
303
+ probe = cached
304
+ } else {
305
+ if (!existsSync(resolved)) {
306
+ probe = { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null }
307
+ } else {
308
+ probe = probeEvidenceFile(root, resolved)
309
+ }
310
+ // Cache every resolved-path outcome (including not-found) so repeated
311
+ // findings on the same file cost one stat/read per audit, not one per
312
+ // finding (O(F×bytes) → O(unique files × bytes)).
313
+ if (opts.cache) opts.cache.set(resolved, probe)
216
314
  }
217
315
  }
218
316
 
219
- let raw: Buffer
220
- try {
221
- raw = readFileSync(resolved)
222
- } catch {
317
+ if (probe.outcome !== 'text') {
223
318
  return {
224
319
  file: relFile,
225
320
  line,
226
321
  lineTotal: null,
227
- resolvedPath: resolved,
322
+ resolvedPath: probe.resolvedPath,
228
323
  verified: false,
229
- error: 'file_not_found',
324
+ error: probe.outcome,
230
325
  }
231
326
  }
232
327
 
233
- // A file is not line-addressable if it contains a NUL byte (binary payload).
234
- // Anchored line numbers on a binary file cannot be trusted, so treat them the
235
- // same as an out-of-range line rather than credulously accepting them
236
- // (mirrors the harness `evidence.py` NUL check).
237
- if (raw.includes(0)) {
328
+ const lineTotal = probe.lineTotal ?? 0
329
+ // Present-but-unanchorable line type (string/boolean/object): the file is
330
+ // real, but the claimed location can never be verified → poisoned.
331
+ if (badLineType) {
238
332
  return {
239
333
  file: relFile,
240
334
  line,
241
- lineTotal: null,
242
- resolvedPath: resolved,
335
+ lineTotal,
336
+ resolvedPath: probe.resolvedPath,
243
337
  verified: false,
244
338
  error: 'line_out_of_range',
245
339
  }
246
340
  }
247
-
248
- const text = raw.toString('utf-8')
249
- const { inBounds, lineTotal } = verifyLineBounds(line, text)
250
- if (!inBounds) {
341
+ if (!lineInBounds(line, lineTotal)) {
251
342
  return {
252
343
  file: relFile,
253
344
  line,
254
345
  lineTotal,
255
- resolvedPath: resolved,
346
+ resolvedPath: probe.resolvedPath,
256
347
  verified: false,
257
348
  error: 'line_out_of_range',
258
349
  }
@@ -262,22 +353,26 @@ export function verifyFinding(
262
353
  file: relFile,
263
354
  line,
264
355
  lineTotal,
265
- resolvedPath: resolved,
356
+ resolvedPath: probe.resolvedPath,
266
357
  verified: true,
267
358
  }
268
359
  if (opts.readSet !== undefined) {
269
- outcome.readVerified = opts.readSet.has(resolved)
360
+ // Per-finding, never cached: the read set may differ between calls sharing
361
+ // a probe cache, and membership is a cheap in-memory lookup.
362
+ outcome.readVerified = opts.readSet.has(resolved!)
270
363
  }
271
364
  return outcome
272
365
  }
273
366
 
274
- /** Attest every finding in a list. */
367
+ /** Attest every finding in a list (one shared per-file probe per call). */
275
368
  export function verifyFindings(
276
369
  root: string,
277
370
  findings: Locatable[],
278
- opts: { readSet?: Set<string> } = {},
371
+ opts: EvidenceVerifyOptions = {},
279
372
  ): EvidenceAudit {
280
- const results = findings.map((f) => verifyFinding(root, f, opts))
373
+ const cache = opts.cache ?? createEvidenceCache()
374
+ const scoped: EvidenceVerifyOptions = opts.cache ? opts : { ...opts, cache }
375
+ const results = findings.map((f) => verifyFinding(root, f, scoped))
281
376
  return { checked: results.length, results }
282
377
  }
283
378
 
@@ -286,7 +381,14 @@ export function evidencePassed(audit: EvidenceAudit): boolean {
286
381
  return audit.results.every((r) => r.error === undefined)
287
382
  }
288
383
 
289
- /** Violating (non-grounded) results. */
384
+ /**
385
+ * Violating (non-grounded) results.
386
+ *
387
+ * Exported PUBLIC API (tests and integrations filter by it directly);
388
+ * the production payload uses `evidenceToPlain`, which inlines the same
389
+ * filter — so this stays as the named, shared accessor rather than being
390
+ * folded into it.
391
+ */
290
392
  export function evidenceViolations(audit: EvidenceAudit): FindingEvidence[] {
291
393
  return audit.results.filter((r) => r.error !== undefined)
292
394
  }