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/src/config-write.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
168
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
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
|
|
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
|
|
151
|
-
//
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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:
|
|
322
|
+
resolvedPath: probe.resolvedPath,
|
|
228
323
|
verified: false,
|
|
229
|
-
error:
|
|
324
|
+
error: probe.outcome,
|
|
230
325
|
}
|
|
231
326
|
}
|
|
232
327
|
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
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
|
|
242
|
-
resolvedPath:
|
|
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:
|
|
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:
|
|
356
|
+
resolvedPath: probe.resolvedPath,
|
|
266
357
|
verified: true,
|
|
267
358
|
}
|
|
268
359
|
if (opts.readSet !== undefined) {
|
|
269
|
-
|
|
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:
|
|
371
|
+
opts: EvidenceVerifyOptions = {},
|
|
279
372
|
): EvidenceAudit {
|
|
280
|
-
const
|
|
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
|
-
/**
|
|
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
|
}
|