iterate-plugin 3.5.7 → 3.6.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 (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  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 +55 -13
  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 +111 -27
  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/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +14 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +396 -66
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +115 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +154 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -13,22 +13,52 @@ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'nod
13
13
  import { basename, dirname, join } from 'node:path';
14
14
  import yaml from 'js-yaml';
15
15
  import { writeTextAtomic } from "./atomic-fs.js";
16
+ import { MAX_ATOMIC_MAX_LINES, MAX_MAX_ADJACENT_METHODS, MAX_MAX_ROUNDS, MAX_SCOPE_CHUNK_SIZE, SUPPORTED_CONFIG_KEYS, } from "./config-loader.js";
17
+ export { MAX_ATOMIC_MAX_LINES, MAX_MAX_ADJACENT_METHODS, MAX_MAX_ROUNDS, MAX_SCOPE_CHUNK_SIZE, };
16
18
  /** Config file name (must match config-loader). */
17
19
  export const CONFIG_FILE = 'iterate.config.yaml';
18
- /** Backup suffix helper (filesystem-safe timestamp). */
20
+ /**
21
+ * Monotonic collision guard for {@link configBackupSuffix}: two writes in the
22
+ * SAME millisecond used to build the identical `config.bak-<iso>` path, so the
23
+ * second backup overwrote the first and one snapshot was silently lost.
24
+ * Only ever moves forward (a clock stepping backwards also counts as a
25
+ * collision), so every suffix issued by this process is unique.
26
+ */
27
+ let lastSuffixMs = -1;
28
+ let suffixSequence = 0;
29
+ /**
30
+ * Backup suffix helper (filesystem-safe timestamp + monotonic disambiguator).
31
+ * The ISO timestamp is kept verbatim when the millisecond is fresh (existing
32
+ * `config.bak-…` names sort chronologically); repeats within the same
33
+ * millisecond get `-1`, `-2`, … appended so the backup path never collides.
34
+ */
19
35
  export function configBackupSuffix(now = new Date()) {
20
- return now.toISOString().replace(/[:.]/g, '-');
36
+ const ms = now.getTime();
37
+ if (ms > lastSuffixMs) {
38
+ lastSuffixMs = ms;
39
+ suffixSequence = 0;
40
+ }
41
+ else {
42
+ suffixSequence += 1;
43
+ }
44
+ const base = now.toISOString().replace(/[:.]/g, '-');
45
+ return suffixSequence === 0 ? base : `${base}-${suffixSequence}`;
21
46
  }
22
- /** Upper bound on configurable iteration rounds (config bomb guard). */
23
- export const MAX_MAX_ROUNDS = 100;
24
- /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
25
- export const MAX_ATOMIC_MAX_LINES = 10_000;
26
- /** Upper bound on `atomic.max_adjacent_methods`. */
27
- export const MAX_MAX_ADJACENT_METHODS = 200;
28
- /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
29
- export const MAX_SCOPE_CHUNK_SIZE = 1000;
30
47
  /** Keep at most this many timestamped config backups (older ones are removed). */
31
48
  export const MAX_CONFIG_BACKUPS = 5;
49
+ /**
50
+ * Top-level keys `iterate_config` accepts in `updates`.
51
+ *
52
+ * The write path MERGES updates into the file, so an unrecognized key used to
53
+ * be persisted verbatim: a typo (`maxRounds`) or a hallucinated field
54
+ * (`task_modes`) silently became part of the project's config, survived every
55
+ * later read, and was never consumed by anything. Rejecting unknown keys turns
56
+ * that silent no-op into a structured error the model can correct.
57
+ *
58
+ * Defined once in config-loader (shared with `validateConfig`) and re-exported
59
+ * here under its write-path name.
60
+ */
61
+ export const SUPPORTED_UPDATE_KEYS = SUPPORTED_CONFIG_KEYS;
32
62
  /**
33
63
  * Bound the timestamped config backups: after a fresh one is written, delete
34
64
  * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
@@ -63,6 +93,13 @@ export function validateConfigUpdates(updates) {
63
93
  if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
64
94
  return ['updates must be a JSON object'];
65
95
  }
96
+ // Unknown top-level keys are refused rather than merged into the file
97
+ // (see SUPPORTED_UPDATE_KEYS for why a typo used to persist forever).
98
+ for (const key of Object.keys(updates)) {
99
+ if (!SUPPORTED_UPDATE_KEYS.includes(key)) {
100
+ errors.push(`updates.${key} is not a supported config key (supported: ${SUPPORTED_UPDATE_KEYS.join(', ')})`);
101
+ }
102
+ }
66
103
  if ('goal' in updates && typeof updates.goal !== 'string') {
67
104
  errors.push('updates.goal must be a string');
68
105
  }
@@ -70,8 +107,12 @@ export function validateConfigUpdates(updates) {
70
107
  errors.push('updates.language must be "zh" or "en"');
71
108
  }
72
109
  if ('dimensions' in updates) {
73
- if (!Array.isArray(updates.dimensions) || updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
74
- errors.push('updates.dimensions must be an array of non-empty strings');
110
+ // `.some` on an EMPTY array is false, so `dimensions: []` used to validate
111
+ // clean and then replace the whole dimension list with "review nothing".
112
+ if (!Array.isArray(updates.dimensions) ||
113
+ updates.dimensions.length === 0 ||
114
+ updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
115
+ errors.push('updates.dimensions must be a non-empty array of non-empty strings');
75
116
  }
76
117
  }
77
118
  if ('max_rounds' in updates) {
@@ -90,7 +131,7 @@ export function validateConfigUpdates(updates) {
90
131
  }
91
132
  if ('reviewer' in updates) {
92
133
  const rv = updates.reviewer;
93
- if (!rv || typeof rv !== 'object') {
134
+ if (!rv || typeof rv !== 'object' || Array.isArray(rv)) {
94
135
  errors.push('updates.reviewer must be an object');
95
136
  }
96
137
  else {
@@ -108,7 +149,7 @@ export function validateConfigUpdates(updates) {
108
149
  }
109
150
  if ('review' in updates) {
110
151
  const r = updates.review;
111
- if (!r || typeof r !== 'object') {
152
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
112
153
  errors.push('updates.review must be an object');
113
154
  }
114
155
  else if (r.scope !== undefined && r.scope !== 'full' && r.scope !== 'changed-only') {
@@ -117,7 +158,7 @@ export function validateConfigUpdates(updates) {
117
158
  }
118
159
  if ('atomic' in updates) {
119
160
  const a = updates.atomic;
120
- if (!a || typeof a !== 'object') {
161
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
121
162
  errors.push('updates.atomic must be an object');
122
163
  }
123
164
  else {
@@ -131,7 +172,7 @@ export function validateConfigUpdates(updates) {
131
172
  }
132
173
  if ('git' in updates) {
133
174
  const g = updates.git;
134
- if (!g || typeof g !== 'object') {
175
+ if (!g || typeof g !== 'object' || Array.isArray(g)) {
135
176
  errors.push('updates.git must be an object');
136
177
  }
137
178
  else {
@@ -147,16 +188,34 @@ export function validateConfigUpdates(updates) {
147
188
  }
148
189
  if ('validation' in updates) {
149
190
  const v = updates.validation;
150
- if (!v || typeof v !== 'object') {
191
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
151
192
  errors.push('updates.validation must be an object');
152
193
  }
153
- else if ('commands' in v && v.commands !== undefined && typeof v.commands !== 'object') {
154
- errors.push('updates.validation.commands must be an object of command arrays');
194
+ else {
195
+ if ('command_whitelist' in v && v.command_whitelist !== undefined) {
196
+ if (!Array.isArray(v.command_whitelist) || !v.command_whitelist.every((x) => typeof x === 'string')) {
197
+ errors.push('updates.validation.command_whitelist must be an array of strings');
198
+ }
199
+ }
200
+ // `typeof [] === 'object'` let `commands: ["npm t"]` validate clean, and
201
+ // `flattenCommands` drops every non-object entry — the allow-list would
202
+ // read as configured while matching NOTHING. Require the real shape:
203
+ // a mapping of module → string[] (string keys, string-array values).
204
+ if ('commands' in v && v.commands !== undefined) {
205
+ const c = v.commands;
206
+ const shapeOk = typeof c === 'object' &&
207
+ c !== null &&
208
+ !Array.isArray(c) &&
209
+ Object.entries(c).every(([, value]) => Array.isArray(value) && value.every((x) => typeof x === 'string'));
210
+ if (!shapeOk) {
211
+ errors.push('updates.validation.commands must be a mapping of module → string[] (not an array)');
212
+ }
213
+ }
155
214
  }
156
215
  }
157
216
  if ('observatory' in updates) {
158
217
  const o = updates.observatory;
159
- if (!o || typeof o !== 'object') {
218
+ if (!o || typeof o !== 'object' || Array.isArray(o)) {
160
219
  errors.push('updates.observatory must be an object');
161
220
  }
162
221
  else {
@@ -175,10 +234,12 @@ export function validateConfigUpdates(updates) {
175
234
  }
176
235
  }
177
236
  }
178
- if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
237
+ if ('personalization' in updates &&
238
+ (!updates.personalization || typeof updates.personalization !== 'object' || Array.isArray(updates.personalization))) {
179
239
  errors.push('updates.personalization must be an object');
180
240
  }
181
- if ('onboarding' in updates && (!updates.onboarding || typeof updates.onboarding !== 'object')) {
241
+ if ('onboarding' in updates &&
242
+ (!updates.onboarding || typeof updates.onboarding !== 'object' || Array.isArray(updates.onboarding))) {
182
243
  errors.push('updates.onboarding must be an object');
183
244
  }
184
245
  return errors;
package/dist/evidence.js 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
@@ -33,6 +40,10 @@ export const WHOLE_FILE_LINE = 0;
33
40
  * treated as not line-addressable.
34
41
  */
35
42
  const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024;
43
+ /** Create an empty probe cache for `opts.cache`. */
44
+ export function createEvidenceCache() {
45
+ return new Map();
46
+ }
36
47
  /** Number of physical lines in `text`. A trailing newline does not add a line. */
37
48
  export function countLines(text) {
38
49
  if (text === '')
@@ -74,22 +85,84 @@ function safeRealpath(p) {
74
85
  return p;
75
86
  }
76
87
  }
88
+ /**
89
+ * Pure decision: does `line` anchor to an existing line in a file with
90
+ * `lineTotal` lines? Shared by `verifyLineBounds` and the cached probe path
91
+ * (which has the line count but not the text).
92
+ */
93
+ function lineInBounds(line, lineTotal) {
94
+ // Whole-file findings (undefined/null/0 = WHOLE_FILE_LINE) are always
95
+ // bounds-valid — semantics preserved from the original gate.
96
+ if (line === undefined || line === null || line === WHOLE_FILE_LINE)
97
+ return true;
98
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
99
+ // 42.5 from a bypassed schema), NaN, ±Infinity or a negative value is out of
100
+ // bounds — a fractional line "within" the file would otherwise be accepted
101
+ // and poison the evidence trail with a location nothing can jump to.
102
+ if (line < 1 || !Number.isInteger(line))
103
+ return false;
104
+ return line <= lineTotal;
105
+ }
77
106
  /**
78
107
  * Pure check that `line` (if anchored) exists in `text`.
79
108
  * Whole-file findings (undefined/0) are always bounds-valid.
109
+ *
110
+ * Exported PUBLIC API: the production gate routes through the cached probe
111
+ * path (`lineInBounds` + the per-file line count), while tests and external
112
+ * consumers use this text-based form. Kept — not deleted — for that reason.
80
113
  */
81
114
  export function verifyLineBounds(line, text) {
82
115
  const lineTotal = countLines(text);
83
- if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
84
- return { inBounds: true, lineTotal };
116
+ return { inBounds: lineInBounds(line, lineTotal), lineTotal };
117
+ }
118
+ /**
119
+ * One filesystem probe for `root`/`resolved`: symlink containment, stat,
120
+ * regular-file + size cap, full read, NUL check, line count. Everything a
121
+ * finding needs beyond its own per-line check is captured in the result so
122
+ * repeated findings on the same file never re-stat or re-read it.
123
+ */
124
+ function probeEvidenceFile(root, resolved) {
125
+ // Symlink containment: resolveWithin is lexical only, but existsSync /
126
+ // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
127
+ // project root so a finding path can never read (or line-count) a file
128
+ // outside the project via a symlinked directory or file.
129
+ const rootReal = safeRealpath(root);
130
+ const real = safeRealpath(resolved);
131
+ if (!isWithin(rootReal, real)) {
132
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null };
85
133
  }
86
- // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
87
- // 3.7 from a bypassed schema) or a negative value is out of bounds — a
88
- // fractional line "within" the file would otherwise be accepted and poison
89
- // the evidence trail with a location nothing can jump to.
90
- if (line < 1 || !Number.isInteger(line))
91
- return { inBounds: false, lineTotal };
92
- return { inBounds: line <= lineTotal, lineTotal };
134
+ // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
135
+ // multi-GB file is not a line-addressable text target. statSync follows
136
+ // symlinks, so a link to a device still lands here and is rejected.
137
+ let st;
138
+ try {
139
+ st = statSync(resolved);
140
+ }
141
+ catch {
142
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null };
143
+ }
144
+ if (!st.isFile()) {
145
+ // Directory / device / FIFO: exists but is not line-addressable at all.
146
+ return { resolvedPath: resolved, outcome: 'line_out_of_range', lineTotal: null };
147
+ }
148
+ if (st.size > MAX_EVIDENCE_BYTES) {
149
+ return { resolvedPath: resolved, outcome: 'file_too_large', lineTotal: null };
150
+ }
151
+ let raw;
152
+ try {
153
+ raw = readFileSync(resolved);
154
+ }
155
+ catch {
156
+ return { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null };
157
+ }
158
+ // A file containing a NUL byte is a binary payload: anchored line numbers
159
+ // on it cannot be trusted (mirrors the harness `evidence.py` NUL check).
160
+ // Reported as `binary_file` so it is distinguishable from a plain
161
+ // out-of-range line anchor.
162
+ if (raw.includes(0)) {
163
+ return { resolvedPath: resolved, outcome: 'binary_file', lineTotal: null };
164
+ }
165
+ return { resolvedPath: resolved, outcome: 'text', lineTotal: countLines(raw.toString('utf-8')) };
93
166
  }
94
167
  /** Verify a single finding's location against the real filesystem. */
95
168
  export function verifyFinding(root, input, opts = {}) {
@@ -108,10 +181,16 @@ export function verifyFinding(root, input, opts = {}) {
108
181
  // A non-string `file` (number, object, …) would make `resolve` throw
109
182
  // ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
110
183
  const relFile = typeof input.file === 'string' ? input.file : '';
111
- const line = typeof input.line === 'number' ? input.line : null;
184
+ const rawLine = input.line;
185
+ const line = typeof rawLine === 'number' ? rawLine : null;
186
+ // A PRESENT `line` must be a number. `line: "42"` / `line: true` (schema-
187
+ // bypassing JSON) is a claimed anchor we cannot resolve — fail closed as
188
+ // poisoned evidence instead of silently degrading it to a whole-file
189
+ // finding. Only an absent / null line keeps the WHOLE_FILE_LINE semantics.
190
+ const badLineType = rawLine !== undefined && rawLine !== null && typeof rawLine !== 'number';
112
191
  // "" resolves to the project ROOT (resolve(root, '') === root), which then
113
- // trips the not-a-regular-file branch as `line_out_of_range` — but there is
114
- // no file reference at all. Report it as `file_not_found` upfront.
192
+ // trips the not-a-regular-file branch — but there is no file reference at
193
+ // all. Report it as `file_not_found` upfront.
115
194
  if (relFile === '') {
116
195
  return {
117
196
  file: '',
@@ -123,95 +202,59 @@ export function verifyFinding(root, input, opts = {}) {
123
202
  };
124
203
  }
125
204
  const resolved = resolveWithin(root, relFile);
126
- if (resolved === null || !existsSync(resolved)) {
127
- return {
128
- file: relFile,
129
- line,
130
- lineTotal: null,
131
- resolvedPath: resolved,
132
- verified: false,
133
- error: 'file_not_found',
134
- };
135
- }
136
- // Symlink containment: resolveWithin is lexical only, but existsSync /
137
- // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
138
- // project root so a finding path can never read (or line-count) a file
139
- // outside the project via a symlinked directory or file.
140
- const rootReal = safeRealpath(root);
141
- const real = safeRealpath(resolved);
142
- if (!isWithin(rootReal, real)) {
143
- return {
144
- file: relFile,
145
- line,
146
- lineTotal: null,
147
- resolvedPath: resolved,
148
- verified: false,
149
- error: 'file_not_found',
150
- };
151
- }
152
- // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
153
- // multi-GB file is not a line-addressable text target. statSync follows
154
- // symlinks, so a link to a device still lands here and is rejected.
155
- let st;
156
- try {
157
- st = statSync(resolved);
158
- }
159
- catch {
160
- return {
161
- file: relFile,
162
- line,
163
- lineTotal: null,
164
- resolvedPath: resolved,
165
- verified: false,
166
- error: 'file_not_found',
167
- };
168
- }
169
- if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
170
- return {
171
- file: relFile,
172
- line,
173
- lineTotal: null,
174
- resolvedPath: resolved,
175
- verified: false,
176
- error: 'line_out_of_range',
177
- };
205
+ let probe;
206
+ if (resolved === null) {
207
+ // Path escapes the root: no I/O happens, so nothing to cache.
208
+ probe = { resolvedPath: null, outcome: 'file_not_found', lineTotal: null };
178
209
  }
179
- let raw;
180
- try {
181
- raw = readFileSync(resolved);
210
+ else {
211
+ const cached = opts.cache?.get(resolved);
212
+ if (cached) {
213
+ probe = cached;
214
+ }
215
+ else {
216
+ if (!existsSync(resolved)) {
217
+ probe = { resolvedPath: resolved, outcome: 'file_not_found', lineTotal: null };
218
+ }
219
+ else {
220
+ probe = probeEvidenceFile(root, resolved);
221
+ }
222
+ // Cache every resolved-path outcome (including not-found) so repeated
223
+ // findings on the same file cost one stat/read per audit, not one per
224
+ // finding (O(F×bytes) → O(unique files × bytes)).
225
+ if (opts.cache)
226
+ opts.cache.set(resolved, probe);
227
+ }
182
228
  }
183
- catch {
229
+ if (probe.outcome !== 'text') {
184
230
  return {
185
231
  file: relFile,
186
232
  line,
187
233
  lineTotal: null,
188
- resolvedPath: resolved,
234
+ resolvedPath: probe.resolvedPath,
189
235
  verified: false,
190
- error: 'file_not_found',
236
+ error: probe.outcome,
191
237
  };
192
238
  }
193
- // A file is not line-addressable if it contains a NUL byte (binary payload).
194
- // Anchored line numbers on a binary file cannot be trusted, so treat them the
195
- // same as an out-of-range line rather than credulously accepting them
196
- // (mirrors the harness `evidence.py` NUL check).
197
- if (raw.includes(0)) {
239
+ const lineTotal = probe.lineTotal ?? 0;
240
+ // Present-but-unanchorable line type (string/boolean/object): the file is
241
+ // real, but the claimed location can never be verified → poisoned.
242
+ if (badLineType) {
198
243
  return {
199
244
  file: relFile,
200
245
  line,
201
- lineTotal: null,
202
- resolvedPath: resolved,
246
+ lineTotal,
247
+ resolvedPath: probe.resolvedPath,
203
248
  verified: false,
204
249
  error: 'line_out_of_range',
205
250
  };
206
251
  }
207
- const text = raw.toString('utf-8');
208
- const { inBounds, lineTotal } = verifyLineBounds(line, text);
209
- if (!inBounds) {
252
+ if (!lineInBounds(line, lineTotal)) {
210
253
  return {
211
254
  file: relFile,
212
255
  line,
213
256
  lineTotal,
214
- resolvedPath: resolved,
257
+ resolvedPath: probe.resolvedPath,
215
258
  verified: false,
216
259
  error: 'line_out_of_range',
217
260
  };
@@ -220,24 +263,35 @@ export function verifyFinding(root, input, opts = {}) {
220
263
  file: relFile,
221
264
  line,
222
265
  lineTotal,
223
- resolvedPath: resolved,
266
+ resolvedPath: probe.resolvedPath,
224
267
  verified: true,
225
268
  };
226
269
  if (opts.readSet !== undefined) {
270
+ // Per-finding, never cached: the read set may differ between calls sharing
271
+ // a probe cache, and membership is a cheap in-memory lookup.
227
272
  outcome.readVerified = opts.readSet.has(resolved);
228
273
  }
229
274
  return outcome;
230
275
  }
231
- /** Attest every finding in a list. */
276
+ /** Attest every finding in a list (one shared per-file probe per call). */
232
277
  export function verifyFindings(root, findings, opts = {}) {
233
- const results = findings.map((f) => verifyFinding(root, f, opts));
278
+ const cache = opts.cache ?? createEvidenceCache();
279
+ const scoped = opts.cache ? opts : { ...opts, cache };
280
+ const results = findings.map((f) => verifyFinding(root, f, scoped));
234
281
  return { checked: results.length, results };
235
282
  }
236
283
  /** `passed` is true only when no real existence failure exists (read is a hint). */
237
284
  export function evidencePassed(audit) {
238
285
  return audit.results.every((r) => r.error === undefined);
239
286
  }
240
- /** Violating (non-grounded) results. */
287
+ /**
288
+ * Violating (non-grounded) results.
289
+ *
290
+ * Exported PUBLIC API (tests and integrations filter by it directly);
291
+ * the production payload uses `evidenceToPlain`, which inlines the same
292
+ * filter — so this stays as the named, shared accessor rather than being
293
+ * folded into it.
294
+ */
241
295
  export function evidenceViolations(audit) {
242
296
  return audit.results.filter((r) => r.error !== undefined);
243
297
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * src/file-lock.ts — generic cross-process advisory lock for `.iterate/` state.
3
+ *
4
+ * Several plugin writers mutate small JSON/JSONL state files with a
5
+ * read-modify-write cycle (fix registry, decision log, defense events,
6
+ * experience bank). `writeJsonAtomic` only makes a SINGLE write atomic — two
7
+ * plugin processes racing on the same project can still lose an update between
8
+ * their read and their write. The decision log already guards itself with a
9
+ * purpose-built mutex (see `tools/decision-log.ts`); this module generalizes
10
+ * that exact pattern so every other writer can serialize the same way.
11
+ *
12
+ * Semantics (identical to the decision-log lock, deliberately):
13
+ * - exclusive create (`openSync(..., 'wx')`) with the holder pid stamped in;
14
+ * - a lock whose owner process is gone, or whose mtime is ancient, is
15
+ * stolen (unlink + retry);
16
+ * - bounded wait — on timeout the lock degrades to "proceed unlocked"
17
+ * (returns a callable no-op release) instead of wedging the tool call;
18
+ * - the returned release is always callable, idempotent, and never throws.
19
+ *
20
+ * Best-effort by design: an advisory lock that cannot be taken must never turn
21
+ * a working tool into a failed one; the worst case is the pre-existing
22
+ * (unlocked) behavior.
23
+ *
24
+ * In-process callers get synchronous mutual exclusion through the same API —
25
+ * tools run on one thread, so acquiring twice in one process would self-deadlock;
26
+ * `withProjectLock` is therefore NOT reentrant and must not be nested for the
27
+ * same (projectRoot, name).
28
+ */
29
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ const LOCK_DIR = '.iterate';
32
+ const DEFAULT_WAIT_MS = 5000;
33
+ const DEFAULT_STALE_MS = 10000;
34
+ const POLL_MS = 25;
35
+ /** True when a pid refers to a live process (or we lack permission to tell). */
36
+ function processAlive(pid) {
37
+ if (!Number.isInteger(pid) || pid <= 0)
38
+ return false;
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (err) {
44
+ return err.code === 'EPERM';
45
+ }
46
+ }
47
+ /** Synchronous sleep (Atomics.wait is a reliable sleep in Node). */
48
+ function sleepSync(ms) {
49
+ const sab = new Int32Array(new SharedArrayBuffer(4));
50
+ Atomics.wait(sab, 0, 0, ms);
51
+ }
52
+ /**
53
+ * Validate a lock name into a safe file-name fragment.
54
+ * Throws on anything that could escape `.iterate/` (path separators, dots).
55
+ */
56
+ function lockFileName(name) {
57
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
58
+ throw new Error(`invalid lock name: ${JSON.stringify(name)}`);
59
+ }
60
+ return `.${name}.lock`;
61
+ }
62
+ /**
63
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
64
+ *
65
+ * Returns a release function: always callable, idempotent, never throws.
66
+ * A no-op (but callable) release is returned when the lock directory cannot be
67
+ * created or the wait times out — callers then proceed unlocked, which is the
68
+ * historical best-effort behavior, rather than failing the operation.
69
+ */
70
+ export function acquireProjectLock(projectRoot, name, options = {}) {
71
+ const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
72
+ ? options.waitMs
73
+ : DEFAULT_WAIT_MS;
74
+ const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
75
+ ? options.staleMs
76
+ : DEFAULT_STALE_MS;
77
+ const dir = join(projectRoot, LOCK_DIR);
78
+ if (!existsSync(dir)) {
79
+ try {
80
+ mkdirSync(dir, { recursive: true });
81
+ }
82
+ catch {
83
+ return () => { };
84
+ }
85
+ }
86
+ const lockPath = join(dir, lockFileName(name));
87
+ const start = Date.now();
88
+ for (;;) {
89
+ let fd = null;
90
+ try {
91
+ fd = openSync(lockPath, 'wx');
92
+ }
93
+ catch (err) {
94
+ if (err.code !== 'EEXIST')
95
+ return () => { };
96
+ // Lock exists — steal it when the owner is gone or the lock is ancient.
97
+ let stale = false;
98
+ try {
99
+ const st = statSync(lockPath);
100
+ if (Date.now() - st.mtimeMs > staleMs)
101
+ stale = true;
102
+ else {
103
+ const pid = Number(readFileSync(lockPath, 'utf-8'));
104
+ if (!processAlive(pid))
105
+ stale = true;
106
+ }
107
+ }
108
+ catch {
109
+ stale = true; // unreadable/vanishing lock → retry as stale
110
+ }
111
+ if (stale) {
112
+ try {
113
+ unlinkSync(lockPath);
114
+ }
115
+ catch {
116
+ // another holder stole it first — retry immediately
117
+ }
118
+ continue;
119
+ }
120
+ if (Date.now() - start > waitMs)
121
+ return () => { };
122
+ sleepSync(POLL_MS);
123
+ continue;
124
+ }
125
+ // Owned the lock: stamp the holder pid, then release once on return.
126
+ try {
127
+ writeSync(fd, String(process.pid));
128
+ }
129
+ catch {
130
+ // pid stamp is advisory
131
+ }
132
+ try {
133
+ closeSync(fd);
134
+ }
135
+ catch {
136
+ // best-effort
137
+ }
138
+ let released = false;
139
+ return () => {
140
+ if (released)
141
+ return;
142
+ released = true;
143
+ try {
144
+ unlinkSync(lockPath);
145
+ }
146
+ catch {
147
+ // already gone
148
+ }
149
+ };
150
+ }
151
+ }
152
+ /**
153
+ * Run `fn` while holding the advisory lock named `name`.
154
+ * The release is guaranteed via `finally`; `fn`'s value or error propagates
155
+ * unchanged. Not reentrant for the same (projectRoot, name) — see header.
156
+ */
157
+ export function withProjectLock(projectRoot, name, fn, options = {}) {
158
+ const release = acquireProjectLock(projectRoot, name, options);
159
+ try {
160
+ return fn();
161
+ }
162
+ finally {
163
+ release();
164
+ }
165
+ }