klyro 0.1.17 → 0.1.18
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/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.js +139 -4
- package/dist/cli/eval.js +15 -0
- package/dist/cli/repl.js +23 -0
- package/dist/cli/run.d.ts +1 -0
- package/dist/cli/run.js +17 -0
- package/dist/cli/slash/parser.d.ts +2 -0
- package/dist/cli/slash/parser.js +2 -1
- package/dist/index.js +2 -0
- package/dist/verification/baseline.d.ts +17 -0
- package/dist/verification/baseline.js +108 -0
- package/dist/verification/classify.d.ts +22 -0
- package/dist/verification/classify.js +108 -0
- package/dist/verification/detect.js +175 -10
- package/dist/verification/registry.d.ts +23 -0
- package/dist/verification/registry.js +130 -0
- package/dist/verification/scoped.d.ts +22 -0
- package/dist/verification/scoped.js +214 -0
- package/package.json +1 -1
package/dist/agent/runtime.d.ts
CHANGED
package/dist/agent/runtime.js
CHANGED
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
import { text, toolUse, toolResult as mkToolResult } from './message.js';
|
|
18
18
|
import { redact } from '../policy/secret-redactor.js';
|
|
19
19
|
import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
|
|
20
|
+
import * as path from 'node:path';
|
|
20
21
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
21
22
|
import { detectVerifyCommand } from '../verification/auto.js';
|
|
23
|
+
import { ensureBaseline, getBaseline } from '../verification/baseline.js';
|
|
24
|
+
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
25
|
+
import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
|
|
22
26
|
import { globalBus } from '../events/bus.js';
|
|
23
27
|
import { TraceWriter } from '../trace/writer.js';
|
|
24
28
|
const DEFAULT_MAX_STEPS = 30;
|
|
@@ -92,6 +96,20 @@ export async function run(opts, deps) {
|
|
|
92
96
|
};
|
|
93
97
|
const store = opts.persist?.store;
|
|
94
98
|
const sessionId = opts.persist?.sessionId;
|
|
99
|
+
// 6.1 baseline cache per HEAD — capture before first edit
|
|
100
|
+
let baselinePrimed = false;
|
|
101
|
+
async function primeBaseline() {
|
|
102
|
+
if (baselinePrimed)
|
|
103
|
+
return;
|
|
104
|
+
baselinePrimed = true;
|
|
105
|
+
const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
|
|
106
|
+
if (!cmd)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
await ensureBaseline(opts.cwd, cmd);
|
|
110
|
+
}
|
|
111
|
+
catch { /* ignore */ }
|
|
112
|
+
}
|
|
95
113
|
async function checkpoint(msg, obs) {
|
|
96
114
|
if (!store || !sessionId)
|
|
97
115
|
return;
|
|
@@ -271,13 +289,53 @@ export async function run(opts, deps) {
|
|
|
271
289
|
if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
|
|
272
290
|
emit?.({ kind: 'verification_started', command: verifyCmd });
|
|
273
291
|
let vResult;
|
|
292
|
+
// 6.3 — scoped run if edited files known
|
|
293
|
+
const edited = [...fileEditCounts.keys()];
|
|
294
|
+
const related = findRelatedTests(opts.cwd, edited);
|
|
295
|
+
const scopedCmd = buildScopedCommand(opts.cwd, verifyCmd, related);
|
|
296
|
+
const cmdToRun = scopedCmd ?? verifyCmd;
|
|
297
|
+
const isScoped = !!scopedCmd;
|
|
298
|
+
if (isScoped)
|
|
299
|
+
emit?.({ kind: 'verification_started', command: cmdToRun });
|
|
274
300
|
try {
|
|
275
|
-
|
|
301
|
+
if (isScoped) {
|
|
302
|
+
const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
|
|
303
|
+
const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
|
|
304
|
+
vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
|
|
308
|
+
}
|
|
276
309
|
}
|
|
277
310
|
catch (e) {
|
|
278
311
|
const msg = e instanceof Error ? e.message : String(e);
|
|
279
312
|
vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
|
|
280
313
|
}
|
|
314
|
+
// 6.3 — sanity checks before full verify
|
|
315
|
+
if (vResult.ok) {
|
|
316
|
+
// quick syntax/import guard for last edited files
|
|
317
|
+
for (const f of edited.slice(-3)) {
|
|
318
|
+
const sc = await syntaxCheck(opts.cwd, f);
|
|
319
|
+
if (!sc.ok) {
|
|
320
|
+
vResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const ic = checkImports(opts.cwd, f);
|
|
324
|
+
if (!ic.ok) {
|
|
325
|
+
vResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// If scoped passed but full may still fail, run full before declaring success
|
|
331
|
+
if (vResult.ok && isScoped) {
|
|
332
|
+
try {
|
|
333
|
+
const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
|
|
334
|
+
if (!full.ok)
|
|
335
|
+
vResult = full;
|
|
336
|
+
}
|
|
337
|
+
catch { /* scoped success is enough */ }
|
|
338
|
+
}
|
|
281
339
|
if (vResult.ok) {
|
|
282
340
|
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
283
341
|
emit?.({ kind: 'final_text', text: finalText });
|
|
@@ -291,16 +349,90 @@ export async function run(opts, deps) {
|
|
|
291
349
|
await closeTracer();
|
|
292
350
|
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
293
351
|
}
|
|
294
|
-
//
|
|
352
|
+
// 6.4 — classify
|
|
353
|
+
const baseline = await getBaseline(opts.cwd, verifyCmd);
|
|
354
|
+
const isFlaky = await rerunOnce(opts.cwd, cmdToRun, 30_000);
|
|
355
|
+
const cls = classifyFailure({ failure: vResult.failure, stdout: vResult.stdout, stderr: vResult.stderr }, baseline, isFlaky);
|
|
356
|
+
if (cls === 'flaky') {
|
|
357
|
+
// rerun succeeded on second try — treat as flaky, don't count as repair
|
|
358
|
+
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
359
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
360
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
361
|
+
if (store && sessionId) {
|
|
362
|
+
try {
|
|
363
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
364
|
+
}
|
|
365
|
+
catch { /* ignore */ }
|
|
366
|
+
}
|
|
367
|
+
await closeTracer();
|
|
368
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
369
|
+
}
|
|
370
|
+
if (cls === 'env') {
|
|
371
|
+
// don't try to repair env failures with code edits
|
|
372
|
+
const envMsg = { role: 'user', content: [text(`Verification failed due to environment issue (not code):\n\n${diagnosticForModel(vResult)}\n\nPlease suggest how to fix the environment (install deps, set env vars) rather than editing code. If this is a missing binary, ask the user.`)] };
|
|
373
|
+
transcript.push(envMsg);
|
|
374
|
+
await checkpoint(envMsg);
|
|
375
|
+
verificationAttempts++;
|
|
376
|
+
emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
|
|
377
|
+
if (verificationAttempts >= maxRepairs) {
|
|
378
|
+
await closeTracer();
|
|
379
|
+
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
|
|
380
|
+
}
|
|
381
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (cls === 'pre_existing') {
|
|
385
|
+
// pre-existing — don't penalize, but still surface
|
|
386
|
+
const preMsg = { role: 'user', content: [text(`Note: verification failure appears pre-existing (present in baseline at HEAD). Current failure:\n\n${diagnosticForModel(vResult)}\n\nIf this failure is unrelated to your changes, you may proceed, but try to avoid making it worse.`)] };
|
|
387
|
+
transcript.push(preMsg);
|
|
388
|
+
await checkpoint(preMsg);
|
|
389
|
+
// still count as needing repair if introduced files overlap, else allow completion
|
|
390
|
+
// For now, treat pre-existing as non-blocking after one warning if no introduced files in failure
|
|
391
|
+
const introducedPaths = new Set(edited);
|
|
392
|
+
const failurePaths = new Set(vResult.failure?.files.map((f) => f.path).filter(Boolean) ?? []);
|
|
393
|
+
const overlaps = [...failurePaths].some((p) => introducedPaths.has(p) || introducedPaths.has(path.basename(p)));
|
|
394
|
+
if (!overlaps) {
|
|
395
|
+
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
396
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
397
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
398
|
+
if (store && sessionId) {
|
|
399
|
+
try {
|
|
400
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
401
|
+
}
|
|
402
|
+
catch { /* ignore */ }
|
|
403
|
+
}
|
|
404
|
+
await closeTracer();
|
|
405
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Failure → repair loop (introduced)
|
|
295
409
|
verificationAttempts++;
|
|
296
410
|
const diagnostic = diagnosticForModel(vResult);
|
|
297
411
|
const failureType = vResult.failure?.type ?? 'unknown';
|
|
412
|
+
// 6.4 — gather context
|
|
413
|
+
const ctx = await gatherRepairContext(opts.cwd, vResult.failure);
|
|
414
|
+
const ctxBlock = [
|
|
415
|
+
ctx.hunks ? `Changed hunks:\n${ctx.hunks.slice(0, 1500)}` : '',
|
|
416
|
+
ctx.failingTests.length > 0 ? `Failing test excerpt:\n${ctx.failingTests[0]?.content.slice(0, 1500)}` : '',
|
|
417
|
+
ctx.blame ? `Blame:\n${ctx.blame.slice(0, 800)}` : '',
|
|
418
|
+
].filter(Boolean).join('\n\n');
|
|
298
419
|
emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
|
|
299
420
|
emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
|
|
300
421
|
telemetry.recordError(`verify_${failureType}`);
|
|
422
|
+
// 6.4 guard — check if last diff touches assertions/skips (would need approval)
|
|
423
|
+
try {
|
|
424
|
+
const diffText = await (await import('node:fs/promises')).readFile(path.join(opts.cwd, '.klyro', 'checkpoints', 'last.diff'), 'utf-8').catch(() => ctx.hunks);
|
|
425
|
+
const g = guardRepair(diffText ?? ctx.hunks, edited);
|
|
426
|
+
if (g.blocked) {
|
|
427
|
+
const guardMsg = { role: 'user', content: [text(`Repair guard blocked: ${g.reason}\n\nIf you must edit test assertions or add skips, first ask the user for explicit approval via ask_user.`)] };
|
|
428
|
+
transcript.push(guardMsg);
|
|
429
|
+
await checkpoint(guardMsg);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch { /* ignore guard */ }
|
|
301
433
|
const repairMsg = {
|
|
302
434
|
role: 'user',
|
|
303
|
-
content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above.`)],
|
|
435
|
+
content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}, class=${cls}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\n${ctxBlock ? `\nContext:\n${ctxBlock}\n` : ''}\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above. Do not edit test assertions unless the test itself is wrong — fix the source.`)],
|
|
304
436
|
};
|
|
305
437
|
transcript.push(repairMsg);
|
|
306
438
|
await checkpoint(repairMsg);
|
|
@@ -398,8 +530,11 @@ export async function run(opts, deps) {
|
|
|
398
530
|
await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
|
|
399
531
|
if (obs.ok) {
|
|
400
532
|
telemetry.recordToolCall(call, latencyMs, false);
|
|
401
|
-
if (call.name === 'write_file' || call.name === 'edit_file')
|
|
533
|
+
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch')
|
|
402
534
|
hasEdits = true;
|
|
535
|
+
// 6.1 — prime baseline on first edit
|
|
536
|
+
if (hasEdits && !baselinePrimed)
|
|
537
|
+
void primeBaseline();
|
|
403
538
|
}
|
|
404
539
|
else {
|
|
405
540
|
const code = String(obs.error?.code ?? 'tool_error');
|
package/dist/cli/eval.js
CHANGED
|
@@ -61,6 +61,21 @@ export async function runEval(opts) {
|
|
|
61
61
|
for (const e of entries) {
|
|
62
62
|
if (opts.filter && !e.includes(opts.filter))
|
|
63
63
|
continue;
|
|
64
|
+
// 6.5 — suite filter: smoke = type smoke, l6 = prefix l6-introduce
|
|
65
|
+
if (opts.suite && opts.suite !== 'smoke') {
|
|
66
|
+
if (!e.startsWith(opts.suite) && !e.includes(opts.suite))
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
else if (opts.suite === 'smoke') {
|
|
70
|
+
// smoke = only type smoke (exclude l6 introduce fixtures)
|
|
71
|
+
try {
|
|
72
|
+
const metaRaw = await fs.readFile(path.join(fixturesDir, e, 'meta.json'), 'utf-8');
|
|
73
|
+
const meta = JSON.parse(metaRaw);
|
|
74
|
+
if (meta.suite === 'l6' || meta.type === 'l6-introduce')
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
catch { /* no meta → include */ }
|
|
78
|
+
}
|
|
64
79
|
const taskPath = path.join(fixturesDir, e, 'task.md');
|
|
65
80
|
try {
|
|
66
81
|
const task = await fs.readFile(taskPath, 'utf-8');
|
package/dist/cli/repl.js
CHANGED
|
@@ -480,6 +480,29 @@ export async function startRepl(opts = {}) {
|
|
|
480
480
|
}
|
|
481
481
|
return;
|
|
482
482
|
}
|
|
483
|
+
case 'verify': {
|
|
484
|
+
const { detectVerifiers, primaryVerifyCommand } = await import('../verification/registry.js');
|
|
485
|
+
const { verify } = await import('../verification/engine.js');
|
|
486
|
+
const verifiers = detectVerifiers(cwd);
|
|
487
|
+
if (verifiers.length === 0) {
|
|
488
|
+
queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: 'No verifiers detected (no test/typecheck/lint/build). Try `npm test` manually.', role: 'assistant' });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const list = verifiers.map((v) => ` ${v.id}: ${v.command}`).join('\n');
|
|
492
|
+
queuedAppend({ id: `vrfy-list-${Date.now()}`, kind: 'text', text: `Verifiers:\n${list}`, role: 'assistant' });
|
|
493
|
+
const cmd = primaryVerifyCommand(cwd);
|
|
494
|
+
if (!cmd)
|
|
495
|
+
return;
|
|
496
|
+
queuedAppend({ id: `vrfy-run-${Date.now()}`, kind: 'text', text: `[verify] running \`${cmd}\`...`, role: 'assistant' });
|
|
497
|
+
try {
|
|
498
|
+
const res = await verify({ cwd, command: cmd });
|
|
499
|
+
queuedAppend({ id: `vrfy-res-${Date.now()}`, kind: 'text', text: res.ok ? `[verify] passed (${cmd})` : `[verify] failed (${cmd}): ${res.stderr.slice(0, 500)}`, role: 'assistant' });
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
queuedAppend({ id: `vrfy-err-${Date.now()}`, kind: 'error', message: `verify failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
483
506
|
case 'compact':
|
|
484
507
|
queuedAppend({
|
|
485
508
|
id: `stub-${Date.now()}`,
|
package/dist/cli/run.d.ts
CHANGED
package/dist/cli/run.js
CHANGED
|
@@ -110,6 +110,7 @@ export async function runOnce(opts) {
|
|
|
110
110
|
command: opts.verifyCommand,
|
|
111
111
|
maxRepairAttempts: opts.maxRepairAttempts ?? 3,
|
|
112
112
|
timeoutMs: opts.verifyTimeoutMs,
|
|
113
|
+
requireVerify: opts.requireVerify,
|
|
113
114
|
};
|
|
114
115
|
const result = await run({
|
|
115
116
|
task: opts.task,
|
|
@@ -212,6 +213,22 @@ export async function runOnce(opts) {
|
|
|
212
213
|
stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
|
|
213
214
|
return 5;
|
|
214
215
|
}
|
|
216
|
+
// 6.5 — --require-verify: if edits were made but verification never passed, exit 8
|
|
217
|
+
if (opts.requireVerify && result.verification && !result.verification.ok) {
|
|
218
|
+
if (output === 'json')
|
|
219
|
+
stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_failed' }) + '\n');
|
|
220
|
+
else
|
|
221
|
+
stderr.write('klyro: --require-verify: verification required but not passed\n');
|
|
222
|
+
return 8;
|
|
223
|
+
}
|
|
224
|
+
if (opts.requireVerify && !result.verification) {
|
|
225
|
+
// hasEdits but no verification command found
|
|
226
|
+
if (output === 'json')
|
|
227
|
+
stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_missing' }) + '\n');
|
|
228
|
+
else
|
|
229
|
+
stderr.write('klyro: --require-verify: no verification command found and edits were made\n');
|
|
230
|
+
return 8;
|
|
231
|
+
}
|
|
215
232
|
if (output === 'json')
|
|
216
233
|
stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
|
|
217
234
|
return 0;
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Anything not starting with "/" is a regular prompt and yields
|
|
15
15
|
* { kind: 'prompt', text }.
|
|
16
16
|
*/
|
|
17
|
-
const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'exit', 'clear'];
|
|
17
|
+
const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'verify', 'exit', 'clear'];
|
|
18
18
|
export function parse(input) {
|
|
19
19
|
const trimmed = input.trim();
|
|
20
20
|
if (!trimmed.startsWith('/')) {
|
|
@@ -35,6 +35,7 @@ export function parse(input) {
|
|
|
35
35
|
case 'thinking': return { kind: 'thinking' };
|
|
36
36
|
case 'memory': return { kind: 'memory' };
|
|
37
37
|
case 'jobs': return { kind: 'jobs' };
|
|
38
|
+
case 'verify': return { kind: 'verify' };
|
|
38
39
|
case 'quit':
|
|
39
40
|
case 'exit':
|
|
40
41
|
case 'q': return { kind: 'quit' };
|
package/dist/index.js
CHANGED
|
@@ -296,6 +296,7 @@ async function main() {
|
|
|
296
296
|
.option('--verify-command <cmd>', 'Custom verification command (default: auto-detected)')
|
|
297
297
|
.option('--max-repairs <n>', 'Max autonomous repair attempts (default 3)', (v) => parsePositiveInt('--max-repairs', v))
|
|
298
298
|
.option('--persist', 'Enable session persistence (Level 9, default: enabled)')
|
|
299
|
+
.option('--require-verify', 'Fail with exit 8 if no verification passed after edits (6.5)')
|
|
299
300
|
.action(async (prompt, opts) => {
|
|
300
301
|
const model = opts.model ?? process.env.KLYRO_MODEL;
|
|
301
302
|
if (!model) {
|
|
@@ -328,6 +329,7 @@ async function main() {
|
|
|
328
329
|
verifyCommand: opts.verifyCommand,
|
|
329
330
|
maxRepairAttempts: opts.maxRepairs,
|
|
330
331
|
persist: opts.persist,
|
|
332
|
+
requireVerify: !!opts.requireVerify,
|
|
331
333
|
});
|
|
332
334
|
process.exit(code);
|
|
333
335
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Baseline run (pre-existing failure cache per HEAD)
|
|
3
|
+
* Before first edit, run primary verifier and cache result keyed by git HEAD.
|
|
4
|
+
* Used to distinguish introduced vs pre-existing failures (6.4 classify).
|
|
5
|
+
*/
|
|
6
|
+
export interface BaselineResult {
|
|
7
|
+
head: string;
|
|
8
|
+
command: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
capturedAt: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function getBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
|
|
16
|
+
export declare function runBaseline(cwd: string, command?: string, timeoutMs?: number): Promise<BaselineResult | null>;
|
|
17
|
+
export declare function ensureBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Baseline run (pre-existing failure cache per HEAD)
|
|
3
|
+
* Before first edit, run primary verifier and cache result keyed by git HEAD.
|
|
4
|
+
* Used to distinguish introduced vs pre-existing failures (6.4 classify).
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as crypto from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { primaryVerifyCommand } from './registry.js';
|
|
11
|
+
async function gitHead(cwd) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn('git', ['rev-parse', 'HEAD'], { cwd, shell: false });
|
|
14
|
+
let out = '';
|
|
15
|
+
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
16
|
+
child.on('close', () => resolve(out.trim() || 'no-head'));
|
|
17
|
+
child.on('error', () => resolve('no-head'));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function baselinePath(cwd, head) {
|
|
21
|
+
const safe = head.slice(0, 12) || 'no-head';
|
|
22
|
+
return path.join(cwd, '.klyro', 'baselines', `${safe}.json`);
|
|
23
|
+
}
|
|
24
|
+
export async function getBaseline(cwd, command) {
|
|
25
|
+
const cmd = command ?? primaryVerifyCommand(cwd);
|
|
26
|
+
if (!cmd)
|
|
27
|
+
return null;
|
|
28
|
+
const head = await gitHead(cwd);
|
|
29
|
+
const p = baselinePath(cwd, head);
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (parsed.head === head && parsed.command === cmd)
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
catch { /* miss */ }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export async function runBaseline(cwd, command, timeoutMs = 90_000) {
|
|
40
|
+
const cmd = command ?? primaryVerifyCommand(cwd);
|
|
41
|
+
if (!cmd)
|
|
42
|
+
return null;
|
|
43
|
+
const head = await gitHead(cwd);
|
|
44
|
+
const p = baselinePath(cwd, head);
|
|
45
|
+
// if cached, return
|
|
46
|
+
const cached = await getBaseline(cwd, cmd);
|
|
47
|
+
if (cached)
|
|
48
|
+
return cached;
|
|
49
|
+
const result = await runCmd(cwd, cmd, timeoutMs);
|
|
50
|
+
const baseline = {
|
|
51
|
+
head,
|
|
52
|
+
command: cmd,
|
|
53
|
+
ok: result.ok,
|
|
54
|
+
exitCode: result.exitCode,
|
|
55
|
+
stdout: result.stdout.slice(0, 8000),
|
|
56
|
+
stderr: result.stderr.slice(0, 8000),
|
|
57
|
+
capturedAt: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
61
|
+
const tmp = `${p}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2));
|
|
63
|
+
fs.renameSync(tmp, p);
|
|
64
|
+
}
|
|
65
|
+
catch { /* ignore */ }
|
|
66
|
+
return baseline;
|
|
67
|
+
}
|
|
68
|
+
function runCmd(cwd, command, timeoutMs) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
71
|
+
let stdout = '';
|
|
72
|
+
let stderr = '';
|
|
73
|
+
let done = false;
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
if (done)
|
|
76
|
+
return;
|
|
77
|
+
done = true;
|
|
78
|
+
try {
|
|
79
|
+
child.kill();
|
|
80
|
+
}
|
|
81
|
+
catch { /* ignore */ }
|
|
82
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
child.stdout.on('data', (b) => { stdout += b.toString(); });
|
|
85
|
+
child.stderr.on('data', (b) => { stderr += b.toString(); });
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
if (done)
|
|
88
|
+
return;
|
|
89
|
+
done = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
const exit = typeof code === 'number' ? code : -1;
|
|
92
|
+
resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
|
|
93
|
+
});
|
|
94
|
+
child.on('error', (err) => {
|
|
95
|
+
if (done)
|
|
96
|
+
return;
|
|
97
|
+
done = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export async function ensureBaseline(cwd, command) {
|
|
104
|
+
const existing = await getBaseline(cwd, command);
|
|
105
|
+
if (existing)
|
|
106
|
+
return existing;
|
|
107
|
+
return runBaseline(cwd, command);
|
|
108
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Failure } from './detect.js';
|
|
2
|
+
import type { BaselineResult } from './baseline.js';
|
|
3
|
+
export type FailureClass = 'introduced' | 'pre_existing' | 'flaky' | 'env';
|
|
4
|
+
export declare function classifyFailure(current: {
|
|
5
|
+
failure?: Failure;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
}, baseline: BaselineResult | null, flakyRerunOk?: boolean): FailureClass;
|
|
9
|
+
export declare function rerunOnce(cwd: string, command: string, timeoutMs?: number): Promise<boolean>;
|
|
10
|
+
export interface RepairContext {
|
|
11
|
+
failingTests: {
|
|
12
|
+
file: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}[];
|
|
15
|
+
hunks: string;
|
|
16
|
+
blame: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function gatherRepairContext(cwd: string, failure: Failure | undefined): Promise<RepairContext>;
|
|
19
|
+
export declare function guardRepair(diff: string, editedFiles: string[]): {
|
|
20
|
+
blocked: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.4 — Repair classifier + context gather + guard
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
export function classifyFailure(current, baseline, flakyRerunOk) {
|
|
8
|
+
const combined = (current.stderr + '\n' + current.stdout).toLowerCase();
|
|
9
|
+
// env: missing binary, network, permission, no such file, EACCES etc
|
|
10
|
+
if (/enoent|command not found|no such file|network|econn|etimedout|eacces|permission denied|env/.test(combined) && /error/i.test(combined)) {
|
|
11
|
+
// only if not a real test failure but env
|
|
12
|
+
if (!current.failure || current.failure.type === 'unknown')
|
|
13
|
+
return 'env';
|
|
14
|
+
// even test failures can be env if message hints
|
|
15
|
+
if (/enoent|not found/.test(combined))
|
|
16
|
+
return 'env';
|
|
17
|
+
}
|
|
18
|
+
if (flakyRerunOk)
|
|
19
|
+
return 'flaky';
|
|
20
|
+
if (baseline && !baseline.ok) {
|
|
21
|
+
const baseRaw = (baseline.stderr + '\n' + baseline.stdout);
|
|
22
|
+
// if current failure files overlap baseline failure raw, treat as pre-existing
|
|
23
|
+
if (current.failure && baseline.stdout + baseline.stderr) {
|
|
24
|
+
const curPaths = new Set(current.failure.files.map((f) => f.path).filter(Boolean));
|
|
25
|
+
const baseContains = [...curPaths].some((p) => baseRaw.includes(p));
|
|
26
|
+
if (baseContains)
|
|
27
|
+
return 'pre_existing';
|
|
28
|
+
// also if same exit code and type, likely pre-existing
|
|
29
|
+
if (baseRaw.includes(current.failure.files[0]?.message ?? '') && baseRaw.length > 0)
|
|
30
|
+
return 'pre_existing';
|
|
31
|
+
}
|
|
32
|
+
// if baseline failed and current also fails with similar raw length, assume pre-existing
|
|
33
|
+
if (current.failure && baseline.stderr.length > 0 && combined.includes(baseline.stderr.slice(0, 200).toLowerCase()))
|
|
34
|
+
return 'pre_existing';
|
|
35
|
+
}
|
|
36
|
+
return 'introduced';
|
|
37
|
+
}
|
|
38
|
+
export async function rerunOnce(cwd, command, timeoutMs = 45_000) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
41
|
+
let done = false;
|
|
42
|
+
const t = setTimeout(() => { if (!done) {
|
|
43
|
+
done = true;
|
|
44
|
+
try {
|
|
45
|
+
child.kill();
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
resolve(false);
|
|
49
|
+
} }, timeoutMs);
|
|
50
|
+
child.on('close', (code) => { if (done)
|
|
51
|
+
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
52
|
+
child.on('error', () => { if (done)
|
|
53
|
+
return; done = true; clearTimeout(t); resolve(false); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function gatherRepairContext(cwd, failure) {
|
|
57
|
+
const failingTests = [];
|
|
58
|
+
if (failure) {
|
|
59
|
+
for (const f of failure.files.slice(0, 3)) {
|
|
60
|
+
if (!f.path)
|
|
61
|
+
continue;
|
|
62
|
+
const full = path.join(cwd, f.path);
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(full, 'utf-8');
|
|
65
|
+
// slice around failing line if available
|
|
66
|
+
const lines = raw.split('\n');
|
|
67
|
+
const start = Math.max(0, (f.line ?? 1) - 20);
|
|
68
|
+
const end = Math.min(lines.length, (f.line ?? 1) + 20);
|
|
69
|
+
failingTests.push({ file: f.path, content: lines.slice(start, end).join('\n') });
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// hunks: git diff --stat + --unified=2 for changed files
|
|
75
|
+
const hunks = await execCapture(cwd, 'git diff --stat && echo "---" && git diff -U2 2>&1 | head -n 300');
|
|
76
|
+
const blame = failure?.files[0]?.path
|
|
77
|
+
? await execCapture(cwd, `git blame "${failure.files[0].path}" 2>&1 | head -n 20`)
|
|
78
|
+
: '';
|
|
79
|
+
return { failingTests, hunks, blame };
|
|
80
|
+
}
|
|
81
|
+
function execCapture(cwd, cmd) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const child = spawn(cmd, { cwd, shell: true, env: process.env });
|
|
84
|
+
let out = '';
|
|
85
|
+
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
86
|
+
child.stderr.on('data', (b) => { out += b.toString(); });
|
|
87
|
+
child.on('close', () => resolve(out.slice(0, 4000)));
|
|
88
|
+
child.on('error', () => resolve(''));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Guard: does diff touch assertions or add skips?
|
|
92
|
+
const ASSERT_RE = /(?:expect\s*\(|assert\.|assert\(|should\.|chai\.)/;
|
|
93
|
+
const SKIP_RE = /(?:\.skip\(|\.todo\(|xdescribe\(|xit\(|xtest\(|@pytest\.mark\.skip|pytest\.skip|:\s*skip\b)/i;
|
|
94
|
+
export function guardRepair(diff, editedFiles) {
|
|
95
|
+
if (!diff)
|
|
96
|
+
return { blocked: false };
|
|
97
|
+
const addedLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++'));
|
|
98
|
+
const touchesAssert = addedLines.some((l) => ASSERT_RE.test(l));
|
|
99
|
+
const addsSkip = addedLines.some((l) => SKIP_RE.test(l));
|
|
100
|
+
if (touchesAssert && addedLines.length < 20) {
|
|
101
|
+
// touching assertions in a small diff likely means editing test expectations — require approval
|
|
102
|
+
return { blocked: true, reason: 'repair touches test assertions — requires explicit approval (edit expectations directly is discouraged)' };
|
|
103
|
+
}
|
|
104
|
+
if (addsSkip) {
|
|
105
|
+
return { blocked: true, reason: 'repair adds skip/todo — requires explicit approval (skipping tests is not a fix)' };
|
|
106
|
+
}
|
|
107
|
+
return { blocked: false };
|
|
108
|
+
}
|
|
@@ -11,39 +11,178 @@
|
|
|
11
11
|
* the raw output; we just pre-structure the high-signal lines.
|
|
12
12
|
*/
|
|
13
13
|
const TS_LINE = /^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
|
|
14
|
+
const TSC_LINE2 = /^(.+?):(\d+):(\d+)\s+-\s+error\s+(TS\d+):\s+(.+)$/;
|
|
14
15
|
const TEST_FAIL = /^\s*✘|FAIL\s+(\S+)|✗\s+(\S+)|×\s+(.+?)\s/;
|
|
15
|
-
const LINT_LINE = /^(.+?)\s*$/;
|
|
16
16
|
const RUNTIME_LINE = /^(?:Error|TypeError|ReferenceError):\s+(.+)$/;
|
|
17
|
+
// 6.2 — dedicated patterns for each runner/linter/compiler
|
|
18
|
+
const PYTEST_FAIL = /FAILED\s+(\S+?)(?:::\S+)?\s+-\s+(.+)/;
|
|
19
|
+
const PYTEST_ASSERT = /AssertionError:\s*(.+)/;
|
|
20
|
+
const GO_FAIL = /^---\s+FAIL:\s+(\S+)\s+\((.+)\)/;
|
|
21
|
+
const GO_PKG_FAIL = /^FAIL\s+(\S+)\s/;
|
|
22
|
+
const CARGO_FAIL = /^test\s+(\S+)\s+\.\.\.\s+FAILED/;
|
|
23
|
+
const CARGO_PANIC = /thread\s+'(.+?)'\s+panicked at\s+'(.+?)',\s+(.+?):(\d+):(\d+)/;
|
|
24
|
+
const MOCHA_FAIL = /^\s*\d+\)\s+(.+)$/;
|
|
25
|
+
const JUNIT_FAIL = /<(?:failure|error)[\s>]/;
|
|
26
|
+
const ESLINT_LINE = /^(.+?):(\d+):(\d+):\s+(error|warning)\s+(.+?)\s+\((.+?)\)\s*$/;
|
|
27
|
+
const RUFF_LINE = /^(.+?):(\d+):(\d+):\s+([A-Z]\d+)\s+(.+)$/;
|
|
28
|
+
const MYPY_LINE = /^(.+?):(\d+):\s+error:\s+(.+?)\s+\[(.+?)\]\s*$/;
|
|
29
|
+
const GCC_LINE = /^(.+?):(\d+):(\d+):\s+(?:fatal\s+)?error:\s+(.+)$/;
|
|
30
|
+
const VITE_ERROR = /ERROR\s+in\s+(.+)|Module build failed.+/;
|
|
31
|
+
const DOTNET_CS = /^(.+?)\((\d+),(\d+)\):\s+error\s+(CS\d+):\s+(.+)$/;
|
|
32
|
+
const GENERIC_FILE_LINE = /^(.+?):(\d+)(?::(\d+))?:\s*(.+)$/;
|
|
17
33
|
export function detect(stdout, stderr, exitCode) {
|
|
18
34
|
const combined = stderr + '\n' + stdout;
|
|
19
35
|
if (exitCode === 0) {
|
|
20
36
|
return { type: 'unknown', files: [], raw: combined, exitCode };
|
|
21
37
|
}
|
|
22
|
-
// TypeScript tsc
|
|
38
|
+
// TypeScript tsc (both formats)
|
|
23
39
|
const tsMatches = [];
|
|
24
40
|
for (const line of combined.split(/\r?\n/)) {
|
|
25
|
-
|
|
41
|
+
let m = TS_LINE.exec(line);
|
|
26
42
|
if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
|
|
27
43
|
tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
m = TSC_LINE2.exec(line);
|
|
47
|
+
if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
|
|
48
|
+
tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
49
|
+
}
|
|
50
|
+
const dm = DOTNET_CS.exec(line);
|
|
51
|
+
if (dm && dm[1] && dm[2] && dm[3] && dm[4] && dm[5]) {
|
|
52
|
+
tsMatches.push({ path: dm[1], line: Number(dm[2]), column: Number(dm[3]), code: dm[4], message: dm[5] });
|
|
28
53
|
}
|
|
29
54
|
}
|
|
30
55
|
if (tsMatches.length > 0)
|
|
31
|
-
return { type: 'type', files: tsMatches, raw: combined, exitCode };
|
|
32
|
-
//
|
|
33
|
-
if (
|
|
56
|
+
return { type: 'type', files: dedupe(tsMatches), raw: combined, exitCode };
|
|
57
|
+
// JUnit XML
|
|
58
|
+
if (JUNIT_FAIL.test(combined)) {
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
61
|
+
if (JUNIT_FAIL.test(line))
|
|
62
|
+
files.push({ path: '', message: line.trim() });
|
|
63
|
+
}
|
|
64
|
+
// also extract file:line fallback
|
|
65
|
+
if (files.length === 0)
|
|
66
|
+
return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
|
|
67
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
68
|
+
}
|
|
69
|
+
// pytest
|
|
70
|
+
if (/FAILED\s+\S+|AssertionError|pytest/.test(combined)) {
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
73
|
+
let m = PYTEST_FAIL.exec(line);
|
|
74
|
+
if (m && m[1]) {
|
|
75
|
+
files.push({ path: m[1], message: m[2] ?? line.trim() });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
m = PYTEST_ASSERT.exec(line);
|
|
79
|
+
if (m) {
|
|
80
|
+
files.push({ path: '', message: m[0] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (files.length > 0)
|
|
85
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
86
|
+
}
|
|
87
|
+
// go test
|
|
88
|
+
if (/---\s+FAIL:|FAIL\s+\S+/.test(combined)) {
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
91
|
+
let m = GO_FAIL.exec(line);
|
|
92
|
+
if (m && m[1]) {
|
|
93
|
+
files.push({ path: m[1], message: line.trim() });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
m = GO_PKG_FAIL.exec(line);
|
|
97
|
+
if (m && m[1]) {
|
|
98
|
+
files.push({ path: m[1], message: line.trim() });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (files.length > 0)
|
|
102
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
103
|
+
}
|
|
104
|
+
// cargo test
|
|
105
|
+
if (/test result: FAILED|FAILED.*cargo|panicked at/.test(combined)) {
|
|
106
|
+
const files = [];
|
|
107
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
108
|
+
let m = CARGO_FAIL.exec(line);
|
|
109
|
+
if (m && m[1]) {
|
|
110
|
+
files.push({ path: m[1], message: line.trim() });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
m = CARGO_PANIC.exec(line);
|
|
114
|
+
if (m && m[3] && m[4] && m[5]) {
|
|
115
|
+
files.push({ path: m[3], line: Number(m[4]), column: Number(m[5]), message: m[2] ?? line.trim() });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (files.length > 0)
|
|
119
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
120
|
+
}
|
|
121
|
+
// Generic test runner (vitest/jest/mocha)
|
|
122
|
+
if (/Test Suites:|FAIL\s|Tests:.*failed|✘|✗|×\s+/.test(combined) || MOCHA_FAIL.test(combined)) {
|
|
34
123
|
const files = [];
|
|
35
124
|
for (const line of combined.split(/\r?\n/)) {
|
|
36
125
|
const m = TEST_FAIL.exec(line);
|
|
37
126
|
if (m) {
|
|
38
127
|
const target = m[1] ?? m[2] ?? m[3] ?? line.trim();
|
|
39
128
|
files.push({ path: target, message: line.trim() });
|
|
129
|
+
continue;
|
|
40
130
|
}
|
|
131
|
+
const mm = MOCHA_FAIL.exec(line);
|
|
132
|
+
if (mm && mm[1])
|
|
133
|
+
files.push({ path: '', message: mm[1] });
|
|
41
134
|
}
|
|
42
|
-
|
|
135
|
+
if (files.length > 0)
|
|
136
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
137
|
+
// fallback: any FAIL line
|
|
138
|
+
if (/FAIL/.test(combined))
|
|
139
|
+
return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
|
|
43
140
|
}
|
|
44
|
-
// ESLint
|
|
45
|
-
|
|
46
|
-
|
|
141
|
+
// ESLint
|
|
142
|
+
{
|
|
143
|
+
const es = [];
|
|
144
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
145
|
+
const m = ESLINT_LINE.exec(line);
|
|
146
|
+
if (m && m[1] && m[2] && m[3] && m[5])
|
|
147
|
+
es.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[6], message: m[5] });
|
|
148
|
+
}
|
|
149
|
+
if (es.length > 0)
|
|
150
|
+
return { type: 'lint', files: dedupe(es), raw: combined, exitCode };
|
|
151
|
+
}
|
|
152
|
+
// ruff / flake8
|
|
153
|
+
{
|
|
154
|
+
const rf = [];
|
|
155
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
156
|
+
const m = RUFF_LINE.exec(line);
|
|
157
|
+
if (m && m[1] && m[2] && m[3] && m[4] && m[5])
|
|
158
|
+
rf.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
159
|
+
}
|
|
160
|
+
if (rf.length > 0)
|
|
161
|
+
return { type: 'lint', files: dedupe(rf), raw: combined, exitCode };
|
|
162
|
+
}
|
|
163
|
+
// mypy
|
|
164
|
+
{
|
|
165
|
+
const mp = [];
|
|
166
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
167
|
+
const m = MYPY_LINE.exec(line);
|
|
168
|
+
if (m && m[1] && m[2] && m[3] && m[4])
|
|
169
|
+
mp.push({ path: m[1], line: Number(m[2]), code: m[4], message: m[3] });
|
|
170
|
+
}
|
|
171
|
+
if (mp.length > 0)
|
|
172
|
+
return { type: 'type', files: dedupe(mp), raw: combined, exitCode };
|
|
173
|
+
}
|
|
174
|
+
// gcc/clang, vite/webpack
|
|
175
|
+
{
|
|
176
|
+
const gc = [];
|
|
177
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
178
|
+
const m = GCC_LINE.exec(line);
|
|
179
|
+
if (m && m[1] && m[2] && m[3] && m[4])
|
|
180
|
+
gc.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), message: m[4] });
|
|
181
|
+
else if (VITE_ERROR.test(line))
|
|
182
|
+
gc.push({ path: '', message: line.trim() });
|
|
183
|
+
}
|
|
184
|
+
if (gc.length > 0)
|
|
185
|
+
return { type: 'build', files: dedupe(gc), raw: combined, exitCode };
|
|
47
186
|
}
|
|
48
187
|
// Runtime exception.
|
|
49
188
|
const lines = combined.split(/\r?\n/);
|
|
@@ -53,10 +192,36 @@ export function detect(stdout, stderr, exitCode) {
|
|
|
53
192
|
}
|
|
54
193
|
// Generic build.
|
|
55
194
|
if (/error/i.test(combined)) {
|
|
195
|
+
const gf = extractGeneric(combined, 5);
|
|
196
|
+
if (gf.length > 0)
|
|
197
|
+
return { type: 'build', files: gf, raw: combined, exitCode };
|
|
56
198
|
return { type: 'build', files: extractFirstLines(combined, 5), raw: combined, exitCode };
|
|
57
199
|
}
|
|
58
200
|
return { type: 'unknown', files: extractFirstLines(combined, 3), raw: combined, exitCode };
|
|
59
201
|
}
|
|
202
|
+
function dedupe(files) {
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
const key = `${f.path}:${f.line ?? ''}:${f.column ?? ''}:${f.message}`;
|
|
207
|
+
if (!seen.has(key)) {
|
|
208
|
+
seen.add(key);
|
|
209
|
+
out.push(f);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function extractGeneric(s, n) {
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const line of s.split(/\r?\n/)) {
|
|
217
|
+
const m = GENERIC_FILE_LINE.exec(line);
|
|
218
|
+
if (m && m[1] && m[2] && m[4])
|
|
219
|
+
out.push({ path: m[1], line: Number(m[2]), column: m[3] ? Number(m[3]) : undefined, message: m[4] });
|
|
220
|
+
if (out.length >= n)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
60
225
|
function extractFirstLines(s, n) {
|
|
61
226
|
return s
|
|
62
227
|
.split(/\r?\n/)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Verifier registry
|
|
3
|
+
* Detects available verifiers from project heuristics (package.json / Makefile / pyproject / Cargo).
|
|
4
|
+
* Each verifier is a named command that can be run via `verify` engine.
|
|
5
|
+
*/
|
|
6
|
+
export type VerifierKind = 'tests' | 'typecheck' | 'lint' | 'build' | 'format-check' | 'custom';
|
|
7
|
+
export interface Verifier {
|
|
8
|
+
id: VerifierKind;
|
|
9
|
+
label: string;
|
|
10
|
+
command: string;
|
|
11
|
+
priority: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function detectVerifiers(cwd: string): Verifier[];
|
|
14
|
+
export declare function primaryVerifyCommand(cwd: string): string | null;
|
|
15
|
+
export interface VerifySettings {
|
|
16
|
+
commands?: string[];
|
|
17
|
+
onEdit?: boolean;
|
|
18
|
+
afterChange?: boolean;
|
|
19
|
+
beforeDone?: boolean;
|
|
20
|
+
maxRepairs?: number;
|
|
21
|
+
requireVerify?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function resolveVerifySettings(raw: Record<string, unknown> | undefined): VerifySettings;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Verifier registry
|
|
3
|
+
* Detects available verifiers from project heuristics (package.json / Makefile / pyproject / Cargo).
|
|
4
|
+
* Each verifier is a named command that can be run via `verify` engine.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
function exists(p) {
|
|
9
|
+
try {
|
|
10
|
+
return fs.existsSync(p);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function readJson(p) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function pm(cwd) {
|
|
26
|
+
if (exists(path.join(cwd, 'pnpm-lock.yaml')))
|
|
27
|
+
return 'pnpm';
|
|
28
|
+
if (exists(path.join(cwd, 'yarn.lock')))
|
|
29
|
+
return 'yarn';
|
|
30
|
+
if (exists(path.join(cwd, 'bun.lockb')))
|
|
31
|
+
return 'bun';
|
|
32
|
+
return 'npm';
|
|
33
|
+
}
|
|
34
|
+
function runCmd(base, cwd) {
|
|
35
|
+
const m = pm(cwd);
|
|
36
|
+
if (base === 'test')
|
|
37
|
+
return `${m} test`;
|
|
38
|
+
if (base === 'build')
|
|
39
|
+
return `${m} run build`;
|
|
40
|
+
if (base === 'lint')
|
|
41
|
+
return `${m} run lint`;
|
|
42
|
+
return `${m} run ${base}`;
|
|
43
|
+
}
|
|
44
|
+
export function detectVerifiers(cwd) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const pkg = readJson(path.join(cwd, 'package.json'));
|
|
47
|
+
const hasPkg = !!pkg;
|
|
48
|
+
// tests — highest priority
|
|
49
|
+
if (hasPkg && pkg?.scripts?.test) {
|
|
50
|
+
out.push({ id: 'tests', label: 'tests', command: runCmd('test', cwd), priority: 10 });
|
|
51
|
+
}
|
|
52
|
+
else if (exists(path.join(cwd, 'pyproject.toml')) || exists(path.join(cwd, 'requirements.txt')) || exists(path.join(cwd, 'setup.py'))) {
|
|
53
|
+
// pytest heuristic — check pyproject for pytest
|
|
54
|
+
out.push({ id: 'tests', label: 'pytest', command: 'pytest', priority: 10 });
|
|
55
|
+
}
|
|
56
|
+
else if (exists(path.join(cwd, 'go.mod'))) {
|
|
57
|
+
out.push({ id: 'tests', label: 'go test', command: 'go test ./...', priority: 10 });
|
|
58
|
+
}
|
|
59
|
+
else if (exists(path.join(cwd, 'Cargo.toml'))) {
|
|
60
|
+
out.push({ id: 'tests', label: 'cargo test', command: 'cargo test', priority: 10 });
|
|
61
|
+
}
|
|
62
|
+
else if (exists(path.join(cwd, 'Makefile')) || exists(path.join(cwd, 'makefile'))) {
|
|
63
|
+
try {
|
|
64
|
+
const mk = fs.readFileSync(path.join(cwd, exists(path.join(cwd, 'Makefile')) ? 'Makefile' : 'makefile'), 'utf-8');
|
|
65
|
+
if (/^test:/m.test(mk))
|
|
66
|
+
out.push({ id: 'tests', label: 'make test', command: 'make test', priority: 10 });
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
// typecheck
|
|
71
|
+
if (exists(path.join(cwd, 'tsconfig.json'))) {
|
|
72
|
+
out.push({ id: 'typecheck', label: 'tsc', command: 'npx tsc --noEmit', priority: 20 });
|
|
73
|
+
}
|
|
74
|
+
else if (exists(path.join(cwd, 'pyproject.toml'))) {
|
|
75
|
+
try {
|
|
76
|
+
const py = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
|
|
77
|
+
if (py.includes('mypy'))
|
|
78
|
+
out.push({ id: 'typecheck', label: 'mypy', command: 'mypy .', priority: 20 });
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
// lint — check for eslint config or lint script
|
|
83
|
+
if (hasPkg && pkg?.scripts?.lint) {
|
|
84
|
+
out.push({ id: 'lint', label: 'lint', command: runCmd('lint', cwd), priority: 30 });
|
|
85
|
+
}
|
|
86
|
+
else if (exists(path.join(cwd, '.eslintrc.json')) || exists(path.join(cwd, '.eslintrc.js')) || exists(path.join(cwd, '.eslintrc.cjs')) || exists(path.join(cwd, 'eslint.config.js')) || exists(path.join(cwd, 'eslint.config.cjs')) || exists(path.join(cwd, 'eslint.config.mjs'))) {
|
|
87
|
+
out.push({ id: 'lint', label: 'eslint', command: 'npx eslint .', priority: 30 });
|
|
88
|
+
}
|
|
89
|
+
else if (exists(path.join(cwd, 'ruff.toml')) || exists(path.join(cwd, '.ruff.toml')) || exists(path.join(cwd, 'pyproject.toml'))) {
|
|
90
|
+
try {
|
|
91
|
+
if (exists(path.join(cwd, 'pyproject.toml'))) {
|
|
92
|
+
const py2 = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
|
|
93
|
+
if (py2.includes('[tool.ruff]'))
|
|
94
|
+
out.push({ id: 'lint', label: 'ruff', command: 'ruff check .', priority: 30 });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
// build
|
|
100
|
+
if (hasPkg && pkg?.scripts?.build) {
|
|
101
|
+
out.push({ id: 'build', label: 'build', command: runCmd('build', cwd), priority: 40 });
|
|
102
|
+
}
|
|
103
|
+
// format-check
|
|
104
|
+
if (exists(path.join(cwd, '.prettierrc')) || exists(path.join(cwd, '.prettierrc.json')) || exists(path.join(cwd, 'prettier.config.js')) || exists(path.join(cwd, '.prettierrc.cjs'))) {
|
|
105
|
+
out.push({ id: 'format-check', label: 'prettier', command: 'npx prettier --check .', priority: 50 });
|
|
106
|
+
}
|
|
107
|
+
else if (hasPkg && pkg?.scripts?.['format:check']) {
|
|
108
|
+
out.push({ id: 'format-check', label: 'format:check', command: runCmd('format:check', cwd), priority: 50 });
|
|
109
|
+
}
|
|
110
|
+
return out.sort((a, b) => a.priority - b.priority);
|
|
111
|
+
}
|
|
112
|
+
export function primaryVerifyCommand(cwd) {
|
|
113
|
+
const v = detectVerifiers(cwd);
|
|
114
|
+
return v[0]?.command ?? null;
|
|
115
|
+
}
|
|
116
|
+
export function resolveVerifySettings(raw) {
|
|
117
|
+
if (!raw || typeof raw !== 'object')
|
|
118
|
+
return {};
|
|
119
|
+
const v = raw.verify;
|
|
120
|
+
if (!v || typeof v !== 'object')
|
|
121
|
+
return {};
|
|
122
|
+
return {
|
|
123
|
+
commands: Array.isArray(v.commands) ? v.commands : undefined,
|
|
124
|
+
onEdit: typeof v.onEdit === 'boolean' ? v.onEdit : undefined,
|
|
125
|
+
afterChange: typeof v.afterChange === 'boolean' ? v.afterChange : undefined,
|
|
126
|
+
beforeDone: typeof v.beforeDone === 'boolean' ? v.beforeDone : undefined,
|
|
127
|
+
maxRepairs: typeof v.maxRepairs === 'number' ? v.maxRepairs : undefined,
|
|
128
|
+
requireVerify: typeof v.requireVerify === 'boolean' ? v.requireVerify : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.3 — Scoped runs & sanity checks
|
|
3
|
+
* Edited files → related tests (name match), scoped verify command, syntax + import checks.
|
|
4
|
+
*/
|
|
5
|
+
export declare function findRelatedTests(cwd: string, editedFiles: string[]): string[];
|
|
6
|
+
export declare function buildScopedCommand(cwd: string, baseCommand: string, relatedTests: string[]): string | null;
|
|
7
|
+
export declare function runScopedVerify(cwd: string, command: string, timeoutMs?: number): Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** Quick syntax check: try to parse file with node --check or tsc snippet */
|
|
14
|
+
export declare function syntaxCheck(cwd: string, file: string): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
}>;
|
|
18
|
+
/** Import-path existence check (TS/JS): read file, extract imports, verify targets exist */
|
|
19
|
+
export declare function checkImports(cwd: string, file: string): {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
missing: string[];
|
|
22
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.3 — Scoped runs & sanity checks
|
|
3
|
+
* Edited files → related tests (name match), scoped verify command, syntax + import checks.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
export function findRelatedTests(cwd, editedFiles) {
|
|
9
|
+
if (editedFiles.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const allTests = [];
|
|
12
|
+
// collect candidate test files via simple walk (respect .gitignore minimally)
|
|
13
|
+
function walk(dir, depth = 0) {
|
|
14
|
+
if (depth > 6)
|
|
15
|
+
return;
|
|
16
|
+
let entries = [];
|
|
17
|
+
try {
|
|
18
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === '.klyro')
|
|
25
|
+
continue;
|
|
26
|
+
const p = path.join(dir, e.name);
|
|
27
|
+
if (e.isDirectory())
|
|
28
|
+
walk(p, depth + 1);
|
|
29
|
+
else if (e.isFile() && /(?:\.test\.|\.spec\.|__tests__|test_)/.test(p))
|
|
30
|
+
allTests.push(p);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
walk(cwd);
|
|
34
|
+
const related = new Set();
|
|
35
|
+
for (const edited of editedFiles) {
|
|
36
|
+
const base = path.basename(edited, path.extname(edited)); // foo.ts -> foo
|
|
37
|
+
const dir = path.dirname(edited);
|
|
38
|
+
for (const t of allTests) {
|
|
39
|
+
const tb = path.basename(t);
|
|
40
|
+
// name match: foo.ts -> foo.test.ts, test_foo.py, foo_spec.ts, etc.
|
|
41
|
+
if (tb.includes(base) || base.includes(path.basename(t, path.extname(t)).replace(/\.test|\.spec|test_/g, ''))) {
|
|
42
|
+
related.add(path.relative(cwd, t));
|
|
43
|
+
}
|
|
44
|
+
// same directory prefix also counts
|
|
45
|
+
if (t.startsWith(dir) && tb.includes(base.slice(0, 4)))
|
|
46
|
+
related.add(path.relative(cwd, t));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...related];
|
|
50
|
+
}
|
|
51
|
+
export function buildScopedCommand(cwd, baseCommand, relatedTests) {
|
|
52
|
+
if (relatedTests.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (relatedTests.length > 10)
|
|
55
|
+
return null; // too many → full suite
|
|
56
|
+
// npm test heuristic: npm test -- <files> or vitest/jest file list
|
|
57
|
+
if (baseCommand.includes('npm test') || baseCommand.includes('pnpm test') || baseCommand.includes('yarn test') || baseCommand.includes('bun test')) {
|
|
58
|
+
return `${baseCommand} -- ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
|
|
59
|
+
}
|
|
60
|
+
if (baseCommand.includes('pytest')) {
|
|
61
|
+
return `pytest ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
|
|
62
|
+
}
|
|
63
|
+
if (baseCommand.includes('go test')) {
|
|
64
|
+
// go test ./... with file filter — fallback to full
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (baseCommand.includes('cargo test')) {
|
|
68
|
+
// cargo test --test <name>
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
let done = false;
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
if (done)
|
|
81
|
+
return;
|
|
82
|
+
done = true;
|
|
83
|
+
try {
|
|
84
|
+
child.kill();
|
|
85
|
+
}
|
|
86
|
+
catch { /* ignore */ }
|
|
87
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[scoped timeout]' });
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
child.stdout.on('data', (b) => { stdout += b.toString(); });
|
|
90
|
+
child.stderr.on('data', (b) => { stderr += b.toString(); });
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
if (done)
|
|
93
|
+
return;
|
|
94
|
+
done = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
const exit = typeof code === 'number' ? code : -1;
|
|
97
|
+
resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
|
|
98
|
+
});
|
|
99
|
+
child.on('error', (err) => {
|
|
100
|
+
if (done)
|
|
101
|
+
return;
|
|
102
|
+
done = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Quick syntax check: try to parse file with node --check or tsc snippet */
|
|
109
|
+
export async function syntaxCheck(cwd, file) {
|
|
110
|
+
const full = path.join(cwd, file);
|
|
111
|
+
const ext = path.extname(file);
|
|
112
|
+
if (ext === '.ts' || ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
113
|
+
// Use tsc transpileModule if available, else node --check for js
|
|
114
|
+
try {
|
|
115
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
116
|
+
// minimal check: try to parse via new Function (for js) or just check no obvious syntax error via tsc
|
|
117
|
+
// For now, use tsc --noEmit --skipLibCheck on single file quickly
|
|
118
|
+
if (ext === '.ts') {
|
|
119
|
+
// spawn tsc --noEmit --skipLibCheck <file> with 10s timeout
|
|
120
|
+
const ok = await new Promise((resolve) => {
|
|
121
|
+
const child = spawn(`npx tsc --noEmit --skipLibCheck "${full}"`, { cwd, shell: true, env: process.env });
|
|
122
|
+
let done = false;
|
|
123
|
+
const t = setTimeout(() => { if (!done) {
|
|
124
|
+
done = true;
|
|
125
|
+
try {
|
|
126
|
+
child.kill();
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
resolve(false);
|
|
130
|
+
} }, 10_000);
|
|
131
|
+
child.on('close', (code) => { if (done)
|
|
132
|
+
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
133
|
+
child.on('error', () => { if (done)
|
|
134
|
+
return; done = true; clearTimeout(t); resolve(false); });
|
|
135
|
+
});
|
|
136
|
+
if (!ok)
|
|
137
|
+
return { ok: false, error: `syntax error in ${file} (tsc)` };
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
// js: node --check
|
|
141
|
+
const ok2 = await new Promise((resolve) => {
|
|
142
|
+
const child = spawn(`node --check "${full}"`, { cwd, shell: true, env: process.env });
|
|
143
|
+
let done = false;
|
|
144
|
+
const t = setTimeout(() => { if (!done) {
|
|
145
|
+
done = true;
|
|
146
|
+
try {
|
|
147
|
+
child.kill();
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
resolve(false);
|
|
151
|
+
} }, 5000);
|
|
152
|
+
child.on('close', (code) => { if (done)
|
|
153
|
+
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
154
|
+
child.on('error', () => { if (done)
|
|
155
|
+
return; done = true; clearTimeout(t); resolve(false); });
|
|
156
|
+
});
|
|
157
|
+
if (!ok2)
|
|
158
|
+
return { ok: false, error: `syntax error in ${file} (node --check)` };
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return { ok: false, error: String(e) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (ext === '.py') {
|
|
166
|
+
const ok = await new Promise((resolve) => {
|
|
167
|
+
const child = spawn(`python -m py_compile "${full}"`, { cwd, shell: true, env: process.env });
|
|
168
|
+
let done = false;
|
|
169
|
+
const t = setTimeout(() => { if (!done) {
|
|
170
|
+
done = true;
|
|
171
|
+
try {
|
|
172
|
+
child.kill();
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
resolve(false);
|
|
176
|
+
} }, 5000);
|
|
177
|
+
child.on('close', (code) => { if (done)
|
|
178
|
+
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
179
|
+
child.on('error', () => { if (done)
|
|
180
|
+
return; done = true; clearTimeout(t); resolve(true); }); // python may not exist → skip
|
|
181
|
+
});
|
|
182
|
+
if (!ok)
|
|
183
|
+
return { ok: false, error: `syntax error in ${file} (py_compile)` };
|
|
184
|
+
}
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
/** Import-path existence check (TS/JS): read file, extract imports, verify targets exist */
|
|
188
|
+
export function checkImports(cwd, file) {
|
|
189
|
+
const full = path.join(cwd, file);
|
|
190
|
+
const ext = path.extname(file);
|
|
191
|
+
if (!['.ts', '.js', '.tsx', '.jsx', '.mjs', '.cjs'].includes(ext))
|
|
192
|
+
return { ok: true, missing: [] };
|
|
193
|
+
let content = '';
|
|
194
|
+
try {
|
|
195
|
+
content = fs.readFileSync(full, 'utf-8');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { ok: true, missing: [] };
|
|
199
|
+
}
|
|
200
|
+
const importRe = /(?:import\s+.*?from\s+['"](.+?)['"]|require\(['"](.+?)['"]\))/g;
|
|
201
|
+
const missing = [];
|
|
202
|
+
let m;
|
|
203
|
+
while ((m = importRe.exec(content))) {
|
|
204
|
+
const spec = m[1] ?? m[2];
|
|
205
|
+
if (!spec || !spec.startsWith('.'))
|
|
206
|
+
continue; // only relative imports
|
|
207
|
+
const target = path.resolve(path.dirname(full), spec);
|
|
208
|
+
const candidates = [target, `${target}.ts`, `${target}.js`, `${target}/index.ts`, `${target}/index.js`, `${target}.tsx`];
|
|
209
|
+
const exists = candidates.some((p) => fs.existsSync(p));
|
|
210
|
+
if (!exists)
|
|
211
|
+
missing.push(spec);
|
|
212
|
+
}
|
|
213
|
+
return { ok: missing.length === 0, missing };
|
|
214
|
+
}
|