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.
- package/README.md +8 -8
- package/README.zh-CN.md +5 -4
- package/dist/tools/config.js +109 -9
- package/dist/tools/preflight.js +45 -4
- package/dist/validation-detect.js +193 -0
- package/lib/client.js +267 -7
- package/lib/parse.js +52 -0
- package/package.json +22 -20
- package/src/client/context-menu.ts +289 -0
- package/src/client/i18n.ts +3 -0
- package/src/client/index.ts +145 -8
- package/src/tools/config.ts +113 -10
- package/src/tools/preflight.ts +52 -4
- package/src/validation-detect.ts +207 -0
package/src/tools/config.ts
CHANGED
|
@@ -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
|
|
25
|
-
//
|
|
26
|
-
isConcurrencySafe: (args) =>
|
|
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
|
|
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:
|
|
48
|
-
|
|
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
|
package/src/tools/preflight.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|