iterate-plugin 3.12.0 → 3.14.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.
@@ -2,7 +2,7 @@ import { join } from 'node:path'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
- import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
+ import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec, flattenCommands } from '../config-loader.ts'
6
6
  import {
7
7
  applyConfigUpdates,
8
8
  readRawConfig,
@@ -10,6 +10,7 @@ import {
10
10
  writeConfigFile,
11
11
  } from '../config-write.ts'
12
12
  import { withProjectLock } from '../file-lock.ts'
13
+ import { buildValidationUpdates, detectValidationCommands } from '../validation-detect.ts'
13
14
 
14
15
  /**
15
16
  * Register the `iterate_config` tool.
@@ -21,16 +22,24 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
21
22
  ctx.tools.register(
22
23
  defineTool({
23
24
  name: 'iterate_config',
24
- // `read` never writes; `write` persists a config update → only read
25
- // joins a parallel dispatch group.
26
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'write',
25
+ // `read` never writes; `write` persists a config update. `detect` is a
26
+ // read UNLESS it is asked to apply the detected commands.
27
+ isConcurrencySafe: (args) => {
28
+ const a = args as { operation?: unknown; apply?: unknown }
29
+ return a.operation !== 'write' && !(a.operation === 'detect' && a.apply === true)
30
+ },
27
31
  // Pending-call card: a config read vs a validated write.
28
32
  presentCall: (args) => {
29
- const a = args as { operation?: unknown; section?: unknown }
30
- const isWrite = a.operation === 'write'
33
+ const a = args as { operation?: unknown; section?: unknown; apply?: unknown }
34
+ const isWrite = a.operation === 'write' || (a.operation === 'detect' && a.apply === true)
35
+ const title = isWrite
36
+ ? 'Update iterate.config.yaml'
37
+ : a.operation === 'detect'
38
+ ? 'Detect validation commands'
39
+ : 'Read iterate.config.yaml'
31
40
  return {
32
41
  card: 'generic',
33
- title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
42
+ title,
34
43
  kind: isWrite ? 'edit' : 'read',
35
44
  ...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
36
45
  }
@@ -39,13 +48,17 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
39
48
  'Read or update the iterate.config.yaml configuration from the project root. ' +
40
49
  'Returns the full parsed config, a specific section, or validation errors. ' +
41
50
  'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
42
- 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.).',
51
+ 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.). ' +
52
+ 'operation "detect" inspects the project manifest (package.json scripts / pytest / Cargo / Go / Make) and ' +
53
+ 'proposes a ready-to-apply validation.commands set; pass apply:true to install it in one call.',
43
54
 
44
55
  parameters: {
45
56
  operation: {
46
57
  type: 'string',
47
- description: 'Default "read". "write" validates and applies a partial config update (backed up first).',
48
- enum: ['read', 'write'],
58
+ description:
59
+ 'Default "read". "write" validates and applies a partial config update (backed up first). ' +
60
+ '"detect" proposes validation commands from the project manifest.',
61
+ enum: ['read', 'write', 'detect'],
49
62
  },
50
63
  path: {
51
64
  type: 'string',
@@ -60,6 +73,16 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
60
73
  type: 'boolean',
61
74
  description: 'If true, validate the config schema and return any missing fields.',
62
75
  },
76
+ apply: {
77
+ type: 'boolean',
78
+ description:
79
+ 'For operation "detect": when true, install the detected commands under validation.commands.default ' +
80
+ '(refused if commands are already configured unless force:true).',
81
+ },
82
+ force: {
83
+ type: 'boolean',
84
+ description: 'For operation "detect" with apply:true: replace an existing default command set.',
85
+ },
63
86
  updates: {
64
87
  type: 'json',
65
88
  description:
@@ -83,6 +106,11 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
83
106
  availableSections: { type: 'array', items: { type: 'string' } },
84
107
  operation: { type: 'string' },
85
108
  ok: { type: 'boolean' },
109
+ applied: { type: 'boolean' },
110
+ ecosystem: { oneOf: [{ type: 'string' }, { type: 'null' }] },
111
+ commands: { type: 'array', items: { type: 'string' } },
112
+ sources: { type: 'json' },
113
+ updates: { type: 'json' },
86
114
  backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
87
115
  error: { type: 'string' },
88
116
  },
@@ -99,6 +127,81 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
99
127
  }
100
128
  const projectRoot = resolved.root
101
129
 
130
+ // ── Detect operation (validation-command auto-detection) ───────────
131
+ // Reads the project manifest and proposes a curated command set. With
132
+ // apply:true it installs the set under validation.commands.default in
133
+ // the same validated, backed-up, locked write path as operation:write.
134
+ if (args.operation === 'detect') {
135
+ const configPath = join(projectRoot, 'iterate.config.yaml')
136
+ const configExists = existsSync(configPath)
137
+ const detection = detectValidationCommands(projectRoot)
138
+ const updates = buildValidationUpdates(detection.commands)
139
+ const base = {
140
+ operation: 'detect',
141
+ found: configExists,
142
+ ecosystem: detection.ecosystem,
143
+ commands: detection.commands,
144
+ sources: detection.sources as unknown as JsonValue,
145
+ updates: updates as unknown as JsonValue,
146
+ }
147
+ if (detection.commands.length === 0) {
148
+ return {
149
+ ...base,
150
+ ok: false,
151
+ applied: false,
152
+ error:
153
+ 'no validation commands detected — the project does not advertise a test/typecheck/lint/build ' +
154
+ 'command; configure validation.commands manually',
155
+ }
156
+ }
157
+ if (args.apply !== true) {
158
+ return { ...base, ok: true, applied: false }
159
+ }
160
+ // Refuse to silently clobber a hand-tuned command set.
161
+ const current = loadEffectiveConfig(projectRoot).config
162
+ const existing = flattenCommands(current.validation?.commands)
163
+ if (existing.length > 0 && args.force !== true) {
164
+ return {
165
+ ...base,
166
+ ok: false,
167
+ applied: false,
168
+ error:
169
+ `validation.commands already has ${existing.length} command(s); ` +
170
+ 'pass force:true to replace the "default" module',
171
+ }
172
+ }
173
+ return withProjectLock(projectRoot, 'config-write', () => {
174
+ let cfgBase: Record<string, unknown>
175
+ try {
176
+ cfgBase = configExists
177
+ ? readRawConfig(configPath)
178
+ : (defaultConfig() as unknown as Record<string, unknown>)
179
+ } catch (err) {
180
+ return { ...base, ok: false, applied: false, error: `failed to read config: ${String(err)}` }
181
+ }
182
+ const merged = applyConfigUpdates(cfgBase, updates)
183
+ const schemaErrors = validateConfig(merged)
184
+ if (schemaErrors.length > 0) {
185
+ return {
186
+ ...base,
187
+ ok: false,
188
+ applied: false,
189
+ errors: schemaErrors.map((e) => `missing/required field: ${e}`),
190
+ }
191
+ }
192
+ const result = writeConfigFile(projectRoot, merged)
193
+ if (!result.ok) return { ...base, ok: false, applied: false, error: result.error }
194
+ const { config } = loadEffectiveConfig(projectRoot)
195
+ return {
196
+ ...base,
197
+ ok: true,
198
+ applied: true,
199
+ backupPath: result.backupPath ?? null,
200
+ config: config as unknown as JsonValue,
201
+ }
202
+ })
203
+ }
204
+
102
205
  // ── Write operation ────────────────────────────────────────────────
103
206
  if (args.operation === 'write') {
104
207
  const updates = args.updates as Record<string, unknown> | undefined
@@ -25,6 +25,7 @@ import { readCheckpoint, computeStatus } from './checkpoint.ts'
25
25
  import { readDecisionEntries } from './decision-log.ts'
26
26
  import { readRegistry } from './fix.ts'
27
27
  import { resultHeadline } from './present.ts'
28
+ import { detectValidationCommands } from '../validation-detect.ts'
28
29
 
29
30
  /** A single check's outcome. `fail` blocks a clean run; `warn` is advisory. */
30
31
  export type PreflightStatus = 'pass' | 'warn' | 'fail'
@@ -65,6 +66,12 @@ export interface PreflightInput {
65
66
  interrupted: boolean
66
67
  /** How many times the leftover checkpoint has already been resumed. */
67
68
  resumeCount: number
69
+ /** Commands auto-detected from the project manifest (only when none are configured). */
70
+ suggestedCommands?: string[]
71
+ /** Ecosystem that produced the suggestions (node/python/rust/go/make). */
72
+ ecosystem?: string | null
73
+ /** Manifest files the suggestions were derived from. */
74
+ detectedFrom?: string[]
68
75
  }
69
76
 
70
77
  /**
@@ -88,11 +95,23 @@ export interface PreflightInput {
88
95
  * progressed; `warn` + resume or clear.
89
96
  */
90
97
  export function buildPreflightReport(input: PreflightInput): PreflightReport {
98
+ const suggestions = Array.isArray(input.suggestedCommands) ? input.suggestedCommands.filter((c) => typeof c === 'string' && c.length > 0) : []
91
99
  const checks: PreflightCheck[] = [
92
100
  {
93
101
  id: 'validation',
94
102
  status: input.validationCommandCount > 0 ? 'pass' : 'fail',
95
- detail: { commandCount: input.validationCommandCount },
103
+ detail: {
104
+ commandCount: input.validationCommandCount,
105
+ // Only meaningful when the blocking case is hit; the client turns these
106
+ // into a one-click "apply detected commands" action.
107
+ ...(input.validationCommandCount === 0 && suggestions.length > 0
108
+ ? {
109
+ suggestedCommands: suggestions,
110
+ ecosystem: input.ecosystem ?? null,
111
+ detectedFrom: Array.isArray(input.detectedFrom) ? input.detectedFrom : [],
112
+ }
113
+ : {}),
114
+ },
96
115
  },
97
116
  {
98
117
  id: 'git',
@@ -126,9 +145,17 @@ function describeCheck(id: string, detail: Record<string, unknown>): string {
126
145
  switch (id) {
127
146
  case 'validation': {
128
147
  const n = Number(detail.commandCount ?? 0)
129
- return n > 0
130
- ? `${n} validation command(s) configured`
131
- : 'no validation.commands configured — the run cannot prove fixes (blocking)'
148
+ if (n > 0) return `${n} validation command(s) configured`
149
+ const suggested = Array.isArray(detail.suggestedCommands)
150
+ ? detail.suggestedCommands.map((c) => String(c))
151
+ : []
152
+ if (suggested.length > 0) {
153
+ return (
154
+ 'no validation.commands configured — the run cannot prove fixes (blocking); ' +
155
+ `detected ${String(detail.ecosystem ?? 'project')} commands: ${suggested.join(', ')}`
156
+ )
157
+ }
158
+ return 'no validation.commands configured — the run cannot prove fixes (blocking)'
132
159
  }
133
160
  case 'git':
134
161
  return detail.isRepo === true
@@ -229,6 +256,24 @@ export function registerPreflightTool(ctx: { tools: { register: (def: ReturnType
229
256
  validationCommandCount = 0
230
257
  }
231
258
 
259
+ // 1b. When nothing is configured, inspect the project manifest so the
260
+ // blocking check ships with an actionable, ready-to-apply set.
261
+ let suggestedCommands: string[] = []
262
+ let detectedEcosystem: string | null = null
263
+ let detectedFrom: string[] = []
264
+ if (validationCommandCount === 0) {
265
+ try {
266
+ const detection = detectValidationCommands(projectRoot)
267
+ suggestedCommands = detection.commands
268
+ detectedEcosystem = detection.ecosystem
269
+ detectedFrom = detection.sources.map((s) => s.file)
270
+ } catch {
271
+ suggestedCommands = []
272
+ detectedEcosystem = null
273
+ detectedFrom = []
274
+ }
275
+ }
276
+
232
277
  // 2/3. git repo + dirty tree (best-effort; any failure degrades to unknown).
233
278
  let isGitRepo = false
234
279
  let changedFiles: number | null = null
@@ -281,6 +326,9 @@ export function registerPreflightTool(ctx: { tools: { register: (def: ReturnType
281
326
  gateOverallStatus,
282
327
  interrupted,
283
328
  resumeCount,
329
+ suggestedCommands,
330
+ ecosystem: detectedEcosystem,
331
+ detectedFrom,
284
332
  })
285
333
  return { ...report, checks: report.checks as unknown as JsonValue }
286
334
  },
@@ -0,0 +1,207 @@
1
+ /**
2
+ * src/validation-detect.ts — detect a project's validation commands.
3
+ *
4
+ * Why it exists: the iterate loop is fail-closed on `validation.commands` — an
5
+ * empty set means it cannot tell a fix from a regression, so `iterate_preflight`
6
+ * blocks the run. Until now the only remedy was to hand-write the YAML, which is
7
+ * error-prone (wrong nesting, wrong module key, commands the project does not
8
+ * actually expose). This module inspects the project's own manifest files and
9
+ * proposes a small, curated command set that the config tool can apply in one
10
+ * call.
11
+ *
12
+ * Detection is advisory and read-only: it only proposes commands the project's
13
+ * manifest actually advertises (package.json scripts, pytest/ruff/mypy config,
14
+ * Cargo, Go, Make targets). It never invents a command and never executes one.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ /** Ecosystems this module knows how to read a manifest for. */
21
+ export type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'make'
22
+
23
+ /** One manifest fact that produced a command. */
24
+ export interface DetectionSource {
25
+ /** Manifest file, relative to the project root. */
26
+ file: string
27
+ /** Short machine-stable reason, e.g. `scripts.test`. */
28
+ reason: string
29
+ }
30
+
31
+ /** The result of inspecting a project root. */
32
+ export interface ValidationDetection {
33
+ /** The ecosystem that produced the first command, or null when none matched. */
34
+ ecosystem: Ecosystem | null
35
+ /** Ordered, de-duplicated commands for `validation.commands.default`. */
36
+ commands: string[]
37
+ /** One entry per command, same order/length as `commands`. */
38
+ sources: DetectionSource[]
39
+ }
40
+
41
+ /** Hard cap so a project with many scripts cannot flood the config. */
42
+ const MAX_COMMANDS = 6
43
+
44
+ function readText(path: string): string | null {
45
+ try {
46
+ return readFileSync(path, 'utf-8')
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function readJsonObject(path: string): Record<string, unknown> | null {
53
+ const text = readText(path)
54
+ if (text === null) return null
55
+ try {
56
+ const parsed: unknown = JSON.parse(text)
57
+ // A JSON array / scalar is not a manifest object — treat it as absent.
58
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
59
+ ? (parsed as Record<string, unknown>)
60
+ : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function existsFile(root: string, name: string): boolean {
67
+ try {
68
+ return existsSync(join(root, name))
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ function asRecord(value: unknown): Record<string, unknown> {
75
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
76
+ ? (value as Record<string, unknown>)
77
+ : {}
78
+ }
79
+
80
+ type AddCommand = (command: string, file: string, reason: string) => void
81
+
82
+ /** Node: only scripts the manifest explicitly defines, in a curated order. */
83
+ function detectNode(root: string, add: AddCommand): boolean {
84
+ const pkg = readJsonObject(join(root, 'package.json'))
85
+ if (!pkg) return false
86
+ const scripts = asRecord(pkg.scripts)
87
+ const has = (name: string): boolean =>
88
+ typeof scripts[name] === 'string' && (scripts[name] as string).trim().length > 0
89
+ // test/typecheck/lint/build are the four signals that actually prove a change
90
+ // is sound. Arbitrary user scripts are intentionally NOT auto-run.
91
+ if (has('test')) add('npm test', 'package.json', 'scripts.test')
92
+ if (has('typecheck')) add('npm run typecheck', 'package.json', 'scripts.typecheck')
93
+ if (has('lint')) add('npm run lint', 'package.json', 'scripts.lint')
94
+ if (has('build')) add('npm run build', 'package.json', 'scripts.build')
95
+ return true
96
+ }
97
+
98
+ /** Python: pytest plus configured ruff / mypy. A bare pyproject is not pytest. */
99
+ function detectPython(root: string, add: AddCommand): boolean {
100
+ const pyproject = readText(join(root, 'pyproject.toml'))
101
+ const setupCfg = readText(join(root, 'setup.cfg'))
102
+ const pytestIni = existsFile(root, 'pytest.ini')
103
+ const toxIni = existsFile(root, 'tox.ini')
104
+ const testsDir = existsFile(root, 'tests')
105
+ const pyprojectPytest = pyproject !== null && /\[tool\.pytest/.test(pyproject)
106
+ const setupCfgPytest = setupCfg !== null && /\[tool:pytest\]/.test(setupCfg)
107
+ const matched = pyproject !== null || setupCfg !== null || pytestIni || toxIni || testsDir
108
+ if (!matched) return false
109
+
110
+ if (pytestIni) add('pytest -q', 'pytest.ini', 'pytest')
111
+ else if (toxIni) add('pytest -q', 'tox.ini', 'pytest')
112
+ else if (pyprojectPytest) add('pytest -q', 'pyproject.toml', 'tool.pytest')
113
+ else if (setupCfgPytest) add('pytest -q', 'setup.cfg', 'tool:pytest')
114
+ else if (testsDir) add('pytest -q', 'tests', 'tests-dir')
115
+
116
+ if (pyproject !== null && /\[tool\.ruff/.test(pyproject)) add('ruff check .', 'pyproject.toml', 'tool.ruff')
117
+ if (pyproject !== null && /\[tool\.mypy\]/.test(pyproject)) add('mypy .', 'pyproject.toml', 'tool.mypy')
118
+ return true
119
+ }
120
+
121
+ /** Rust: cargo test, plus clippy/rustfmt when the project configures them. */
122
+ function detectRust(root: string, add: AddCommand): boolean {
123
+ if (!existsFile(root, 'Cargo.toml')) return false
124
+ add('cargo test', 'Cargo.toml', 'cargo.test')
125
+ if (existsFile(root, 'clippy.toml') || existsFile(root, '.clippy.toml')) {
126
+ add('cargo clippy --all-targets -- -D warnings', 'clippy.toml', 'cargo.clippy')
127
+ }
128
+ if (existsFile(root, 'rustfmt.toml') || existsFile(root, '.rustfmt.toml')) {
129
+ add('cargo fmt --check', 'rustfmt.toml', 'cargo.fmt')
130
+ }
131
+ return true
132
+ }
133
+
134
+ /** Go: test + vet are the two checks a go module always supports. */
135
+ function detectGo(root: string, add: AddCommand): boolean {
136
+ if (!existsFile(root, 'go.mod')) return false
137
+ add('go test ./...', 'go.mod', 'go.test')
138
+ add('go vet ./...', 'go.mod', 'go.vet')
139
+ return true
140
+ }
141
+
142
+ /** Make: the generic fallback, only when no ecosystem-specific command exists. */
143
+ function detectMake(root: string, add: AddCommand): boolean {
144
+ const makefile = ['Makefile', 'makefile', 'GNUmakefile'].find((name) => existsFile(root, name))
145
+ if (!makefile) return false
146
+ const text = readText(join(root, makefile)) ?? ''
147
+ const targets = new Set<string>()
148
+ for (const line of text.split(/\r?\n/)) {
149
+ // `target:`, but NOT a `VAR := value` / `VAR = value` assignment. The
150
+ // lookahead has to skip the optional space before `=` (`test := nope`).
151
+ const match = /^([A-Za-z0-9_.-]+)\s*:(?!=|\s*=)/.exec(line)
152
+ if (match) targets.add(match[1] as string)
153
+ }
154
+ if (targets.has('test')) add('make test', makefile, 'target.test')
155
+ if (targets.has('check')) add('make check', makefile, 'target.check')
156
+ return true
157
+ }
158
+
159
+ /**
160
+ * Inspect `projectRoot` and return a curated, ordered validation-command set.
161
+ *
162
+ * Never throws: unreadable or malformed manifests simply contribute nothing.
163
+ * The returned `commands` and `sources` always have the same length.
164
+ */
165
+ export function detectValidationCommands(projectRoot: string): ValidationDetection {
166
+ const commands: string[] = []
167
+ const sources: DetectionSource[] = []
168
+ const seen = new Set<string>()
169
+ const add: AddCommand = (command, file, reason) => {
170
+ if (commands.length >= MAX_COMMANDS) return
171
+ if (seen.has(command)) return
172
+ seen.add(command)
173
+ commands.push(command)
174
+ sources.push({ file, reason })
175
+ }
176
+
177
+ let ecosystem: Ecosystem | null = null
178
+ const run = (name: Ecosystem, detect: (add: AddCommand) => boolean): void => {
179
+ const before = commands.length
180
+ let matched = false
181
+ try {
182
+ matched = detect(add)
183
+ } catch {
184
+ matched = false
185
+ }
186
+ if (matched && commands.length > before && ecosystem === null) ecosystem = name
187
+ }
188
+
189
+ run('node', (a) => detectNode(projectRoot, a))
190
+ run('python', (a) => detectPython(projectRoot, a))
191
+ run('rust', (a) => detectRust(projectRoot, a))
192
+ run('go', (a) => detectGo(projectRoot, a))
193
+ // Make is the generic wrapper: only consult it when nothing else produced a
194
+ // command, so a Makefile that just calls `npm test` does not double-report.
195
+ if (commands.length === 0) run('make', (a) => detectMake(projectRoot, a))
196
+
197
+ return { ecosystem, commands, sources }
198
+ }
199
+
200
+ /**
201
+ * Build the partial config update that installs a detected command set under
202
+ * the `default` module. The config tool deep-merges this into an existing
203
+ * `validation.commands` map, so other modules are preserved.
204
+ */
205
+ export function buildValidationUpdates(commands: string[]): Record<string, unknown> {
206
+ return { validation: { commands: { default: [...commands] } } }
207
+ }