iterate-plugin 3.5.4 → 3.5.6
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +30 -5
- package/dist/config-write.js +78 -9
- package/dist/evidence.js +33 -2
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +7 -3
- package/dist/review-scope.js +6 -2
- package/dist/review.js +34 -15
- package/dist/skill-prompt.js +63 -24
- package/dist/tools/checkpoint.js +11 -4
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/fix.js +24 -3
- package/dist/tools/history.js +1 -1
- package/dist/tools/prune.js +57 -26
- package/dist/tools/transcript.js +17 -6
- package/dist/tools/triage.js +3 -2
- package/dist/transcript.js +136 -34
- package/lib/client.js +67 -31
- package/lib/parse.js +34 -0
- package/package.json +12 -8
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +45 -24
- package/src/config-loader.ts +28 -5
- package/src/config-write.ts +91 -9
- package/src/evidence.ts +34 -2
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +7 -3
- package/src/review-scope.ts +6 -2
- package/src/review.ts +47 -14
- package/src/skill-prompt.ts +63 -24
- package/src/tools/checkpoint.ts +12 -4
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/fix.ts +25 -3
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +51 -23
- package/src/tools/transcript.ts +16 -5
- package/src/tools/triage.ts +3 -2
- package/src/transcript.ts +144 -31
- package/src/types.ts +9 -4
package/src/client/index.ts
CHANGED
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
latestPhase,
|
|
82
82
|
SEVERITY_LABEL,
|
|
83
83
|
SEVERITY_COLOR,
|
|
84
|
+
stoppedReasonLabel,
|
|
84
85
|
} from '../../lib/parse.js'
|
|
85
86
|
|
|
86
87
|
// ─── Module contract ─────────────────────────────────────────────────────────
|
|
@@ -737,17 +738,6 @@ function severityLabel(severity: string | undefined): string {
|
|
|
737
738
|
return SEVERITY_LABEL[coerceSeverity(severity)]
|
|
738
739
|
}
|
|
739
740
|
|
|
740
|
-
/** Human label for WHY a finished run stopped (transcript manifest). */
|
|
741
|
-
function stoppedReasonLabel(reason: string | null | undefined): string {
|
|
742
|
-
switch (reason) {
|
|
743
|
-
case 'converged': return '已结束 · 已收敛(无新发现)'
|
|
744
|
-
case 'max_rounds_reached': return '已结束 · 达到轮数上限'
|
|
745
|
-
case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
|
|
746
|
-
case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
|
|
747
|
-
default: return reason ? `已结束 · ${reason}` : '已结束'
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
741
|
/** Badge text for the run's live/finished state, annotated with the stop reason. */
|
|
752
742
|
function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
|
|
753
743
|
if (live) return '运行中'
|
|
@@ -785,6 +775,7 @@ let slotsSvc: SlotsService | undefined = undefined
|
|
|
785
775
|
let themeSvc: ThemeService | undefined = undefined
|
|
786
776
|
let storage: ReturnType<typeof createStorage> | null = null
|
|
787
777
|
let themeDisposer: (() => void) | null = null
|
|
778
|
+
let themeListener: (() => void) | null = null
|
|
788
779
|
let themeEnabled = true
|
|
789
780
|
const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
|
|
790
781
|
|
|
@@ -814,6 +805,33 @@ function clearThemeSkin(): void {
|
|
|
814
805
|
}
|
|
815
806
|
}
|
|
816
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Register the theme/change hook exactly once per client lifecycle.
|
|
810
|
+
* The listener is tracked so a re-`apply()` (hot reload / re-mount) never
|
|
811
|
+
* stacks a second copy — every new registration first drops the previous
|
|
812
|
+
* one, and `ctx.effect` pairs it off when the client unmounts.
|
|
813
|
+
*/
|
|
814
|
+
function registerThemeListener(ctx: ClientContext): void {
|
|
815
|
+
if (typeof ctx.on !== 'function') return
|
|
816
|
+
if (themeListener) {
|
|
817
|
+
try { themeListener() } catch { /* noop */ }
|
|
818
|
+
themeListener = null
|
|
819
|
+
}
|
|
820
|
+
const off = ctx.on('theme/change', () => {
|
|
821
|
+
if (themeEnabled) applyThemeSkin()
|
|
822
|
+
})
|
|
823
|
+
// `ctx.on` may return void (no unsubscribe support) — only track a callback.
|
|
824
|
+
themeListener = typeof off === 'function' ? off : null
|
|
825
|
+
if (typeof ctx.effect === 'function') {
|
|
826
|
+
ctx.effect(() => {
|
|
827
|
+
if (themeListener) {
|
|
828
|
+
try { themeListener() } catch { /* noop */ }
|
|
829
|
+
themeListener = null
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
817
835
|
/** Persist + apply the theme preference. */
|
|
818
836
|
function setThemeEnabled(enabled: boolean): void {
|
|
819
837
|
themeEnabled = enabled
|
|
@@ -2826,15 +2844,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
2826
2844
|
themeEnabled = savedTheme === null ? true : savedTheme === '1'
|
|
2827
2845
|
if (themeEnabled) applyThemeSkin()
|
|
2828
2846
|
|
|
2829
|
-
// 3. Inject styles (independent of slots / React availability).
|
|
2847
|
+
// 3. Inject styles (independent of slots / React availability). Guarded so a
|
|
2848
|
+
// re-`apply()` never appends a duplicate <style>: the tag is keyed by a
|
|
2849
|
+
// `dataset.iterateLock` marker instead of relying on the CSS content.
|
|
2830
2850
|
if (typeof document !== 'undefined' && document.createElement && document.head) {
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2851
|
+
if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
|
|
2852
|
+
const style = document.createElement('style')
|
|
2853
|
+
style.dataset.iterateLock = '1'
|
|
2854
|
+
style.dataset.plugin = PLUGIN_TAG
|
|
2855
|
+
style.dataset.pluginCss = 'iterate-main'
|
|
2856
|
+
style.textContent = ITERATE_CSS
|
|
2857
|
+
document.head.appendChild(style)
|
|
2858
|
+
if (typeof ctx.effect === 'function') {
|
|
2859
|
+
ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
|
|
2860
|
+
}
|
|
2838
2861
|
}
|
|
2839
2862
|
}
|
|
2840
2863
|
|
|
@@ -2889,9 +2912,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
2889
2912
|
}
|
|
2890
2913
|
|
|
2891
2914
|
// 6: keep theme toggle state consistent across theme/change events.
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
})
|
|
2896
|
-
}
|
|
2915
|
+
// Registered through registerThemeListener so repeated apply() and client
|
|
2916
|
+
// unmounts never leak a second listener.
|
|
2917
|
+
registerThemeListener(ctx)
|
|
2897
2918
|
}
|
package/src/config-loader.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join, resolve, sep } from 'node:path'
|
|
4
4
|
import yaml from 'js-yaml'
|
|
@@ -12,7 +12,10 @@ export function loadConfig(projectRoot: string): IterateConfig | null {
|
|
|
12
12
|
try {
|
|
13
13
|
const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8')
|
|
14
14
|
const parsed = yaml.load(content) as Record<string, unknown>
|
|
15
|
-
|
|
15
|
+
// A YAML sequence root (e.g. a list of findings or a `- foo` file) must
|
|
16
|
+
// not masquerade as a config object — treating it as one would merge its
|
|
17
|
+
// indices into the config (config bomb). Reject it, not coerce it.
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
16
19
|
return parsed as unknown as IterateConfig
|
|
17
20
|
} catch {
|
|
18
21
|
return null
|
|
@@ -158,7 +161,7 @@ export function flattenCommands(
|
|
|
158
161
|
*/
|
|
159
162
|
export function validateConfig(config: unknown): string[] {
|
|
160
163
|
const errors: string[] = []
|
|
161
|
-
if (!config || typeof config !== 'object') {
|
|
164
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
162
165
|
errors.push('root')
|
|
163
166
|
return errors
|
|
164
167
|
}
|
|
@@ -206,13 +209,33 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
|
|
|
206
209
|
* the DSH runtime injects per-session env into tool sub-processes).
|
|
207
210
|
*/
|
|
208
211
|
export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
|
|
209
|
-
|
|
212
|
+
// A non-string input (number, array, …) from a hostile tool invocation must
|
|
213
|
+
// be treated as "no explicit path" instead of crashing on `.trim()`.
|
|
214
|
+
const raw = (typeof input === 'string' ? input : '').trim()
|
|
210
215
|
// A NUL byte can never name a real path and makes `resolve()` (and every
|
|
211
216
|
// downstream fs call) throw — treat it as unsafe input, not a throw path.
|
|
212
217
|
if (raw.includes('\0')) {
|
|
213
218
|
return { ok: false, reason: 'Refusing project root containing NUL bytes.' }
|
|
214
219
|
}
|
|
215
|
-
const
|
|
220
|
+
const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
|
|
221
|
+
// Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
|
|
222
|
+
// a path through a symlinked directory must resolve to its REAL location so
|
|
223
|
+
// two aliases of the same project share one `.iterate/` state, and tools can
|
|
224
|
+
// never read outside the resolved root via a link. Only when the path
|
|
225
|
+
// exists: a not-yet-created target (dirs created through the tools) keeps
|
|
226
|
+
// the lexical path so the caller can still write into it. The filesystem
|
|
227
|
+
// root check runs against the NORMALIZED path, so a symlink to `/` is
|
|
228
|
+
// refused too.
|
|
229
|
+
let root = rootLexical
|
|
230
|
+
try {
|
|
231
|
+
if (existsSync(rootLexical)) {
|
|
232
|
+
const real = realpathSync(rootLexical)
|
|
233
|
+
if (real) root = real
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// realpath can throw on a broken link or a permission fence — keep the
|
|
237
|
+
// lexical path (existing behavior) rather than failing the resolution.
|
|
238
|
+
}
|
|
216
239
|
if (!root || root === sep) {
|
|
217
240
|
return { ok: false, reason: 'Refusing filesystem root as project root.' }
|
|
218
241
|
}
|
package/src/config-write.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
14
|
-
import { join } from 'node:path'
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
14
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import yaml from 'js-yaml'
|
|
16
16
|
import { writeTextAtomic } from './atomic-fs.ts'
|
|
17
17
|
|
|
@@ -23,6 +23,49 @@ export function configBackupSuffix(now = new Date()): string {
|
|
|
23
23
|
return now.toISOString().replace(/[:.]/g, '-')
|
|
24
24
|
}
|
|
25
25
|
|
|
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
|
+
/** Keep at most this many timestamped config backups (older ones are removed). */
|
|
39
|
+
export const MAX_CONFIG_BACKUPS = 5
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
43
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
44
|
+
* filesystem failure here must never fail the write that just succeeded.
|
|
45
|
+
* @returns the absolute paths of the backups that were removed.
|
|
46
|
+
*/
|
|
47
|
+
export function pruneOldConfigBackups(
|
|
48
|
+
configPath: string,
|
|
49
|
+
keep = MAX_CONFIG_BACKUPS,
|
|
50
|
+
): string[] {
|
|
51
|
+
const removed: string[] = []
|
|
52
|
+
try {
|
|
53
|
+
const dir = dirname(configPath)
|
|
54
|
+
const prefix = `${basename(configPath)}.bak-`
|
|
55
|
+
const matches = existsSync(dir)
|
|
56
|
+
? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
|
|
57
|
+
: []
|
|
58
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep))
|
|
59
|
+
for (const f of doomed) {
|
|
60
|
+
rmSync(join(dir, f), { force: true })
|
|
61
|
+
removed.push(join(dir, f))
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
65
|
+
}
|
|
66
|
+
return removed
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
/**
|
|
27
70
|
* Validate a partial config update.
|
|
28
71
|
* Returns an array of error strings (empty when the update is valid).
|
|
@@ -45,8 +88,40 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
45
88
|
}
|
|
46
89
|
}
|
|
47
90
|
if ('max_rounds' in updates) {
|
|
48
|
-
if (
|
|
49
|
-
|
|
91
|
+
if (
|
|
92
|
+
typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
|
|
93
|
+
updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS
|
|
94
|
+
) {
|
|
95
|
+
errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ('reasoning_effort' in updates) {
|
|
99
|
+
if (
|
|
100
|
+
updates.reasoning_effort !== undefined &&
|
|
101
|
+
updates.reasoning_effort !== 'low' &&
|
|
102
|
+
updates.reasoning_effort !== 'medium' &&
|
|
103
|
+
updates.reasoning_effort !== 'high'
|
|
104
|
+
) {
|
|
105
|
+
errors.push('updates.reasoning_effort must be "low", "medium", or "high"')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if ('reviewer' in updates) {
|
|
109
|
+
const rv = updates.reviewer as Record<string, unknown> | undefined
|
|
110
|
+
if (!rv || typeof rv !== 'object') {
|
|
111
|
+
errors.push('updates.reviewer must be an object')
|
|
112
|
+
} else {
|
|
113
|
+
for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
|
|
114
|
+
if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
|
|
115
|
+
errors.push(`updates.reviewer.${boolKey} must be a boolean`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
rv.scope_chunk_size !== undefined &&
|
|
120
|
+
(typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
|
|
121
|
+
rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)
|
|
122
|
+
) {
|
|
123
|
+
errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`)
|
|
124
|
+
}
|
|
50
125
|
}
|
|
51
126
|
}
|
|
52
127
|
if ('review' in updates) {
|
|
@@ -62,11 +137,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
62
137
|
if (!a || typeof a !== 'object') {
|
|
63
138
|
errors.push('updates.atomic must be an object')
|
|
64
139
|
} else {
|
|
65
|
-
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
66
|
-
errors.push(
|
|
140
|
+
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)) {
|
|
141
|
+
errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`)
|
|
67
142
|
}
|
|
68
|
-
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
69
|
-
errors.push(
|
|
143
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
|
|
144
|
+
errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`)
|
|
70
145
|
}
|
|
71
146
|
}
|
|
72
147
|
}
|
|
@@ -168,7 +243,10 @@ export function readRawConfig(configPath: string): Record<string, unknown> {
|
|
|
168
243
|
} catch {
|
|
169
244
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
170
245
|
}
|
|
171
|
-
|
|
246
|
+
// A YAML sequence root (array) must not masquerade as a config object —
|
|
247
|
+
// `typeof [] === 'object'`, so the presence check alone would accept a
|
|
248
|
+
// config file that is actually a list. Writing over it would destroy data.
|
|
249
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
172
250
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
173
251
|
}
|
|
174
252
|
return parsed as Record<string, unknown>
|
|
@@ -207,5 +285,9 @@ export function writeConfigFile(
|
|
|
207
285
|
return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
|
|
208
286
|
}
|
|
209
287
|
|
|
288
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
289
|
+
// project never collects an unbounded pile of config snapshots.
|
|
290
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
291
|
+
|
|
210
292
|
return { ok: true, backupPath }
|
|
211
293
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -115,7 +115,11 @@ export function verifyLineBounds(
|
|
|
115
115
|
if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
|
|
116
116
|
return { inBounds: true, lineTotal }
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
// Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
|
|
119
|
+
// 3.7 from a bypassed schema) or a negative value is out of bounds — a
|
|
120
|
+
// fractional line "within" the file would otherwise be accepted and poison
|
|
121
|
+
// the evidence trail with a location nothing can jump to.
|
|
122
|
+
if (line < 1 || !Number.isInteger(line)) return { inBounds: false, lineTotal }
|
|
119
123
|
return { inBounds: line <= lineTotal, lineTotal }
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -125,8 +129,36 @@ export function verifyFinding(
|
|
|
125
129
|
input: Locatable,
|
|
126
130
|
opts: { readSet?: Set<string> } = {},
|
|
127
131
|
): FindingEvidence {
|
|
128
|
-
|
|
132
|
+
// A null element / non-object finding (hostile or model-authored JSON) must
|
|
133
|
+
// be reported as poisoned evidence, never crash `path.resolve` downstream.
|
|
134
|
+
if (!input || typeof input !== 'object') {
|
|
135
|
+
return {
|
|
136
|
+
file: '',
|
|
137
|
+
line: null,
|
|
138
|
+
lineTotal: null,
|
|
139
|
+
resolvedPath: null,
|
|
140
|
+
verified: false,
|
|
141
|
+
error: 'file_not_found',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// A non-string `file` (number, object, …) would make `resolve` throw
|
|
145
|
+
// ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
|
|
146
|
+
const relFile = typeof input.file === 'string' ? input.file : ''
|
|
129
147
|
const line = typeof input.line === 'number' ? input.line : null
|
|
148
|
+
|
|
149
|
+
// "" resolves to the project ROOT (resolve(root, '') === root), which then
|
|
150
|
+
// trips the not-a-regular-file branch as `line_out_of_range` — but there is
|
|
151
|
+
// no file reference at all. Report it as `file_not_found` upfront.
|
|
152
|
+
if (relFile === '') {
|
|
153
|
+
return {
|
|
154
|
+
file: '',
|
|
155
|
+
line,
|
|
156
|
+
lineTotal: null,
|
|
157
|
+
resolvedPath: null,
|
|
158
|
+
verified: false,
|
|
159
|
+
error: 'file_not_found',
|
|
160
|
+
}
|
|
161
|
+
}
|
|
130
162
|
const resolved = resolveWithin(root, relFile)
|
|
131
163
|
|
|
132
164
|
if (resolved === null || !existsSync(resolved)) {
|
package/src/git-scope.ts
CHANGED
|
@@ -90,7 +90,10 @@ function decodeQuotedPath(content: string): string {
|
|
|
90
90
|
|
|
91
91
|
export function parseChangedFiles(stdout: string): string[] {
|
|
92
92
|
if (stdout.includes('\0')) {
|
|
93
|
-
|
|
93
|
+
// NUL mode: git emits exact names with no surrounding whitespace, so any
|
|
94
|
+
// trim would CORRUPT a filename that legitimately starts/ends with spaces
|
|
95
|
+
// (git supports such names). Split and drop only the trailing empty entry.
|
|
96
|
+
return stdout.split('\0').filter((s) => s.length > 0)
|
|
94
97
|
}
|
|
95
98
|
return stdout
|
|
96
99
|
.split('\n')
|
|
@@ -152,12 +155,19 @@ export function decideScope(changedFiles: string[]): {
|
|
|
152
155
|
export function runGit(
|
|
153
156
|
args: string[],
|
|
154
157
|
cwd: string,
|
|
158
|
+
opts: { signal?: AbortSignal } = {},
|
|
155
159
|
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
|
|
156
160
|
return new Promise((resolve) => {
|
|
157
161
|
execFile(
|
|
158
162
|
'git',
|
|
159
163
|
args,
|
|
160
|
-
{
|
|
164
|
+
{
|
|
165
|
+
cwd,
|
|
166
|
+
timeout: 30_000,
|
|
167
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
168
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
169
|
+
signal: opts.signal,
|
|
170
|
+
},
|
|
161
171
|
(error, stdout, stderr) => {
|
|
162
172
|
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
163
173
|
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
|
|
@@ -175,6 +185,7 @@ export function runGit(
|
|
|
175
185
|
export async function resolveChangedFiles(
|
|
176
186
|
root: string,
|
|
177
187
|
targetBranch: string,
|
|
188
|
+
opts: { signal?: AbortSignal } = {},
|
|
178
189
|
): Promise<GitScopeResult> {
|
|
179
190
|
// Option-injection guard: a branch name starting with '-' would be parsed by
|
|
180
191
|
// git as an option (e.g. --output=...), not a ref. Reject it outright.
|
|
@@ -188,7 +199,7 @@ export async function resolveChangedFiles(
|
|
|
188
199
|
}
|
|
189
200
|
// -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
|
|
190
201
|
// non-ASCII), and never confused with option-like content.
|
|
191
|
-
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
|
|
202
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts)
|
|
192
203
|
if (!ok) {
|
|
193
204
|
const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
|
|
194
205
|
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
|
package/src/jobs.ts
CHANGED
|
@@ -39,6 +39,20 @@ interface JobsLike {
|
|
|
39
39
|
}): string
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
44
|
+
* `jobs` getter throws (or is missing) when no job registry is present. A
|
|
45
|
+
* Proxy throw on property access must degrade to "no jobs", never crash the
|
|
46
|
+
* tool execution we are about to wrap.
|
|
47
|
+
*/
|
|
48
|
+
function safeJobs(ctx: unknown): JobsLike | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return (ctx as { jobs?: JobsLike } | undefined)?.jobs
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
44
58
|
* with the execution's outcome. When the host exposes no job registry (or
|
|
@@ -57,7 +71,7 @@ export async function runWithJob<T>(
|
|
|
57
71
|
label: string,
|
|
58
72
|
fn: () => Promise<T> | T,
|
|
59
73
|
): Promise<{ result: T; jobId: string | null }> {
|
|
60
|
-
const jobs = (ctx
|
|
74
|
+
const jobs = safeJobs(ctx)
|
|
61
75
|
if (!jobs || typeof jobs.start !== 'function') {
|
|
62
76
|
return { result: await fn(), jobId: null }
|
|
63
77
|
}
|
package/src/live.ts
CHANGED
|
@@ -68,6 +68,47 @@ export function liveFilePath(projectRoot: string): string {
|
|
|
68
68
|
return join(projectRoot, '.iterate', 'transcript-live.ndjson')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Module-level in-process queue serializing live-feed writes. The trim path
|
|
73
|
+
* (read → rewrite → append) is a read-modify-write: two concurrent tool
|
|
74
|
+
* results could interleave and one's rewrite would silently drop the other's
|
|
75
|
+
* freshly-appended line. Chaining every append onto one tail makes the whole
|
|
76
|
+
* read+rewrite+append atomic within the process (the only realistic mode for
|
|
77
|
+
* the plugin); cross-process racing remains a theoretical hazard, but the
|
|
78
|
+
* cap-trim makes a lost line a cosmetic issue, not a correctness one.
|
|
79
|
+
*/
|
|
80
|
+
let liveWriteQueue: Promise<void> = Promise.resolve()
|
|
81
|
+
|
|
82
|
+
function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
|
|
83
|
+
const next = liveWriteQueue.then(task, task)
|
|
84
|
+
liveWriteQueue = next.catch(() => {})
|
|
85
|
+
return next
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append one activity record to the project's live feed (byte-capped). */
|
|
89
|
+
export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
90
|
+
return enqueueLiveWrite(async () => {
|
|
91
|
+
const file = liveFilePath(projectRoot)
|
|
92
|
+
const line = JSON.stringify(entry) + '\n'
|
|
93
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
94
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
95
|
+
try {
|
|
96
|
+
const st = await stat(file).catch(() => null)
|
|
97
|
+
if (st && st.size > LIVE_MAX_BYTES) {
|
|
98
|
+
const raw = await readFile(file, 'utf-8')
|
|
99
|
+
const lines = raw.split('\n').filter(Boolean)
|
|
100
|
+
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
101
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
102
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
103
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
104
|
+
}
|
|
105
|
+
await appendFile(file, line, 'utf-8')
|
|
106
|
+
} catch {
|
|
107
|
+
// Fire-and-forget: never let live capture break a tool call.
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
72
113
|
function projectRootOf(exec: ToolExecution): string | null {
|
|
73
114
|
const cwd = exec.agent?.session?.header?.cwd
|
|
@@ -130,28 +171,6 @@ export function classifyTool(
|
|
|
130
171
|
return { ts: new Date().toISOString(), type, tool: name, target }
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
/** Append one activity record to the project's live feed (byte-capped). */
|
|
134
|
-
export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
135
|
-
const file = liveFilePath(projectRoot)
|
|
136
|
-
const line = JSON.stringify(entry) + '\n'
|
|
137
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
138
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
139
|
-
try {
|
|
140
|
-
const st = await stat(file).catch(() => null)
|
|
141
|
-
if (st && st.size > LIVE_MAX_BYTES) {
|
|
142
|
-
const raw = await readFile(file, 'utf-8')
|
|
143
|
-
const lines = raw.split('\n').filter(Boolean)
|
|
144
|
-
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
145
|
-
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
146
|
-
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
147
|
-
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
148
|
-
}
|
|
149
|
-
await appendFile(file, line, 'utf-8')
|
|
150
|
-
} catch {
|
|
151
|
-
// Fire-and-forget: never let live capture break a tool call.
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
174
|
/** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
|
|
156
175
|
export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
|
|
157
176
|
const file = liveFilePath(projectRoot)
|
package/src/meta-review.ts
CHANGED
|
@@ -184,7 +184,7 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
184
184
|
`byDimension sums to ${dimSum}, but totalFindings is ${total}.`,
|
|
185
185
|
)
|
|
186
186
|
}
|
|
187
|
-
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension))
|
|
187
|
+
const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension))
|
|
188
188
|
if (invalidDim) {
|
|
189
189
|
add(
|
|
190
190
|
'DIMENSION_UNKNOWN',
|
|
@@ -233,7 +233,11 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
233
233
|
: null
|
|
234
234
|
const lastRoundNew =
|
|
235
235
|
lastRecordedRound !== null && lastRecordedRound > 0
|
|
236
|
-
|
|
236
|
+
// Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
|
|
237
|
+
// reported round number may exceed the array length — read the SAME
|
|
238
|
+
// clamped index buildReviewReport/computeConvergence use, or a real
|
|
239
|
+
// over-cap round would be mis-read as 0 new (flag mismatch).
|
|
240
|
+
? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
|
|
237
241
|
: null
|
|
238
242
|
const expectedConverged = lastRoundNew === 0
|
|
239
243
|
if (report.convergence?.converged !== expectedConverged) {
|
|
@@ -364,7 +368,7 @@ export function buildFinalReviewReport(
|
|
|
364
368
|
// surfaced it (best-effort; report rounds carry it).
|
|
365
369
|
for (const r of report.rounds ?? []) {
|
|
366
370
|
const matched = (r.findings ?? []).some(
|
|
367
|
-
(fnd) => fnd
|
|
371
|
+
(fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
|
|
368
372
|
)
|
|
369
373
|
if (matched) {
|
|
370
374
|
roundHint = ` (round ${r.round})`
|
package/src/review-scope.ts
CHANGED
|
@@ -72,8 +72,12 @@ function normalizePath(path: string): string {
|
|
|
72
72
|
for (const part of cleaned.split(SEP)) {
|
|
73
73
|
if (part === '' || part === '.') continue
|
|
74
74
|
if (part === '..') {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
|
|
76
|
+
// second `..` is NEVER collapsed into a bare `/` away — otherwise a
|
|
77
|
+
// traversal like `../../evil` folds into the single segment `evil` and
|
|
78
|
+
// the escape becomes undetectable downstream.
|
|
79
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
|
|
80
|
+
else parts.push(part)
|
|
77
81
|
continue
|
|
78
82
|
}
|
|
79
83
|
parts.push(part)
|