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.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/dist/config-write.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
154
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
114
|
-
//
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
229
|
+
if (probe.outcome !== 'text') {
|
|
184
230
|
return {
|
|
185
231
|
file: relFile,
|
|
186
232
|
line,
|
|
187
233
|
lineTotal: null,
|
|
188
|
-
resolvedPath:
|
|
234
|
+
resolvedPath: probe.resolvedPath,
|
|
189
235
|
verified: false,
|
|
190
|
-
error:
|
|
236
|
+
error: probe.outcome,
|
|
191
237
|
};
|
|
192
238
|
}
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
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
|
|
202
|
-
resolvedPath:
|
|
246
|
+
lineTotal,
|
|
247
|
+
resolvedPath: probe.resolvedPath,
|
|
203
248
|
verified: false,
|
|
204
249
|
error: 'line_out_of_range',
|
|
205
250
|
};
|
|
206
251
|
}
|
|
207
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
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
|
+
}
|