@pablozrrrr/jevguard 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -95
- package/cli/main.js +4729 -1413
- package/index.js +4239 -1650
- package/package.json +6 -2
- package/skills/jev-init/SKILL.md +9 -3
- package/skills/jev-init/validate-init.js +2002 -696
- package/skills/jevguard-rules/SKILL.md +45 -3
- package/skills/jevguard-rules/validate-rules.js +1915 -613
|
@@ -3,43 +3,70 @@
|
|
|
3
3
|
import { readFileSync, statSync } from "fs";
|
|
4
4
|
import { resolve } from "path";
|
|
5
5
|
|
|
6
|
-
// packages/core/src/
|
|
7
|
-
var
|
|
8
|
-
var
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// packages/core/src/builtins/complexity.ts
|
|
7
|
+
var COMPLEXITY_CHECK_ID = "COMPLEXITY";
|
|
8
|
+
var COMPLEXITY_GATE_CONFIG = {
|
|
9
|
+
version: 1,
|
|
10
|
+
thresholds: {
|
|
11
|
+
error: { warn: 0.4, fail: 0.7 },
|
|
12
|
+
warning: { warn: 0.5 }
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var COMPLEXITY_INSTRUCTIONS = "Does this attributed change introduce material complexity that is disproportionate to, or not reasonably necessary for, completing the user's task? Necessary supporting changes, tests, validation, error handling, and complexity explicitly required by the task should not count as unnecessary complexity. Return the probability from 0 to 1 that the change introduces disproportionate or unnecessary complexity.";
|
|
16
|
+
var COMPLEXITY_DESCRIPTION = "Material complexity introduced by the attributed change that is disproportionate to the problem and not reasonably necessary for completing the task.";
|
|
17
|
+
var COMPLEXITY_VIOLATION = "The change adds unnecessary abstractions, layers or indirections without proportional gain, new dependencies without clear need, excessive configuration, premature generalization, or structure materially larger than the problem requires.";
|
|
18
|
+
var COMPLEXITY_ALLOWED = "Complexity explicitly required by the task, and necessary supporting changes, tests, validation, error handling, auxiliary changes, or following an existing codebase abstraction to avoid breaking its pattern.";
|
|
19
|
+
function buildComplexityQuestion() {
|
|
12
20
|
return {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
violation: rule.violation,
|
|
22
|
-
...rule.allowed === null ? {} : { allowed: rule.allowed }
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
change: { files: evidence.files, diff: evidence.diff }
|
|
21
|
+
type: "noul",
|
|
22
|
+
instructions: COMPLEXITY_INSTRUCTIONS,
|
|
23
|
+
criteria: {
|
|
24
|
+
id: COMPLEXITY_CHECK_ID,
|
|
25
|
+
description: COMPLEXITY_DESCRIPTION,
|
|
26
|
+
violation: COMPLEXITY_VIOLATION,
|
|
27
|
+
allowed: COMPLEXITY_ALLOWED
|
|
28
|
+
}
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
function buildReviewContext(turn, rule, scopedPaths) {
|
|
31
|
+
function buildComplexityContext(turn, scopedPaths) {
|
|
30
32
|
return {
|
|
31
|
-
kind: "
|
|
33
|
+
kind: "BUILT_IN",
|
|
32
34
|
turnId: turn.id,
|
|
33
|
-
ruleId:
|
|
34
|
-
|
|
35
|
+
ruleId: null,
|
|
36
|
+
checkId: COMPLEXITY_CHECK_ID,
|
|
37
|
+
severity: "warning",
|
|
35
38
|
scopedPaths
|
|
36
39
|
};
|
|
37
40
|
}
|
|
41
|
+
// packages/core/src/evaluation/evaluate-with-port.ts
|
|
42
|
+
async function evaluateRuleBatchWithPort(port, requests) {
|
|
43
|
+
try {
|
|
44
|
+
const results = await port.evaluateRuleBatch(requests);
|
|
45
|
+
if (results.length !== requests.length) {
|
|
46
|
+
return requests.map(() => ruleFailure("INVALID_RESPONSE"));
|
|
47
|
+
}
|
|
48
|
+
return results.map((result) => result.kind === "RULE" ? result : ruleFailure("INVALID_RESPONSE"));
|
|
49
|
+
} catch {
|
|
50
|
+
return requests.map(() => ruleFailure("API_ERROR"));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function ruleFailure(reason) {
|
|
54
|
+
return { kind: "RULE", status: "FAILED", reason };
|
|
55
|
+
}
|
|
56
|
+
async function evaluateBuiltInBatchWithPort(port, request) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await port.evaluate(request);
|
|
59
|
+
return result.kind === "BUILT_IN_BATCH" ? result : { kind: "BUILT_IN_BATCH", status: "FAILED", reason: "INVALID_RESPONSE" };
|
|
60
|
+
} catch {
|
|
61
|
+
return { kind: "BUILT_IN_BATCH", status: "FAILED", reason: "API_ERROR" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
// packages/core/src/evidence/defaults.ts
|
|
39
66
|
var DEFAULT_MAX_DIFF_LENGTH = 1e5;
|
|
40
67
|
var DEFAULT_EVIDENCE_POLICY = {
|
|
41
68
|
maxDiffLength: DEFAULT_MAX_DIFF_LENGTH,
|
|
42
|
-
|
|
69
|
+
codeExtensions: [
|
|
43
70
|
".ts",
|
|
44
71
|
".tsx",
|
|
45
72
|
".mts",
|
|
@@ -50,9 +77,6 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
50
77
|
".cjs",
|
|
51
78
|
".json",
|
|
52
79
|
".jsonc",
|
|
53
|
-
".md",
|
|
54
|
-
".mdx",
|
|
55
|
-
".txt",
|
|
56
80
|
".yaml",
|
|
57
81
|
".yml",
|
|
58
82
|
".toml",
|
|
@@ -108,6 +132,7 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
108
132
|
".diff",
|
|
109
133
|
".patch"
|
|
110
134
|
],
|
|
135
|
+
docsExtensions: [".md", ".mdx", ".txt"],
|
|
111
136
|
deniedFileNames: [
|
|
112
137
|
".env",
|
|
113
138
|
".envrc",
|
|
@@ -182,18 +207,26 @@ function checkFilePath(path, policy) {
|
|
|
182
207
|
if (policy.deniedExtensions.includes(extension)) {
|
|
183
208
|
return DENIED_SENSITIVE;
|
|
184
209
|
}
|
|
185
|
-
if (!
|
|
210
|
+
if (!isAllowlistedExtension(extension, policy)) {
|
|
186
211
|
return DENIED_EXTENSION;
|
|
187
212
|
}
|
|
188
213
|
return { allowed: true };
|
|
189
214
|
}
|
|
215
|
+
function isAllowlistedExtension(extension, policy) {
|
|
216
|
+
return policy.codeExtensions.includes(extension) || policy.docsExtensions.includes(extension);
|
|
217
|
+
}
|
|
190
218
|
function matchesDeniedFileName(basename, policy) {
|
|
191
219
|
return policy.deniedFileNames.some((name) => name.startsWith(".") ? basename === name || basename.startsWith(`${name}.`) : basename === name);
|
|
192
220
|
}
|
|
193
221
|
|
|
194
222
|
// packages/core/src/evidence/scope.ts
|
|
195
|
-
function
|
|
196
|
-
|
|
223
|
+
function matchesRuleScope(path, scope) {
|
|
224
|
+
const normalized = normalizePath(path);
|
|
225
|
+
const included = scope.include.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
226
|
+
if (!included) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return !scope.exclude.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
197
230
|
}
|
|
198
231
|
function globToRegExp(glob) {
|
|
199
232
|
const source = glob.replace(/\\/g, "/");
|
|
@@ -220,16 +253,6 @@ function globToRegExp(glob) {
|
|
|
220
253
|
}
|
|
221
254
|
|
|
222
255
|
// packages/core/src/evidence/select-evidence.ts
|
|
223
|
-
function selectRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
224
|
-
if (hasNoAttributedPatch(turn)) {
|
|
225
|
-
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
226
|
-
}
|
|
227
|
-
const applicableFiles = selectApplicableFiles(turn.files, rule.scope);
|
|
228
|
-
if (applicableFiles.length === 0) {
|
|
229
|
-
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
230
|
-
}
|
|
231
|
-
return mountEvidence(applicableFiles, policy);
|
|
232
|
-
}
|
|
233
256
|
function selectTurnEvidence(turn, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
234
257
|
if (hasNoAttributedPatch(turn)) {
|
|
235
258
|
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
@@ -256,11 +279,21 @@ function mountEvidence(files, policy) {
|
|
|
256
279
|
function hasNoAttributedPatch(turn) {
|
|
257
280
|
return turn.files.every((file) => file.patch.trim() === "");
|
|
258
281
|
}
|
|
259
|
-
function selectApplicableFiles(files, scope) {
|
|
260
|
-
|
|
261
|
-
|
|
282
|
+
function selectApplicableFiles(files, scope, evidenceClass, policy) {
|
|
283
|
+
const scoped = scope === null ? files : files.filter((file) => matchesRuleScope(file.path, scope));
|
|
284
|
+
return scoped.filter((file) => isInEvidenceClass(file.path, evidenceClass, policy));
|
|
285
|
+
}
|
|
286
|
+
function isInEvidenceClass(path, evidenceClass, policy) {
|
|
287
|
+
if (evidenceClass === "any") {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
const safety = checkFilePath(path, policy);
|
|
291
|
+
if (!safety.allowed && safety.reason === "DENIED_SENSITIVE_PATH") {
|
|
292
|
+
return true;
|
|
262
293
|
}
|
|
263
|
-
|
|
294
|
+
const extension = fileExtension(path);
|
|
295
|
+
const otherClass = evidenceClass === "code" ? policy.docsExtensions : policy.codeExtensions;
|
|
296
|
+
return !otherClass.includes(extension);
|
|
264
297
|
}
|
|
265
298
|
function assembleDiff(files) {
|
|
266
299
|
return files.map((file) => file.patch).join(`
|
|
@@ -300,592 +333,1034 @@ function evaluateWarningGate(probability, thresholds) {
|
|
|
300
333
|
return { outcome: "WARN", violationProbability: probability };
|
|
301
334
|
}
|
|
302
335
|
|
|
303
|
-
// packages/core/src/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
336
|
+
// packages/core/src/ports/types.ts
|
|
337
|
+
var SCOPE_CREEP_ANSWER = "scopeCreep";
|
|
338
|
+
var COMPLEXITY_ANSWER = "complexity";
|
|
339
|
+
|
|
340
|
+
// packages/core/src/builtins/scope-creep.ts
|
|
341
|
+
var SCOPE_CREEP_CHECK_ID = "SCOPE-CREEP";
|
|
342
|
+
var SCOPE_CREEP_GATE_CONFIG = {
|
|
343
|
+
version: 1,
|
|
344
|
+
thresholds: {
|
|
345
|
+
error: { warn: 0.65, fail: 0.9 },
|
|
346
|
+
warning: { warn: 0.6 }
|
|
310
347
|
}
|
|
348
|
+
};
|
|
349
|
+
var SCOPE_CREEP_INSTRUCTIONS = "Answer true when the attributed change contains scope creep: material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete the task. Answer false when every change is requested work, or supporting, testing, or documentation work reasonably necessary to complete the requested work, including immaterial incidental edits. Return the probability from 0 to 1 that the change is scope creep.";
|
|
350
|
+
var SCOPE_CREEP_DESCRIPTION = "A change is scope creep when it introduces material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete it.";
|
|
351
|
+
var SCOPE_CREEP_VIOLATION = "The change adds material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete the task.";
|
|
352
|
+
var SCOPE_CREEP_ALLOWED = "Changes the task requested, and supporting, testing, and documentation changes reasonably necessary to complete the requested work, including immaterial incidental edits.";
|
|
353
|
+
function buildScopeCreepQuestion() {
|
|
354
|
+
return {
|
|
355
|
+
type: "noul",
|
|
356
|
+
instructions: SCOPE_CREEP_INSTRUCTIONS,
|
|
357
|
+
criteria: {
|
|
358
|
+
id: SCOPE_CREEP_CHECK_ID,
|
|
359
|
+
description: SCOPE_CREEP_DESCRIPTION,
|
|
360
|
+
violation: SCOPE_CREEP_VIOLATION,
|
|
361
|
+
allowed: SCOPE_CREEP_ALLOWED
|
|
362
|
+
}
|
|
363
|
+
};
|
|
311
364
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
365
|
+
function buildScopeCreepContext(turn, scopedPaths) {
|
|
366
|
+
return {
|
|
367
|
+
kind: "BUILT_IN",
|
|
368
|
+
turnId: turn.id,
|
|
369
|
+
ruleId: null,
|
|
370
|
+
checkId: SCOPE_CREEP_CHECK_ID,
|
|
371
|
+
severity: "error",
|
|
372
|
+
scopedPaths
|
|
373
|
+
};
|
|
319
374
|
}
|
|
320
375
|
|
|
321
|
-
// packages/core/src/
|
|
322
|
-
|
|
323
|
-
|
|
376
|
+
// packages/core/src/builtins/evaluate-builtins.ts
|
|
377
|
+
function buildBuiltInBatchRequest(task, evidence) {
|
|
378
|
+
return {
|
|
379
|
+
kind: "BUILT_IN_BATCH",
|
|
380
|
+
task,
|
|
381
|
+
questions: {
|
|
382
|
+
[SCOPE_CREEP_ANSWER]: buildScopeCreepQuestion(),
|
|
383
|
+
[COMPLEXITY_ANSWER]: buildComplexityQuestion()
|
|
384
|
+
},
|
|
385
|
+
change: { files: evidence.files, diff: evidence.diff }
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function evaluateBuiltIns(input, dependencies) {
|
|
389
|
+
const selection = selectTurnEvidence(input.turn, input.evidencePolicy);
|
|
324
390
|
if (selection.status === "SKIPPED") {
|
|
325
|
-
|
|
326
|
-
|
|
391
|
+
return {
|
|
392
|
+
scopeCreep: {
|
|
393
|
+
...buildScopeCreepContext(input.turn, []),
|
|
394
|
+
outcome: "SKIPPED",
|
|
395
|
+
reason: selection.reason
|
|
396
|
+
},
|
|
397
|
+
complexity: {
|
|
398
|
+
...buildComplexityContext(input.turn, []),
|
|
399
|
+
outcome: "SKIPPED",
|
|
400
|
+
reason: selection.reason
|
|
401
|
+
}
|
|
402
|
+
};
|
|
327
403
|
}
|
|
328
404
|
if (selection.status === "UNAVAILABLE") {
|
|
329
|
-
|
|
330
|
-
|
|
405
|
+
return {
|
|
406
|
+
scopeCreep: {
|
|
407
|
+
...buildScopeCreepContext(input.turn, []),
|
|
408
|
+
outcome: "UNAVAILABLE",
|
|
409
|
+
reason: selection.reason
|
|
410
|
+
},
|
|
411
|
+
complexity: {
|
|
412
|
+
...buildComplexityContext(input.turn, []),
|
|
413
|
+
outcome: "UNAVAILABLE",
|
|
414
|
+
reason: selection.reason
|
|
415
|
+
}
|
|
416
|
+
};
|
|
331
417
|
}
|
|
332
|
-
const
|
|
333
|
-
const request =
|
|
334
|
-
const evaluation = await
|
|
418
|
+
const evidence = selection.evidence;
|
|
419
|
+
const request = buildBuiltInBatchRequest(input.turn.task, evidence);
|
|
420
|
+
const evaluation = await evaluateBuiltInBatchWithPort(dependencies.jev, request);
|
|
421
|
+
const scopeCreepContext = buildScopeCreepContext(input.turn, evidence.files);
|
|
422
|
+
const complexityContext = buildComplexityContext(input.turn, evidence.files);
|
|
335
423
|
if (evaluation.status === "FAILED") {
|
|
336
|
-
return {
|
|
424
|
+
return {
|
|
425
|
+
scopeCreep: { ...scopeCreepContext, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" },
|
|
426
|
+
complexity: { ...complexityContext, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" }
|
|
427
|
+
};
|
|
337
428
|
}
|
|
338
|
-
const gate = evaluateGate(input.rule.severity, evaluation.noul.violationProbability, input.gateConfig);
|
|
339
|
-
return { ...context, ...gate };
|
|
340
|
-
}
|
|
341
|
-
// packages/core/src/review/aggregate.ts
|
|
342
|
-
var VERDICT_RANK = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
343
|
-
function aggregateReview(turnId, results) {
|
|
344
|
-
const snapshot = [...results];
|
|
345
|
-
return { turnId, results: snapshot, summary: summarize(snapshot) };
|
|
346
|
-
}
|
|
347
|
-
function summarize(results) {
|
|
348
|
-
const counts = countOutcomes(results);
|
|
349
429
|
return {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
counts
|
|
430
|
+
scopeCreep: gateScopeCreep(evaluation.answers.scopeCreep, scopeCreepContext),
|
|
431
|
+
complexity: gateComplexity(evaluation.answers.complexity, complexityContext)
|
|
353
432
|
};
|
|
354
433
|
}
|
|
355
|
-
function
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
switch (result.outcome) {
|
|
359
|
-
case "PASS":
|
|
360
|
-
counts.pass += 1;
|
|
361
|
-
break;
|
|
362
|
-
case "WARN":
|
|
363
|
-
counts.warn += 1;
|
|
364
|
-
break;
|
|
365
|
-
case "FAIL":
|
|
366
|
-
counts.fail += 1;
|
|
367
|
-
break;
|
|
368
|
-
case "SKIPPED":
|
|
369
|
-
counts.skipped += 1;
|
|
370
|
-
break;
|
|
371
|
-
case "UNAVAILABLE":
|
|
372
|
-
counts.unavailable += 1;
|
|
373
|
-
break;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return counts;
|
|
377
|
-
}
|
|
378
|
-
function highestVerdict(results) {
|
|
379
|
-
let highest = null;
|
|
380
|
-
for (const result of results) {
|
|
381
|
-
if (!isSemanticVerdict(result.outcome)) {
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
if (highest === null || VERDICT_RANK[result.outcome] > VERDICT_RANK[highest]) {
|
|
385
|
-
highest = result.outcome;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return highest;
|
|
389
|
-
}
|
|
390
|
-
function isSemanticVerdict(outcome) {
|
|
391
|
-
return outcome === "PASS" || outcome === "WARN" || outcome === "FAIL";
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// packages/core/src/evaluation/evaluate-rules.ts
|
|
395
|
-
async function evaluateRules(input, dependencies) {
|
|
396
|
-
const results = [];
|
|
397
|
-
for (const rule of input.rules) {
|
|
398
|
-
results.push(await evaluateRuleResult(input, rule, dependencies));
|
|
399
|
-
}
|
|
400
|
-
return aggregateReview(input.turn.id, results);
|
|
401
|
-
}
|
|
402
|
-
async function evaluateRuleResult(input, rule, dependencies) {
|
|
403
|
-
if (rule.status === "INVALID") {
|
|
404
|
-
return invalidRuleResult(input.turn.id, rule);
|
|
405
|
-
}
|
|
406
|
-
if (input.gateConfig.status === "INVALID") {
|
|
407
|
-
return invalidConfigResult(input.turn, rule.rule);
|
|
434
|
+
function gateScopeCreep(answer, context) {
|
|
435
|
+
if (answer.status === "FAILED") {
|
|
436
|
+
return { ...context, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" };
|
|
408
437
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
gateConfig: input.gateConfig.config,
|
|
413
|
-
evidencePolicy: input.evidencePolicy
|
|
438
|
+
return {
|
|
439
|
+
...context,
|
|
440
|
+
...evaluateGate("error", answer.noul.violationProbability, SCOPE_CREEP_GATE_CONFIG)
|
|
414
441
|
};
|
|
415
|
-
return evaluateRule(ruleInput, dependencies);
|
|
416
442
|
}
|
|
417
|
-
function
|
|
443
|
+
function gateComplexity(answer, context) {
|
|
444
|
+
if (answer.status === "FAILED") {
|
|
445
|
+
return { ...context, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" };
|
|
446
|
+
}
|
|
418
447
|
return {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
ruleId: failure.ruleId,
|
|
422
|
-
severity: null,
|
|
423
|
-
scopedPaths: [],
|
|
424
|
-
outcome: "UNAVAILABLE",
|
|
425
|
-
reason: "INVALID_RULE"
|
|
448
|
+
...context,
|
|
449
|
+
...evaluateGate("warning", answer.noul.violationProbability, COMPLEXITY_GATE_CONFIG)
|
|
426
450
|
};
|
|
427
451
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
// packages/core/src/rules/headings.ts
|
|
436
|
-
var RULE_HEADING = /^##\s+(.*)$/;
|
|
437
|
-
var SECTION_HEADING = /^###\s+(.*)$/;
|
|
438
|
-
function matchRuleHeading(line) {
|
|
439
|
-
const match = RULE_HEADING.exec(line);
|
|
440
|
-
if (match === null) {
|
|
441
|
-
return null;
|
|
442
|
-
}
|
|
443
|
-
return (match[1] ?? "").trim();
|
|
444
|
-
}
|
|
445
|
-
function matchSectionHeading(line) {
|
|
446
|
-
const match = SECTION_HEADING.exec(line);
|
|
447
|
-
if (match === null) {
|
|
448
|
-
return null;
|
|
452
|
+
// packages/core/src/concurrency/fifo-port.ts
|
|
453
|
+
function createFifoJevPort(port, config) {
|
|
454
|
+
const maxConcurrency = config.maxConcurrency;
|
|
455
|
+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
|
456
|
+
throw new RangeError("maxConcurrency must be a positive integer");
|
|
449
457
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const entry = parseMetadataEntry(line);
|
|
461
|
-
if (entry === null) {
|
|
462
|
-
return { ok: false, code: "MALFORMED_METADATA" };
|
|
458
|
+
const queue = [];
|
|
459
|
+
let active = 0;
|
|
460
|
+
function drain() {
|
|
461
|
+
while (active < maxConcurrency && queue.length > 0) {
|
|
462
|
+
const pending = queue.shift();
|
|
463
|
+
if (pending === undefined) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
active += 1;
|
|
467
|
+
start(pending);
|
|
463
468
|
}
|
|
464
|
-
|
|
465
|
-
|
|
469
|
+
}
|
|
470
|
+
function start(pending) {
|
|
471
|
+
if (pending.kind === "BATCH") {
|
|
472
|
+
Promise.resolve().then(() => port.evaluateRuleBatch(pending.requests)).then((results) => {
|
|
473
|
+
active -= 1;
|
|
474
|
+
pending.settle(results);
|
|
475
|
+
drain();
|
|
476
|
+
}, (error) => {
|
|
477
|
+
active -= 1;
|
|
478
|
+
pending.fail(error);
|
|
479
|
+
drain();
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
466
482
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
483
|
+
Promise.resolve().then(() => port.evaluate(pending.request)).then((result) => {
|
|
484
|
+
active -= 1;
|
|
485
|
+
pending.settle(result);
|
|
486
|
+
drain();
|
|
487
|
+
}, (error) => {
|
|
488
|
+
active -= 1;
|
|
489
|
+
pending.fail(error);
|
|
490
|
+
drain();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
evaluate(request) {
|
|
495
|
+
return new Promise((settle, fail) => {
|
|
496
|
+
queue.push({ kind: "SINGLE", request, settle, fail });
|
|
497
|
+
drain();
|
|
498
|
+
});
|
|
499
|
+
},
|
|
500
|
+
evaluateRuleBatch(requests) {
|
|
501
|
+
return new Promise((settle, fail) => {
|
|
502
|
+
queue.push({ kind: "BATCH", requests, settle, fail });
|
|
503
|
+
drain();
|
|
504
|
+
});
|
|
475
505
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
// packages/core/src/evaluation/build-context.ts
|
|
509
|
+
function buildReviewContext(turn, rule, scopedPaths) {
|
|
510
|
+
return {
|
|
511
|
+
kind: "RULE",
|
|
512
|
+
turnId: turn.id,
|
|
513
|
+
ruleId: rule.id,
|
|
514
|
+
severity: rule.severity,
|
|
515
|
+
scopedPaths
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// packages/core/src/evaluation/build-request.ts
|
|
519
|
+
var NOUL_INSTRUCTIONS = "Answer true when the attributed change violates the rule's Violation, excluding behavior described by the rule's Allowed exception. Answer false when the change does not violate the rule, including allowed behavior. Return the probability from 0 to 1 that the change violates the rule.";
|
|
520
|
+
function buildJevRequest(task, rule, evidence) {
|
|
521
|
+
return {
|
|
522
|
+
kind: "RULE",
|
|
523
|
+
task,
|
|
524
|
+
question: {
|
|
525
|
+
type: "noul",
|
|
526
|
+
instructions: NOUL_INSTRUCTIONS,
|
|
527
|
+
criteria: {
|
|
528
|
+
id: rule.id,
|
|
529
|
+
description: rule.description,
|
|
530
|
+
violation: rule.violation,
|
|
531
|
+
...rule.allowed === null ? {} : { allowed: rule.allowed }
|
|
479
532
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
533
|
+
},
|
|
534
|
+
change: { files: evidence.files, diff: evidence.diff }
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// packages/core/src/evidence/plan-slices.ts
|
|
538
|
+
var HUNK_MARKER = /^@@ [^\n]*@@/gm;
|
|
539
|
+
function planRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
540
|
+
if (hasNoAttributedPatch(turn)) {
|
|
541
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
484
542
|
}
|
|
485
|
-
|
|
486
|
-
|
|
543
|
+
const applicableFiles = selectApplicableFiles(turn.files, rule.scope, rule.evidence, policy);
|
|
544
|
+
if (applicableFiles.length === 0) {
|
|
545
|
+
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
546
|
+
}
|
|
547
|
+
const diff = assembleDiff(applicableFiles);
|
|
548
|
+
if (diff.trim() === "") {
|
|
549
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
487
550
|
}
|
|
488
|
-
|
|
551
|
+
if (applicableFiles.some((file) => !checkFilePath(file.path, policy).allowed)) {
|
|
552
|
+
return { status: "UNAVAILABLE", reason: "BLOCKED_EVIDENCE" };
|
|
553
|
+
}
|
|
554
|
+
if (diff.length <= policy.maxDiffLength) {
|
|
555
|
+
return { status: "PLANNED", slices: [wholeSlice(applicableFiles, diff)] };
|
|
556
|
+
}
|
|
557
|
+
return sliceByFile(applicableFiles, policy);
|
|
489
558
|
}
|
|
490
|
-
function
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
559
|
+
function wholeSlice(files, diff) {
|
|
560
|
+
const paths = files.map((file) => file.path);
|
|
561
|
+
return {
|
|
562
|
+
index: 0,
|
|
563
|
+
kind: "WHOLE",
|
|
564
|
+
path: paths.length === 1 ? paths[0] ?? null : null,
|
|
565
|
+
hunkOrdinal: null,
|
|
566
|
+
files: paths,
|
|
567
|
+
diff
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function sliceByFile(files, policy) {
|
|
571
|
+
const slices = [];
|
|
572
|
+
for (const file of files) {
|
|
573
|
+
if (file.patch.length <= policy.maxDiffLength) {
|
|
574
|
+
slices.push(fileSlice(slices.length, file));
|
|
575
|
+
continue;
|
|
495
576
|
}
|
|
496
|
-
|
|
497
|
-
|
|
577
|
+
const hunks = sliceHunks(slices.length, file, policy);
|
|
578
|
+
if (hunks === null) {
|
|
579
|
+
return { status: "UNAVAILABLE", reason: "OVERSIZED_DIFF" };
|
|
498
580
|
}
|
|
581
|
+
slices.push(...hunks);
|
|
499
582
|
}
|
|
500
|
-
return
|
|
583
|
+
return { status: "PLANNED", slices };
|
|
501
584
|
}
|
|
502
|
-
function
|
|
503
|
-
|
|
504
|
-
|
|
585
|
+
function fileSlice(index, file) {
|
|
586
|
+
return {
|
|
587
|
+
index,
|
|
588
|
+
kind: "FILE",
|
|
589
|
+
path: file.path,
|
|
590
|
+
hunkOrdinal: null,
|
|
591
|
+
files: [file.path],
|
|
592
|
+
diff: file.patch
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function sliceHunks(startIndex, file, policy) {
|
|
596
|
+
const starts = hunkStarts(file.patch);
|
|
597
|
+
if (starts.length === 0) {
|
|
505
598
|
return null;
|
|
506
599
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
600
|
+
const firstStart = starts[0];
|
|
601
|
+
if (firstStart === undefined) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
const preamble = file.patch.slice(0, firstStart);
|
|
605
|
+
const slices = [];
|
|
606
|
+
for (let ordinal = 0;ordinal < starts.length; ordinal += 1) {
|
|
607
|
+
const start = starts[ordinal];
|
|
608
|
+
if (start === undefined) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
const end = starts[ordinal + 1] ?? file.patch.length;
|
|
612
|
+
const rendered = preamble + file.patch.slice(start, end);
|
|
613
|
+
if (rendered.length > policy.maxDiffLength) {
|
|
515
614
|
return null;
|
|
615
|
+
}
|
|
616
|
+
slices.push({
|
|
617
|
+
index: startIndex + ordinal,
|
|
618
|
+
kind: "HUNK",
|
|
619
|
+
path: file.path,
|
|
620
|
+
hunkOrdinal: ordinal,
|
|
621
|
+
files: [file.path],
|
|
622
|
+
diff: rendered
|
|
623
|
+
});
|
|
516
624
|
}
|
|
625
|
+
return slices;
|
|
626
|
+
}
|
|
627
|
+
function hunkStarts(patch) {
|
|
628
|
+
return Array.from(patch.matchAll(HUNK_MARKER), (match) => match.index ?? 0);
|
|
517
629
|
}
|
|
518
630
|
|
|
519
|
-
// packages/core/src/
|
|
520
|
-
var
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
631
|
+
// packages/core/src/evidence/slice-budget.ts
|
|
632
|
+
var MAX_SLICES_PER_RULE = 16;
|
|
633
|
+
var MAX_EXTRA_RULE_CALLS_PER_TURN = 32;
|
|
634
|
+
function planTurnRuleSlices(plans) {
|
|
635
|
+
let remainingExtraCalls = MAX_EXTRA_RULE_CALLS_PER_TURN;
|
|
636
|
+
return plans.map((plan) => {
|
|
637
|
+
if (plan.status !== "PLANNED") {
|
|
638
|
+
return plan;
|
|
639
|
+
}
|
|
640
|
+
const extraCalls = plan.slices.length - 1;
|
|
641
|
+
if (plan.slices.length > MAX_SLICES_PER_RULE || extraCalls > remainingExtraCalls) {
|
|
642
|
+
return { status: "SLICE_LIMIT_EXCEEDED", slices: plan.slices };
|
|
643
|
+
}
|
|
644
|
+
remainingExtraCalls -= extraCalls;
|
|
645
|
+
return plan;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// packages/core/src/review/aggregate.ts
|
|
650
|
+
var VERDICT_RANK = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
651
|
+
function aggregateReview(turnId, results) {
|
|
652
|
+
const snapshot = [...results];
|
|
653
|
+
return { turnId, results: snapshot, summary: summarize(snapshot) };
|
|
654
|
+
}
|
|
655
|
+
function summarize(results) {
|
|
656
|
+
const counts = countOutcomes(results);
|
|
657
|
+
return {
|
|
658
|
+
verdict: highestVerdict(results),
|
|
659
|
+
hasUnavailable: counts.unavailable > 0,
|
|
660
|
+
counts
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function countOutcomes(results) {
|
|
664
|
+
const counts = { pass: 0, warn: 0, fail: 0, skipped: 0, unavailable: 0 };
|
|
665
|
+
for (const result of results) {
|
|
666
|
+
switch (result.outcome) {
|
|
667
|
+
case "PASS":
|
|
668
|
+
counts.pass += 1;
|
|
669
|
+
break;
|
|
670
|
+
case "WARN":
|
|
671
|
+
counts.warn += 1;
|
|
672
|
+
break;
|
|
673
|
+
case "FAIL":
|
|
674
|
+
counts.fail += 1;
|
|
675
|
+
break;
|
|
676
|
+
case "SKIPPED":
|
|
677
|
+
counts.skipped += 1;
|
|
678
|
+
break;
|
|
679
|
+
case "UNAVAILABLE":
|
|
680
|
+
counts.unavailable += 1;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
525
683
|
}
|
|
526
|
-
return
|
|
684
|
+
return counts;
|
|
527
685
|
}
|
|
528
|
-
function
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
for (const line of lines) {
|
|
533
|
-
const heading = matchSectionHeading(line);
|
|
534
|
-
if (heading === null) {
|
|
535
|
-
if (current !== null) {
|
|
536
|
-
content.push(line);
|
|
537
|
-
}
|
|
686
|
+
function highestVerdict(results) {
|
|
687
|
+
let highest = null;
|
|
688
|
+
for (const result of results) {
|
|
689
|
+
if (!isSemanticVerdict(result.outcome)) {
|
|
538
690
|
continue;
|
|
539
691
|
}
|
|
540
|
-
if (
|
|
541
|
-
|
|
692
|
+
if (highest === null || VERDICT_RANK[result.outcome] > VERDICT_RANK[highest]) {
|
|
693
|
+
highest = result.outcome;
|
|
542
694
|
}
|
|
543
|
-
|
|
544
|
-
|
|
695
|
+
}
|
|
696
|
+
return highest;
|
|
697
|
+
}
|
|
698
|
+
function isSemanticVerdict(outcome) {
|
|
699
|
+
return outcome === "PASS" || outcome === "WARN" || outcome === "FAIL";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// packages/core/src/evaluation/assemble-rule.ts
|
|
703
|
+
var VERDICT_RANK2 = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
704
|
+
function assembleSlicedRuleResult(rule, context, slices, evaluations, gateConfig) {
|
|
705
|
+
if (slices.length === 0) {
|
|
706
|
+
return { ...context, outcome: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
707
|
+
}
|
|
708
|
+
const summaries = slices.map(sliceSummary);
|
|
709
|
+
const mode = evidenceMode(slices.length);
|
|
710
|
+
const gates = [];
|
|
711
|
+
const judgments = [];
|
|
712
|
+
let failureIndex = null;
|
|
713
|
+
for (let index = 0;index < slices.length; index += 1) {
|
|
714
|
+
const slice = slices[index];
|
|
715
|
+
const evaluation = evaluations[index];
|
|
716
|
+
if (slice === undefined || evaluation === undefined || evaluation.status === "FAILED") {
|
|
717
|
+
failureIndex = index;
|
|
718
|
+
break;
|
|
545
719
|
}
|
|
546
|
-
|
|
547
|
-
|
|
720
|
+
const gate = evaluateGate(rule.severity, evaluation.noul.violationProbability, gateConfig);
|
|
721
|
+
if (gate.outcome === "UNAVAILABLE") {
|
|
722
|
+
failureIndex = index;
|
|
723
|
+
break;
|
|
548
724
|
}
|
|
549
|
-
|
|
550
|
-
|
|
725
|
+
gates.push(gate);
|
|
726
|
+
judgments.push({
|
|
727
|
+
index: slice.index,
|
|
728
|
+
probability: gate.violationProbability,
|
|
729
|
+
outcome: gate.outcome
|
|
730
|
+
});
|
|
551
731
|
}
|
|
552
|
-
if (
|
|
553
|
-
|
|
732
|
+
if (failureIndex !== null) {
|
|
733
|
+
return {
|
|
734
|
+
...context,
|
|
735
|
+
outcome: "UNAVAILABLE",
|
|
736
|
+
reason: "JEV_FAILURE",
|
|
737
|
+
evidence: failedMetadata(mode, summaries, failureIndex)
|
|
738
|
+
};
|
|
554
739
|
}
|
|
555
|
-
return {
|
|
740
|
+
return {
|
|
741
|
+
...context,
|
|
742
|
+
outcome: mergeVerdict(gates),
|
|
743
|
+
violationProbability: maxProbability(gates),
|
|
744
|
+
evidence: completeMetadata(mode, summaries, judgments)
|
|
745
|
+
};
|
|
556
746
|
}
|
|
557
|
-
function
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
747
|
+
function overBudgetMetadata(slices) {
|
|
748
|
+
const summaries = slices.map(sliceSummary);
|
|
749
|
+
return {
|
|
750
|
+
mode: "SLICED",
|
|
751
|
+
plannedSliceCount: summaries.length,
|
|
752
|
+
evaluatedSliceCount: 0,
|
|
753
|
+
failedSliceCount: 0,
|
|
754
|
+
notEvaluatedSliceCount: summaries.length,
|
|
755
|
+
slices: summaries,
|
|
756
|
+
sliceJudgments: [],
|
|
757
|
+
failingSliceIndex: null,
|
|
758
|
+
failureReason: "SLICE_LIMIT_EXCEEDED"
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function sliceSummary(slice) {
|
|
762
|
+
return {
|
|
763
|
+
index: slice.index,
|
|
764
|
+
path: slice.path,
|
|
765
|
+
kind: slice.kind,
|
|
766
|
+
hunkOrdinal: slice.hunkOrdinal
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function evidenceMode(sliceCount) {
|
|
770
|
+
return sliceCount === 1 ? "WHOLE" : "SLICED";
|
|
771
|
+
}
|
|
772
|
+
function mergeVerdict(gates) {
|
|
773
|
+
let verdict = "PASS";
|
|
774
|
+
for (const gate of gates) {
|
|
775
|
+
if (VERDICT_RANK2[gate.outcome] > VERDICT_RANK2[verdict]) {
|
|
776
|
+
verdict = gate.outcome;
|
|
777
|
+
}
|
|
566
778
|
}
|
|
567
|
-
|
|
568
|
-
|
|
779
|
+
return verdict;
|
|
780
|
+
}
|
|
781
|
+
function maxProbability(gates) {
|
|
782
|
+
return gates.reduce((highest, gate) => Math.max(highest, gate.violationProbability), Number.NEGATIVE_INFINITY);
|
|
783
|
+
}
|
|
784
|
+
function completeMetadata(mode, summaries, judgments) {
|
|
785
|
+
return {
|
|
786
|
+
mode,
|
|
787
|
+
plannedSliceCount: summaries.length,
|
|
788
|
+
evaluatedSliceCount: summaries.length,
|
|
789
|
+
failedSliceCount: 0,
|
|
790
|
+
notEvaluatedSliceCount: 0,
|
|
791
|
+
slices: summaries,
|
|
792
|
+
sliceJudgments: judgments,
|
|
793
|
+
failingSliceIndex: null,
|
|
794
|
+
failureReason: null
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function failedMetadata(mode, summaries, failingIndex) {
|
|
798
|
+
return {
|
|
799
|
+
mode,
|
|
800
|
+
plannedSliceCount: summaries.length,
|
|
801
|
+
evaluatedSliceCount: failingIndex,
|
|
802
|
+
failedSliceCount: 1,
|
|
803
|
+
notEvaluatedSliceCount: summaries.length - failingIndex - 1,
|
|
804
|
+
slices: summaries,
|
|
805
|
+
sliceJudgments: [],
|
|
806
|
+
failingSliceIndex: failingIndex,
|
|
807
|
+
failureReason: "JEV_FAILURE"
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// packages/core/src/evaluation/evaluate-rules.ts
|
|
812
|
+
async function evaluateRules(input, dependencies) {
|
|
813
|
+
if (input.gateConfig.status === "INVALID") {
|
|
814
|
+
const results = input.rules.map((candidate) => candidate.status === "INVALID" ? invalidRuleResult(input.turn.id, candidate) : invalidConfigResult(input.turn, candidate.rule));
|
|
815
|
+
return aggregateReview(input.turn.id, results);
|
|
816
|
+
}
|
|
817
|
+
const gateConfig = input.gateConfig.config;
|
|
818
|
+
const slots = resolveSlots(input);
|
|
819
|
+
const requests = collectRequests(input.turn.task, slots);
|
|
820
|
+
const evaluations = requests.length === 0 ? [] : await evaluateRuleBatchWithPort(dependencies.jev, requests);
|
|
821
|
+
return aggregateReview(input.turn.id, assembleResults(input, slots, evaluations, gateConfig));
|
|
822
|
+
}
|
|
823
|
+
function resolveSlots(input) {
|
|
824
|
+
const planned = input.rules.map((candidate) => candidate.status === "INVALID" ? { kind: "RESULT", result: invalidRuleResult(input.turn.id, candidate) } : {
|
|
825
|
+
kind: "PLAN",
|
|
826
|
+
rule: candidate.rule,
|
|
827
|
+
plan: planRuleEvidence(input.turn, candidate.rule, input.evidencePolicy)
|
|
828
|
+
});
|
|
829
|
+
const budgeted = planTurnRuleSlices(planned.flatMap((slot) => slot.kind === "PLAN" ? [slot.plan] : []));
|
|
830
|
+
let cursor = 0;
|
|
831
|
+
return planned.map((slot) => {
|
|
832
|
+
if (slot.kind === "RESULT") {
|
|
833
|
+
return slot;
|
|
834
|
+
}
|
|
835
|
+
const plan = budgeted[cursor];
|
|
836
|
+
cursor += 1;
|
|
837
|
+
return plan === undefined ? slot : { kind: "PLAN", rule: slot.rule, plan };
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
function collectRequests(task, slots) {
|
|
841
|
+
const requests = [];
|
|
842
|
+
for (const slot of slots) {
|
|
843
|
+
if (slot.kind !== "PLAN" || slot.plan.status !== "PLANNED") {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
for (const slice of slot.plan.slices) {
|
|
847
|
+
requests.push(buildJevRequest(task, slot.rule, { files: slice.files, diff: slice.diff }));
|
|
848
|
+
}
|
|
569
849
|
}
|
|
570
|
-
|
|
571
|
-
|
|
850
|
+
return requests;
|
|
851
|
+
}
|
|
852
|
+
function assembleResults(input, slots, evaluations, gateConfig) {
|
|
853
|
+
const results = [];
|
|
854
|
+
let evaluationCursor = 0;
|
|
855
|
+
for (const slot of slots) {
|
|
856
|
+
if (slot.kind === "RESULT") {
|
|
857
|
+
results.push(slot.result);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
const plan = slot.plan;
|
|
861
|
+
if (plan.status === "SKIPPED") {
|
|
862
|
+
results.push(skippedResult(input.turn, slot.rule, plan.reason));
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (plan.status === "UNAVAILABLE") {
|
|
866
|
+
results.push(unavailableResult(input.turn, slot.rule, plan.reason));
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (plan.status === "SLICE_LIMIT_EXCEEDED") {
|
|
870
|
+
results.push({
|
|
871
|
+
...buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)),
|
|
872
|
+
outcome: "UNAVAILABLE",
|
|
873
|
+
reason: "SLICE_LIMIT_EXCEEDED",
|
|
874
|
+
evidence: overBudgetMetadata(plan.slices)
|
|
875
|
+
});
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const sliceEvaluations = evaluations.slice(evaluationCursor, evaluationCursor + plan.slices.length);
|
|
879
|
+
evaluationCursor += plan.slices.length;
|
|
880
|
+
results.push(assembleSlicedRuleResult(slot.rule, buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)), plan.slices, sliceEvaluations, gateConfig));
|
|
881
|
+
}
|
|
882
|
+
return results;
|
|
883
|
+
}
|
|
884
|
+
function collectSlicePaths(slices) {
|
|
885
|
+
const paths = [];
|
|
886
|
+
for (const slice of slices) {
|
|
887
|
+
for (const path of slice.files) {
|
|
888
|
+
if (!paths.includes(path)) {
|
|
889
|
+
paths.push(path);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
572
892
|
}
|
|
573
|
-
|
|
574
|
-
|
|
893
|
+
return paths;
|
|
894
|
+
}
|
|
895
|
+
function skippedResult(turn, rule, reason) {
|
|
896
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "SKIPPED", reason };
|
|
897
|
+
}
|
|
898
|
+
function unavailableResult(turn, rule, reason) {
|
|
899
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "UNAVAILABLE", reason };
|
|
900
|
+
}
|
|
901
|
+
function invalidRuleResult(turnId, failure) {
|
|
902
|
+
return {
|
|
903
|
+
kind: "RULE",
|
|
904
|
+
turnId,
|
|
905
|
+
ruleId: failure.ruleId,
|
|
906
|
+
severity: null,
|
|
907
|
+
scopedPaths: [],
|
|
908
|
+
outcome: "UNAVAILABLE",
|
|
909
|
+
reason: "INVALID_RULE"
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function invalidConfigResult(turn, rule) {
|
|
913
|
+
return {
|
|
914
|
+
...buildReviewContext(turn, rule, []),
|
|
915
|
+
outcome: "UNAVAILABLE",
|
|
916
|
+
reason: "INVALID_CONFIG"
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
// packages/core/src/evidence/parse-unified-diff.ts
|
|
920
|
+
var GIT_HEADER = "diff --git ";
|
|
921
|
+
var OLD_FILE_MARKER = "--- ";
|
|
922
|
+
var NEW_FILE_MARKER = "+++ ";
|
|
923
|
+
var DEV_NULL = "/dev/null";
|
|
924
|
+
var OLD_PREFIX = "a/";
|
|
925
|
+
var NEW_PREFIX = "b/";
|
|
926
|
+
var NO_NEWLINE_MARKER = "\\ ";
|
|
927
|
+
var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
|
|
928
|
+
var COMBINED_HEADERS = ["diff --cc ", "diff --combined "];
|
|
929
|
+
var RENAME_FROM_HEADERS = ["rename from ", "copy from "];
|
|
930
|
+
var RENAME_TO_HEADERS = ["rename to ", "copy to "];
|
|
931
|
+
var KNOWN_EXTENDED_HEADERS = [
|
|
932
|
+
"old mode ",
|
|
933
|
+
"new mode ",
|
|
934
|
+
"deleted file mode ",
|
|
935
|
+
"new file mode ",
|
|
936
|
+
"similarity index ",
|
|
937
|
+
"dissimilarity index ",
|
|
938
|
+
"index ",
|
|
939
|
+
...RENAME_FROM_HEADERS,
|
|
940
|
+
...RENAME_TO_HEADERS
|
|
941
|
+
];
|
|
942
|
+
function parseUnifiedDiff(text) {
|
|
943
|
+
const diff = splitLines(text);
|
|
944
|
+
const first = diff.text.findIndex((line) => line.trim() !== "");
|
|
945
|
+
if (first === -1) {
|
|
946
|
+
return { status: "PARSED", files: [] };
|
|
947
|
+
}
|
|
948
|
+
if (diff.text.some(isCombinedHeader)) {
|
|
949
|
+
return invalid("COMBINED_DIFF");
|
|
950
|
+
}
|
|
951
|
+
const format = detectFormat(diff.text, first);
|
|
952
|
+
if (format === null) {
|
|
953
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
954
|
+
}
|
|
955
|
+
return collectFiles(diff, first, format);
|
|
956
|
+
}
|
|
957
|
+
function invalid(reason) {
|
|
958
|
+
return { status: "INVALID", reason };
|
|
959
|
+
}
|
|
960
|
+
function splitLines(text) {
|
|
961
|
+
const raw = text.split(`
|
|
962
|
+
`);
|
|
963
|
+
const endsWithNewline = text.endsWith(`
|
|
964
|
+
`);
|
|
965
|
+
if (endsWithNewline) {
|
|
966
|
+
raw.pop();
|
|
575
967
|
}
|
|
576
|
-
|
|
577
|
-
|
|
968
|
+
const stripped = raw.map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
969
|
+
return { raw, text: stripped, endsWithNewline };
|
|
970
|
+
}
|
|
971
|
+
function isCombinedHeader(line) {
|
|
972
|
+
return COMBINED_HEADERS.some((header) => line.startsWith(header));
|
|
973
|
+
}
|
|
974
|
+
function detectFormat(text, first) {
|
|
975
|
+
if ((text[first] ?? "").startsWith(GIT_HEADER)) {
|
|
976
|
+
return "GIT";
|
|
578
977
|
}
|
|
579
|
-
|
|
978
|
+
if (text.some((line) => line.startsWith(GIT_HEADER))) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
return isPlainSectionStart(text, first) ? "PLAIN" : null;
|
|
580
982
|
}
|
|
581
|
-
function
|
|
582
|
-
return
|
|
583
|
-
`).trim();
|
|
983
|
+
function isPlainSectionStart(text, index) {
|
|
984
|
+
return (text[index] ?? "").startsWith(OLD_FILE_MARKER) && (text[index + 1] ?? "").startsWith(NEW_FILE_MARKER);
|
|
584
985
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
var RULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
588
|
-
function isValidRuleId(id) {
|
|
589
|
-
return RULE_ID.test(id);
|
|
986
|
+
function isSectionStart(text, index, format) {
|
|
987
|
+
return format === "GIT" ? (text[index] ?? "").startsWith(GIT_HEADER) : isPlainSectionStart(text, index);
|
|
590
988
|
}
|
|
591
|
-
function
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const id = matchRuleHeading(line);
|
|
596
|
-
if (id !== null) {
|
|
597
|
-
current = { id, body: [] };
|
|
598
|
-
blocks.push(current);
|
|
599
|
-
continue;
|
|
989
|
+
function isBlankTail(text, from) {
|
|
990
|
+
for (let index = from;index < text.length; index += 1) {
|
|
991
|
+
if ((text[index] ?? "").trim() !== "") {
|
|
992
|
+
return false;
|
|
600
993
|
}
|
|
601
|
-
|
|
602
|
-
|
|
994
|
+
}
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
function collectFiles(diff, first, format) {
|
|
998
|
+
const files = [];
|
|
999
|
+
let cursor = first;
|
|
1000
|
+
while (cursor < diff.text.length) {
|
|
1001
|
+
const section = format === "GIT" ? readGitSection(diff, cursor) : readPlainSection(diff, cursor);
|
|
1002
|
+
if (section.status === "INVALID") {
|
|
1003
|
+
return section;
|
|
1004
|
+
}
|
|
1005
|
+
if (section.file !== null) {
|
|
1006
|
+
files.push(section.file);
|
|
603
1007
|
}
|
|
1008
|
+
cursor = section.next;
|
|
604
1009
|
}
|
|
605
|
-
return
|
|
1010
|
+
return { status: "PARSED", files };
|
|
606
1011
|
}
|
|
607
|
-
function
|
|
608
|
-
|
|
609
|
-
|
|
1012
|
+
function readGitSection(diff, start) {
|
|
1013
|
+
const header = parseGitHeader(diff.text[start] ?? "");
|
|
1014
|
+
if (header.status === "INVALID") {
|
|
1015
|
+
return header;
|
|
610
1016
|
}
|
|
611
|
-
|
|
612
|
-
|
|
1017
|
+
const extended = readExtendedHeaders(diff.text, start + 1);
|
|
1018
|
+
if (extended.status === "INVALID") {
|
|
1019
|
+
return extended;
|
|
613
1020
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return ruleCandidateFailure(metadata.code, block.id);
|
|
1021
|
+
if (!(diff.text[extended.next] ?? "").startsWith(OLD_FILE_MARKER)) {
|
|
1022
|
+
return readSectionWithoutContent(diff.text, extended.next);
|
|
617
1023
|
}
|
|
618
|
-
const
|
|
619
|
-
if (
|
|
620
|
-
return
|
|
1024
|
+
const paths = readFilePaths(diff.text, extended.next, "GIT");
|
|
1025
|
+
if (paths.status === "INVALID") {
|
|
1026
|
+
return paths;
|
|
621
1027
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
1028
|
+
const failure = checkGitPaths(header, extended, paths);
|
|
1029
|
+
if (failure !== null) {
|
|
1030
|
+
return failure;
|
|
1031
|
+
}
|
|
1032
|
+
return readContent(diff, start, extended.next + 2, paths, "GIT");
|
|
626
1033
|
}
|
|
627
|
-
function
|
|
628
|
-
|
|
1034
|
+
function readSectionWithoutContent(text, index) {
|
|
1035
|
+
if (index >= text.length || text[index]?.startsWith(GIT_HEADER)) {
|
|
1036
|
+
return { status: "OK", file: null, next: index };
|
|
1037
|
+
}
|
|
1038
|
+
return isBlankTail(text, index) ? { status: "OK", file: null, next: text.length } : invalid("UNRECOGNIZED_DIFF");
|
|
629
1039
|
}
|
|
630
|
-
function
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1040
|
+
function parseGitHeader(line) {
|
|
1041
|
+
const rest = line.slice(GIT_HEADER.length);
|
|
1042
|
+
if (rest.startsWith('"') || rest.includes(' "')) {
|
|
1043
|
+
return invalid("QUOTED_PATH");
|
|
1044
|
+
}
|
|
1045
|
+
if (!rest.startsWith(OLD_PREFIX)) {
|
|
1046
|
+
return invalid("UNSUPPORTED_PREFIX");
|
|
1047
|
+
}
|
|
1048
|
+
const split = splitGitHeaderPaths(rest);
|
|
1049
|
+
if (split === null) {
|
|
1050
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1051
|
+
}
|
|
1052
|
+
if (split.oldPath === "" || split.newPath === "") {
|
|
1053
|
+
return invalid("MISSING_PATH");
|
|
1054
|
+
}
|
|
1055
|
+
return { status: "OK", ...split };
|
|
634
1056
|
}
|
|
635
|
-
function
|
|
1057
|
+
function splitGitHeaderPaths(rest) {
|
|
1058
|
+
const separator = ` ${NEW_PREFIX}`;
|
|
1059
|
+
const pathLength = (rest.length - OLD_PREFIX.length - separator.length) / 2;
|
|
1060
|
+
if (Number.isInteger(pathLength) && pathLength > 0) {
|
|
1061
|
+
const oldPath = rest.slice(OLD_PREFIX.length, OLD_PREFIX.length + pathLength);
|
|
1062
|
+
const tail = rest.slice(OLD_PREFIX.length + pathLength);
|
|
1063
|
+
if (tail === `${separator}${oldPath}`) {
|
|
1064
|
+
return { oldPath, newPath: oldPath };
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const index = rest.indexOf(separator, OLD_PREFIX.length);
|
|
1068
|
+
if (index === -1) {
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
636
1071
|
return {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
scope: metadata.scope,
|
|
640
|
-
description: sections.rule,
|
|
641
|
-
violation: sections.violation,
|
|
642
|
-
allowed: sections.allowed
|
|
1072
|
+
oldPath: rest.slice(OLD_PREFIX.length, index),
|
|
1073
|
+
newPath: rest.slice(index + separator.length)
|
|
643
1074
|
};
|
|
644
1075
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1076
|
+
function readExtendedHeaders(text, start) {
|
|
1077
|
+
let movedFrom = null;
|
|
1078
|
+
let movedTo = null;
|
|
1079
|
+
let cursor = start;
|
|
1080
|
+
while (cursor < text.length) {
|
|
1081
|
+
const line = text[cursor] ?? "";
|
|
1082
|
+
if (line.startsWith("GIT binary patch") || line.startsWith("Binary files ")) {
|
|
1083
|
+
return invalid("BINARY_PATCH");
|
|
1084
|
+
}
|
|
1085
|
+
const fromValue = headerValue(line, RENAME_FROM_HEADERS);
|
|
1086
|
+
const toValue = headerValue(line, RENAME_TO_HEADERS);
|
|
1087
|
+
if (fromValue?.startsWith('"') || toValue?.startsWith('"')) {
|
|
1088
|
+
return invalid("QUOTED_PATH");
|
|
1089
|
+
}
|
|
1090
|
+
movedFrom = fromValue ?? movedFrom;
|
|
1091
|
+
movedTo = toValue ?? movedTo;
|
|
1092
|
+
if (!KNOWN_EXTENDED_HEADERS.some((header) => line.startsWith(header))) {
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
cursor += 1;
|
|
650
1096
|
}
|
|
651
|
-
|
|
652
|
-
return blocks.map((block) => parseRuleBlock(block, duplicates.has(block.id)));
|
|
1097
|
+
return { status: "OK", next: cursor, movedFrom, movedTo };
|
|
653
1098
|
}
|
|
654
|
-
function
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1099
|
+
function headerValue(line, headers) {
|
|
1100
|
+
const header = headers.find((candidate) => line.startsWith(candidate));
|
|
1101
|
+
return header === undefined ? null : line.slice(header.length);
|
|
1102
|
+
}
|
|
1103
|
+
function readFilePaths(text, index, format) {
|
|
1104
|
+
const oldLine = text[index] ?? "";
|
|
1105
|
+
const newLine = text[index + 1] ?? "";
|
|
1106
|
+
if (!oldLine.startsWith(OLD_FILE_MARKER) || !newLine.startsWith(NEW_FILE_MARKER)) {
|
|
1107
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1108
|
+
}
|
|
1109
|
+
const oldValue = cutAtTab(oldLine.slice(OLD_FILE_MARKER.length));
|
|
1110
|
+
const newValue = cutAtTab(newLine.slice(NEW_FILE_MARKER.length));
|
|
1111
|
+
if (oldValue.startsWith('"') || newValue.startsWith('"')) {
|
|
1112
|
+
return invalid("QUOTED_PATH");
|
|
1113
|
+
}
|
|
1114
|
+
const stripped = stripPrefixes(oldValue, newValue, format);
|
|
1115
|
+
if (stripped === null) {
|
|
1116
|
+
return invalid("UNSUPPORTED_PREFIX");
|
|
1117
|
+
}
|
|
1118
|
+
if (isMissing(stripped.oldPath) && isMissing(stripped.newPath)) {
|
|
1119
|
+
return invalid("MISSING_PATH");
|
|
1120
|
+
}
|
|
1121
|
+
const oldPath = stripped.oldPath === DEV_NULL ? null : stripped.oldPath;
|
|
1122
|
+
const newPath = stripped.newPath === DEV_NULL ? null : stripped.newPath;
|
|
1123
|
+
if (oldPath === "" || newPath === "") {
|
|
1124
|
+
return invalid("MISSING_PATH");
|
|
1125
|
+
}
|
|
1126
|
+
return { status: "OK", oldPath, newPath };
|
|
1127
|
+
}
|
|
1128
|
+
function isMissing(path) {
|
|
1129
|
+
return path === "" || path === DEV_NULL;
|
|
1130
|
+
}
|
|
1131
|
+
function cutAtTab(value) {
|
|
1132
|
+
const tabIndex = value.indexOf("\t");
|
|
1133
|
+
return tabIndex === -1 ? value : value.slice(0, tabIndex);
|
|
1134
|
+
}
|
|
1135
|
+
function stripPrefixes(oldValue, newValue, format) {
|
|
1136
|
+
const hasOldPrefix = oldValue.startsWith(OLD_PREFIX);
|
|
1137
|
+
const hasNewPrefix = newValue.startsWith(NEW_PREFIX);
|
|
1138
|
+
const oldAllowed = hasOldPrefix || oldValue === DEV_NULL;
|
|
1139
|
+
const newAllowed = hasNewPrefix || newValue === DEV_NULL;
|
|
1140
|
+
if (oldAllowed && newAllowed) {
|
|
1141
|
+
return {
|
|
1142
|
+
oldPath: hasOldPrefix ? oldValue.slice(OLD_PREFIX.length) : oldValue,
|
|
1143
|
+
newPath: hasNewPrefix ? newValue.slice(NEW_PREFIX.length) : newValue
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
if (format === "PLAIN" && !hasOldPrefix && !hasNewPrefix) {
|
|
1147
|
+
return { oldPath: oldValue, newPath: newValue };
|
|
1148
|
+
}
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
function checkGitPaths(header, extended, paths) {
|
|
1152
|
+
const oldMatches = paths.oldPath === null || paths.oldPath === header.oldPath;
|
|
1153
|
+
const newMatches = paths.newPath === null || paths.newPath === header.newPath;
|
|
1154
|
+
if (!oldMatches || !newMatches) {
|
|
1155
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1156
|
+
}
|
|
1157
|
+
const movedByHeader = extended.movedFrom !== null && extended.movedTo !== null && extended.movedFrom !== extended.movedTo;
|
|
1158
|
+
if (header.oldPath !== header.newPath || movedByHeader) {
|
|
1159
|
+
return invalid("RENAMED_PATH");
|
|
1160
|
+
}
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
function readPlainSection(diff, start) {
|
|
1164
|
+
const paths = readFilePaths(diff.text, start, "PLAIN");
|
|
1165
|
+
if (paths.status === "INVALID") {
|
|
1166
|
+
return paths;
|
|
1167
|
+
}
|
|
1168
|
+
if (paths.oldPath !== null && paths.newPath !== null && paths.oldPath !== paths.newPath) {
|
|
1169
|
+
return invalid("RENAMED_PATH");
|
|
1170
|
+
}
|
|
1171
|
+
return readContent(diff, start, start + 2, paths, "PLAIN");
|
|
1172
|
+
}
|
|
1173
|
+
function readContent(diff, start, hunksStart, paths, format) {
|
|
1174
|
+
const hunks = readHunks(diff.text, hunksStart);
|
|
1175
|
+
if (hunks.status === "INVALID") {
|
|
1176
|
+
return hunks;
|
|
1177
|
+
}
|
|
1178
|
+
const next = nextSectionIndex(diff.text, hunks.end, format);
|
|
1179
|
+
if (next === null) {
|
|
1180
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1181
|
+
}
|
|
1182
|
+
const path = paths.newPath ?? paths.oldPath ?? "";
|
|
1183
|
+
const patch = renderPatch(diff, start, hunks.end);
|
|
1184
|
+
return { status: "OK", file: { path, patch }, next };
|
|
1185
|
+
}
|
|
1186
|
+
function renderPatch(diff, start, end) {
|
|
1187
|
+
const body = diff.raw.slice(start, end).join(`
|
|
1188
|
+
`);
|
|
1189
|
+
return end < diff.raw.length || diff.endsWithNewline ? `${body}
|
|
1190
|
+
` : body;
|
|
1191
|
+
}
|
|
1192
|
+
function nextSectionIndex(text, end, format) {
|
|
1193
|
+
if (end >= text.length || isSectionStart(text, end, format)) {
|
|
1194
|
+
return end;
|
|
1195
|
+
}
|
|
1196
|
+
return isBlankTail(text, end) ? text.length : null;
|
|
1197
|
+
}
|
|
1198
|
+
function readHunks(text, start) {
|
|
1199
|
+
let cursor = start;
|
|
1200
|
+
let hunkCount = 0;
|
|
1201
|
+
while (cursor < text.length && (text[cursor] ?? "").startsWith("@@")) {
|
|
1202
|
+
const hunk = readHunk(text, cursor);
|
|
1203
|
+
if (hunk.status === "INVALID") {
|
|
1204
|
+
return hunk;
|
|
659
1205
|
}
|
|
660
|
-
|
|
1206
|
+
cursor = hunk.next;
|
|
1207
|
+
hunkCount += 1;
|
|
661
1208
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1209
|
+
if (hunkCount === 0) {
|
|
1210
|
+
return invalid(cursor >= text.length ? "TRUNCATED_HUNK" : "UNRECOGNIZED_DIFF");
|
|
1211
|
+
}
|
|
1212
|
+
return { status: "OK", end: cursor };
|
|
1213
|
+
}
|
|
1214
|
+
function readHunk(text, at) {
|
|
1215
|
+
const match = HUNK_HEADER.exec(text[at] ?? "");
|
|
1216
|
+
if (match === null) {
|
|
1217
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1218
|
+
}
|
|
1219
|
+
let oldLeft = Number(match[2] ?? 1);
|
|
1220
|
+
let newLeft = Number(match[4] ?? 1);
|
|
1221
|
+
let cursor = at + 1;
|
|
1222
|
+
let afterBodyLine = false;
|
|
1223
|
+
while (oldLeft > 0 || newLeft > 0) {
|
|
1224
|
+
if (cursor >= text.length) {
|
|
1225
|
+
return invalid("TRUNCATED_HUNK");
|
|
1226
|
+
}
|
|
1227
|
+
const line = text[cursor] ?? "";
|
|
1228
|
+
if (line.startsWith(NO_NEWLINE_MARKER)) {
|
|
1229
|
+
if (!afterBodyLine) {
|
|
1230
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1231
|
+
}
|
|
1232
|
+
afterBodyLine = false;
|
|
1233
|
+
cursor += 1;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
const marker = line === "" ? " " : line.charAt(0);
|
|
1237
|
+
const consumesOld = marker === " " || marker === "-";
|
|
1238
|
+
const consumesNew = marker === " " || marker === "+";
|
|
1239
|
+
const exceedsCount = consumesOld && oldLeft === 0 || consumesNew && newLeft === 0;
|
|
1240
|
+
if (!(consumesOld || consumesNew) || exceedsCount) {
|
|
1241
|
+
return invalid("UNRECOGNIZED_DIFF");
|
|
1242
|
+
}
|
|
1243
|
+
oldLeft -= consumesOld ? 1 : 0;
|
|
1244
|
+
newLeft -= consumesNew ? 1 : 0;
|
|
1245
|
+
afterBodyLine = true;
|
|
1246
|
+
cursor += 1;
|
|
1247
|
+
}
|
|
1248
|
+
const closesWithMarker = afterBodyLine && (text[cursor] ?? "").startsWith(NO_NEWLINE_MARKER);
|
|
1249
|
+
return { status: "OK", next: closesWithMarker ? cursor + 1 : cursor };
|
|
1250
|
+
}
|
|
1251
|
+
// packages/core/src/feedback/identity.ts
|
|
1252
|
+
function failIdentityKey(identity) {
|
|
1253
|
+
return JSON.stringify([identity.turnId, identity.ruleId]);
|
|
1254
|
+
}
|
|
1255
|
+
function collectRuleFails(records) {
|
|
1256
|
+
const fails = new Map;
|
|
1257
|
+
for (const record of records) {
|
|
1258
|
+
for (const result of record.results) {
|
|
1259
|
+
if (result.kind !== "RULE" || result.ruleId === null || result.outcome !== "FAIL") {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const fail = {
|
|
1263
|
+
turnId: record.turnId,
|
|
1264
|
+
ruleId: result.ruleId,
|
|
1265
|
+
timestamp: record.timestamp,
|
|
1266
|
+
violationProbability: result.violationProbability
|
|
1267
|
+
};
|
|
1268
|
+
const key = failIdentityKey(fail);
|
|
1269
|
+
const existing = fails.get(key);
|
|
1270
|
+
if (existing === undefined || fail.timestamp >= existing.timestamp) {
|
|
1271
|
+
fails.set(key, fail);
|
|
1272
|
+
}
|
|
666
1273
|
}
|
|
667
1274
|
}
|
|
668
|
-
return
|
|
1275
|
+
return fails;
|
|
669
1276
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
thresholds: {
|
|
675
|
-
error: { warn: 0.4, fail: 0.7 },
|
|
676
|
-
warning: { warn: 0.5 }
|
|
1277
|
+
function latestDecisions(feedback) {
|
|
1278
|
+
const decisions = new Map;
|
|
1279
|
+
for (const record of feedback) {
|
|
1280
|
+
decisions.set(failIdentityKey(record), record.decision);
|
|
677
1281
|
}
|
|
678
|
-
|
|
679
|
-
var COMPLEXITY_INSTRUCTIONS = "Does this attributed change introduce material complexity that is disproportionate to, or not reasonably necessary for, completing the user's task? Necessary supporting changes, tests, validation, error handling, and complexity explicitly required by the task should not count as unnecessary complexity. Return the probability from 0 to 1 that the change introduces disproportionate or unnecessary complexity.";
|
|
680
|
-
var COMPLEXITY_DESCRIPTION = "Material complexity introduced by the attributed change that is disproportionate to the problem and not reasonably necessary for completing the task.";
|
|
681
|
-
var COMPLEXITY_VIOLATION = "The change adds unnecessary abstractions, layers or indirections without proportional gain, new dependencies without clear need, excessive configuration, premature generalization, or structure materially larger than the problem requires.";
|
|
682
|
-
var COMPLEXITY_ALLOWED = "Complexity explicitly required by the task, and necessary supporting changes, tests, validation, error handling, auxiliary changes, or following an existing codebase abstraction to avoid breaking its pattern.";
|
|
683
|
-
function buildComplexityQuestion() {
|
|
684
|
-
return {
|
|
685
|
-
type: "noul",
|
|
686
|
-
instructions: COMPLEXITY_INSTRUCTIONS,
|
|
687
|
-
criteria: {
|
|
688
|
-
id: COMPLEXITY_CHECK_ID,
|
|
689
|
-
description: COMPLEXITY_DESCRIPTION,
|
|
690
|
-
violation: COMPLEXITY_VIOLATION,
|
|
691
|
-
allowed: COMPLEXITY_ALLOWED
|
|
692
|
-
}
|
|
693
|
-
};
|
|
1282
|
+
return decisions;
|
|
694
1283
|
}
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1284
|
+
function latestFeedbackRecords(feedback) {
|
|
1285
|
+
const latest = new Map;
|
|
1286
|
+
for (const record of feedback) {
|
|
1287
|
+
const key = failIdentityKey(record);
|
|
1288
|
+
latest.delete(key);
|
|
1289
|
+
latest.set(key, record);
|
|
1290
|
+
}
|
|
1291
|
+
return [...latest.values()];
|
|
1292
|
+
}
|
|
1293
|
+
// packages/core/src/report/compare.ts
|
|
1294
|
+
function compareStrings(left, right) {
|
|
1295
|
+
if (left < right) {
|
|
1296
|
+
return -1;
|
|
1297
|
+
}
|
|
1298
|
+
return left > right ? 1 : 0;
|
|
704
1299
|
}
|
|
705
1300
|
|
|
706
|
-
// packages/core/src/
|
|
707
|
-
var
|
|
708
|
-
|
|
1301
|
+
// packages/core/src/feedback/pending.ts
|
|
1302
|
+
var FEEDBACK_LIST_LIMIT = 20;
|
|
1303
|
+
function listPendingFails(records, feedback, limit) {
|
|
1304
|
+
const answered = latestDecisions(feedback);
|
|
1305
|
+
const pending = [...collectRuleFails(records)].filter(([key]) => !answered.has(key)).map(([, fail]) => fail).sort(compareNewestFirst);
|
|
1306
|
+
return { items: pending.slice(0, limit), total: pending.length };
|
|
1307
|
+
}
|
|
1308
|
+
function compareNewestFirst(left, right) {
|
|
1309
|
+
return compareStrings(right.timestamp, left.timestamp) || compareStrings(left.ruleId, right.ruleId) || compareStrings(left.turnId, right.turnId);
|
|
1310
|
+
}
|
|
1311
|
+
// packages/core/src/feedback/resolve.ts
|
|
1312
|
+
function resolveFeedbackTarget(selection, number, records) {
|
|
1313
|
+
if (selection === null) {
|
|
1314
|
+
return { status: "NO_SELECTION" };
|
|
1315
|
+
}
|
|
1316
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
1317
|
+
return { status: "NUMBER_OUT_OF_RANGE" };
|
|
1318
|
+
}
|
|
1319
|
+
const identity = selection.items[number - 1];
|
|
1320
|
+
if (identity === undefined) {
|
|
1321
|
+
return { status: "NUMBER_OUT_OF_RANGE" };
|
|
1322
|
+
}
|
|
1323
|
+
const fail = collectRuleFails(records).get(failIdentityKey(identity));
|
|
1324
|
+
return fail === undefined ? { status: "NO_LONGER_IN_HISTORY" } : { status: "FOUND", fail };
|
|
1325
|
+
}
|
|
1326
|
+
function planFeedbackDecision(target, decision, feedback) {
|
|
1327
|
+
const previous = latestDecisions(feedback).get(failIdentityKey(target)) ?? null;
|
|
1328
|
+
return previous === decision ? { status: "UNCHANGED" } : { status: "RECORD", previous };
|
|
1329
|
+
}
|
|
1330
|
+
// packages/core/src/gate/defaults.ts
|
|
1331
|
+
var DEFAULT_GATE_CONFIG = {
|
|
709
1332
|
version: 1,
|
|
710
1333
|
thresholds: {
|
|
711
|
-
error: { warn: 0.
|
|
1334
|
+
error: { warn: 0.4, fail: 0.7 },
|
|
712
1335
|
warning: { warn: 0.6 }
|
|
713
1336
|
}
|
|
714
1337
|
};
|
|
715
|
-
var SCOPE_CREEP_INSTRUCTIONS = "Answer true when the attributed change contains scope creep: material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete the task. Answer false when every change is requested work, or supporting, testing, or documentation work reasonably necessary to complete the requested work, including immaterial incidental edits. Return the probability from 0 to 1 that the change is scope creep.";
|
|
716
|
-
var SCOPE_CREEP_DESCRIPTION = "A change is scope creep when it introduces material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete it.";
|
|
717
|
-
var SCOPE_CREEP_VIOLATION = "The change adds material functional, behavioral, architectural, dependency, configuration, documentation, or refactoring work that the task did not request and that is not reasonably necessary to complete the task.";
|
|
718
|
-
var SCOPE_CREEP_ALLOWED = "Changes the task requested, and supporting, testing, and documentation changes reasonably necessary to complete the requested work, including immaterial incidental edits.";
|
|
719
|
-
function buildScopeCreepQuestion() {
|
|
720
|
-
return {
|
|
721
|
-
type: "noul",
|
|
722
|
-
instructions: SCOPE_CREEP_INSTRUCTIONS,
|
|
723
|
-
criteria: {
|
|
724
|
-
id: SCOPE_CREEP_CHECK_ID,
|
|
725
|
-
description: SCOPE_CREEP_DESCRIPTION,
|
|
726
|
-
violation: SCOPE_CREEP_VIOLATION,
|
|
727
|
-
allowed: SCOPE_CREEP_ALLOWED
|
|
728
|
-
}
|
|
729
|
-
};
|
|
730
|
-
}
|
|
731
|
-
function buildScopeCreepContext(turn, scopedPaths) {
|
|
732
|
-
return {
|
|
733
|
-
kind: "BUILT_IN",
|
|
734
|
-
turnId: turn.id,
|
|
735
|
-
ruleId: null,
|
|
736
|
-
checkId: SCOPE_CREEP_CHECK_ID,
|
|
737
|
-
severity: "error",
|
|
738
|
-
scopedPaths
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
1338
|
|
|
742
|
-
// packages/core/src/builtins/evaluate-builtins.ts
|
|
743
|
-
function buildBuiltInBatchRequest(task, evidence) {
|
|
744
|
-
return {
|
|
745
|
-
kind: "BUILT_IN_BATCH",
|
|
746
|
-
task,
|
|
747
|
-
questions: {
|
|
748
|
-
[SCOPE_CREEP_ANSWER]: buildScopeCreepQuestion(),
|
|
749
|
-
[COMPLEXITY_ANSWER]: buildComplexityQuestion()
|
|
750
|
-
},
|
|
751
|
-
change: { files: evidence.files, diff: evidence.diff }
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
async function evaluateBuiltIns(input, dependencies) {
|
|
755
|
-
const selection = selectTurnEvidence(input.turn, input.evidencePolicy);
|
|
756
|
-
if (selection.status === "SKIPPED") {
|
|
757
|
-
return {
|
|
758
|
-
scopeCreep: {
|
|
759
|
-
...buildScopeCreepContext(input.turn, []),
|
|
760
|
-
outcome: "SKIPPED",
|
|
761
|
-
reason: selection.reason
|
|
762
|
-
},
|
|
763
|
-
complexity: {
|
|
764
|
-
...buildComplexityContext(input.turn, []),
|
|
765
|
-
outcome: "SKIPPED",
|
|
766
|
-
reason: selection.reason
|
|
767
|
-
}
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
if (selection.status === "UNAVAILABLE") {
|
|
771
|
-
return {
|
|
772
|
-
scopeCreep: {
|
|
773
|
-
...buildScopeCreepContext(input.turn, []),
|
|
774
|
-
outcome: "UNAVAILABLE",
|
|
775
|
-
reason: selection.reason
|
|
776
|
-
},
|
|
777
|
-
complexity: {
|
|
778
|
-
...buildComplexityContext(input.turn, []),
|
|
779
|
-
outcome: "UNAVAILABLE",
|
|
780
|
-
reason: selection.reason
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
const evidence = selection.evidence;
|
|
785
|
-
const request = buildBuiltInBatchRequest(input.turn.task, evidence);
|
|
786
|
-
const evaluation = await evaluateBuiltInBatchWithPort(dependencies.jev, request);
|
|
787
|
-
const scopeCreepContext = buildScopeCreepContext(input.turn, evidence.files);
|
|
788
|
-
const complexityContext = buildComplexityContext(input.turn, evidence.files);
|
|
789
|
-
if (evaluation.status === "FAILED") {
|
|
790
|
-
return {
|
|
791
|
-
scopeCreep: { ...scopeCreepContext, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" },
|
|
792
|
-
complexity: { ...complexityContext, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" }
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
return {
|
|
796
|
-
scopeCreep: gateScopeCreep(evaluation.answers.scopeCreep, scopeCreepContext),
|
|
797
|
-
complexity: gateComplexity(evaluation.answers.complexity, complexityContext)
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
function gateScopeCreep(answer, context) {
|
|
801
|
-
if (answer.status === "FAILED") {
|
|
802
|
-
return { ...context, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" };
|
|
803
|
-
}
|
|
804
|
-
return {
|
|
805
|
-
...context,
|
|
806
|
-
...evaluateGate("error", answer.noul.violationProbability, SCOPE_CREEP_GATE_CONFIG)
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
function gateComplexity(answer, context) {
|
|
810
|
-
if (answer.status === "FAILED") {
|
|
811
|
-
return { ...context, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" };
|
|
812
|
-
}
|
|
813
|
-
return {
|
|
814
|
-
...context,
|
|
815
|
-
...evaluateGate("warning", answer.noul.violationProbability, COMPLEXITY_GATE_CONFIG)
|
|
816
|
-
};
|
|
817
|
-
}
|
|
818
|
-
// packages/core/src/concurrency/fifo-port.ts
|
|
819
|
-
function createFifoJevPort(port, config) {
|
|
820
|
-
const maxConcurrency = config.maxConcurrency;
|
|
821
|
-
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
|
822
|
-
throw new RangeError("maxConcurrency must be a positive integer");
|
|
823
|
-
}
|
|
824
|
-
const queue = [];
|
|
825
|
-
let active = 0;
|
|
826
|
-
function drain() {
|
|
827
|
-
while (active < maxConcurrency && queue.length > 0) {
|
|
828
|
-
const pending = queue.shift();
|
|
829
|
-
if (pending === undefined) {
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
active += 1;
|
|
833
|
-
start(pending);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
function start(pending) {
|
|
837
|
-
Promise.resolve().then(() => port.evaluate(pending.request)).then((result) => {
|
|
838
|
-
active -= 1;
|
|
839
|
-
pending.settle(result);
|
|
840
|
-
drain();
|
|
841
|
-
}, (error) => {
|
|
842
|
-
active -= 1;
|
|
843
|
-
pending.fail(error);
|
|
844
|
-
drain();
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
return {
|
|
848
|
-
evaluate(request) {
|
|
849
|
-
return new Promise((settle, fail) => {
|
|
850
|
-
queue.push({ request, settle, fail });
|
|
851
|
-
drain();
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
// packages/core/src/gate/defaults.ts
|
|
857
|
-
var DEFAULT_GATE_CONFIG = {
|
|
858
|
-
version: 1,
|
|
859
|
-
thresholds: {
|
|
860
|
-
error: { warn: 0.4, fail: 0.7 },
|
|
861
|
-
warning: { warn: 0.6 }
|
|
862
|
-
}
|
|
863
|
-
};
|
|
864
1339
|
// packages/core/src/gate/config.ts
|
|
865
1340
|
function resolveGateConfig(value) {
|
|
866
1341
|
if (value === null || value === undefined) {
|
|
867
1342
|
return { status: "VALID", config: DEFAULT_GATE_CONFIG };
|
|
868
1343
|
}
|
|
869
1344
|
if (!isRecord(value) || value.version !== 1) {
|
|
870
|
-
return
|
|
1345
|
+
return invalid2();
|
|
871
1346
|
}
|
|
872
1347
|
const thresholds = value.thresholds;
|
|
873
1348
|
if (!isRecord(thresholds)) {
|
|
874
|
-
return
|
|
1349
|
+
return invalid2();
|
|
875
1350
|
}
|
|
876
1351
|
const error = thresholds.error;
|
|
877
1352
|
const warning = thresholds.warning;
|
|
878
1353
|
if (!isRecord(error) || !isRecord(warning)) {
|
|
879
|
-
return
|
|
1354
|
+
return invalid2();
|
|
880
1355
|
}
|
|
881
1356
|
const errorWarn = error.warn;
|
|
882
1357
|
const errorFail = error.fail;
|
|
883
1358
|
const warningWarn = warning.warn;
|
|
884
1359
|
if (!isProbability(errorWarn) || !isProbability(errorFail) || !isProbability(warningWarn)) {
|
|
885
|
-
return
|
|
1360
|
+
return invalid2();
|
|
886
1361
|
}
|
|
887
1362
|
if (errorWarn >= errorFail) {
|
|
888
|
-
return
|
|
1363
|
+
return invalid2();
|
|
889
1364
|
}
|
|
890
1365
|
return {
|
|
891
1366
|
status: "VALID",
|
|
@@ -901,102 +1376,223 @@ function resolveGateConfig(value) {
|
|
|
901
1376
|
function isRecord(value) {
|
|
902
1377
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
903
1378
|
}
|
|
904
|
-
function
|
|
1379
|
+
function invalid2() {
|
|
905
1380
|
return { status: "INVALID", reason: "INVALID_CONFIG" };
|
|
906
1381
|
}
|
|
907
1382
|
// packages/core/src/remediation/types.ts
|
|
908
|
-
var PROPOSER_AGENT_NAME = "jevguard-proposer";
|
|
909
1383
|
var REMEDIATION_MAX_TASK_LENGTH = 16384;
|
|
1384
|
+
var MAX_ATTEMPTS_DEFAULT = 2;
|
|
1385
|
+
var MAX_ATTEMPTS_CEILING = 3;
|
|
1386
|
+
var CHAIN_ID_SOURCE = "[A-Za-z0-9._:-]{1,512}";
|
|
1387
|
+
var CHAIN_ID_PATTERN = new RegExp(`^${CHAIN_ID_SOURCE}$`);
|
|
1388
|
+
function isAttemptCount(value) {
|
|
1389
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_ATTEMPTS_CEILING;
|
|
1390
|
+
}
|
|
1391
|
+
var MAX_AGENT_DELIVERIES_PER_SESSION = 3;
|
|
1392
|
+
var PROPOSAL_MAX_TEXT_LENGTH = 32768;
|
|
1393
|
+
function withinSessionLimit(deliveredInSession) {
|
|
1394
|
+
return deliveredInSession < MAX_AGENT_DELIVERIES_PER_SESSION;
|
|
1395
|
+
}
|
|
910
1396
|
var DEFAULT_REMEDIATION_CONFIG = {
|
|
911
1397
|
autoPropose: true,
|
|
912
1398
|
proposeOn: ["FAIL"],
|
|
913
1399
|
model: null
|
|
914
1400
|
};
|
|
915
1401
|
|
|
916
|
-
// packages/core/src/remediation/
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1402
|
+
// packages/core/src/remediation/chain.ts
|
|
1403
|
+
var FNV_PRIME = 16777619;
|
|
1404
|
+
var FNV_OFFSETS = [2166136261, 84696351];
|
|
1405
|
+
function fnv1a(text, offset) {
|
|
1406
|
+
let hash = offset;
|
|
1407
|
+
for (let index = 0;index < text.length; index += 1) {
|
|
1408
|
+
hash = Math.imul(hash ^ text.charCodeAt(index), FNV_PRIME) >>> 0;
|
|
920
1409
|
}
|
|
921
|
-
|
|
922
|
-
return null;
|
|
923
|
-
}
|
|
924
|
-
const selection = selectTurnEvidence(input.turn, input.evidencePolicy ?? DEFAULT_EVIDENCE_POLICY);
|
|
925
|
-
if (selection.status !== "SELECTED") {
|
|
926
|
-
return null;
|
|
927
|
-
}
|
|
928
|
-
const ruleFindings = collectRuleFindings(input.results, indexParsedRules(input.rules));
|
|
929
|
-
const builtInFindings = collectBuiltInFindings(input.results);
|
|
930
|
-
if (ruleFindings.length === 0 && builtInFindings.length === 0) {
|
|
931
|
-
return null;
|
|
932
|
-
}
|
|
933
|
-
return {
|
|
934
|
-
evaluationId: input.turn.id,
|
|
935
|
-
sessionID: input.sessionID,
|
|
936
|
-
messageID: input.turn.id,
|
|
937
|
-
model: input.config.model,
|
|
938
|
-
task: input.turn.task,
|
|
939
|
-
paths: [...selection.evidence.files],
|
|
940
|
-
diff: selection.evidence.diff,
|
|
941
|
-
ruleFindings,
|
|
942
|
-
builtInFindings
|
|
943
|
-
};
|
|
1410
|
+
return hash.toString(16).padStart(8, "0");
|
|
944
1411
|
}
|
|
945
|
-
function
|
|
946
|
-
|
|
1412
|
+
function hashOf(text) {
|
|
1413
|
+
return FNV_OFFSETS.map((offset) => fnv1a(text, offset)).join("");
|
|
1414
|
+
}
|
|
1415
|
+
function idOf(result) {
|
|
1416
|
+
if (result.kind === "RULE")
|
|
1417
|
+
return result.ruleId;
|
|
1418
|
+
return result.kind === "BUILT_IN" ? result.checkId : null;
|
|
1419
|
+
}
|
|
1420
|
+
function sortedUnique(values) {
|
|
1421
|
+
return [...new Set(values)].sort();
|
|
1422
|
+
}
|
|
1423
|
+
function chainFailuresOf(results) {
|
|
1424
|
+
const failures = [];
|
|
947
1425
|
for (const result of results) {
|
|
948
|
-
if (
|
|
1426
|
+
if (result.outcome !== "FAIL")
|
|
949
1427
|
continue;
|
|
950
|
-
|
|
951
|
-
const rule = rulesById.get(result.ruleId);
|
|
952
|
-
if (rule === undefined) {
|
|
1428
|
+
if (result.kind === "RULE" && result.severity !== "error")
|
|
953
1429
|
continue;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
severity: "error",
|
|
958
|
-
rule: snapshotRule(rule),
|
|
959
|
-
probability: result.violationProbability,
|
|
960
|
-
scopedPaths: [...result.scopedPaths]
|
|
961
|
-
});
|
|
1430
|
+
const id = idOf(result);
|
|
1431
|
+
if (id !== null)
|
|
1432
|
+
failures.push({ id, scopedPaths: [...result.scopedPaths] });
|
|
962
1433
|
}
|
|
963
|
-
return
|
|
1434
|
+
return failures;
|
|
964
1435
|
}
|
|
965
|
-
function
|
|
966
|
-
const
|
|
1436
|
+
function failureSignature(failures) {
|
|
1437
|
+
const parts = failures.map((failure) => `${failure.id}:${sortedUnique(failure.scopedPaths).join(",")}`);
|
|
1438
|
+
return hashOf(sortedUnique(parts).join("|"));
|
|
1439
|
+
}
|
|
1440
|
+
function scopesOf(results, ids) {
|
|
1441
|
+
const scopes = {};
|
|
967
1442
|
for (const result of results) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
findings.push({
|
|
972
|
-
checkId: result.checkId,
|
|
973
|
-
severity: result.severity,
|
|
974
|
-
probability: result.violationProbability,
|
|
975
|
-
scopedPaths: [...result.scopedPaths]
|
|
976
|
-
});
|
|
1443
|
+
const id = idOf(result);
|
|
1444
|
+
if (id !== null && ids.includes(id))
|
|
1445
|
+
scopes[id] = sortedUnique(result.scopedPaths);
|
|
977
1446
|
}
|
|
978
|
-
return
|
|
1447
|
+
return scopes;
|
|
979
1448
|
}
|
|
980
|
-
function
|
|
981
|
-
return result.
|
|
1449
|
+
function isJudged(result) {
|
|
1450
|
+
return result.outcome === "PASS" || result.outcome === "FAIL";
|
|
982
1451
|
}
|
|
983
|
-
function
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1452
|
+
function narrowed(before, after) {
|
|
1453
|
+
return before?.some((path) => !after.includes(path)) === true;
|
|
1454
|
+
}
|
|
1455
|
+
function judgedOf(id, results) {
|
|
1456
|
+
return results.filter((result) => idOf(result) === id && isJudged(result));
|
|
1457
|
+
}
|
|
1458
|
+
function hasUnverifiedOrigin(state, results) {
|
|
1459
|
+
return state.originIds.some((id) => {
|
|
1460
|
+
const judged = judgedOf(id, results);
|
|
1461
|
+
return judged.length === 0 || judged.some((result) => narrowed(state.scopes[id], sortedUnique(result.scopedPaths)));
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
function chainIdsOf(state, results) {
|
|
1465
|
+
const resolvedIds = state.originIds.filter((id) => {
|
|
1466
|
+
const judged = judgedOf(id, results);
|
|
1467
|
+
return judged.length > 0 && judged.every((result) => result.outcome === "PASS" && !narrowed(state.scopes[id], sortedUnique(result.scopedPaths)));
|
|
1468
|
+
});
|
|
1469
|
+
return { resolvedIds, remainingIds: state.originIds.filter((id) => !resolvedIds.includes(id)) };
|
|
1470
|
+
}
|
|
1471
|
+
function unresolvedIds(state) {
|
|
1472
|
+
return { resolvedIds: [], remainingIds: [...state.originIds] };
|
|
1473
|
+
}
|
|
1474
|
+
function stop(code, input, state, ids = chainIdsOf(state, input.results)) {
|
|
1475
|
+
return { kind: "STOP", code, maxAttempts: input.maxAttempts, state, ids };
|
|
1476
|
+
}
|
|
1477
|
+
function startOf(input, failures) {
|
|
1478
|
+
if (input.request === null || failures.length === 0)
|
|
1479
|
+
return { kind: "NONE" };
|
|
1480
|
+
const signature = failureSignature(failures);
|
|
1481
|
+
const originIds = sortedUnique(failures.map((failure) => failure.id));
|
|
1482
|
+
return {
|
|
1483
|
+
kind: "DELIVER",
|
|
1484
|
+
chainId: input.turnId,
|
|
1485
|
+
attempt: 1,
|
|
1486
|
+
maxAttempts: input.maxAttempts,
|
|
1487
|
+
signature,
|
|
1488
|
+
state: {
|
|
1489
|
+
chainId: input.turnId,
|
|
1490
|
+
originIds,
|
|
1491
|
+
signatures: [signature],
|
|
1492
|
+
turnIds: [input.turnId],
|
|
1493
|
+
attempts: 1,
|
|
1494
|
+
scopes: scopesOf(input.results, originIds)
|
|
988
1495
|
}
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
function continuation(input, state, failures) {
|
|
1499
|
+
if (hasUnverifiedOrigin(state, input.results))
|
|
1500
|
+
return stop("UNVERIFIED", input, state);
|
|
1501
|
+
if (failures.length === 0) {
|
|
1502
|
+
return {
|
|
1503
|
+
kind: "RESOLVED",
|
|
1504
|
+
maxAttempts: input.maxAttempts,
|
|
1505
|
+
state,
|
|
1506
|
+
ids: chainIdsOf(state, input.results)
|
|
1507
|
+
};
|
|
989
1508
|
}
|
|
990
|
-
|
|
1509
|
+
if (input.request === null)
|
|
1510
|
+
return stop("UNVERIFIED", input, state);
|
|
1511
|
+
if (failures.some((failure) => !state.originIds.includes(failure.id))) {
|
|
1512
|
+
return stop("REGRESSION_INTRODUCED", input, state);
|
|
1513
|
+
}
|
|
1514
|
+
const signature = failureSignature(failures);
|
|
1515
|
+
if (state.signatures.includes(signature))
|
|
1516
|
+
return stop("REPEATED_FAILURE", input, state);
|
|
1517
|
+
if (state.attempts >= input.maxAttempts)
|
|
1518
|
+
return stop("LIMIT", input, state);
|
|
1519
|
+
const attempt = state.attempts + 1;
|
|
1520
|
+
return {
|
|
1521
|
+
kind: "DELIVER",
|
|
1522
|
+
chainId: state.chainId,
|
|
1523
|
+
attempt,
|
|
1524
|
+
maxAttempts: input.maxAttempts,
|
|
1525
|
+
signature,
|
|
1526
|
+
state: {
|
|
1527
|
+
...state,
|
|
1528
|
+
signatures: [...state.signatures, signature],
|
|
1529
|
+
turnIds: [...state.turnIds, input.turnId],
|
|
1530
|
+
attempts: attempt,
|
|
1531
|
+
scopes: { ...state.scopes, ...scopesOf(input.results, state.originIds) }
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
991
1534
|
}
|
|
992
|
-
function
|
|
1535
|
+
function unverifiedWithoutState(input, delivery, known) {
|
|
1536
|
+
const state = known ?? {
|
|
1537
|
+
chainId: delivery.chainId,
|
|
1538
|
+
originIds: [],
|
|
1539
|
+
signatures: [],
|
|
1540
|
+
turnIds: [],
|
|
1541
|
+
attempts: Math.min(Math.max(delivery.attempt, 1), MAX_ATTEMPTS_CEILING),
|
|
1542
|
+
scopes: {}
|
|
1543
|
+
};
|
|
1544
|
+
return stop("UNVERIFIED", input, state, unresolvedIds(state));
|
|
1545
|
+
}
|
|
1546
|
+
function decideChainStep(input) {
|
|
1547
|
+
if (input.mode === "observe" || input.delivery === "LEGACY")
|
|
1548
|
+
return { kind: "NONE" };
|
|
1549
|
+
const failures = chainFailuresOf(input.results);
|
|
1550
|
+
const { delivery, chain } = input;
|
|
1551
|
+
if (delivery === null)
|
|
1552
|
+
return startOf(input, failures);
|
|
1553
|
+
if (chain === null || chain.chainId !== delivery.chainId || chain.attempts !== delivery.attempt) {
|
|
1554
|
+
return unverifiedWithoutState(input, delivery, chain);
|
|
1555
|
+
}
|
|
1556
|
+
return continuation(input, chain, failures);
|
|
1557
|
+
}
|
|
1558
|
+
function decideRemediateStep(input) {
|
|
1559
|
+
const { mixed = false, request, ...rest } = input;
|
|
1560
|
+
if (mixed && rest.chain !== null)
|
|
1561
|
+
return decideOrphanStep(rest.chain, rest.maxAttempts);
|
|
1562
|
+
return decideChainStep({ ...rest, mode: "remediate", request: mixed ? null : request });
|
|
1563
|
+
}
|
|
1564
|
+
function decideOrphanStep(state, maxAttempts) {
|
|
1565
|
+
return { kind: "STOP", code: "UNVERIFIED", maxAttempts, state, ids: unresolvedIds(state) };
|
|
1566
|
+
}
|
|
1567
|
+
// packages/core/src/remediation/closures.ts
|
|
1568
|
+
function expiredClosure(state) {
|
|
1569
|
+
return { state, code: "EXPIRED" };
|
|
1570
|
+
}
|
|
1571
|
+
function endRecord(state, maxAttempts, result, ids, turnIds) {
|
|
993
1572
|
return {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1573
|
+
chainId: state.chainId,
|
|
1574
|
+
turnIds,
|
|
1575
|
+
attempts: state.attempts,
|
|
1576
|
+
maxAttempts,
|
|
1577
|
+
signatures: state.signatures,
|
|
1578
|
+
originIds: state.originIds,
|
|
1579
|
+
resolvedIds: ids.resolvedIds,
|
|
1580
|
+
remainingIds: ids.remainingIds,
|
|
1581
|
+
result
|
|
998
1582
|
};
|
|
999
1583
|
}
|
|
1584
|
+
function chainEndOf(step, turnId) {
|
|
1585
|
+
const { state } = step;
|
|
1586
|
+
const turnIds = state.turnIds.includes(turnId) ? state.turnIds : [...state.turnIds, turnId];
|
|
1587
|
+
return endRecord(state, step.maxAttempts, step.kind === "STOP" ? step.code : "RESOLVED", step.ids, turnIds);
|
|
1588
|
+
}
|
|
1589
|
+
async function recordClosures(closures, maxAttempts, sink) {
|
|
1590
|
+
for (const { state, code } of closures) {
|
|
1591
|
+
const entry = endRecord(state, Math.max(maxAttempts, state.attempts), code, unresolvedIds(state), state.turnIds);
|
|
1592
|
+
await sink.stopped(code, entry);
|
|
1593
|
+
await sink.record(entry);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1000
1596
|
// packages/core/src/remediation/config.ts
|
|
1001
1597
|
var MODEL_PATTERN = /^[^\s/]+\/[^\s/]+$/;
|
|
1002
1598
|
var KNOWN_KEYS = new Set(["auto_propose", "propose_on", "model"]);
|
|
@@ -1006,26 +1602,26 @@ function resolveRemediationConfig(value) {
|
|
|
1006
1602
|
return valid(DEFAULT_REMEDIATION_CONFIG);
|
|
1007
1603
|
}
|
|
1008
1604
|
if (!isRecord2(value)) {
|
|
1009
|
-
return
|
|
1605
|
+
return invalid3();
|
|
1010
1606
|
}
|
|
1011
1607
|
const remediation = value.remediation;
|
|
1012
1608
|
if (remediation === undefined) {
|
|
1013
1609
|
return valid(DEFAULT_REMEDIATION_CONFIG);
|
|
1014
1610
|
}
|
|
1015
1611
|
if (!isRecord2(remediation) || hasUnknownKey(remediation)) {
|
|
1016
|
-
return
|
|
1612
|
+
return invalid3();
|
|
1017
1613
|
}
|
|
1018
1614
|
const autoPropose = remediation.auto_propose ?? DEFAULT_REMEDIATION_CONFIG.autoPropose;
|
|
1019
1615
|
if (typeof autoPropose !== "boolean") {
|
|
1020
|
-
return
|
|
1616
|
+
return invalid3();
|
|
1021
1617
|
}
|
|
1022
1618
|
const proposeOn = readProposeOn(remediation.propose_on);
|
|
1023
1619
|
if (proposeOn === null) {
|
|
1024
|
-
return
|
|
1620
|
+
return invalid3();
|
|
1025
1621
|
}
|
|
1026
1622
|
const model = resolveModel(remediation.model);
|
|
1027
1623
|
if (model === INVALID_MODEL) {
|
|
1028
|
-
return
|
|
1624
|
+
return invalid3();
|
|
1029
1625
|
}
|
|
1030
1626
|
return valid({ autoPropose, proposeOn, model });
|
|
1031
1627
|
}
|
|
@@ -1073,9 +1669,76 @@ function isRecord2(value) {
|
|
|
1073
1669
|
function valid(config) {
|
|
1074
1670
|
return { status: "VALID", config };
|
|
1075
1671
|
}
|
|
1076
|
-
function
|
|
1672
|
+
function invalid3() {
|
|
1077
1673
|
return { status: "INVALID", reason: "INVALID_CONFIG" };
|
|
1078
1674
|
}
|
|
1675
|
+
// packages/core/src/remediation/delivery-findings.ts
|
|
1676
|
+
function deliveryFindingsOf(request) {
|
|
1677
|
+
return [
|
|
1678
|
+
...request.ruleFindings.map((finding) => ({
|
|
1679
|
+
id: finding.ruleId,
|
|
1680
|
+
severity: finding.severity,
|
|
1681
|
+
probability: finding.probability,
|
|
1682
|
+
paths: finding.scopedPaths,
|
|
1683
|
+
violation: finding.rule.violation,
|
|
1684
|
+
allowed: finding.rule.allowed
|
|
1685
|
+
})),
|
|
1686
|
+
...request.builtInFindings.map((finding) => ({
|
|
1687
|
+
id: finding.checkId,
|
|
1688
|
+
severity: finding.severity,
|
|
1689
|
+
probability: finding.probability,
|
|
1690
|
+
paths: finding.scopedPaths,
|
|
1691
|
+
violation: null,
|
|
1692
|
+
allowed: null
|
|
1693
|
+
}))
|
|
1694
|
+
];
|
|
1695
|
+
}
|
|
1696
|
+
// packages/core/src/remediation/delivery-frame.ts
|
|
1697
|
+
var AUTO_DELIVERY_MARKER = "[JevGuard remediation proposal \xB7 automatic delivery";
|
|
1698
|
+
var AGENT_NOTICE = "Sent by the JevGuard plugin, not typed by the user. Strategy-only proposal generated without tools for the FAIL findings of the previous work unit. Apply it only if it is consistent with the user's instructions; do not change tests, configuration or dependencies without explicit user confirmation.";
|
|
1699
|
+
var PROPOSAL_START = "--- proposal start ---";
|
|
1700
|
+
var PROPOSAL_END = "--- proposal end ---";
|
|
1701
|
+
var CONSTRAINTS = "Constraints: touch only the listed paths; do not change tests, configuration, dependencies or .jev/; do not commit.";
|
|
1702
|
+
var STOP_CLAUSE = "If you believe a finding is wrong, say so and stop instead of forcing a change.";
|
|
1703
|
+
var CONTROL_CHARACTERS = /(?![\n\t])\p{Cc}/gu;
|
|
1704
|
+
var FINDING_TEXT_MAX = 1000;
|
|
1705
|
+
var MARK = new RegExp(`\\[JevGuard remediation proposal \xB7 automatic delivery(?: \xB7 [^\\n\\]\xB7]*? \xB7 attempt ([1-9][0-9]*)/([1-9][0-9]*) \xB7 chain (${CHAIN_ID_SOURCE}))?\\]`);
|
|
1706
|
+
function clean(text) {
|
|
1707
|
+
return text.replace(CONTROL_CHARACTERS, "");
|
|
1708
|
+
}
|
|
1709
|
+
function oneLine(text) {
|
|
1710
|
+
return clean(text).replace(/\s+/g, " ").trim().slice(0, FINDING_TEXT_MAX);
|
|
1711
|
+
}
|
|
1712
|
+
function findingLines(finding) {
|
|
1713
|
+
const head = `- ${oneLine(finding.id)} (${oneLine(finding.severity)}, p=${finding.probability.toFixed(2)}) in ${finding.paths.map(oneLine).join(", ")}`;
|
|
1714
|
+
const lines = [head];
|
|
1715
|
+
if (finding.violation !== null && finding.violation !== "") {
|
|
1716
|
+
lines.push(` Violation: ${oneLine(finding.violation)}`);
|
|
1717
|
+
}
|
|
1718
|
+
if (finding.allowed !== null && finding.allowed !== "") {
|
|
1719
|
+
lines.push(` Allowed: ${oneLine(finding.allowed)}`);
|
|
1720
|
+
}
|
|
1721
|
+
return lines;
|
|
1722
|
+
}
|
|
1723
|
+
function formatAgentDelivery(input) {
|
|
1724
|
+
const strategy = clean(input.strategyText).split(`
|
|
1725
|
+
`).filter((line) => line.trim() !== PROPOSAL_START && line.trim() !== PROPOSAL_END);
|
|
1726
|
+
return [
|
|
1727
|
+
`${AUTO_DELIVERY_MARKER} \xB7 ${oneLine(input.unit)} \xB7 attempt ${input.attempt}/${input.maxAttempts} \xB7 chain ${input.chainId}]`,
|
|
1728
|
+
AGENT_NOTICE,
|
|
1729
|
+
"",
|
|
1730
|
+
"Findings:",
|
|
1731
|
+
...input.findings.flatMap(findingLines),
|
|
1732
|
+
"",
|
|
1733
|
+
PROPOSAL_START,
|
|
1734
|
+
...strategy,
|
|
1735
|
+
PROPOSAL_END,
|
|
1736
|
+
"",
|
|
1737
|
+
CONSTRAINTS,
|
|
1738
|
+
STOP_CLAUSE
|
|
1739
|
+
].join(`
|
|
1740
|
+
`);
|
|
1741
|
+
}
|
|
1079
1742
|
// packages/core/src/remediation/prompt.ts
|
|
1080
1743
|
var PROPOSER_AGENT_PROMPT = [
|
|
1081
1744
|
"JevGuard remediation proposer.",
|
|
@@ -1146,19 +1809,103 @@ function section(label, value) {
|
|
|
1146
1809
|
${value}
|
|
1147
1810
|
`;
|
|
1148
1811
|
}
|
|
1149
|
-
// packages/core/src/remediation/
|
|
1150
|
-
function
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1812
|
+
// packages/core/src/remediation/request.ts
|
|
1813
|
+
function buildProposalRequest(input) {
|
|
1814
|
+
if (!input.config.autoPropose || !input.config.proposeOn.includes("FAIL")) {
|
|
1815
|
+
return null;
|
|
1816
|
+
}
|
|
1817
|
+
if (input.turn.task.length > REMEDIATION_MAX_TASK_LENGTH) {
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1820
|
+
const selection = selectTurnEvidence(input.turn, input.evidencePolicy ?? DEFAULT_EVIDENCE_POLICY);
|
|
1821
|
+
if (selection.status !== "SELECTED") {
|
|
1822
|
+
return null;
|
|
1823
|
+
}
|
|
1824
|
+
const ruleFindings = collectRuleFindings(input.results, indexParsedRules(input.rules));
|
|
1825
|
+
const builtInFindings = collectBuiltInFindings(input.results);
|
|
1826
|
+
if (ruleFindings.length === 0 && builtInFindings.length === 0) {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
return {
|
|
1830
|
+
evaluationId: input.turn.id,
|
|
1831
|
+
sessionID: input.sessionID,
|
|
1832
|
+
messageID: input.turn.id,
|
|
1833
|
+
model: input.config.model,
|
|
1834
|
+
task: input.turn.task,
|
|
1835
|
+
paths: [...selection.evidence.files],
|
|
1836
|
+
diff: selection.evidence.diff,
|
|
1837
|
+
ruleFindings,
|
|
1838
|
+
builtInFindings
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
function collectRuleFindings(results, rulesById) {
|
|
1842
|
+
const findings = [];
|
|
1843
|
+
for (const result of results) {
|
|
1844
|
+
if (!isFailingRule(result)) {
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
const rule = rulesById.get(result.ruleId);
|
|
1848
|
+
if (rule === undefined) {
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
findings.push({
|
|
1852
|
+
ruleId: rule.id,
|
|
1853
|
+
severity: "error",
|
|
1854
|
+
rule: snapshotRule(rule),
|
|
1855
|
+
probability: result.violationProbability,
|
|
1856
|
+
scopedPaths: [...result.scopedPaths]
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
return findings;
|
|
1860
|
+
}
|
|
1861
|
+
function collectBuiltInFindings(results) {
|
|
1862
|
+
const findings = [];
|
|
1863
|
+
for (const result of results) {
|
|
1864
|
+
if (result.kind !== "BUILT_IN" || result.outcome !== "FAIL") {
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
findings.push({
|
|
1868
|
+
checkId: result.checkId,
|
|
1869
|
+
severity: result.severity,
|
|
1870
|
+
probability: result.violationProbability,
|
|
1871
|
+
scopedPaths: [...result.scopedPaths]
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
return findings;
|
|
1875
|
+
}
|
|
1876
|
+
function isFailingRule(result) {
|
|
1877
|
+
return result.kind === "RULE" && result.outcome === "FAIL" && result.severity === "error" && result.ruleId !== null;
|
|
1878
|
+
}
|
|
1879
|
+
function indexParsedRules(rules) {
|
|
1880
|
+
const byId = new Map;
|
|
1881
|
+
for (const candidate of rules) {
|
|
1882
|
+
if (candidate.status === "PARSED") {
|
|
1883
|
+
byId.set(candidate.rule.id, candidate.rule);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return byId;
|
|
1887
|
+
}
|
|
1888
|
+
function snapshotRule(rule) {
|
|
1889
|
+
return {
|
|
1890
|
+
id: rule.id,
|
|
1891
|
+
description: rule.description,
|
|
1892
|
+
violation: rule.violation,
|
|
1893
|
+
allowed: rule.allowed ?? ""
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
// packages/core/src/remediation/store.ts
|
|
1897
|
+
function createRemediationProposalStore() {
|
|
1898
|
+
const claimed = new Set;
|
|
1899
|
+
const childSessions = new Set;
|
|
1900
|
+
return {
|
|
1901
|
+
claim(evaluationId) {
|
|
1902
|
+
if (evaluationId === "" || claimed.has(evaluationId)) {
|
|
1903
|
+
return false;
|
|
1904
|
+
}
|
|
1905
|
+
claimed.add(evaluationId);
|
|
1906
|
+
return true;
|
|
1907
|
+
},
|
|
1908
|
+
registerChildSession(sessionID) {
|
|
1162
1909
|
if (sessionID !== "") {
|
|
1163
1910
|
childSessions.add(sessionID);
|
|
1164
1911
|
}
|
|
@@ -1168,6 +1915,561 @@ function createRemediationProposalStore() {
|
|
|
1168
1915
|
}
|
|
1169
1916
|
};
|
|
1170
1917
|
}
|
|
1918
|
+
// packages/core/src/report/aggregate.ts
|
|
1919
|
+
function aggregateReviewHistory(records, skippedLineCount, feedback) {
|
|
1920
|
+
const outcomes = emptyAccumulator();
|
|
1921
|
+
const unavailableReasons = new Map;
|
|
1922
|
+
const skippedReasons = new Map;
|
|
1923
|
+
const builtIns = new Map;
|
|
1924
|
+
const rules = new Map;
|
|
1925
|
+
const evaluation = { wholeEvaluations: 0, slicedEvaluations: 0, totalSliceJudgments: 0 };
|
|
1926
|
+
let firstTimestamp = null;
|
|
1927
|
+
let lastTimestamp = null;
|
|
1928
|
+
for (const record of records) {
|
|
1929
|
+
firstTimestamp = earlier(firstTimestamp, record.timestamp);
|
|
1930
|
+
lastTimestamp = later(lastTimestamp, record.timestamp);
|
|
1931
|
+
for (const result of record.results) {
|
|
1932
|
+
countOutcome(outcomes, result.outcome);
|
|
1933
|
+
if (result.outcome === "UNAVAILABLE") {
|
|
1934
|
+
bump(unavailableReasons, result.reason);
|
|
1935
|
+
}
|
|
1936
|
+
if (result.outcome === "SKIPPED") {
|
|
1937
|
+
bump(skippedReasons, result.reason);
|
|
1938
|
+
}
|
|
1939
|
+
if (result.kind === "BUILT_IN") {
|
|
1940
|
+
bump(builtIns, result.checkId);
|
|
1941
|
+
}
|
|
1942
|
+
if (result.kind === "RULE" && result.ruleId !== null) {
|
|
1943
|
+
countRule(rules, result.ruleId, result.outcome, result.evidence);
|
|
1944
|
+
}
|
|
1945
|
+
if (result.kind === "RULE" && result.evidence !== undefined) {
|
|
1946
|
+
countEvidence(evaluation, result.evidence.mode, result.evidence.sliceJudgments.length);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return {
|
|
1951
|
+
entryCount: records.length,
|
|
1952
|
+
skippedLineCount,
|
|
1953
|
+
firstTimestamp,
|
|
1954
|
+
lastTimestamp,
|
|
1955
|
+
outcomes: finalizeAccumulator(outcomes),
|
|
1956
|
+
unavailableReasons: sortedReasons(unavailableReasons),
|
|
1957
|
+
skippedReasons: sortedReasons(skippedReasons),
|
|
1958
|
+
builtIns: sortedBuiltIns(builtIns),
|
|
1959
|
+
rules: sortedRules(rules, calibrateRules(records, feedback)),
|
|
1960
|
+
evaluation: finalizeEvaluation(evaluation)
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
function emptyAccumulator() {
|
|
1964
|
+
return { pass: 0, warn: 0, fail: 0, unavailable: 0, skipped: 0 };
|
|
1965
|
+
}
|
|
1966
|
+
function emptyCalibration() {
|
|
1967
|
+
return { right: 0, wrong: 0, unanswered: 0 };
|
|
1968
|
+
}
|
|
1969
|
+
function countOutcome(counts, outcome) {
|
|
1970
|
+
switch (outcome) {
|
|
1971
|
+
case "PASS":
|
|
1972
|
+
counts.pass += 1;
|
|
1973
|
+
return;
|
|
1974
|
+
case "WARN":
|
|
1975
|
+
counts.warn += 1;
|
|
1976
|
+
return;
|
|
1977
|
+
case "FAIL":
|
|
1978
|
+
counts.fail += 1;
|
|
1979
|
+
return;
|
|
1980
|
+
case "UNAVAILABLE":
|
|
1981
|
+
counts.unavailable += 1;
|
|
1982
|
+
return;
|
|
1983
|
+
case "SKIPPED":
|
|
1984
|
+
counts.skipped += 1;
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
function countRule(rules, ruleId, outcome, evidence) {
|
|
1989
|
+
const accumulator = rules.get(ruleId) ?? { counts: emptyAccumulator(), sliced: 0 };
|
|
1990
|
+
countOutcome(accumulator.counts, outcome);
|
|
1991
|
+
if (evidence?.mode === "SLICED") {
|
|
1992
|
+
accumulator.sliced += 1;
|
|
1993
|
+
}
|
|
1994
|
+
rules.set(ruleId, accumulator);
|
|
1995
|
+
}
|
|
1996
|
+
function countEvidence(coverage, mode, sliceJudgmentCount) {
|
|
1997
|
+
if (mode === "WHOLE") {
|
|
1998
|
+
coverage.wholeEvaluations += 1;
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
coverage.slicedEvaluations += 1;
|
|
2002
|
+
coverage.totalSliceJudgments += sliceJudgmentCount;
|
|
2003
|
+
}
|
|
2004
|
+
function finalizeAccumulator(counts) {
|
|
2005
|
+
return {
|
|
2006
|
+
pass: counts.pass,
|
|
2007
|
+
warn: counts.warn,
|
|
2008
|
+
fail: counts.fail,
|
|
2009
|
+
unavailable: counts.unavailable,
|
|
2010
|
+
skipped: counts.skipped,
|
|
2011
|
+
total: counts.pass + counts.warn + counts.fail + counts.unavailable + counts.skipped
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
function finalizeEvaluation(coverage) {
|
|
2015
|
+
return { ...coverage };
|
|
2016
|
+
}
|
|
2017
|
+
function bump(map, key) {
|
|
2018
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
2019
|
+
}
|
|
2020
|
+
function sortedReasons(map) {
|
|
2021
|
+
return [...map.entries()].map(([reason, count]) => ({ reason, count })).sort((left, right) => compareStrings(left.reason, right.reason));
|
|
2022
|
+
}
|
|
2023
|
+
function sortedBuiltIns(map) {
|
|
2024
|
+
return [...map.entries()].map(([checkId, count]) => ({ checkId, count })).sort((left, right) => compareStrings(left.checkId, right.checkId));
|
|
2025
|
+
}
|
|
2026
|
+
function calibrateRules(records, feedback) {
|
|
2027
|
+
const decisions = latestDecisions(feedback);
|
|
2028
|
+
const calibrations = new Map;
|
|
2029
|
+
for (const [key, fail] of collectRuleFails(records)) {
|
|
2030
|
+
const accumulator = calibrations.get(fail.ruleId) ?? emptyCalibration();
|
|
2031
|
+
const decision = decisions.get(key);
|
|
2032
|
+
if (decision === "RIGHT") {
|
|
2033
|
+
accumulator.right += 1;
|
|
2034
|
+
} else if (decision === "WRONG") {
|
|
2035
|
+
accumulator.wrong += 1;
|
|
2036
|
+
} else {
|
|
2037
|
+
accumulator.unanswered += 1;
|
|
2038
|
+
}
|
|
2039
|
+
calibrations.set(fail.ruleId, accumulator);
|
|
2040
|
+
}
|
|
2041
|
+
return calibrations;
|
|
2042
|
+
}
|
|
2043
|
+
function finalizeCalibration(accumulator) {
|
|
2044
|
+
const { right, wrong, unanswered } = accumulator ?? emptyCalibration();
|
|
2045
|
+
const answered = right + wrong;
|
|
2046
|
+
return { right, wrong, unanswered, precision: answered === 0 ? null : right / answered };
|
|
2047
|
+
}
|
|
2048
|
+
function sortedRules(map, calibrations) {
|
|
2049
|
+
return [...map.entries()].map(([ruleId, accumulator]) => ({
|
|
2050
|
+
ruleId,
|
|
2051
|
+
counts: finalizeAccumulator(accumulator.counts),
|
|
2052
|
+
slicedRuleCount: accumulator.sliced,
|
|
2053
|
+
calibration: finalizeCalibration(calibrations.get(ruleId))
|
|
2054
|
+
})).sort((left, right) => compareStrings(left.ruleId, right.ruleId));
|
|
2055
|
+
}
|
|
2056
|
+
function earlier(current, candidate) {
|
|
2057
|
+
return current === null || candidate < current ? candidate : current;
|
|
2058
|
+
}
|
|
2059
|
+
function later(current, candidate) {
|
|
2060
|
+
return current === null || candidate > current ? candidate : current;
|
|
2061
|
+
}
|
|
2062
|
+
// packages/core/src/review/policy.ts
|
|
2063
|
+
async function loadPolicy(policy) {
|
|
2064
|
+
try {
|
|
2065
|
+
return await policy.load();
|
|
2066
|
+
} catch {
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
function readPolicyConfig(loaded, parseConfig) {
|
|
2071
|
+
if (loaded === null || loaded.status === "FAILED") {
|
|
2072
|
+
return invalidPolicyConfig();
|
|
2073
|
+
}
|
|
2074
|
+
return diagnosePolicyConfig(loaded.source.config, parseConfig).config;
|
|
2075
|
+
}
|
|
2076
|
+
function diagnosePolicyConfig(config, parseConfig) {
|
|
2077
|
+
const parsed = config === null ? null : parseConfig(config);
|
|
2078
|
+
if (parsed !== null && parsed.status !== "PARSED") {
|
|
2079
|
+
return { code: "PARSE_INVALID", config: invalidPolicyConfig() };
|
|
2080
|
+
}
|
|
2081
|
+
const value = parsed === null ? null : parsed.value;
|
|
2082
|
+
const gate = resolveGateConfig(value);
|
|
2083
|
+
const remediation = resolveRemediationConfig(value);
|
|
2084
|
+
if (gate.status === "INVALID") {
|
|
2085
|
+
return { code: "GATE_INVALID", config: invalidPolicyConfig() };
|
|
2086
|
+
}
|
|
2087
|
+
if (remediation.status === "INVALID") {
|
|
2088
|
+
return { code: "REMEDIATION_INVALID", config: invalidPolicyConfig() };
|
|
2089
|
+
}
|
|
2090
|
+
return { code: config === null ? "DEFAULTS" : "VALID", config: { gate, remediation } };
|
|
2091
|
+
}
|
|
2092
|
+
function invalidPolicyConfig() {
|
|
2093
|
+
return {
|
|
2094
|
+
gate: { status: "INVALID", reason: "INVALID_CONFIG" },
|
|
2095
|
+
remediation: { status: "INVALID", reason: "INVALID_CONFIG" }
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// packages/core/src/rules/headings.ts
|
|
2100
|
+
var RULE_HEADING = /^##\s+(.*)$/;
|
|
2101
|
+
var SECTION_HEADING = /^###\s+(.*)$/;
|
|
2102
|
+
function matchRuleHeading(line) {
|
|
2103
|
+
const match = RULE_HEADING.exec(line);
|
|
2104
|
+
if (match === null) {
|
|
2105
|
+
return null;
|
|
2106
|
+
}
|
|
2107
|
+
return (match[1] ?? "").trim();
|
|
2108
|
+
}
|
|
2109
|
+
function matchSectionHeading(line) {
|
|
2110
|
+
const match = SECTION_HEADING.exec(line);
|
|
2111
|
+
if (match === null) {
|
|
2112
|
+
return null;
|
|
2113
|
+
}
|
|
2114
|
+
return (match[1] ?? "").trim();
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// packages/core/src/rules/metadata.ts
|
|
2118
|
+
var METADATA_ENTRY = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/;
|
|
2119
|
+
function parseRuleMetadata(lines) {
|
|
2120
|
+
let severity = null;
|
|
2121
|
+
let evidence = null;
|
|
2122
|
+
let scopeDeclared = false;
|
|
2123
|
+
const includePatterns = [];
|
|
2124
|
+
const excludePatterns = [];
|
|
2125
|
+
const seenKeys = new Set;
|
|
2126
|
+
for (const line of collectMetadataLines(lines)) {
|
|
2127
|
+
const entry = parseMetadataEntry(line);
|
|
2128
|
+
if (entry === null) {
|
|
2129
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
2130
|
+
}
|
|
2131
|
+
if (entry.key === "scope") {
|
|
2132
|
+
const pattern = parseScopePattern(entry.value);
|
|
2133
|
+
if (pattern === null) {
|
|
2134
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
2135
|
+
}
|
|
2136
|
+
scopeDeclared = true;
|
|
2137
|
+
if (pattern.exclude) {
|
|
2138
|
+
excludePatterns.push(pattern.pattern);
|
|
2139
|
+
} else {
|
|
2140
|
+
includePatterns.push(pattern.pattern);
|
|
2141
|
+
}
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
if (seenKeys.has(entry.key)) {
|
|
2145
|
+
return { ok: false, code: "DUPLICATE_METADATA" };
|
|
2146
|
+
}
|
|
2147
|
+
seenKeys.add(entry.key);
|
|
2148
|
+
if (entry.key === "severity") {
|
|
2149
|
+
const parsed = parseSeverity(entry.value);
|
|
2150
|
+
if (parsed === null) {
|
|
2151
|
+
return { ok: false, code: "INVALID_SEVERITY" };
|
|
2152
|
+
}
|
|
2153
|
+
severity = parsed;
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
if (entry.key === "evidence") {
|
|
2157
|
+
const parsed = parseEvidenceClass(entry.value);
|
|
2158
|
+
if (parsed === null) {
|
|
2159
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
2160
|
+
}
|
|
2161
|
+
evidence = parsed;
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
2165
|
+
}
|
|
2166
|
+
if (severity === null) {
|
|
2167
|
+
return { ok: false, code: "MISSING_SEVERITY" };
|
|
2168
|
+
}
|
|
2169
|
+
if (scopeDeclared && includePatterns.length === 0) {
|
|
2170
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
2171
|
+
}
|
|
2172
|
+
const scope = scopeDeclared ? { include: includePatterns, exclude: excludePatterns } : null;
|
|
2173
|
+
return { ok: true, value: { severity, scope, evidence: evidence ?? "code" } };
|
|
2174
|
+
}
|
|
2175
|
+
function collectMetadataLines(lines) {
|
|
2176
|
+
const collected = [];
|
|
2177
|
+
for (const line of lines) {
|
|
2178
|
+
if (matchSectionHeading(line) !== null) {
|
|
2179
|
+
break;
|
|
2180
|
+
}
|
|
2181
|
+
if (line.trim() !== "") {
|
|
2182
|
+
collected.push(line);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return collected;
|
|
2186
|
+
}
|
|
2187
|
+
function parseMetadataEntry(line) {
|
|
2188
|
+
const match = METADATA_ENTRY.exec(line);
|
|
2189
|
+
if (match === null) {
|
|
2190
|
+
return null;
|
|
2191
|
+
}
|
|
2192
|
+
return { key: match[1] ?? "", value: (match[2] ?? "").trim() };
|
|
2193
|
+
}
|
|
2194
|
+
function parseSeverity(value) {
|
|
2195
|
+
switch (value) {
|
|
2196
|
+
case "error":
|
|
2197
|
+
case "warning":
|
|
2198
|
+
return value;
|
|
2199
|
+
default:
|
|
2200
|
+
return null;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
function parseEvidenceClass(value) {
|
|
2204
|
+
switch (value) {
|
|
2205
|
+
case "code":
|
|
2206
|
+
case "docs":
|
|
2207
|
+
case "any":
|
|
2208
|
+
return value;
|
|
2209
|
+
default:
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
function parseScopePattern(value) {
|
|
2214
|
+
if (value === "") {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
if (value.startsWith("!")) {
|
|
2218
|
+
const pattern = value.slice(1).trim();
|
|
2219
|
+
return pattern === "" ? null : { pattern, exclude: true };
|
|
2220
|
+
}
|
|
2221
|
+
return { pattern: value, exclude: false };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// packages/core/src/rules/sections.ts
|
|
2225
|
+
var SECTION_NAMES = ["Rule", "Violation", "Allowed"];
|
|
2226
|
+
function parseRuleSections(lines) {
|
|
2227
|
+
const collected = collectSections(lines);
|
|
2228
|
+
if (!collected.ok) {
|
|
2229
|
+
return collected;
|
|
2230
|
+
}
|
|
2231
|
+
return validateSections(collected.value);
|
|
2232
|
+
}
|
|
2233
|
+
function collectSections(lines) {
|
|
2234
|
+
const sections = new Map;
|
|
2235
|
+
let current = null;
|
|
2236
|
+
let content = [];
|
|
2237
|
+
for (const line of lines) {
|
|
2238
|
+
const heading = matchSectionHeading(line);
|
|
2239
|
+
if (heading === null) {
|
|
2240
|
+
if (current !== null) {
|
|
2241
|
+
content.push(line);
|
|
2242
|
+
}
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (current !== null) {
|
|
2246
|
+
sections.set(current, joinContent(content));
|
|
2247
|
+
}
|
|
2248
|
+
if (!SECTION_NAMES.includes(heading)) {
|
|
2249
|
+
return { ok: false, code: "UNKNOWN_SECTION" };
|
|
2250
|
+
}
|
|
2251
|
+
if (sections.has(heading)) {
|
|
2252
|
+
return { ok: false, code: "DUPLICATE_SECTION" };
|
|
2253
|
+
}
|
|
2254
|
+
current = heading;
|
|
2255
|
+
content = [];
|
|
2256
|
+
}
|
|
2257
|
+
if (current !== null) {
|
|
2258
|
+
sections.set(current, joinContent(content));
|
|
2259
|
+
}
|
|
2260
|
+
return { ok: true, value: sections };
|
|
2261
|
+
}
|
|
2262
|
+
function validateSections(sections) {
|
|
2263
|
+
const rule = sections.get("Rule");
|
|
2264
|
+
const violation = sections.get("Violation");
|
|
2265
|
+
const allowed = sections.get("Allowed") ?? null;
|
|
2266
|
+
if (rule === undefined) {
|
|
2267
|
+
return { ok: false, code: "MISSING_RULE" };
|
|
2268
|
+
}
|
|
2269
|
+
if (violation === undefined) {
|
|
2270
|
+
return { ok: false, code: "MISSING_VIOLATION" };
|
|
2271
|
+
}
|
|
2272
|
+
if (rule === "") {
|
|
2273
|
+
return { ok: false, code: "EMPTY_SECTION" };
|
|
2274
|
+
}
|
|
2275
|
+
if (violation === "") {
|
|
2276
|
+
return { ok: false, code: "EMPTY_SECTION" };
|
|
2277
|
+
}
|
|
2278
|
+
if (allowed === "") {
|
|
2279
|
+
return { ok: false, code: "EMPTY_ALLOWED" };
|
|
2280
|
+
}
|
|
2281
|
+
if (allowed !== null && allowed === violation) {
|
|
2282
|
+
return { ok: false, code: "ALLOWED_EQUALS_VIOLATION" };
|
|
2283
|
+
}
|
|
2284
|
+
return { ok: true, value: { rule, violation, allowed } };
|
|
2285
|
+
}
|
|
2286
|
+
function joinContent(lines) {
|
|
2287
|
+
return lines.join(`
|
|
2288
|
+
`).trim();
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// packages/core/src/rules/blocks.ts
|
|
2292
|
+
var RULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2293
|
+
function isValidRuleId(id) {
|
|
2294
|
+
return RULE_ID.test(id);
|
|
2295
|
+
}
|
|
2296
|
+
function collectRuleBlocks(text) {
|
|
2297
|
+
const blocks = [];
|
|
2298
|
+
let current = null;
|
|
2299
|
+
for (const line of splitLines2(text)) {
|
|
2300
|
+
const id = matchRuleHeading(line);
|
|
2301
|
+
if (id !== null) {
|
|
2302
|
+
current = { id, body: [] };
|
|
2303
|
+
blocks.push(current);
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
if (current !== null) {
|
|
2307
|
+
current.body.push(line);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
return blocks;
|
|
2311
|
+
}
|
|
2312
|
+
function parseRuleBlock(block, duplicate) {
|
|
2313
|
+
if (!isValidRuleId(block.id)) {
|
|
2314
|
+
return ruleCandidateFailure("INVALID_ID", null);
|
|
2315
|
+
}
|
|
2316
|
+
if (duplicate) {
|
|
2317
|
+
return ruleCandidateFailure("DUPLICATE_ID", block.id);
|
|
2318
|
+
}
|
|
2319
|
+
const metadata = parseRuleMetadata(block.body);
|
|
2320
|
+
if (!metadata.ok) {
|
|
2321
|
+
return ruleCandidateFailure(metadata.code, block.id);
|
|
2322
|
+
}
|
|
2323
|
+
const sections = parseRuleSections(block.body);
|
|
2324
|
+
if (!sections.ok) {
|
|
2325
|
+
return ruleCandidateFailure(sections.code, block.id);
|
|
2326
|
+
}
|
|
2327
|
+
return {
|
|
2328
|
+
status: "PARSED",
|
|
2329
|
+
rule: buildParsedRule(block.id, metadata.value, sections.value)
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
function ruleCandidateFailure(code, ruleId) {
|
|
2333
|
+
return { status: "INVALID", reason: "INVALID_RULE", code, ruleId };
|
|
2334
|
+
}
|
|
2335
|
+
function splitLines2(text) {
|
|
2336
|
+
return text.replace(/\r\n?/g, `
|
|
2337
|
+
`).split(`
|
|
2338
|
+
`);
|
|
2339
|
+
}
|
|
2340
|
+
function buildParsedRule(id, metadata, sections) {
|
|
2341
|
+
return {
|
|
2342
|
+
id,
|
|
2343
|
+
severity: metadata.severity,
|
|
2344
|
+
scope: metadata.scope,
|
|
2345
|
+
evidence: metadata.evidence,
|
|
2346
|
+
description: sections.rule,
|
|
2347
|
+
violation: sections.violation,
|
|
2348
|
+
allowed: sections.allowed
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// packages/core/src/rules/parse-rules.ts
|
|
2353
|
+
function parseRules(text) {
|
|
2354
|
+
const blocks = collectRuleBlocks(text);
|
|
2355
|
+
if (blocks.length === 0) {
|
|
2356
|
+
return [ruleCandidateFailure("MISSING_ID", null)];
|
|
2357
|
+
}
|
|
2358
|
+
const duplicates = findDuplicateIds(blocks.map((block) => block.id));
|
|
2359
|
+
return blocks.map((block) => parseRuleBlock(block, duplicates.has(block.id)));
|
|
2360
|
+
}
|
|
2361
|
+
function findDuplicateIds(ids) {
|
|
2362
|
+
const counts = new Map;
|
|
2363
|
+
for (const id of ids) {
|
|
2364
|
+
if (!isValidRuleId(id)) {
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
2368
|
+
}
|
|
2369
|
+
const duplicates = new Set;
|
|
2370
|
+
for (const [id, count] of counts) {
|
|
2371
|
+
if (count > 1) {
|
|
2372
|
+
duplicates.add(id);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return duplicates;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// packages/core/src/review/unavailable.ts
|
|
2379
|
+
function reviewLevelUnavailable(turnId, reason) {
|
|
2380
|
+
return {
|
|
2381
|
+
kind: "REVIEW",
|
|
2382
|
+
turnId,
|
|
2383
|
+
ruleId: null,
|
|
2384
|
+
severity: null,
|
|
2385
|
+
scopedPaths: [],
|
|
2386
|
+
outcome: "UNAVAILABLE",
|
|
2387
|
+
reason
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function unavailableReview(turnId, reason) {
|
|
2391
|
+
return aggregateReview(turnId, [reviewLevelUnavailable(turnId, reason)]);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// packages/core/src/review/rule-lane.ts
|
|
2395
|
+
function resolveRuleCandidates(turnId, loaded) {
|
|
2396
|
+
if (loaded === null) {
|
|
2397
|
+
return unavailableResolution(turnId, "INVALID_RULE");
|
|
2398
|
+
}
|
|
2399
|
+
if (loaded.status === "FAILED") {
|
|
2400
|
+
const reason = loaded.reason === "CONFIG_READ_FAILURE" ? "INVALID_CONFIG" : "INVALID_RULE";
|
|
2401
|
+
return unavailableResolution(turnId, reason);
|
|
2402
|
+
}
|
|
2403
|
+
const rules = loaded.source.rules;
|
|
2404
|
+
if (rules === null) {
|
|
2405
|
+
return unavailableResolution(turnId, "INVALID_RULE");
|
|
2406
|
+
}
|
|
2407
|
+
return { status: "RULES", rules: parseRules(rules) };
|
|
2408
|
+
}
|
|
2409
|
+
async function evaluateRuleCandidates(turn, rules, gate, dependencies) {
|
|
2410
|
+
const review = await evaluateRules({
|
|
2411
|
+
turn,
|
|
2412
|
+
rules,
|
|
2413
|
+
gateConfig: gate,
|
|
2414
|
+
evidencePolicy: DEFAULT_EVIDENCE_POLICY
|
|
2415
|
+
}, { jev: dependencies.jev });
|
|
2416
|
+
return { results: review.results, rules };
|
|
2417
|
+
}
|
|
2418
|
+
function ruleLaneUnavailable(turnId, reason) {
|
|
2419
|
+
return { results: [reviewLevelUnavailable(turnId, reason)], rules: [] };
|
|
2420
|
+
}
|
|
2421
|
+
function unavailableResolution(turnId, reason) {
|
|
2422
|
+
return { status: "UNAVAILABLE", lane: ruleLaneUnavailable(turnId, reason) };
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// packages/core/src/review/check-rules.ts
|
|
2426
|
+
async function checkRules(input, dependencies) {
|
|
2427
|
+
const { turn, ruleId } = input;
|
|
2428
|
+
const loaded = await loadPolicy(dependencies.policy);
|
|
2429
|
+
const resolution = resolveRuleCandidates(turn.id, loaded);
|
|
2430
|
+
if (resolution.status === "UNAVAILABLE") {
|
|
2431
|
+
return { status: "REVIEWED", review: aggregateReview(turn.id, resolution.lane.results) };
|
|
2432
|
+
}
|
|
2433
|
+
const candidates = selectCandidates(resolution.rules, ruleId);
|
|
2434
|
+
if (candidates.length === 0) {
|
|
2435
|
+
return { status: "RULE_NOT_FOUND" };
|
|
2436
|
+
}
|
|
2437
|
+
const config = readPolicyConfig(loaded, dependencies.parseConfig);
|
|
2438
|
+
const lane = await evaluateRuleCandidates(turn, candidates, config.gate, dependencies);
|
|
2439
|
+
return { status: "REVIEWED", review: aggregateReview(turn.id, lane.results) };
|
|
2440
|
+
}
|
|
2441
|
+
function selectCandidates(rules, ruleId) {
|
|
2442
|
+
if (ruleId === null) {
|
|
2443
|
+
return rules;
|
|
2444
|
+
}
|
|
2445
|
+
return rules.filter((candidate) => candidateId(candidate) === ruleId);
|
|
2446
|
+
}
|
|
2447
|
+
function candidateId(candidate) {
|
|
2448
|
+
return candidate.status === "PARSED" ? candidate.rule.id : candidate.ruleId;
|
|
2449
|
+
}
|
|
2450
|
+
// packages/core/src/review/review-turn.ts
|
|
2451
|
+
async function reviewTurn(turn, dependencies) {
|
|
2452
|
+
const loaded = await loadPolicy(dependencies.policy);
|
|
2453
|
+
const config = readPolicyConfig(loaded, dependencies.parseConfig);
|
|
2454
|
+
const builtInInput = { turn, evidencePolicy: DEFAULT_EVIDENCE_POLICY };
|
|
2455
|
+
const [ruleLane, builtIns] = await Promise.all([
|
|
2456
|
+
evaluateRuleLane(turn, loaded, config.gate, dependencies),
|
|
2457
|
+
evaluateBuiltIns(builtInInput, { jev: dependencies.jev })
|
|
2458
|
+
]);
|
|
2459
|
+
const review = aggregateReview(turn.id, [
|
|
2460
|
+
...ruleLane.results,
|
|
2461
|
+
builtIns.scopeCreep,
|
|
2462
|
+
builtIns.complexity
|
|
2463
|
+
]);
|
|
2464
|
+
return { review, rules: ruleLane.rules, remediation: config.remediation };
|
|
2465
|
+
}
|
|
2466
|
+
async function evaluateRuleLane(turn, loaded, gate, dependencies) {
|
|
2467
|
+
const resolution = resolveRuleCandidates(turn.id, loaded);
|
|
2468
|
+
if (resolution.status === "UNAVAILABLE") {
|
|
2469
|
+
return resolution.lane;
|
|
2470
|
+
}
|
|
2471
|
+
return evaluateRuleCandidates(turn, resolution.rules, gate, dependencies);
|
|
2472
|
+
}
|
|
1171
2473
|
// packages/plugin/src/skills/jevguard-rules/rules-summary.ts
|
|
1172
2474
|
function summarizeRules(text) {
|
|
1173
2475
|
const results = parseRules(text);
|