copilot-guardian 0.2.5
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/.github/workflows/ci.yml +53 -0
- package/.test-output-run-abstain/guardian.report.json +8 -0
- package/CHANGELOG.md +602 -0
- package/CONTRIBUTING.md +28 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/SECURITY.md +150 -0
- package/dist/cli.js +384 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine/analyze.js +294 -0
- package/dist/engine/analyze.js.map +1 -0
- package/dist/engine/async-exec.js +314 -0
- package/dist/engine/async-exec.js.map +1 -0
- package/dist/engine/auto-apply.js +424 -0
- package/dist/engine/auto-apply.js.map +1 -0
- package/dist/engine/context-enhancer.js +141 -0
- package/dist/engine/context-enhancer.js.map +1 -0
- package/dist/engine/debug.js +77 -0
- package/dist/engine/debug.js.map +1 -0
- package/dist/engine/eval.js +437 -0
- package/dist/engine/eval.js.map +1 -0
- package/dist/engine/github.js +191 -0
- package/dist/engine/github.js.map +1 -0
- package/dist/engine/mcp.js +217 -0
- package/dist/engine/mcp.js.map +1 -0
- package/dist/engine/patch_options.js +474 -0
- package/dist/engine/patch_options.js.map +1 -0
- package/dist/engine/run.js +124 -0
- package/dist/engine/run.js.map +1 -0
- package/dist/engine/util.js +167 -0
- package/dist/engine/util.js.map +1 -0
- package/dist/ui/dashboard.js +81 -0
- package/dist/ui/dashboard.js.map +1 -0
- package/docs/ARCHITECTURE.md +292 -0
- package/docs/Logo.png +0 -0
- package/docs/screenshots/05-hypothesis-dashboard.png +0 -0
- package/docs/screenshots/07-patch-spectrum.png +0 -0
- package/docs/screenshots/final-demo.gif +0 -0
- package/examples/demo-failure/.github/workflows/ci.yml +23 -0
- package/examples/demo-failure/README.md +93 -0
- package/examples/demo-failure/package.json +9 -0
- package/examples/demo-failure/test/require-api-url.js +10 -0
- package/jest.config.cjs +35 -0
- package/package.json +39 -0
- package/prompts/analysis.v2.txt +62 -0
- package/prompts/debug.followup.v1.txt +18 -0
- package/prompts/patch.options.v1.txt +47 -0
- package/prompts/patch.simple.v1.txt +12 -0
- package/prompts/quality.v1.txt +25 -0
- package/schemas/analysis.schema.json +65 -0
- package/schemas/patch_options.schema.json +23 -0
- package/schemas/quality.schema.json +12 -0
- package/src/cli.ts +417 -0
- package/src/engine/analyze.ts +412 -0
- package/src/engine/async-exec.ts +384 -0
- package/src/engine/auto-apply.ts +516 -0
- package/src/engine/context-enhancer.ts +176 -0
- package/src/engine/debug.ts +91 -0
- package/src/engine/eval.ts +546 -0
- package/src/engine/github.ts +223 -0
- package/src/engine/mcp.ts +267 -0
- package/src/engine/patch_options.ts +604 -0
- package/src/engine/run.ts +154 -0
- package/src/engine/util.ts +195 -0
- package/src/ui/dashboard.ts +90 -0
- package/test-sdk.mjs +51 -0
- package/tests/auto_heal_branch_safety.test.ts +76 -0
- package/tests/github_redaction_failclosed.test.ts +24 -0
- package/tests/mocks/copilot-sdk.mock.ts +15 -0
- package/tests/quality_guard_regression_matrix.test.ts +432 -0
- package/tests/run_abstain_policy.test.ts +83 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
|
|
4
|
+
import { copilotChatAsync } from "./async-exec.js";
|
|
5
|
+
import {
|
|
6
|
+
ensureDir,
|
|
7
|
+
loadText,
|
|
8
|
+
writeJson,
|
|
9
|
+
writeText,
|
|
10
|
+
extractJsonObject,
|
|
11
|
+
validateJson,
|
|
12
|
+
PACKAGE_ROOT
|
|
13
|
+
} from "./util.js";
|
|
14
|
+
|
|
15
|
+
type StrategyId = "conservative" | "balanced" | "aggressive";
|
|
16
|
+
|
|
17
|
+
export type PatchStrategy = {
|
|
18
|
+
label: string;
|
|
19
|
+
id: StrategyId;
|
|
20
|
+
risk_level: "low" | "medium" | "high";
|
|
21
|
+
summary: string;
|
|
22
|
+
diff: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PatchOptions = {
|
|
26
|
+
strategies: PatchStrategy[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type QualityReview = {
|
|
30
|
+
verdict: "GO" | "NO_GO";
|
|
31
|
+
reasons: string[];
|
|
32
|
+
risk_level: "low" | "medium" | "high";
|
|
33
|
+
slop_score: number;
|
|
34
|
+
suggested_adjustments: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type PatchGenerationOptions = {
|
|
38
|
+
fast?: boolean;
|
|
39
|
+
patchRetries?: number;
|
|
40
|
+
patchTimeoutMs?: number;
|
|
41
|
+
qualityTimeoutMs?: number;
|
|
42
|
+
qualityParallel?: number;
|
|
43
|
+
skipModelQualityOnDeterministicNoGo?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type QualityReviewOptions = {
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
skipModelOnDeterministicNoGo?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RISK_RANK: Record<"low" | "medium" | "high", number> = {
|
|
52
|
+
low: 0,
|
|
53
|
+
medium: 1,
|
|
54
|
+
high: 2
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function clamp01(n: number): number {
|
|
58
|
+
if (!Number.isFinite(n)) return 0;
|
|
59
|
+
return Math.max(0, Math.min(1, n));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toPositiveInt(value: unknown, fallback: number): number {
|
|
63
|
+
const n = Number(value);
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
65
|
+
return Math.floor(n);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unique(values: string[]): string[] {
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
for (const value of values) {
|
|
72
|
+
const v = String(value || "").trim();
|
|
73
|
+
if (!v || seen.has(v)) continue;
|
|
74
|
+
seen.add(v);
|
|
75
|
+
out.push(v);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function globToRegex(glob: string): RegExp {
|
|
81
|
+
const normalized = glob.replace(/\\/g, "/").trim();
|
|
82
|
+
const segments = normalized.split("/");
|
|
83
|
+
let out = "^";
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < segments.length; i++) {
|
|
86
|
+
const segment = segments[i];
|
|
87
|
+
const isLast = i === segments.length - 1;
|
|
88
|
+
|
|
89
|
+
if (segment === "**") {
|
|
90
|
+
// "**/" should match zero or more directories.
|
|
91
|
+
out += isLast ? ".*" : "(?:[^/]+/)*";
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let segOut = "";
|
|
96
|
+
for (let j = 0; j < segment.length; j++) {
|
|
97
|
+
const ch = segment[j];
|
|
98
|
+
if (ch === "*") {
|
|
99
|
+
segOut += "[^/]*";
|
|
100
|
+
} else if (ch === "?") {
|
|
101
|
+
segOut += "[^/]";
|
|
102
|
+
} else if ("\\.[]{}()+-^$|".includes(ch)) {
|
|
103
|
+
segOut += `\\${ch}`;
|
|
104
|
+
} else {
|
|
105
|
+
segOut += ch;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
out += segOut;
|
|
110
|
+
if (!isLast) out += "/";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
out += "$";
|
|
114
|
+
return new RegExp(out);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isAllowedByPatterns(filePath: string, allowedFiles: string[]): boolean {
|
|
118
|
+
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
119
|
+
return allowedFiles.some((rawPattern) => {
|
|
120
|
+
const pattern = String(rawPattern || "").trim().replace(/\\/g, "/");
|
|
121
|
+
if (!pattern) return false;
|
|
122
|
+
if (pattern === normalizedFile) return true;
|
|
123
|
+
if (!pattern.includes("*") && !pattern.includes("?")) return false;
|
|
124
|
+
return globToRegex(pattern).test(normalizedFile);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inferIntentKind(intent: string): "test" | "lint" | "build" | "install" | "unknown" {
|
|
129
|
+
const i = String(intent || "").toLowerCase();
|
|
130
|
+
if (i.includes("test")) return "test";
|
|
131
|
+
if (i.includes("lint")) return "lint";
|
|
132
|
+
if (i.includes("build") || i.includes("compile")) return "build";
|
|
133
|
+
if (i.includes("install") || i.includes("dependency") || i.includes("npm ci")) return "install";
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function computeAddedLines(diff: string): number {
|
|
138
|
+
return diff
|
|
139
|
+
.split(/\r?\n/)
|
|
140
|
+
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
141
|
+
.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getAddedContent(diff: string): string {
|
|
145
|
+
return diff
|
|
146
|
+
.split(/\r?\n/)
|
|
147
|
+
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
148
|
+
.map((line) => line.slice(1))
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function deterministicQualityReview(
|
|
153
|
+
analysisJson: any,
|
|
154
|
+
strat: PatchStrategy,
|
|
155
|
+
affectedFiles: string[]
|
|
156
|
+
): QualityReview {
|
|
157
|
+
const reasons: string[] = [];
|
|
158
|
+
const suggestedAdjustments: string[] = [];
|
|
159
|
+
const allowedFilesRaw = Array.isArray(analysisJson?.patch_plan?.allowed_files)
|
|
160
|
+
? analysisJson.patch_plan.allowed_files
|
|
161
|
+
: [];
|
|
162
|
+
const allowedFiles = allowedFilesRaw.filter((f: any) => typeof f === "string" && f.trim().length > 0);
|
|
163
|
+
const intent = String(analysisJson?.patch_plan?.intent || "");
|
|
164
|
+
const intentKind = inferIntentKind(intent);
|
|
165
|
+
|
|
166
|
+
let score = 0;
|
|
167
|
+
let forceNoGo = false;
|
|
168
|
+
|
|
169
|
+
if (allowedFiles.length > 0 && affectedFiles.length > 0) {
|
|
170
|
+
const outOfScope = affectedFiles.filter((file) => !isAllowedByPatterns(file, allowedFiles));
|
|
171
|
+
if (outOfScope.length > 0) {
|
|
172
|
+
score += 0.45;
|
|
173
|
+
forceNoGo = true;
|
|
174
|
+
reasons.push(`Out-of-scope file changes detected: ${outOfScope.join(", ")}`);
|
|
175
|
+
suggestedAdjustments.push("Restrict patch to analysis.patch_plan.allowed_files scope.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const diff = String(strat.diff || "");
|
|
180
|
+
const diffLower = diff.toLowerCase();
|
|
181
|
+
const touchesWorkflowFiles = affectedFiles.some((file) => file.startsWith(".github/workflows/"));
|
|
182
|
+
if (touchesWorkflowFiles) {
|
|
183
|
+
score += 0.45;
|
|
184
|
+
forceNoGo = true;
|
|
185
|
+
reasons.push("Workflow file modification detected; require human review (default NO_GO).");
|
|
186
|
+
suggestedAdjustments.push("Route workflow changes through manual review/PR approval.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hasDeletionPattern = /(?:^deleted file mode\s+\d+|^---\s+a\/[^\r\n]+\r?\n\+\+\+\s+\/dev\/null|^---\s+\/dev\/null\r?\n\+\+\+\s+b\/[^\r\n]+)/m.test(
|
|
190
|
+
diff
|
|
191
|
+
);
|
|
192
|
+
if (hasDeletionPattern) {
|
|
193
|
+
score += 0.4;
|
|
194
|
+
forceNoGo = true;
|
|
195
|
+
reasons.push("File deletion detected in patch; require human review.");
|
|
196
|
+
suggestedAdjustments.push("Avoid deletions in auto-fix mode or escalate to manual review.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const maxFilesWithoutReview = 6;
|
|
200
|
+
if (affectedFiles.length > maxFilesWithoutReview) {
|
|
201
|
+
score += 0.35;
|
|
202
|
+
forceNoGo = true;
|
|
203
|
+
reasons.push(`Patch footprint exceeds cap (${affectedFiles.length} files > ${maxFilesWithoutReview}).`);
|
|
204
|
+
suggestedAdjustments.push("Split into smaller patch sets; keep auto-fix scope narrow.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const addedContentLower = getAddedContent(diff).toLowerCase();
|
|
208
|
+
const hasBypassAntiPattern =
|
|
209
|
+
/(?:\bexit\s+0\b|lint:\s*skipped|continue-on-error:\s*true|--no-verify\b|process\.exit\(0\)|\|\|\s*true\b|set\s+\+e\b)/i.test(
|
|
210
|
+
diffLower
|
|
211
|
+
) ||
|
|
212
|
+
/(?:node_tls_reject_unauthorized\s*=\s*0|git_ssl_no_verify\s*=\s*(?:1|true)|strict-ssl\s*(?:=|\s)\s*false|npm\s+config\s+set\s+strict-ssl\s+false|--insecure\b|\bcurl\b[^\r\n]*\s-k\b)/i.test(
|
|
213
|
+
diffLower
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (hasBypassAntiPattern) {
|
|
217
|
+
score += 0.55;
|
|
218
|
+
forceNoGo = true;
|
|
219
|
+
reasons.push("Bypass anti-pattern detected (quality gate circumvention signal).");
|
|
220
|
+
suggestedAdjustments.push("Replace bypass logic with real fix for failing step.");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (/\b(todo|fixme|hack)\b/.test(addedContentLower)) {
|
|
224
|
+
score += 0.2;
|
|
225
|
+
forceNoGo = true;
|
|
226
|
+
reasons.push("Patch introduces TODO/FIXME/HACK markers.");
|
|
227
|
+
suggestedAdjustments.push("Ship executable fix, not placeholder follow-up markers.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (/(?:@ts-ignore|@ts-nocheck|eslint-disable)/i.test(addedContentLower)) {
|
|
231
|
+
score += 0.35;
|
|
232
|
+
forceNoGo = true;
|
|
233
|
+
reasons.push("TS/lint suppression marker added (@ts-ignore/@ts-nocheck/eslint-disable).");
|
|
234
|
+
suggestedAdjustments.push("Fix underlying TS/lint issue instead of adding suppression.");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const addedLines = computeAddedLines(diff);
|
|
238
|
+
if (addedLines > 240) {
|
|
239
|
+
score += 0.25;
|
|
240
|
+
reasons.push(`Patch size is large (+${addedLines} lines), raising slop/scope risk.`);
|
|
241
|
+
suggestedAdjustments.push("Split patch into smaller focused changes.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (affectedFiles.length >= 8) {
|
|
245
|
+
score += 0.2;
|
|
246
|
+
reasons.push(`Patch touches ${affectedFiles.length} files (high scope for single failure).`);
|
|
247
|
+
suggestedAdjustments.push("Reduce unrelated file churn.");
|
|
248
|
+
} else if (affectedFiles.length >= 5) {
|
|
249
|
+
score += 0.12;
|
|
250
|
+
reasons.push(`Patch touches ${affectedFiles.length} files (medium scope).`);
|
|
251
|
+
suggestedAdjustments.push("Keep patch focused on root-cause path.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const touchesSourceOrTests = affectedFiles.some((file) =>
|
|
255
|
+
/^(src|test|tests)\//.test(file) || /\.(test|spec)\.[jt]sx?$/.test(file)
|
|
256
|
+
);
|
|
257
|
+
const touchesLintInfra = affectedFiles.some((file) =>
|
|
258
|
+
file === "package.json" || file.startsWith(".eslintrc") || file === ".eslintignore"
|
|
259
|
+
);
|
|
260
|
+
const touchesBuildInfra = affectedFiles.some((file) =>
|
|
261
|
+
file === "tsconfig.json" || file === "package.json" || file.startsWith(".github/workflows/")
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (intentKind === "test" && !touchesSourceOrTests) {
|
|
265
|
+
score += 0.3;
|
|
266
|
+
reasons.push("Intent says test fix, but no test/source file is changed.");
|
|
267
|
+
suggestedAdjustments.push("Update failing test files or related source implementation.");
|
|
268
|
+
}
|
|
269
|
+
if (intentKind === "lint" && !(touchesLintInfra || touchesSourceOrTests)) {
|
|
270
|
+
score += 0.25;
|
|
271
|
+
reasons.push("Intent says lint fix, but patch does not touch lint/source targets.");
|
|
272
|
+
suggestedAdjustments.push("Include actual lint remediation in source or lint config.");
|
|
273
|
+
}
|
|
274
|
+
if (intentKind === "build" && !(touchesBuildInfra || touchesSourceOrTests)) {
|
|
275
|
+
score += 0.2;
|
|
276
|
+
reasons.push("Intent says build fix, but patch does not touch build-relevant files.");
|
|
277
|
+
suggestedAdjustments.push("Change build config or affected source modules.");
|
|
278
|
+
}
|
|
279
|
+
if (intentKind === "install" && !affectedFiles.some((f) => f === "package.json" || f === "package-lock.json")) {
|
|
280
|
+
score += 0.2;
|
|
281
|
+
reasons.push("Intent says dependency/install fix, but package manifests are unchanged.");
|
|
282
|
+
suggestedAdjustments.push("Update package manifests/lockfile for dependency failures.");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const slop_score = clamp01(score);
|
|
286
|
+
const risk_level: "low" | "medium" | "high" =
|
|
287
|
+
slop_score >= 0.7 || forceNoGo ? "high" : slop_score >= 0.35 ? "medium" : "low";
|
|
288
|
+
const verdict: "GO" | "NO_GO" = forceNoGo || slop_score >= 0.65 ? "NO_GO" : "GO";
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
verdict,
|
|
292
|
+
risk_level,
|
|
293
|
+
slop_score,
|
|
294
|
+
reasons,
|
|
295
|
+
suggested_adjustments: suggestedAdjustments
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function mergeQualityReview(modelReview: QualityReview, deterministicReview: QualityReview): QualityReview {
|
|
300
|
+
const mergedRiskRank = Math.max(RISK_RANK[modelReview.risk_level], RISK_RANK[deterministicReview.risk_level]);
|
|
301
|
+
const risk_level: "low" | "medium" | "high" =
|
|
302
|
+
mergedRiskRank === 2 ? "high" : mergedRiskRank === 1 ? "medium" : "low";
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
verdict: modelReview.verdict === "NO_GO" || deterministicReview.verdict === "NO_GO" ? "NO_GO" : "GO",
|
|
306
|
+
risk_level,
|
|
307
|
+
slop_score: clamp01(Math.max(modelReview.slop_score, deterministicReview.slop_score)),
|
|
308
|
+
reasons: unique([...deterministicReview.reasons, ...modelReview.reasons]),
|
|
309
|
+
suggested_adjustments: unique([
|
|
310
|
+
...deterministicReview.suggested_adjustments,
|
|
311
|
+
...modelReview.suggested_adjustments
|
|
312
|
+
])
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeQualityReview(raw: any): QualityReview {
|
|
317
|
+
const verdict: "GO" | "NO_GO" = raw?.verdict === "GO" ? "GO" : "NO_GO";
|
|
318
|
+
const riskLevel: "low" | "medium" | "high" =
|
|
319
|
+
raw?.risk_level === "low" || raw?.risk_level === "medium" || raw?.risk_level === "high"
|
|
320
|
+
? raw.risk_level
|
|
321
|
+
: "high";
|
|
322
|
+
const reasons = Array.isArray(raw?.reasons) ? raw.reasons.map((x: any) => String(x)) : [];
|
|
323
|
+
const suggestedAdjustments = Array.isArray(raw?.suggested_adjustments)
|
|
324
|
+
? raw.suggested_adjustments.map((x: any) => String(x))
|
|
325
|
+
: [];
|
|
326
|
+
const numericSlop = Number(raw?.slop_score);
|
|
327
|
+
const slopScore = Number.isFinite(numericSlop) ? numericSlop : 1;
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
verdict,
|
|
331
|
+
reasons,
|
|
332
|
+
risk_level: riskLevel,
|
|
333
|
+
slop_score: slopScore,
|
|
334
|
+
suggested_adjustments: suggestedAdjustments
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function extractFilesFromDiff(diff: string): string[] {
|
|
339
|
+
const files = new Set<string>();
|
|
340
|
+
const addModifyRegex = /^[\+]{3} (?:b\/)?(.+)$/gm;
|
|
341
|
+
const deleteRegex = /^--- (?:a\/)?(.+)$/gm;
|
|
342
|
+
const renameRegex = /^rename (?:from|to) (.+)$/gm;
|
|
343
|
+
|
|
344
|
+
const collect = (regex: RegExp) => {
|
|
345
|
+
let match;
|
|
346
|
+
while ((match = regex.exec(diff)) !== null) {
|
|
347
|
+
const file = match[1];
|
|
348
|
+
if (file && file !== "/dev/null") {
|
|
349
|
+
files.add(file);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
collect(addModifyRegex);
|
|
355
|
+
collect(deleteRegex);
|
|
356
|
+
collect(renameRegex);
|
|
357
|
+
|
|
358
|
+
return Array.from(files);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function copilotChat(input: string, context: string, timeout = 150000): Promise<string> {
|
|
362
|
+
return copilotChatAsync(input, {
|
|
363
|
+
spinnerText: `[>] ${context}...`,
|
|
364
|
+
timeout
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function mapWithConcurrency<T, R>(
|
|
369
|
+
items: T[],
|
|
370
|
+
concurrency: number,
|
|
371
|
+
worker: (item: T, index: number) => Promise<R>
|
|
372
|
+
): Promise<R[]> {
|
|
373
|
+
const size = Math.max(1, Math.floor(concurrency));
|
|
374
|
+
const out = new Array<R>(items.length);
|
|
375
|
+
let cursor = 0;
|
|
376
|
+
|
|
377
|
+
const runWorker = async () => {
|
|
378
|
+
while (true) {
|
|
379
|
+
const index = cursor++;
|
|
380
|
+
if (index >= items.length) return;
|
|
381
|
+
out[index] = await worker(items[index], index);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const workers = Array.from({ length: Math.min(size, items.length) }, () => runWorker());
|
|
386
|
+
await Promise.all(workers);
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function generatePatchOptions(
|
|
391
|
+
analysisJson: any,
|
|
392
|
+
outDir = path.join(process.cwd(), ".copilot-guardian"),
|
|
393
|
+
options: PatchGenerationOptions = {}
|
|
394
|
+
) {
|
|
395
|
+
const fastMode = Boolean(options.fast);
|
|
396
|
+
const maxPatchRetries = toPositiveInt(options.patchRetries, fastMode ? 2 : 3);
|
|
397
|
+
const patchTimeoutMs = toPositiveInt(options.patchTimeoutMs, fastMode ? 90000 : 180000);
|
|
398
|
+
const qualityTimeoutMs = toPositiveInt(options.qualityTimeoutMs, fastMode ? 70000 : 120000);
|
|
399
|
+
const qualityParallel = toPositiveInt(options.qualityParallel, fastMode ? 3 : 1);
|
|
400
|
+
const skipModelQualityOnDeterministicNoGo =
|
|
401
|
+
typeof options.skipModelQualityOnDeterministicNoGo === "boolean"
|
|
402
|
+
? options.skipModelQualityOnDeterministicNoGo
|
|
403
|
+
: fastMode;
|
|
404
|
+
|
|
405
|
+
console.log(chalk.cyan('\n[>] Generating 3-strategy patch options...'));
|
|
406
|
+
console.log(chalk.dim(' Conservative: Minimal changes, low risk'));
|
|
407
|
+
console.log(chalk.dim(' Balanced: Standard fix, moderate scope'));
|
|
408
|
+
console.log(chalk.dim(' Aggressive: Comprehensive, high risk'));
|
|
409
|
+
if (fastMode) {
|
|
410
|
+
console.log(chalk.dim(` Fast mode: retries=${maxPatchRetries}, patch_timeout=${patchTimeoutMs}ms, quality_timeout=${qualityTimeoutMs}ms, parallel=${qualityParallel}`));
|
|
411
|
+
}
|
|
412
|
+
ensureDir(outDir);
|
|
413
|
+
|
|
414
|
+
const prompt = loadText(path.join(PACKAGE_ROOT, "prompts", "patch.options.v1.txt"));
|
|
415
|
+
|
|
416
|
+
const baseInput = `${prompt}\n\nANALYSIS_JSON:\n${JSON.stringify(analysisJson, null, 2)}`;
|
|
417
|
+
console.log(chalk.cyan('[>] Asking Copilot for patch strategies...'));
|
|
418
|
+
|
|
419
|
+
let raw = "";
|
|
420
|
+
let obj: PatchOptions | null = null;
|
|
421
|
+
let lastParseError: Error | null = null;
|
|
422
|
+
|
|
423
|
+
for (let attempt = 1; attempt <= maxPatchRetries; attempt++) {
|
|
424
|
+
const retryNote = attempt === 1
|
|
425
|
+
? ""
|
|
426
|
+
: `\n\nRETRY ${attempt}/${maxPatchRetries} - STRICT MODE:\n` +
|
|
427
|
+
`Previous response was invalid JSON.\n` +
|
|
428
|
+
`Return ONLY a single JSON object exactly matching the required schema.\n` +
|
|
429
|
+
`No prose. No markdown. No preface. No summary.\n`;
|
|
430
|
+
const attemptInput = baseInput + retryNote;
|
|
431
|
+
|
|
432
|
+
raw = await copilotChat(
|
|
433
|
+
attemptInput,
|
|
434
|
+
`Generating 3 patch strategies (attempt ${attempt}/${maxPatchRetries})`,
|
|
435
|
+
patchTimeoutMs
|
|
436
|
+
);
|
|
437
|
+
writeText(path.join(outDir, `copilot.patch.options.raw.attempt${attempt}.txt`), raw);
|
|
438
|
+
writeText(path.join(outDir, "copilot.patch.options.raw.txt"), raw);
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
obj = JSON.parse(extractJsonObject(raw)) as PatchOptions;
|
|
442
|
+
console.log(chalk.green('[+] Received patch strategies from Copilot'));
|
|
443
|
+
break;
|
|
444
|
+
} catch (parseError: any) {
|
|
445
|
+
lastParseError = parseError;
|
|
446
|
+
console.log(chalk.yellow(`[!] Patch generation returned invalid JSON (attempt ${attempt}/${maxPatchRetries})`));
|
|
447
|
+
if (attempt < maxPatchRetries) {
|
|
448
|
+
console.log(chalk.dim(' Retrying with stricter JSON-only instruction...'));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!obj) {
|
|
454
|
+
console.log(chalk.red('[-] Patch generation failed: Invalid JSON from Copilot'));
|
|
455
|
+
throw new Error(`Copilot returned invalid JSON after ${maxPatchRetries} attempts: ${lastParseError?.message || 'unknown parse error'}\n\nSaved to: copilot.patch.options.raw.txt`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Validate with fallback
|
|
459
|
+
try {
|
|
460
|
+
validateJson(obj, path.join(PACKAGE_ROOT, "schemas", "patch_options.schema.json"));
|
|
461
|
+
console.log(chalk.green('[+] Patch options validated'));
|
|
462
|
+
} catch (error: any) {
|
|
463
|
+
console.log(chalk.yellow('[!] Schema validation warning:'), error.message);
|
|
464
|
+
console.log(chalk.dim(` Raw preview: ${raw.slice(0, 240).replace(/\s+/g, ' ')}`));
|
|
465
|
+
|
|
466
|
+
// Check critical structure
|
|
467
|
+
if (!obj.strategies || !Array.isArray(obj.strategies) || obj.strategies.length === 0) {
|
|
468
|
+
throw new Error('No patch strategies generated. Check copilot.patch.options.raw.txt for details.');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Write patch files
|
|
473
|
+
console.log(chalk.cyan('[>] Running quality reviews on each strategy...'));
|
|
474
|
+
const strategies = Array.isArray(obj.strategies) ? obj.strategies : [];
|
|
475
|
+
for (const strat of strategies) {
|
|
476
|
+
const patchPath = path.join(outDir, `fix.${strat.id}.patch`);
|
|
477
|
+
writeText(patchPath, strat.diff.trim() + "\n");
|
|
478
|
+
console.log(chalk.dim(`[>] Saved patch: fix.${strat.id}.patch`));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const results = await mapWithConcurrency(strategies, qualityParallel, async (strat) => {
|
|
482
|
+
const affectedFiles = extractFilesFromDiff(strat.diff);
|
|
483
|
+
const patchPath = path.join(outDir, `fix.${strat.id}.patch`);
|
|
484
|
+
console.log(chalk.dim(`[>] Quality checking ${strat.id} strategy...`));
|
|
485
|
+
const quality = await qualityReview(analysisJson, strat, affectedFiles, outDir, {
|
|
486
|
+
timeoutMs: qualityTimeoutMs,
|
|
487
|
+
skipModelOnDeterministicNoGo: skipModelQualityOnDeterministicNoGo
|
|
488
|
+
});
|
|
489
|
+
const verdictColor = quality.verdict === "GO" ? chalk.green : chalk.red;
|
|
490
|
+
const slopScore = quality.slop_score !== undefined ? quality.slop_score.toFixed(2) : "0.00";
|
|
491
|
+
console.log(chalk.dim(` ${strat.label.padEnd(15)}`), verdictColor(quality.verdict), chalk.dim(`slop=${slopScore}`));
|
|
492
|
+
return {
|
|
493
|
+
label: strat.label,
|
|
494
|
+
id: strat.id,
|
|
495
|
+
risk_level: quality.risk_level,
|
|
496
|
+
verdict: quality.verdict,
|
|
497
|
+
slop_score: quality.slop_score,
|
|
498
|
+
patchPath,
|
|
499
|
+
files: affectedFiles,
|
|
500
|
+
summary: strat.summary
|
|
501
|
+
};
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const index = { timestamp: new Date().toISOString(), results };
|
|
505
|
+
writeJson(path.join(outDir, "patch_options.json"), index);
|
|
506
|
+
console.log(chalk.green(`[+] Saved patch index: patch_options.json`));
|
|
507
|
+
|
|
508
|
+
console.log(chalk.green.bold('\n[+] Patch options complete!'));
|
|
509
|
+
console.log(chalk.dim(` Total strategies: ${results.length}`));
|
|
510
|
+
console.log(chalk.dim(` GO verdicts: ${results.filter(r => r.verdict === 'GO').length}`));
|
|
511
|
+
console.log(chalk.dim(` NO-GO (slop detected): ${results.filter(r => r.verdict === 'NO_GO').length}`));
|
|
512
|
+
|
|
513
|
+
return { options: obj, index };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function qualityReview(
|
|
517
|
+
analysisJson: any,
|
|
518
|
+
strat: PatchStrategy,
|
|
519
|
+
affectedFiles: string[],
|
|
520
|
+
outDir: string,
|
|
521
|
+
options: QualityReviewOptions = {}
|
|
522
|
+
): Promise<QualityReview> {
|
|
523
|
+
const timeoutMs = toPositiveInt(options.timeoutMs, 120000);
|
|
524
|
+
const skipModelOnDeterministicNoGo = Boolean(options.skipModelOnDeterministicNoGo);
|
|
525
|
+
const deterministic = deterministicQualityReview(analysisJson, strat, affectedFiles);
|
|
526
|
+
const rawPath = path.join(outDir, `copilot.quality.${strat.id}.raw.txt`);
|
|
527
|
+
const reviewPath = path.join(outDir, `quality_review.${strat.id}.json`);
|
|
528
|
+
|
|
529
|
+
if (skipModelOnDeterministicNoGo && deterministic.verdict === "NO_GO") {
|
|
530
|
+
const skipped: QualityReview = {
|
|
531
|
+
...deterministic,
|
|
532
|
+
reasons: unique([
|
|
533
|
+
"Model quality review skipped because deterministic guard already returned NO_GO.",
|
|
534
|
+
...deterministic.reasons
|
|
535
|
+
])
|
|
536
|
+
};
|
|
537
|
+
writeText(rawPath, "[SKIPPED] Model quality review was skipped due to deterministic NO_GO.\n");
|
|
538
|
+
writeJson(reviewPath, skipped);
|
|
539
|
+
return skipped;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const prompt = loadText(path.join(PACKAGE_ROOT, "prompts", "quality.v1.txt"));
|
|
543
|
+
const input = `${prompt}\n\nINPUT:\n${JSON.stringify({
|
|
544
|
+
intent: analysisJson?.patch_plan?.intent,
|
|
545
|
+
allowed_files: analysisJson?.patch_plan?.allowed_files,
|
|
546
|
+
strategy: strat.id,
|
|
547
|
+
diff: strat.diff
|
|
548
|
+
}, null, 2)}`;
|
|
549
|
+
|
|
550
|
+
const raw = await copilotChat(input, `Quality review: ${strat.id}`, timeoutMs);
|
|
551
|
+
writeText(rawPath, raw);
|
|
552
|
+
|
|
553
|
+
// S2 FIX: Add try-catch for JSON parsing
|
|
554
|
+
let obj: any;
|
|
555
|
+
try {
|
|
556
|
+
obj = JSON.parse(extractJsonObject(raw));
|
|
557
|
+
} catch (parseError: any) {
|
|
558
|
+
console.log(chalk.red(`[-] Quality review failed for ${strat.id}: Invalid JSON`));
|
|
559
|
+
const parseFallback: QualityReview = {
|
|
560
|
+
verdict: "NO_GO",
|
|
561
|
+
slop_score: 1,
|
|
562
|
+
risk_level: "high" as const,
|
|
563
|
+
reasons: [`Parse error: ${parseError.message}`],
|
|
564
|
+
suggested_adjustments: [
|
|
565
|
+
"Re-run quality review with stricter JSON-only response",
|
|
566
|
+
"Inspect copilot.quality.*.raw.txt for malformed output"
|
|
567
|
+
]
|
|
568
|
+
};
|
|
569
|
+
const merged = mergeQualityReview(parseFallback, deterministic);
|
|
570
|
+
writeJson(reviewPath, merged);
|
|
571
|
+
return merged;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let schemaInvalid = false;
|
|
575
|
+
try {
|
|
576
|
+
validateJson(obj, path.join(PACKAGE_ROOT, "schemas", "quality.schema.json"));
|
|
577
|
+
} catch (error: any) {
|
|
578
|
+
schemaInvalid = true;
|
|
579
|
+
console.log(chalk.yellow(`[!] Quality schema warning for ${strat.id}:`), error.message);
|
|
580
|
+
console.log(chalk.dim(` Raw preview: ${raw.slice(0, 240).replace(/\s+/g, ' ')}`));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const normalized = normalizeQualityReview(obj);
|
|
584
|
+
const observedSlop = Number(obj?.slop_score);
|
|
585
|
+
if (Number.isFinite(observedSlop) && observedSlop > 1) {
|
|
586
|
+
console.log(chalk.yellow(`[!] Suspicious slop_score (${observedSlop}) detected for ${strat.id}; possible validation bypass.`));
|
|
587
|
+
normalized.reasons.unshift(`slop_score out of range: ${observedSlop} (expected 0..1)`);
|
|
588
|
+
normalized.suggested_adjustments.unshift("Ensure quality model outputs slop_score in [0,1] and enforce schema hard-fail.");
|
|
589
|
+
normalized.verdict = "NO_GO";
|
|
590
|
+
normalized.risk_level = "high";
|
|
591
|
+
normalized.slop_score = 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (schemaInvalid) {
|
|
595
|
+
normalized.reasons.unshift("Quality schema validation failed; verdict forced to NO_GO for safety.");
|
|
596
|
+
normalized.verdict = "NO_GO";
|
|
597
|
+
normalized.risk_level = "high";
|
|
598
|
+
normalized.slop_score = Math.max(normalized.slop_score, 1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const merged = mergeQualityReview(normalized, deterministic);
|
|
602
|
+
writeJson(reviewPath, merged);
|
|
603
|
+
return merged;
|
|
604
|
+
}
|