quick-gate 0.2.0-alpha.1
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/LICENSE +190 -0
- package/README.md +153 -0
- package/package.json +45 -0
- package/schemas/agent-brief.schema.json +112 -0
- package/schemas/failures.schema.json +193 -0
- package/src/cli.js +94 -0
- package/src/config.js +38 -0
- package/src/constants.js +20 -0
- package/src/deterministic-prefix.js +66 -0
- package/src/env-check.js +41 -0
- package/src/exec.js +24 -0
- package/src/fs-utils.js +48 -0
- package/src/gates.js +191 -0
- package/src/model-adapter.js +397 -0
- package/src/repair-command.js +290 -0
- package/src/run-command.js +78 -0
- package/src/schema.js +34 -0
- package/src/summarize-command.js +118 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { runCommand } from './exec.js';
|
|
4
|
+
|
|
5
|
+
function stripAnsi(text) {
|
|
6
|
+
return String(text || '')
|
|
7
|
+
.replace(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
8
|
+
.replace(/\x1B[@-_]/g, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function safeSlice(text, max) {
|
|
12
|
+
return String(text || '').slice(0, max);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseJsonObject(text) {
|
|
16
|
+
const raw = String(text || '').trim();
|
|
17
|
+
if (!raw) return null;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
const start = raw.indexOf('{');
|
|
22
|
+
const end = raw.lastIndexOf('}');
|
|
23
|
+
if (start === -1 || end <= start) return null;
|
|
24
|
+
const candidate = raw.slice(start, end + 1);
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(candidate);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readSnippet(filePath, maxLines = 80) {
|
|
34
|
+
if (!fs.existsSync(filePath)) return '';
|
|
35
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
36
|
+
return lines.slice(0, maxLines).join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gatherFailureContext({ cwd, failures }) {
|
|
40
|
+
const preferredFiles = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const pushFile = (file) => {
|
|
43
|
+
if (!file || seen.has(file)) return;
|
|
44
|
+
seen.add(file);
|
|
45
|
+
preferredFiles.push(file);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const file of failures.changed_files || []) pushFile(file);
|
|
49
|
+
for (const f of failures.findings || []) {
|
|
50
|
+
for (const file of f.files || []) pushFile(file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fileContext = [];
|
|
54
|
+
for (const rel of preferredFiles.slice(0, 3)) {
|
|
55
|
+
const full = path.join(cwd, rel);
|
|
56
|
+
const snippet = readSnippet(full, 40);
|
|
57
|
+
if (!snippet) continue;
|
|
58
|
+
fileContext.push({ file: rel, snippet });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const findings = (failures.findings || []).map((f) => ({
|
|
62
|
+
id: f.id,
|
|
63
|
+
gate: f.gate,
|
|
64
|
+
summary: f.summary,
|
|
65
|
+
files: f.files || [],
|
|
66
|
+
metric: f.metric || null,
|
|
67
|
+
route: f.route || null,
|
|
68
|
+
raw_context: safeSlice(f.raw?.stderr_excerpt || f.raw?.stdout_excerpt || '', 600),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
return { findings, file_context: fileContext, allowed_files: preferredFiles.slice(0, 12) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function callOllama({ model, prompt, cwd, timeoutMs, purpose }) {
|
|
75
|
+
if (!model) {
|
|
76
|
+
return { ok: false, reason: 'missing_model' };
|
|
77
|
+
}
|
|
78
|
+
const mockEnv =
|
|
79
|
+
purpose === 'hint'
|
|
80
|
+
? process.env.QUICK_GATE_MOCK_OLLAMA_HINT
|
|
81
|
+
: process.env.QUICK_GATE_MOCK_OLLAMA_PATCH;
|
|
82
|
+
if (mockEnv) {
|
|
83
|
+
return { ok: true, output: mockEnv };
|
|
84
|
+
}
|
|
85
|
+
const escapedPrompt = prompt.replace(/'/g, `'"'"'`);
|
|
86
|
+
const command = `printf '%s' '${escapedPrompt}' | ollama run ${model}`;
|
|
87
|
+
const result = runCommand(command, { cwd, timeoutMs });
|
|
88
|
+
if (result.exit_code !== 0) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: result.timed_out ? 'model_command_timeout' : 'model_command_failed',
|
|
92
|
+
stderr: safeSlice(result.timed_out ? '' : stripAnsi(result.stderr), 600),
|
|
93
|
+
stdout: safeSlice(result.stdout, 1000),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, output: result.stdout || '' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scoreEditPlan({ edits, failures, maxPatchLines }) {
|
|
100
|
+
const changedFiles = new Set((failures.changed_files || []).map(String));
|
|
101
|
+
const findingFiles = new Set(
|
|
102
|
+
(failures.findings || []).flatMap((f) => (Array.isArray(f.files) ? f.files : [])),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const touched = new Set();
|
|
106
|
+
let predictedLines = 0;
|
|
107
|
+
for (const edit of edits) {
|
|
108
|
+
touched.add(edit.file);
|
|
109
|
+
const removed = Math.max(0, Number(edit.end_line) - Number(edit.start_line) + 1);
|
|
110
|
+
const added = String(edit.replacement || '').split(/\r?\n/).length;
|
|
111
|
+
predictedLines += removed + added;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const touchedFiles = Array.from(touched);
|
|
115
|
+
const overlap = touchedFiles.filter((f) => changedFiles.has(f) || findingFiles.has(f)).length;
|
|
116
|
+
const overlapRatio = touchedFiles.length === 0 ? 0 : overlap / touchedFiles.length;
|
|
117
|
+
const lineScore = predictedLines <= maxPatchLines ? 1 : 0;
|
|
118
|
+
const score = Number((overlapRatio * 0.7 + lineScore * 0.3).toFixed(2));
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
score,
|
|
122
|
+
predictedLines,
|
|
123
|
+
touchedFiles,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeEditPlan(payload) {
|
|
128
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
129
|
+
if (!Array.isArray(payload.edits)) return null;
|
|
130
|
+
const edits = payload.edits
|
|
131
|
+
.map((e) => ({
|
|
132
|
+
file: String(e.file || ''),
|
|
133
|
+
start_line: Number(e.start_line),
|
|
134
|
+
end_line: Number(e.end_line),
|
|
135
|
+
replacement: String(e.replacement ?? ''),
|
|
136
|
+
}))
|
|
137
|
+
.filter((e) => e.file && Number.isInteger(e.start_line) && Number.isInteger(e.end_line));
|
|
138
|
+
if (edits.length === 0) return null;
|
|
139
|
+
return {
|
|
140
|
+
summary: String(payload.summary || ''),
|
|
141
|
+
edits,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sanitizePlanPaths(cwd, plan) {
|
|
146
|
+
const normalized = {
|
|
147
|
+
...plan,
|
|
148
|
+
edits: plan.edits.map((edit) => ({ ...edit })),
|
|
149
|
+
};
|
|
150
|
+
for (const edit of normalized.edits) {
|
|
151
|
+
const raw = String(edit.file);
|
|
152
|
+
if (path.isAbsolute(raw)) {
|
|
153
|
+
if (raw.startsWith(`${cwd}${path.sep}`)) {
|
|
154
|
+
edit.file = path.relative(cwd, raw);
|
|
155
|
+
} else {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return normalized;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyEditPlan({ cwd, plan }) {
|
|
164
|
+
const touched = new Set();
|
|
165
|
+
for (const edit of plan.edits) {
|
|
166
|
+
const filePath = path.join(cwd, edit.file);
|
|
167
|
+
if (!fs.existsSync(filePath)) {
|
|
168
|
+
return { ok: false, reason: `missing_file:${edit.file}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
172
|
+
const startIndex = edit.start_line - 1;
|
|
173
|
+
const endIndexExclusive = edit.end_line;
|
|
174
|
+
if (startIndex < 0 || endIndexExclusive > lines.length || startIndex >= endIndexExclusive) {
|
|
175
|
+
return { ok: false, reason: `invalid_line_range:${edit.file}:${edit.start_line}-${edit.end_line}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const replacementLines = String(edit.replacement).split(/\r?\n/);
|
|
179
|
+
const updated = [
|
|
180
|
+
...lines.slice(0, startIndex),
|
|
181
|
+
...replacementLines,
|
|
182
|
+
...lines.slice(endIndexExclusive),
|
|
183
|
+
].join('\n');
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(filePath, updated, 'utf8');
|
|
186
|
+
touched.add(edit.file);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { ok: true, touched_files: Array.from(touched) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildHintPrompt(context) {
|
|
193
|
+
return [
|
|
194
|
+
'You are assisting a deterministic repair loop.',
|
|
195
|
+
'Return JSON only: {"hints":[{"finding_id":"...","hint":"...","confidence":"low|medium|high"}]}',
|
|
196
|
+
'Keep hints short and actionable.',
|
|
197
|
+
JSON.stringify(context),
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildPatchPrompt(context) {
|
|
202
|
+
return [
|
|
203
|
+
'You are generating an edit plan for deterministic code repair.',
|
|
204
|
+
'Return JSON only with shape:',
|
|
205
|
+
'{"summary":"...","edits":[{"file":"path","start_line":1,"end_line":1,"replacement":"new content"}]}',
|
|
206
|
+
'Rules: touch minimal files, stay within error scope, no prose, no markdown.',
|
|
207
|
+
'Use ONLY file paths from allowed_files.',
|
|
208
|
+
JSON.stringify(context),
|
|
209
|
+
].join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function getModelRoutingPolicy() {
|
|
213
|
+
const hintModel = process.env.QUICK_GATE_HINT_MODEL || 'qwen2.5:1.5b';
|
|
214
|
+
const patchModel = process.env.QUICK_GATE_PATCH_MODEL || 'mistral:7b';
|
|
215
|
+
const allowHintOnlyPatch = process.env.QUICK_GATE_ALLOW_HINT_ONLY_PATCH === '1';
|
|
216
|
+
const hintOnly = allowHintOnlyPatch
|
|
217
|
+
? new Set()
|
|
218
|
+
: new Set(['qwen2.5:1.5b', 'qwen3:4b', 'llama3.2:latest']);
|
|
219
|
+
return {
|
|
220
|
+
hintModel,
|
|
221
|
+
patchModel,
|
|
222
|
+
hintOnly,
|
|
223
|
+
allowHintOnlyPatch,
|
|
224
|
+
timeoutMs: Number(process.env.QUICK_GATE_MODEL_TIMEOUT_MS || 60000),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function runHintModel({ cwd, failures, policy }) {
|
|
229
|
+
const context = gatherFailureContext({ cwd, failures });
|
|
230
|
+
const prompt = buildHintPrompt(context);
|
|
231
|
+
const response = callOllama({
|
|
232
|
+
model: policy.hintModel,
|
|
233
|
+
prompt,
|
|
234
|
+
cwd,
|
|
235
|
+
timeoutMs: policy.timeoutMs,
|
|
236
|
+
purpose: 'hint',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
return {
|
|
241
|
+
attempted: true,
|
|
242
|
+
strategy: 'ollama_hint',
|
|
243
|
+
accepted: false,
|
|
244
|
+
model: policy.hintModel,
|
|
245
|
+
reason: response.reason,
|
|
246
|
+
stderr: response.stderr,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const parsed = parseJsonObject(response.output);
|
|
251
|
+
const hints = Array.isArray(parsed?.hints) ? parsed.hints.slice(0, 6) : [];
|
|
252
|
+
return {
|
|
253
|
+
attempted: true,
|
|
254
|
+
strategy: 'ollama_hint',
|
|
255
|
+
accepted: true,
|
|
256
|
+
model: policy.hintModel,
|
|
257
|
+
hints,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function runPatchModel({ cwd, failures, policy, maxPatchLines }) {
|
|
262
|
+
if (policy.hintOnly.has(policy.patchModel)) {
|
|
263
|
+
return {
|
|
264
|
+
attempted: false,
|
|
265
|
+
strategy: 'ollama_patch_plan',
|
|
266
|
+
accepted: false,
|
|
267
|
+
model: policy.patchModel,
|
|
268
|
+
reason: 'patch_model_is_hint_only',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const context = gatherFailureContext({ cwd, failures });
|
|
273
|
+
const prompt = buildPatchPrompt(context);
|
|
274
|
+
let response = callOllama({
|
|
275
|
+
model: policy.patchModel,
|
|
276
|
+
prompt,
|
|
277
|
+
cwd,
|
|
278
|
+
timeoutMs: policy.timeoutMs,
|
|
279
|
+
purpose: 'patch',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
return {
|
|
284
|
+
attempted: true,
|
|
285
|
+
strategy: 'ollama_patch_plan',
|
|
286
|
+
accepted: false,
|
|
287
|
+
model: policy.patchModel,
|
|
288
|
+
reason: response.reason,
|
|
289
|
+
stderr: response.stderr,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let parsed = parseJsonObject(response.output);
|
|
294
|
+
let basePlan = normalizeEditPlan(parsed);
|
|
295
|
+
let plan = basePlan ? sanitizePlanPaths(cwd, basePlan) : null;
|
|
296
|
+
|
|
297
|
+
if (!plan) {
|
|
298
|
+
const retryPrompt = [
|
|
299
|
+
'Return valid minified JSON only.',
|
|
300
|
+
'No prose, no markdown fences.',
|
|
301
|
+
'Schema: {"summary":"...","edits":[{"file":"path","start_line":1,"end_line":1,"replacement":"text"}]}',
|
|
302
|
+
`Allowed files: ${JSON.stringify(context.allowed_files || [])}`,
|
|
303
|
+
'Previous invalid output:',
|
|
304
|
+
safeSlice(response.output, 1200),
|
|
305
|
+
].join('\n');
|
|
306
|
+
response = callOllama({
|
|
307
|
+
model: policy.patchModel,
|
|
308
|
+
prompt: retryPrompt,
|
|
309
|
+
cwd,
|
|
310
|
+
timeoutMs: policy.timeoutMs,
|
|
311
|
+
purpose: 'patch',
|
|
312
|
+
});
|
|
313
|
+
if (response.ok) {
|
|
314
|
+
parsed = parseJsonObject(response.output);
|
|
315
|
+
basePlan = normalizeEditPlan(parsed);
|
|
316
|
+
plan = basePlan ? sanitizePlanPaths(cwd, basePlan) : null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!plan) {
|
|
321
|
+
return {
|
|
322
|
+
attempted: true,
|
|
323
|
+
strategy: 'ollama_patch_plan',
|
|
324
|
+
accepted: false,
|
|
325
|
+
model: policy.patchModel,
|
|
326
|
+
reason: 'invalid_edit_plan_json',
|
|
327
|
+
output_excerpt: safeSlice(response.output, 500),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const allowed = new Set(context.allowed_files || []);
|
|
332
|
+
const outOfScope = plan.edits
|
|
333
|
+
.map((e) => e.file)
|
|
334
|
+
.filter((file) => !allowed.has(file));
|
|
335
|
+
if (outOfScope.length > 0) {
|
|
336
|
+
return {
|
|
337
|
+
attempted: true,
|
|
338
|
+
strategy: 'ollama_patch_plan',
|
|
339
|
+
accepted: false,
|
|
340
|
+
model: policy.patchModel,
|
|
341
|
+
reason: 'file_out_of_scope',
|
|
342
|
+
out_of_scope_files: outOfScope,
|
|
343
|
+
proposal: plan,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const scoring = scoreEditPlan({ edits: plan.edits, failures, maxPatchLines });
|
|
348
|
+
if (scoring.predictedLines > maxPatchLines) {
|
|
349
|
+
return {
|
|
350
|
+
attempted: true,
|
|
351
|
+
strategy: 'ollama_patch_plan',
|
|
352
|
+
accepted: false,
|
|
353
|
+
model: policy.patchModel,
|
|
354
|
+
reason: 'patch_budget_exceeded',
|
|
355
|
+
predicted_lines: scoring.predictedLines,
|
|
356
|
+
max_patch_lines: maxPatchLines,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (scoring.score < 0.5) {
|
|
361
|
+
return {
|
|
362
|
+
attempted: true,
|
|
363
|
+
strategy: 'ollama_patch_plan',
|
|
364
|
+
accepted: false,
|
|
365
|
+
model: policy.patchModel,
|
|
366
|
+
reason: 'diff_score_too_low',
|
|
367
|
+
score: scoring.score,
|
|
368
|
+
touched_files: scoring.touchedFiles,
|
|
369
|
+
proposal: plan,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const applied = applyEditPlan({ cwd, plan });
|
|
374
|
+
if (!applied.ok) {
|
|
375
|
+
return {
|
|
376
|
+
attempted: true,
|
|
377
|
+
strategy: 'ollama_patch_plan',
|
|
378
|
+
accepted: false,
|
|
379
|
+
model: policy.patchModel,
|
|
380
|
+
reason: 'apply_plan_failed',
|
|
381
|
+
details: applied.reason,
|
|
382
|
+
proposal: plan,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
attempted: true,
|
|
388
|
+
strategy: 'ollama_patch_plan',
|
|
389
|
+
accepted: true,
|
|
390
|
+
model: policy.patchModel,
|
|
391
|
+
score: scoring.score,
|
|
392
|
+
patch_lines: scoring.predictedLines,
|
|
393
|
+
touched_files: scoring.touchedFiles,
|
|
394
|
+
summary: plan.summary,
|
|
395
|
+
proposal: plan,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { runCommand } from './exec.js';
|
|
4
|
+
import { readJsonFileSync, writeJsonFileSync } from './fs-utils.js';
|
|
5
|
+
import { executeRun } from './run-command.js';
|
|
6
|
+
import { executeSummarize } from './summarize-command.js';
|
|
7
|
+
import { DEFAULT_POLICY, ESCALATION_CODES } from './constants.js';
|
|
8
|
+
import { getModelRoutingPolicy, runHintModel, runPatchModel } from './model-adapter.js';
|
|
9
|
+
import { runDeterministicPreFix } from './deterministic-prefix.js';
|
|
10
|
+
import { hasRsync, hasGit } from './env-check.js';
|
|
11
|
+
|
|
12
|
+
function diffSnapshot(cwd) {
|
|
13
|
+
if (!hasGit()) return new Map();
|
|
14
|
+
const diff = runCommand(
|
|
15
|
+
"git diff --numstat -- . ':(exclude).quick-gate' ':(exclude).next' ':(exclude).lighthouseci' ':(exclude)node_modules' ':(exclude)tmp'",
|
|
16
|
+
{ cwd },
|
|
17
|
+
);
|
|
18
|
+
const map = new Map();
|
|
19
|
+
if (diff.exit_code !== 0) {
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
diff.stdout
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.forEach((row) => {
|
|
26
|
+
const cols = row.split(/\t+/);
|
|
27
|
+
if (cols.length < 3) return;
|
|
28
|
+
const added = Number(cols[0]);
|
|
29
|
+
const removed = Number(cols[1]);
|
|
30
|
+
const file = cols.slice(2).join('\t');
|
|
31
|
+
const value = (Number.isFinite(added) ? added : 0) + (Number.isFinite(removed) ? removed : 0);
|
|
32
|
+
map.set(file, value);
|
|
33
|
+
});
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computePatchLines(beforeMap, afterMap) {
|
|
38
|
+
const allFiles = new Set([...beforeMap.keys(), ...afterMap.keys()]);
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (const file of allFiles) {
|
|
41
|
+
const before = beforeMap.get(file) || 0;
|
|
42
|
+
const after = afterMap.get(file) || 0;
|
|
43
|
+
total += Math.abs(after - before);
|
|
44
|
+
}
|
|
45
|
+
return total;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function backupWorkspace(cwd, backupDir) {
|
|
49
|
+
if (hasRsync()) {
|
|
50
|
+
runCommand(`mkdir -p '${backupDir}' && rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.quick-gate' '${cwd}/' '${backupDir}/'`, { cwd });
|
|
51
|
+
} else {
|
|
52
|
+
runCommand(`mkdir -p '${backupDir}' && cp -R '${cwd}/.' '${backupDir}/' 2>/dev/null; rm -rf '${backupDir}/.git' '${backupDir}/node_modules' '${backupDir}/.next' '${backupDir}/.quick-gate'`, { cwd });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function restoreWorkspace(cwd, backupDir) {
|
|
57
|
+
if (hasRsync()) {
|
|
58
|
+
runCommand(`rsync -a --delete --exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude '.quick-gate' '${backupDir}/' '${cwd}/'`, { cwd });
|
|
59
|
+
} else {
|
|
60
|
+
const excludes = ['.git', 'node_modules', '.next', '.quick-gate'];
|
|
61
|
+
const excludeArgs = excludes.map((e) => `! -name '${e}'`).join(' ');
|
|
62
|
+
runCommand(`find '${backupDir}' -maxdepth 1 ${excludeArgs} ! -path '${backupDir}' -exec cp -R {} '${cwd}/' \\;`, { cwd });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runRepairActions(cwd, failures, policy, deterministicOnly) {
|
|
67
|
+
const attempted = [];
|
|
68
|
+
let currentFailures = failures;
|
|
69
|
+
|
|
70
|
+
const deterministicActions = runDeterministicPreFix({ cwd, failures: currentFailures });
|
|
71
|
+
if (deterministicActions.length > 0) {
|
|
72
|
+
attempted.push(...deterministicActions);
|
|
73
|
+
const rerunAfterDeterministic = executeRun({
|
|
74
|
+
mode: currentFailures.mode,
|
|
75
|
+
changedFiles: currentFailures.changed_files || [],
|
|
76
|
+
cwd,
|
|
77
|
+
});
|
|
78
|
+
currentFailures = readJsonFileSync(path.join(cwd, '.quick-gate', 'failures.json'));
|
|
79
|
+
executeSummarize({ input: '.quick-gate/failures.json', cwd });
|
|
80
|
+
|
|
81
|
+
attempted.push({
|
|
82
|
+
strategy: 'deterministic_prefix_rerun',
|
|
83
|
+
status: rerunAfterDeterministic.status,
|
|
84
|
+
findings_after_rerun: currentFailures.findings.length,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ((currentFailures.findings || []).length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
attempted,
|
|
91
|
+
currentFailures,
|
|
92
|
+
shortCircuitPass: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (deterministicOnly) {
|
|
97
|
+
attempted.push({
|
|
98
|
+
strategy: 'deterministic_only_mode',
|
|
99
|
+
note: 'Model-assisted repair skipped (--deterministic-only or Ollama not available).',
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
attempted,
|
|
103
|
+
currentFailures,
|
|
104
|
+
shortCircuitPass: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const hasTypecheckFailure = currentFailures.findings.some((f) => f.gate === 'typecheck');
|
|
109
|
+
const hasBuildFailure = currentFailures.findings.some((f) => f.gate === 'build');
|
|
110
|
+
const hasLighthouseFailure = currentFailures.findings.some((f) => f.gate === 'lighthouse');
|
|
111
|
+
const hasLintFailure = currentFailures.findings.some((f) => f.gate === 'lint');
|
|
112
|
+
|
|
113
|
+
if (hasTypecheckFailure || hasBuildFailure || hasLighthouseFailure) {
|
|
114
|
+
attempted.push({
|
|
115
|
+
strategy: 'requires_manual_or_model_patch',
|
|
116
|
+
note: 'No deterministic local fixer implemented for typecheck/build/lighthouse in MVP.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const modelPatchAllowed = hasLintFailure || hasTypecheckFailure;
|
|
121
|
+
if (!modelPatchAllowed) {
|
|
122
|
+
attempted.push({
|
|
123
|
+
strategy: 'skip_model_patch',
|
|
124
|
+
reason: 'no_patchable_gate_in_findings',
|
|
125
|
+
gates: Array.from(new Set(currentFailures.findings.map((f) => f.gate))),
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
attempted,
|
|
129
|
+
currentFailures,
|
|
130
|
+
shortCircuitPass: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const modelPolicy = getModelRoutingPolicy();
|
|
135
|
+
const hintResult = runHintModel({ cwd, failures: currentFailures, policy: modelPolicy });
|
|
136
|
+
attempted.push(hintResult);
|
|
137
|
+
|
|
138
|
+
const patchResult = runPatchModel({
|
|
139
|
+
cwd,
|
|
140
|
+
failures: currentFailures,
|
|
141
|
+
policy: modelPolicy,
|
|
142
|
+
maxPatchLines: policy.maxPatchLines,
|
|
143
|
+
});
|
|
144
|
+
attempted.push(patchResult);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
attempted,
|
|
148
|
+
currentFailures,
|
|
149
|
+
shortCircuitPass: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function executeRepair({ input, maxAttempts, deterministicOnly = false, cwd = process.cwd() }) {
|
|
154
|
+
const config = loadConfig(cwd);
|
|
155
|
+
const policy = {
|
|
156
|
+
maxAttempts: Number(maxAttempts || config.policy.maxAttempts || DEFAULT_POLICY.maxAttempts),
|
|
157
|
+
maxPatchLines: Number(config.policy.maxPatchLines || DEFAULT_POLICY.maxPatchLines),
|
|
158
|
+
abortOnNoImprovement: Number(config.policy.abortOnNoImprovement || DEFAULT_POLICY.abortOnNoImprovement),
|
|
159
|
+
timeCapMs: Number(config.policy.timeCapMs || DEFAULT_POLICY.timeCapMs),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let failures = readJsonFileSync(path.resolve(cwd, input));
|
|
163
|
+
let previousCount = failures.findings.length;
|
|
164
|
+
let noImprovement = 0;
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
const attempts = [];
|
|
167
|
+
|
|
168
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
|
169
|
+
if (Date.now() - started > policy.timeCapMs) {
|
|
170
|
+
const escalation = {
|
|
171
|
+
status: 'escalated',
|
|
172
|
+
reason_code: ESCALATION_CODES.UNKNOWN_BLOCKER,
|
|
173
|
+
message: `Time cap reached (${policy.timeCapMs}ms).`,
|
|
174
|
+
};
|
|
175
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'escalation.json'), escalation);
|
|
176
|
+
return escalation;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const backupDir = path.join(cwd, '.quick-gate', `backup-attempt-${attempt}`);
|
|
180
|
+
backupWorkspace(cwd, backupDir);
|
|
181
|
+
|
|
182
|
+
const preActionDiff = diffSnapshot(cwd);
|
|
183
|
+
const repairResult = runRepairActions(cwd, failures, policy, deterministicOnly);
|
|
184
|
+
const repairActions = repairResult.attempted;
|
|
185
|
+
const failuresForRerun = repairResult.currentFailures;
|
|
186
|
+
const postActionDiff = diffSnapshot(cwd);
|
|
187
|
+
const patchLines = computePatchLines(preActionDiff, postActionDiff);
|
|
188
|
+
|
|
189
|
+
if (patchLines > policy.maxPatchLines) {
|
|
190
|
+
restoreWorkspace(cwd, backupDir);
|
|
191
|
+
const escalation = {
|
|
192
|
+
status: 'escalated',
|
|
193
|
+
reason_code: ESCALATION_CODES.PATCH_BUDGET_EXCEEDED,
|
|
194
|
+
message: `Patch budget exceeded at attempt ${attempt}: ${patchLines} > ${policy.maxPatchLines}`,
|
|
195
|
+
evidence: {
|
|
196
|
+
attempt,
|
|
197
|
+
patch_lines: patchLines,
|
|
198
|
+
max_patch_lines: policy.maxPatchLines,
|
|
199
|
+
pre_action_diff_files: preActionDiff.size,
|
|
200
|
+
post_action_diff_files: postActionDiff.size,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'escalation.json'), escalation);
|
|
204
|
+
return escalation;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (repairResult.shortCircuitPass) {
|
|
208
|
+
attempts.push({
|
|
209
|
+
attempt,
|
|
210
|
+
patch_lines: patchLines,
|
|
211
|
+
before_findings: previousCount,
|
|
212
|
+
after_findings: 0,
|
|
213
|
+
improved: true,
|
|
214
|
+
worsened: false,
|
|
215
|
+
status: 'pass',
|
|
216
|
+
actions: repairActions,
|
|
217
|
+
});
|
|
218
|
+
const result = { status: 'pass', attempts };
|
|
219
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'repair-report.json'), result);
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const rerun = executeRun({
|
|
224
|
+
mode: failuresForRerun.mode,
|
|
225
|
+
changedFiles: failuresForRerun.changed_files || [],
|
|
226
|
+
cwd,
|
|
227
|
+
});
|
|
228
|
+
failures = readJsonFileSync(path.join(cwd, '.quick-gate', 'failures.json'));
|
|
229
|
+
executeSummarize({ input: '.quick-gate/failures.json', cwd });
|
|
230
|
+
|
|
231
|
+
const currentCount = failures.findings.length;
|
|
232
|
+
const improved = currentCount < previousCount;
|
|
233
|
+
const worsened = currentCount > previousCount;
|
|
234
|
+
|
|
235
|
+
attempts.push({
|
|
236
|
+
attempt,
|
|
237
|
+
patch_lines: patchLines,
|
|
238
|
+
before_findings: previousCount,
|
|
239
|
+
after_findings: currentCount,
|
|
240
|
+
improved,
|
|
241
|
+
worsened,
|
|
242
|
+
status: rerun.status,
|
|
243
|
+
actions: repairActions,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (rerun.status === 'pass') {
|
|
247
|
+
const result = { status: 'pass', attempts };
|
|
248
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'repair-report.json'), result);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (worsened) {
|
|
253
|
+
restoreWorkspace(cwd, backupDir);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (improved) {
|
|
257
|
+
noImprovement = 0;
|
|
258
|
+
} else {
|
|
259
|
+
noImprovement += 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
previousCount = currentCount;
|
|
263
|
+
|
|
264
|
+
if (noImprovement >= policy.abortOnNoImprovement) {
|
|
265
|
+
const escalation = {
|
|
266
|
+
status: 'escalated',
|
|
267
|
+
reason_code: ESCALATION_CODES.NO_IMPROVEMENT,
|
|
268
|
+
message: `No measurable improvement for ${noImprovement} consecutive attempt(s).`,
|
|
269
|
+
evidence: {
|
|
270
|
+
attempts,
|
|
271
|
+
latest_failures_path: '.quick-gate/failures.json',
|
|
272
|
+
latest_metadata_path: '.quick-gate/run-metadata.json',
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'escalation.json'), escalation);
|
|
276
|
+
return escalation;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const escalation = {
|
|
281
|
+
status: 'escalated',
|
|
282
|
+
reason_code: ESCALATION_CODES.UNKNOWN_BLOCKER,
|
|
283
|
+
message: `Attempts exhausted (${policy.maxAttempts}).`,
|
|
284
|
+
evidence: {
|
|
285
|
+
latest_failures_path: '.quick-gate/failures.json',
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
writeJsonFileSync(path.join(cwd, '.quick-gate', 'escalation.json'), escalation);
|
|
289
|
+
return escalation;
|
|
290
|
+
}
|