iterate-plugin 3.11.0 → 3.13.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 +10 -9
- package/README.zh-CN.md +11 -10
- package/dist/index.js +8 -5
- package/dist/tools/config.js +109 -9
- package/dist/tools/preflight.js +280 -0
- package/dist/validation-detect.js +193 -0
- package/lib/client.js +352 -5
- package/lib/parse.js +172 -2
- package/package.json +5 -4
- package/src/client/i18n.ts +43 -1
- package/src/client/index.ts +221 -0
- package/src/index.ts +8 -5
- package/src/tools/config.ts +113 -10
- package/src/tools/preflight.ts +337 -0
- package/src/validation-detect.ts +207 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/preflight.ts — `iterate_preflight` tool.
|
|
3
|
+
*
|
|
4
|
+
* Answers one question BEFORE a run starts: "is this project ready to iterate
|
|
5
|
+
* safely, and what must be fixed first?" It reads only local state (config,
|
|
6
|
+
* git, persisted artifacts, checkpoint) and returns a structured checklist the
|
|
7
|
+
* observatory renders as a preflight panel (F11).
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: the most expensive failure mode is starting a run that cannot
|
|
10
|
+
* prove anything — an empty `validation.commands` means the loop has no way to
|
|
11
|
+
* tell a fix from a regression, and a stale FAIL certificate or a leftover
|
|
12
|
+
* interrupted checkpoint makes the first round lie about its starting state.
|
|
13
|
+
* Preflight surfaces those conditions before the model spends a round
|
|
14
|
+
* discovering them mid-flight.
|
|
15
|
+
*
|
|
16
|
+
* Read-only: never mutates config, files, or artifacts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
21
|
+
import { resolveProjectRootForExec, loadEffectiveConfig, flattenCommands } from '../config-loader.ts'
|
|
22
|
+
import { runGit } from '../git-scope.ts'
|
|
23
|
+
import { readQualityGate } from './quality-store.ts'
|
|
24
|
+
import { readCheckpoint, computeStatus } from './checkpoint.ts'
|
|
25
|
+
import { readDecisionEntries } from './decision-log.ts'
|
|
26
|
+
import { readRegistry } from './fix.ts'
|
|
27
|
+
import { resultHeadline } from './present.ts'
|
|
28
|
+
import { detectValidationCommands } from '../validation-detect.ts'
|
|
29
|
+
|
|
30
|
+
/** A single check's outcome. `fail` blocks a clean run; `warn` is advisory. */
|
|
31
|
+
export type PreflightStatus = 'pass' | 'warn' | 'fail'
|
|
32
|
+
|
|
33
|
+
/** One readiness check. `detail` is structured facts (no prose) so the client
|
|
34
|
+
* can localize the message and offer the right fix action. */
|
|
35
|
+
export interface PreflightCheck {
|
|
36
|
+
/** Stable id the client maps to a localized label + fix action. */
|
|
37
|
+
id: string
|
|
38
|
+
status: PreflightStatus
|
|
39
|
+
detail: Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The full preflight report. */
|
|
43
|
+
export interface PreflightReport {
|
|
44
|
+
ok: boolean
|
|
45
|
+
/** True when no check is `fail` (warnings do not block). */
|
|
46
|
+
ready: boolean
|
|
47
|
+
/** Count of `fail` checks. */
|
|
48
|
+
blocking: number
|
|
49
|
+
/** Count of `warn` checks. */
|
|
50
|
+
warnings: number
|
|
51
|
+
checks: PreflightCheck[]
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Facts gathered from disk/git before the pure decision runs. */
|
|
56
|
+
export interface PreflightInput {
|
|
57
|
+
/** Number of configured validation commands (flattened across modules). */
|
|
58
|
+
validationCommandCount: number
|
|
59
|
+
/** Whether the project root is inside a git work tree. */
|
|
60
|
+
isGitRepo: boolean
|
|
61
|
+
/** Uncommitted/untracked file count, or null when git could not answer. */
|
|
62
|
+
changedFiles: number | null
|
|
63
|
+
/** Persisted quality-gate overall status, or null when none is stored. */
|
|
64
|
+
gateOverallStatus: 'pass' | 'fail' | 'pending' | null
|
|
65
|
+
/** True when a leftover checkpoint is genuinely interrupted (nothing newer). */
|
|
66
|
+
interrupted: boolean
|
|
67
|
+
/** How many times the leftover checkpoint has already been resumed. */
|
|
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[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the readiness checklist from already-gathered facts (pure).
|
|
79
|
+
*
|
|
80
|
+
* The five checks and why each exists:
|
|
81
|
+
* 1. `validation` — empty command set ⇒ the loop cannot prove a
|
|
82
|
+
* fix; the product is fail-closed here, so this
|
|
83
|
+
* is a BLOCKING `fail`, not a warning.
|
|
84
|
+
* 2. `git` — changed-only scope + `iterate_diff` need a
|
|
85
|
+
* repository; full-scope review still works, so
|
|
86
|
+
* absent is a `warn`.
|
|
87
|
+
* 3. `dirty-tree` — a run over uncommitted work mixes the user's
|
|
88
|
+
* WIP with the plugin's fixes; advisory `warn`.
|
|
89
|
+
* Unknown (null) is treated as pass so a git
|
|
90
|
+
* hiccup never fabricates a warning.
|
|
91
|
+
* 4. `stale-gate` — a persisted FAIL certificate makes the first
|
|
92
|
+
* round read stale; `warn` + clear before a
|
|
93
|
+
* fresh run.
|
|
94
|
+
* 5. `interrupted-checkpoint`— a leftover checkpoint from a run that never
|
|
95
|
+
* progressed; `warn` + resume or clear.
|
|
96
|
+
*/
|
|
97
|
+
export function buildPreflightReport(input: PreflightInput): PreflightReport {
|
|
98
|
+
const suggestions = Array.isArray(input.suggestedCommands) ? input.suggestedCommands.filter((c) => typeof c === 'string' && c.length > 0) : []
|
|
99
|
+
const checks: PreflightCheck[] = [
|
|
100
|
+
{
|
|
101
|
+
id: 'validation',
|
|
102
|
+
status: input.validationCommandCount > 0 ? 'pass' : 'fail',
|
|
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
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'git',
|
|
118
|
+
status: input.isGitRepo ? 'pass' : 'warn',
|
|
119
|
+
detail: { isRepo: input.isGitRepo },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'dirty-tree',
|
|
123
|
+
status: input.changedFiles !== null && input.changedFiles > 0 ? 'warn' : 'pass',
|
|
124
|
+
detail: { changedFiles: input.changedFiles },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'stale-gate',
|
|
128
|
+
status: input.gateOverallStatus === 'fail' ? 'warn' : 'pass',
|
|
129
|
+
detail: { overallStatus: input.gateOverallStatus },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'interrupted-checkpoint',
|
|
133
|
+
status: input.interrupted ? 'warn' : 'pass',
|
|
134
|
+
detail: { interrupted: input.interrupted, resumeCount: input.resumeCount },
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
const blocking = checks.filter((c) => c.status === 'fail').length
|
|
139
|
+
const warnings = checks.filter((c) => c.status === 'warn').length
|
|
140
|
+
return { ok: true, ready: blocking === 0, blocking, warnings, checks }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Human-readable (English, for the model-facing render) description per check. */
|
|
144
|
+
function describeCheck(id: string, detail: Record<string, unknown>): string {
|
|
145
|
+
switch (id) {
|
|
146
|
+
case 'validation': {
|
|
147
|
+
const n = Number(detail.commandCount ?? 0)
|
|
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)'
|
|
159
|
+
}
|
|
160
|
+
case 'git':
|
|
161
|
+
return detail.isRepo === true
|
|
162
|
+
? 'git repository detected'
|
|
163
|
+
: 'not a git repository — changed-only scope and iterate_diff need one'
|
|
164
|
+
case 'dirty-tree': {
|
|
165
|
+
const n = detail.changedFiles
|
|
166
|
+
if (n === null || n === undefined) return 'uncommitted changes: unknown'
|
|
167
|
+
const num = Number(n)
|
|
168
|
+
return num > 0
|
|
169
|
+
? `${num} uncommitted/untracked file(s) — commit or stash before a run`
|
|
170
|
+
: 'working tree clean'
|
|
171
|
+
}
|
|
172
|
+
case 'stale-gate': {
|
|
173
|
+
const s = detail.overallStatus
|
|
174
|
+
if (s === 'fail') return 'a stale FAIL quality gate is persisted — clear it before a fresh run'
|
|
175
|
+
return s ? `quality gate: ${String(s)}` : 'no persisted quality gate'
|
|
176
|
+
}
|
|
177
|
+
case 'interrupted-checkpoint': {
|
|
178
|
+
if (detail.interrupted === true) {
|
|
179
|
+
const rc = Number(detail.resumeCount ?? 0)
|
|
180
|
+
return `an interrupted checkpoint is waiting (resumed ${rc}x) — resume or clear it`
|
|
181
|
+
}
|
|
182
|
+
return 'no interrupted checkpoint'
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
return ''
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Register the `iterate_preflight` tool.
|
|
191
|
+
*/
|
|
192
|
+
export function registerPreflightTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
193
|
+
ctx.tools.register(
|
|
194
|
+
defineTool({
|
|
195
|
+
name: 'iterate_preflight',
|
|
196
|
+
// Pure read of on-disk state + git → safe to join a parallel group.
|
|
197
|
+
isConcurrencySafe: () => true,
|
|
198
|
+
presentResult: (_args, result) => {
|
|
199
|
+
const title = resultHeadline(result)
|
|
200
|
+
return title ? { card: 'generic', title } : undefined
|
|
201
|
+
},
|
|
202
|
+
description:
|
|
203
|
+
'Read-only readiness check to run BEFORE starting an iterate run. Returns a checklist: whether ' +
|
|
204
|
+
'validation.commands is configured (an empty set means the loop cannot prove fixes — blocking), ' +
|
|
205
|
+
'whether the project is a git repository, how many files are uncommitted, whether a stale FAIL ' +
|
|
206
|
+
'quality gate is persisted, and whether an interrupted checkpoint is waiting. Use it before a fresh ' +
|
|
207
|
+
'run so you fix the environment first instead of discovering it mid-round.',
|
|
208
|
+
parameters: {
|
|
209
|
+
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
output: {
|
|
213
|
+
schema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
properties: {
|
|
217
|
+
ok: { type: 'boolean', required: true },
|
|
218
|
+
ready: { type: 'boolean' },
|
|
219
|
+
blocking: { type: 'integer' },
|
|
220
|
+
warnings: { type: 'integer' },
|
|
221
|
+
checks: { type: 'json', description: 'Per-check outcomes: { id, status: pass|warn|fail, detail }.' },
|
|
222
|
+
error: { type: 'string' },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
render: (_args, value) => {
|
|
226
|
+
if (!value.ok) return [{ type: 'text', text: `preflight failed: ${value.error}` }]
|
|
227
|
+
const checks = Array.isArray(value.checks) ? value.checks : []
|
|
228
|
+
const warnings = Number(value.warnings ?? 0)
|
|
229
|
+
const noun = warnings === 1 ? 'warning' : 'warnings'
|
|
230
|
+
const head = value.ready
|
|
231
|
+
? `Preflight: READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
232
|
+
: `Preflight: NOT READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
233
|
+
const lines = checks.map((c) => {
|
|
234
|
+
const check = c as { id?: unknown; status?: unknown; detail?: Record<string, unknown> }
|
|
235
|
+
const id = String(check.id ?? '?')
|
|
236
|
+
const status = String(check.status ?? '?').toUpperCase()
|
|
237
|
+
return ` ${status} ${id} — ${describeCheck(id, check.detail ?? {})}`
|
|
238
|
+
})
|
|
239
|
+
return [{ type: 'text', text: [head, ...lines].join('\n') }]
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async execute(args, exec) {
|
|
244
|
+
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
245
|
+
if (!resolved.ok) {
|
|
246
|
+
return { ok: false, ready: false, blocking: 0, warnings: 0, checks: [] as unknown as JsonValue, error: resolved.reason }
|
|
247
|
+
}
|
|
248
|
+
const projectRoot = resolved.root
|
|
249
|
+
|
|
250
|
+
// 1. validation.commands — the load-bearing check.
|
|
251
|
+
let validationCommandCount = 0
|
|
252
|
+
try {
|
|
253
|
+
const effective = loadEffectiveConfig(projectRoot)
|
|
254
|
+
validationCommandCount = flattenCommands(effective.config.validation?.commands).length
|
|
255
|
+
} catch {
|
|
256
|
+
validationCommandCount = 0
|
|
257
|
+
}
|
|
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
|
+
|
|
277
|
+
// 2/3. git repo + dirty tree (best-effort; any failure degrades to unknown).
|
|
278
|
+
let isGitRepo = false
|
|
279
|
+
let changedFiles: number | null = null
|
|
280
|
+
try {
|
|
281
|
+
const inside = await runGit(['rev-parse', '--is-inside-work-tree'], projectRoot, { signal: exec.signal })
|
|
282
|
+
isGitRepo = inside.ok && inside.stdout.trim() === 'true'
|
|
283
|
+
if (isGitRepo) {
|
|
284
|
+
const status = await runGit(['status', '--porcelain', '-z'], projectRoot, { signal: exec.signal })
|
|
285
|
+
if (status.ok) {
|
|
286
|
+
// `-z` NUL-terminates each entry; a rename emits two NUL-separated
|
|
287
|
+
// paths for one entry, but counting non-empty tokens is still a
|
|
288
|
+
// faithful "there is uncommitted work" signal (>=1 when dirty).
|
|
289
|
+
changedFiles = status.stdout.split('\u0000').filter((s) => s.length > 0).length
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
isGitRepo = false
|
|
294
|
+
changedFiles = null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 4. stale quality gate.
|
|
298
|
+
let gateOverallStatus: 'pass' | 'fail' | 'pending' | null = null
|
|
299
|
+
try {
|
|
300
|
+
gateOverallStatus = readQualityGate(projectRoot).overallStatus ?? null
|
|
301
|
+
} catch {
|
|
302
|
+
gateOverallStatus = null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 5. interrupted checkpoint (reuse the exact status logic so preflight
|
|
306
|
+
// and iterate_status can never disagree about "interrupted").
|
|
307
|
+
let interrupted = false
|
|
308
|
+
let resumeCount = 0
|
|
309
|
+
try {
|
|
310
|
+
const status = computeStatus({
|
|
311
|
+
checkpoint: readCheckpoint(projectRoot),
|
|
312
|
+
decisionEntries: readDecisionEntries(projectRoot),
|
|
313
|
+
fixRegistry: readRegistry(projectRoot),
|
|
314
|
+
})
|
|
315
|
+
interrupted = status.interrupted
|
|
316
|
+
resumeCount = status.resumeCount
|
|
317
|
+
} catch {
|
|
318
|
+
interrupted = false
|
|
319
|
+
resumeCount = 0
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const report = buildPreflightReport({
|
|
323
|
+
validationCommandCount,
|
|
324
|
+
isGitRepo,
|
|
325
|
+
changedFiles,
|
|
326
|
+
gateOverallStatus,
|
|
327
|
+
interrupted,
|
|
328
|
+
resumeCount,
|
|
329
|
+
suggestedCommands,
|
|
330
|
+
ecosystem: detectedEcosystem,
|
|
331
|
+
detectedFrom,
|
|
332
|
+
})
|
|
333
|
+
return { ...report, checks: report.checks as unknown as JsonValue }
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
@@ -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
|
+
}
|