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,432 @@
|
|
|
1
|
+
import { generatePatchOptions } from '../src/engine/patch_options';
|
|
2
|
+
import * as asyncExec from '../src/engine/async-exec';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
jest.mock('../src/engine/async-exec');
|
|
7
|
+
|
|
8
|
+
type MockAnalysis = {
|
|
9
|
+
diagnosis: {
|
|
10
|
+
hypotheses: Array<{ id: string; title: string; confidence: number }>;
|
|
11
|
+
selected_hypothesis_id: string;
|
|
12
|
+
root_cause: string;
|
|
13
|
+
};
|
|
14
|
+
patch_plan: {
|
|
15
|
+
intent: string;
|
|
16
|
+
allowed_files: string[];
|
|
17
|
+
strategy: string[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Strategy = {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
risk_level: 'low' | 'medium' | 'high';
|
|
25
|
+
summary: string;
|
|
26
|
+
diff: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type QualityShape = {
|
|
30
|
+
verdict: 'GO' | 'NO_GO';
|
|
31
|
+
slop_score: number;
|
|
32
|
+
risk_level: 'low' | 'medium' | 'high';
|
|
33
|
+
reasons: string[];
|
|
34
|
+
suggested_adjustments: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const OUT_DIR = '.test-output';
|
|
38
|
+
|
|
39
|
+
const MODEL_GO: QualityShape = {
|
|
40
|
+
verdict: 'GO',
|
|
41
|
+
slop_score: 0.08,
|
|
42
|
+
risk_level: 'low',
|
|
43
|
+
reasons: ['Model judged patch as acceptable'],
|
|
44
|
+
suggested_adjustments: []
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function baseAnalysis(overrides?: Partial<MockAnalysis['patch_plan']>): MockAnalysis {
|
|
48
|
+
return {
|
|
49
|
+
diagnosis: {
|
|
50
|
+
hypotheses: [{ id: 'H1', title: 'CI failure', confidence: 0.91 }],
|
|
51
|
+
selected_hypothesis_id: 'H1',
|
|
52
|
+
root_cause: 'Regression detected in CI'
|
|
53
|
+
},
|
|
54
|
+
patch_plan: {
|
|
55
|
+
intent: 'Fix CI failure with minimal, real code changes. No bypasses. No suppressions.',
|
|
56
|
+
allowed_files: ['src/**/*.ts', 'tests/**/*.ts', 'package.json', '.github/workflows/*.yml'],
|
|
57
|
+
strategy: ['Fix root cause'],
|
|
58
|
+
...overrides
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeBalanced(): Strategy {
|
|
64
|
+
return {
|
|
65
|
+
id: 'balanced',
|
|
66
|
+
label: 'BALANCED',
|
|
67
|
+
risk_level: 'low',
|
|
68
|
+
summary: 'Real fix in allowed scope',
|
|
69
|
+
diff: [
|
|
70
|
+
'--- a/src/engine/github.ts',
|
|
71
|
+
'+++ b/src/engine/github.ts',
|
|
72
|
+
'@@ -200,7 +200,7 @@',
|
|
73
|
+
'- return { workflowPath };',
|
|
74
|
+
"+ return { workflowPath: workflowPath || '' };"
|
|
75
|
+
].join('\n')
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeAggressive(): Strategy {
|
|
80
|
+
return {
|
|
81
|
+
id: 'aggressive',
|
|
82
|
+
label: 'AGGRESSIVE',
|
|
83
|
+
risk_level: 'medium',
|
|
84
|
+
summary: 'Real test fix in allowed scope',
|
|
85
|
+
diff: [
|
|
86
|
+
'--- a/tests/quality_guard_regression_matrix.test.ts',
|
|
87
|
+
'+++ b/tests/quality_guard_regression_matrix.test.ts',
|
|
88
|
+
'@@ -1,1 +1,1 @@',
|
|
89
|
+
'-const oldValue = 0;',
|
|
90
|
+
'+const oldValue = 1;'
|
|
91
|
+
].join('\n')
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mockCopilot(
|
|
96
|
+
strategies: Strategy[],
|
|
97
|
+
qualityByStrategy?: Record<string, Partial<QualityShape> | string>
|
|
98
|
+
): void {
|
|
99
|
+
(asyncExec.copilotChatAsync as jest.Mock).mockImplementation((prompt: string) => {
|
|
100
|
+
if (prompt.includes('ANALYSIS_JSON:')) {
|
|
101
|
+
return Promise.resolve(JSON.stringify({ strategies }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const strategyMatch = prompt.match(/"strategy"\s*:\s*"([^"]+)"/);
|
|
105
|
+
const strategyId = strategyMatch?.[1] || '';
|
|
106
|
+
const override = strategyId ? qualityByStrategy?.[strategyId] : undefined;
|
|
107
|
+
|
|
108
|
+
if (typeof override === 'string') {
|
|
109
|
+
return Promise.resolve(override);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (override) {
|
|
113
|
+
return Promise.resolve(JSON.stringify({ ...MODEL_GO, ...override }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Promise.resolve(JSON.stringify(MODEL_GO));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findResult(result: any, id: string): any {
|
|
121
|
+
const item = result.index.results.find((r: any) => r.id === id);
|
|
122
|
+
expect(item).toBeDefined();
|
|
123
|
+
return item;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readQualityReview(strategyId: string): QualityShape {
|
|
127
|
+
const reviewPath = path.join(OUT_DIR, `quality_review.${strategyId}.json`);
|
|
128
|
+
return JSON.parse(fs.readFileSync(reviewPath, 'utf8')) as QualityShape;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function joinedReasons(review: QualityShape): string {
|
|
132
|
+
return (Array.isArray(review.reasons) ? review.reasons : []).join(' | ');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('quality guard regression matrix', () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
jest.clearAllMocks();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('baseline: rejects bypass + out-of-scope and keeps one real GO strategy', async () => {
|
|
141
|
+
const analysis = baseAnalysis({
|
|
142
|
+
allowed_files: ['src/**/*.ts', 'tests/**/*.ts', 'package.json']
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
mockCopilot([
|
|
146
|
+
{
|
|
147
|
+
id: 'conservative',
|
|
148
|
+
label: 'CONSERVATIVE',
|
|
149
|
+
risk_level: 'low',
|
|
150
|
+
summary: 'Bypass lint gate',
|
|
151
|
+
diff: [
|
|
152
|
+
'--- a/package.json',
|
|
153
|
+
'+++ b/package.json',
|
|
154
|
+
'@@ -10,7 +10,7 @@',
|
|
155
|
+
'- "lint": "eslint src --max-warnings=0"',
|
|
156
|
+
'+ "lint": "node -e \\"process.exit(0)\\""'
|
|
157
|
+
].join('\n')
|
|
158
|
+
},
|
|
159
|
+
safeBalanced(),
|
|
160
|
+
{
|
|
161
|
+
id: 'aggressive',
|
|
162
|
+
label: 'AGGRESSIVE',
|
|
163
|
+
risk_level: 'high',
|
|
164
|
+
summary: 'Out-of-scope docs edit',
|
|
165
|
+
diff: [
|
|
166
|
+
'--- a/docs/README.md',
|
|
167
|
+
'+++ b/docs/README.md',
|
|
168
|
+
'@@ -1,1 +1,2 @@',
|
|
169
|
+
'-old',
|
|
170
|
+
'+new',
|
|
171
|
+
'+TODO: follow-up'
|
|
172
|
+
].join('\n')
|
|
173
|
+
}
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
const result = await generatePatchOptions(analysis, OUT_DIR);
|
|
177
|
+
const conservative = findResult(result, 'conservative');
|
|
178
|
+
const balanced = findResult(result, 'balanced');
|
|
179
|
+
const aggressive = findResult(result, 'aggressive');
|
|
180
|
+
|
|
181
|
+
expect(conservative.verdict).toBe('NO_GO');
|
|
182
|
+
expect(aggressive.verdict).toBe('NO_GO');
|
|
183
|
+
expect(balanced.verdict).toBe('GO');
|
|
184
|
+
|
|
185
|
+
const goList = result.index.results.filter((r: any) => r.verdict === 'GO');
|
|
186
|
+
expect(goList).toHaveLength(1);
|
|
187
|
+
expect(goList[0].id).toBe('balanced');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test.each([
|
|
191
|
+
{
|
|
192
|
+
name: 'ts-ignore suppression',
|
|
193
|
+
diff: [
|
|
194
|
+
'--- a/src/engine/github.ts',
|
|
195
|
+
'+++ b/src/engine/github.ts',
|
|
196
|
+
'@@ -1,2 +1,3 @@',
|
|
197
|
+
'+// @ts-ignore',
|
|
198
|
+
"+const x: number = 'nope' as any;"
|
|
199
|
+
].join('\n'),
|
|
200
|
+
reason: 'TS/lint suppression marker'
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'ts-nocheck suppression',
|
|
204
|
+
diff: [
|
|
205
|
+
'--- a/src/engine/github.ts',
|
|
206
|
+
'+++ b/src/engine/github.ts',
|
|
207
|
+
'@@ -1,2 +1,3 @@',
|
|
208
|
+
'+// @ts-nocheck',
|
|
209
|
+
"+const x: number = 'nope' as any;"
|
|
210
|
+
].join('\n'),
|
|
211
|
+
reason: 'TS/lint suppression marker'
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'eslint-disable suppression',
|
|
215
|
+
diff: [
|
|
216
|
+
'--- a/src/engine/github.ts',
|
|
217
|
+
'+++ b/src/engine/github.ts',
|
|
218
|
+
'@@ -1,2 +1,3 @@',
|
|
219
|
+
'+// eslint-disable-next-line @typescript-eslint/no-explicit-any',
|
|
220
|
+
'+const x: any = 1;'
|
|
221
|
+
].join('\n'),
|
|
222
|
+
reason: 'TS/lint suppression marker'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'todo placeholder',
|
|
226
|
+
diff: [
|
|
227
|
+
'--- a/src/engine/github.ts',
|
|
228
|
+
'+++ b/src/engine/github.ts',
|
|
229
|
+
'@@ -10,6 +10,7 @@',
|
|
230
|
+
'+// TODO: fix properly later'
|
|
231
|
+
].join('\n'),
|
|
232
|
+
reason: 'TODO/FIXME/HACK markers'
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'fixme placeholder',
|
|
236
|
+
diff: [
|
|
237
|
+
'--- a/src/engine/github.ts',
|
|
238
|
+
'+++ b/src/engine/github.ts',
|
|
239
|
+
'@@ -10,6 +10,7 @@',
|
|
240
|
+
'+// FIXME: temporary workaround'
|
|
241
|
+
].join('\n'),
|
|
242
|
+
reason: 'TODO/FIXME/HACK markers'
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'hack placeholder',
|
|
246
|
+
diff: [
|
|
247
|
+
'--- a/src/engine/github.ts',
|
|
248
|
+
'+++ b/src/engine/github.ts',
|
|
249
|
+
'@@ -10,6 +10,7 @@',
|
|
250
|
+
'+// HACK: bypass for CI'
|
|
251
|
+
].join('\n'),
|
|
252
|
+
reason: 'TODO/FIXME/HACK markers'
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'process-exit bypass',
|
|
256
|
+
diff: [
|
|
257
|
+
'--- a/package.json',
|
|
258
|
+
'+++ b/package.json',
|
|
259
|
+
'@@ -10,7 +10,7 @@',
|
|
260
|
+
'- "lint": "eslint src --max-warnings=0"',
|
|
261
|
+
'+ "lint": "node -e \\"process.exit(0)\\""'
|
|
262
|
+
].join('\n'),
|
|
263
|
+
reason: 'Bypass anti-pattern'
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'continue-on-error bypass',
|
|
267
|
+
diff: [
|
|
268
|
+
'--- a/.github/workflows/ci.yml',
|
|
269
|
+
'+++ b/.github/workflows/ci.yml',
|
|
270
|
+
'@@ -10,6 +10,7 @@',
|
|
271
|
+
' - name: Run tests',
|
|
272
|
+
' run: npm test',
|
|
273
|
+
'+ continue-on-error: true'
|
|
274
|
+
].join('\n'),
|
|
275
|
+
reason: 'Bypass anti-pattern'
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'tls bypass via NODE_TLS_REJECT_UNAUTHORIZED=0',
|
|
279
|
+
diff: [
|
|
280
|
+
'--- a/package.json',
|
|
281
|
+
'+++ b/package.json',
|
|
282
|
+
'@@ -10,6 +10,7 @@',
|
|
283
|
+
'- "test": "jest"',
|
|
284
|
+
'+ "test": "NODE_TLS_REJECT_UNAUTHORIZED=0 jest"'
|
|
285
|
+
].join('\n'),
|
|
286
|
+
reason: 'Bypass anti-pattern'
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'strict-ssl bypass',
|
|
290
|
+
diff: [
|
|
291
|
+
'--- a/package.json',
|
|
292
|
+
'+++ b/package.json',
|
|
293
|
+
'@@ -10,6 +10,7 @@',
|
|
294
|
+
'- "postinstall": "node scripts/setup.js"',
|
|
295
|
+
'+ "postinstall": "npm config set strict-ssl false && node scripts/setup.js"'
|
|
296
|
+
].join('\n'),
|
|
297
|
+
reason: 'Bypass anti-pattern'
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'curl insecure bypass',
|
|
301
|
+
diff: [
|
|
302
|
+
'--- a/.github/workflows/ci.yml',
|
|
303
|
+
'+++ b/.github/workflows/ci.yml',
|
|
304
|
+
'@@ -10,6 +10,7 @@',
|
|
305
|
+
'- run: curl https://example.com/healthz',
|
|
306
|
+
'+ run: curl -k https://example.com/healthz'
|
|
307
|
+
].join('\n'),
|
|
308
|
+
reason: 'Bypass anti-pattern'
|
|
309
|
+
}
|
|
310
|
+
])('rejects bad patch: $name', async ({ diff, reason }) => {
|
|
311
|
+
const analysis = baseAnalysis();
|
|
312
|
+
|
|
313
|
+
mockCopilot([
|
|
314
|
+
{
|
|
315
|
+
id: 'conservative',
|
|
316
|
+
label: 'CONSERVATIVE',
|
|
317
|
+
risk_level: 'medium',
|
|
318
|
+
summary: `Bad patch: ${reason}`,
|
|
319
|
+
diff
|
|
320
|
+
},
|
|
321
|
+
safeBalanced(),
|
|
322
|
+
safeAggressive()
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const result = await generatePatchOptions(analysis, OUT_DIR);
|
|
326
|
+
const conservative = findResult(result, 'conservative');
|
|
327
|
+
const balanced = findResult(result, 'balanced');
|
|
328
|
+
const conservativeReview = readQualityReview('conservative');
|
|
329
|
+
|
|
330
|
+
expect(conservative.verdict).toBe('NO_GO');
|
|
331
|
+
expect(conservative.risk_level).toBe('high');
|
|
332
|
+
expect(joinedReasons(conservativeReview)).toContain(reason);
|
|
333
|
+
expect(balanced.verdict).toBe('GO');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('rejects out-of-scope edits even when model review says GO', async () => {
|
|
337
|
+
const analysis = baseAnalysis({
|
|
338
|
+
allowed_files: ['src/**/*.ts', 'tests/**/*.ts', 'package.json']
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
mockCopilot([
|
|
342
|
+
{
|
|
343
|
+
id: 'conservative',
|
|
344
|
+
label: 'CONSERVATIVE',
|
|
345
|
+
risk_level: 'low',
|
|
346
|
+
summary: 'Workflow change out of scope',
|
|
347
|
+
diff: [
|
|
348
|
+
'--- a/.github/workflows/ci.yml',
|
|
349
|
+
'+++ b/.github/workflows/ci.yml',
|
|
350
|
+
'@@ -1,3 +1,4 @@',
|
|
351
|
+
' name: CI',
|
|
352
|
+
'+# harmless comment'
|
|
353
|
+
].join('\n')
|
|
354
|
+
},
|
|
355
|
+
safeBalanced(),
|
|
356
|
+
safeAggressive()
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
const result = await generatePatchOptions(analysis, OUT_DIR);
|
|
360
|
+
const conservative = findResult(result, 'conservative');
|
|
361
|
+
const balanced = findResult(result, 'balanced');
|
|
362
|
+
const conservativeReview = readQualityReview('conservative');
|
|
363
|
+
|
|
364
|
+
expect(conservative.verdict).toBe('NO_GO');
|
|
365
|
+
expect(joinedReasons(conservativeReview)).toContain('Out-of-scope file changes detected');
|
|
366
|
+
expect(balanced.verdict).toBe('GO');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('forces NO_GO when quality review slop_score is out of schema range', async () => {
|
|
370
|
+
const analysis = baseAnalysis();
|
|
371
|
+
mockCopilot(
|
|
372
|
+
[
|
|
373
|
+
{
|
|
374
|
+
id: 'conservative',
|
|
375
|
+
label: 'CONSERVATIVE',
|
|
376
|
+
risk_level: 'low',
|
|
377
|
+
summary: 'Looks clean but model emits invalid slop_score',
|
|
378
|
+
diff: safeBalanced().diff
|
|
379
|
+
},
|
|
380
|
+
safeBalanced(),
|
|
381
|
+
safeAggressive()
|
|
382
|
+
],
|
|
383
|
+
{
|
|
384
|
+
conservative: { verdict: 'GO', risk_level: 'low', slop_score: 1.7, reasons: [], suggested_adjustments: [] }
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const result = await generatePatchOptions(analysis, OUT_DIR);
|
|
389
|
+
const conservative = findResult(result, 'conservative');
|
|
390
|
+
const balanced = findResult(result, 'balanced');
|
|
391
|
+
const conservativeReview = readQualityReview('conservative');
|
|
392
|
+
|
|
393
|
+
expect(conservative.verdict).toBe('NO_GO');
|
|
394
|
+
expect(conservative.risk_level).toBe('high');
|
|
395
|
+
expect(conservative.slop_score).toBe(1);
|
|
396
|
+
expect(joinedReasons(conservativeReview)).toContain('slop_score out of range');
|
|
397
|
+
expect(balanced.verdict).toBe('GO');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('forces NO_GO when quality review returns malformed JSON', async () => {
|
|
401
|
+
const analysis = baseAnalysis();
|
|
402
|
+
mockCopilot(
|
|
403
|
+
[
|
|
404
|
+
{
|
|
405
|
+
id: 'conservative',
|
|
406
|
+
label: 'CONSERVATIVE',
|
|
407
|
+
risk_level: 'low',
|
|
408
|
+
summary: 'Model output malformed',
|
|
409
|
+
diff: safeBalanced().diff
|
|
410
|
+
},
|
|
411
|
+
safeBalanced(),
|
|
412
|
+
safeAggressive()
|
|
413
|
+
],
|
|
414
|
+
{
|
|
415
|
+
conservative: '{ "verdict": "GO", "slop_score": '
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const result = await generatePatchOptions(analysis, OUT_DIR);
|
|
420
|
+
const conservative = findResult(result, 'conservative');
|
|
421
|
+
const balanced = findResult(result, 'balanced');
|
|
422
|
+
const conservativeRawPath = path.join(OUT_DIR, 'copilot.quality.conservative.raw.txt');
|
|
423
|
+
const conservativeRaw = fs.readFileSync(conservativeRawPath, 'utf8');
|
|
424
|
+
|
|
425
|
+
expect(conservative.verdict).toBe('NO_GO');
|
|
426
|
+
expect(conservative.risk_level).toBe('high');
|
|
427
|
+
expect(conservative.slop_score).toBe(1);
|
|
428
|
+
expect(conservativeRaw).toContain('"slop_score"');
|
|
429
|
+
expect(balanced.verdict).toBe('GO');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { runGuardian } from '../src/engine/run';
|
|
5
|
+
import { analyzeRun } from '../src/engine/analyze';
|
|
6
|
+
import { generatePatchOptions } from '../src/engine/patch_options';
|
|
7
|
+
|
|
8
|
+
jest.mock('../src/engine/analyze');
|
|
9
|
+
jest.mock('../src/engine/patch_options');
|
|
10
|
+
|
|
11
|
+
const mockedAnalyzeRun = analyzeRun as jest.MockedFunction<typeof analyzeRun>;
|
|
12
|
+
const mockedGeneratePatchOptions = generatePatchOptions as jest.MockedFunction<typeof generatePatchOptions>;
|
|
13
|
+
|
|
14
|
+
function baseAnalysis() {
|
|
15
|
+
return {
|
|
16
|
+
diagnosis: {
|
|
17
|
+
hypotheses: [],
|
|
18
|
+
selected_hypothesis_id: 'H1',
|
|
19
|
+
category: 'source_code',
|
|
20
|
+
root_cause: 'sample'
|
|
21
|
+
},
|
|
22
|
+
patch_plan: {
|
|
23
|
+
intent: 'fix',
|
|
24
|
+
allowed_files: ['src/**/*.ts'],
|
|
25
|
+
strategy: ['minimal']
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('runGuardian abstain policy', () => {
|
|
31
|
+
const outDir = '.test-output-run-abstain';
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.clearAllMocks();
|
|
35
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('forces abstain on strong auth/permission signal and skips patch generation', async () => {
|
|
39
|
+
mockedAnalyzeRun.mockResolvedValue({
|
|
40
|
+
analysisPath: path.join(outDir, 'analysis.json'),
|
|
41
|
+
analysis: baseAnalysis() as any,
|
|
42
|
+
ctx: {
|
|
43
|
+
step: 'Run tests',
|
|
44
|
+
logSummary: 'Request failed with 403 Forbidden',
|
|
45
|
+
logExcerpt: 'resource not accessible by integration'
|
|
46
|
+
} as any
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const res = await runGuardian('owner/repo', 123, {
|
|
50
|
+
showOptions: true,
|
|
51
|
+
outDir
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(res.patchIndex?.abstain?.classification).toBe('NOT_PATCHABLE');
|
|
55
|
+
expect(mockedGeneratePatchOptions).not.toHaveBeenCalled();
|
|
56
|
+
expect(fs.existsSync(path.join(outDir, 'abstain.report.json'))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('does not abstain on a single weak signal; proceeds to patch generation', async () => {
|
|
60
|
+
mockedAnalyzeRun.mockResolvedValue({
|
|
61
|
+
analysisPath: path.join(outDir, 'analysis.json'),
|
|
62
|
+
analysis: baseAnalysis() as any,
|
|
63
|
+
ctx: {
|
|
64
|
+
step: 'Run tests',
|
|
65
|
+
logSummary: 'permission denied while opening local fixture',
|
|
66
|
+
logExcerpt: 'single weak signal only'
|
|
67
|
+
} as any
|
|
68
|
+
});
|
|
69
|
+
mockedGeneratePatchOptions.mockResolvedValue({
|
|
70
|
+
index: {
|
|
71
|
+
results: []
|
|
72
|
+
}
|
|
73
|
+
} as any);
|
|
74
|
+
|
|
75
|
+
const res = await runGuardian('owner/repo', 456, {
|
|
76
|
+
showOptions: true,
|
|
77
|
+
outDir
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(res.patchIndex?.abstain).toBeUndefined();
|
|
81
|
+
expect(mockedGeneratePatchOptions).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"isolatedModules": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*", "schemas/**/*"]
|
|
17
|
+
}
|