@pablozrrrr/jevguard 0.2.0 → 0.2.3
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 +104 -19
- package/cli/main.js +1299 -88
- package/index.js +1174 -96
- package/package.json +1 -1
- package/skills/jev-init/SKILL.md +12 -5
- package/skills/jev-init/validate-init.js +541 -68
- package/skills/jevguard-rules/SKILL.md +44 -2
- package/skills/jevguard-rules/validate-rules.js +541 -68
package/index.js
CHANGED
|
@@ -35,7 +35,7 @@ function buildReviewContext(turn, rule, scopedPaths) {
|
|
|
35
35
|
var DEFAULT_MAX_DIFF_LENGTH = 1e5;
|
|
36
36
|
var DEFAULT_EVIDENCE_POLICY = {
|
|
37
37
|
maxDiffLength: DEFAULT_MAX_DIFF_LENGTH,
|
|
38
|
-
|
|
38
|
+
codeExtensions: [
|
|
39
39
|
".ts",
|
|
40
40
|
".tsx",
|
|
41
41
|
".mts",
|
|
@@ -46,9 +46,6 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
46
46
|
".cjs",
|
|
47
47
|
".json",
|
|
48
48
|
".jsonc",
|
|
49
|
-
".md",
|
|
50
|
-
".mdx",
|
|
51
|
-
".txt",
|
|
52
49
|
".yaml",
|
|
53
50
|
".yml",
|
|
54
51
|
".toml",
|
|
@@ -104,6 +101,7 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
104
101
|
".diff",
|
|
105
102
|
".patch"
|
|
106
103
|
],
|
|
104
|
+
docsExtensions: [".md", ".mdx", ".txt"],
|
|
107
105
|
deniedFileNames: [
|
|
108
106
|
".env",
|
|
109
107
|
".envrc",
|
|
@@ -178,18 +176,26 @@ function checkFilePath(path, policy) {
|
|
|
178
176
|
if (policy.deniedExtensions.includes(extension)) {
|
|
179
177
|
return DENIED_SENSITIVE;
|
|
180
178
|
}
|
|
181
|
-
if (!
|
|
179
|
+
if (!isAllowlistedExtension(extension, policy)) {
|
|
182
180
|
return DENIED_EXTENSION;
|
|
183
181
|
}
|
|
184
182
|
return { allowed: true };
|
|
185
183
|
}
|
|
184
|
+
function isAllowlistedExtension(extension, policy) {
|
|
185
|
+
return policy.codeExtensions.includes(extension) || policy.docsExtensions.includes(extension);
|
|
186
|
+
}
|
|
186
187
|
function matchesDeniedFileName(basename, policy) {
|
|
187
188
|
return policy.deniedFileNames.some((name) => name.startsWith(".") ? basename === name || basename.startsWith(`${name}.`) : basename === name);
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// packages/core/src/evidence/scope.ts
|
|
191
|
-
function
|
|
192
|
-
|
|
192
|
+
function matchesRuleScope(path, scope) {
|
|
193
|
+
const normalized = normalizePath(path);
|
|
194
|
+
const included = scope.include.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
195
|
+
if (!included) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return !scope.exclude.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
193
199
|
}
|
|
194
200
|
function globToRegExp(glob) {
|
|
195
201
|
const source = glob.replace(/\\/g, "/");
|
|
@@ -216,16 +222,6 @@ function globToRegExp(glob) {
|
|
|
216
222
|
}
|
|
217
223
|
|
|
218
224
|
// packages/core/src/evidence/select-evidence.ts
|
|
219
|
-
function selectRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
220
|
-
if (hasNoAttributedPatch(turn)) {
|
|
221
|
-
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
222
|
-
}
|
|
223
|
-
const applicableFiles = selectApplicableFiles(turn.files, rule.scope);
|
|
224
|
-
if (applicableFiles.length === 0) {
|
|
225
|
-
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
226
|
-
}
|
|
227
|
-
return mountEvidence(applicableFiles, policy);
|
|
228
|
-
}
|
|
229
225
|
function selectTurnEvidence(turn, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
230
226
|
if (hasNoAttributedPatch(turn)) {
|
|
231
227
|
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
@@ -252,11 +248,21 @@ function mountEvidence(files, policy) {
|
|
|
252
248
|
function hasNoAttributedPatch(turn) {
|
|
253
249
|
return turn.files.every((file) => file.patch.trim() === "");
|
|
254
250
|
}
|
|
255
|
-
function selectApplicableFiles(files, scope) {
|
|
256
|
-
|
|
257
|
-
|
|
251
|
+
function selectApplicableFiles(files, scope, evidenceClass, policy) {
|
|
252
|
+
const scoped = scope === null ? files : files.filter((file) => matchesRuleScope(file.path, scope));
|
|
253
|
+
return scoped.filter((file) => isInEvidenceClass(file.path, evidenceClass, policy));
|
|
254
|
+
}
|
|
255
|
+
function isInEvidenceClass(path, evidenceClass, policy) {
|
|
256
|
+
if (evidenceClass === "any") {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
const safety = checkFilePath(path, policy);
|
|
260
|
+
if (!safety.allowed && safety.reason === "DENIED_SENSITIVE_PATH") {
|
|
261
|
+
return true;
|
|
258
262
|
}
|
|
259
|
-
|
|
263
|
+
const extension = fileExtension(path);
|
|
264
|
+
const otherClass = evidenceClass === "code" ? policy.docsExtensions : policy.codeExtensions;
|
|
265
|
+
return !otherClass.includes(extension);
|
|
260
266
|
}
|
|
261
267
|
function assembleDiff(files) {
|
|
262
268
|
return files.map((file) => file.patch).join(`
|
|
@@ -297,14 +303,20 @@ function evaluateWarningGate(probability, thresholds) {
|
|
|
297
303
|
}
|
|
298
304
|
|
|
299
305
|
// packages/core/src/evaluation/evaluate-with-port.ts
|
|
300
|
-
async function
|
|
306
|
+
async function evaluateRuleBatchWithPort(port, requests) {
|
|
301
307
|
try {
|
|
302
|
-
const
|
|
303
|
-
|
|
308
|
+
const results = await port.evaluateRuleBatch(requests);
|
|
309
|
+
if (results.length !== requests.length) {
|
|
310
|
+
return requests.map(() => ruleFailure("INVALID_RESPONSE"));
|
|
311
|
+
}
|
|
312
|
+
return results.map((result) => result.kind === "RULE" ? result : ruleFailure("INVALID_RESPONSE"));
|
|
304
313
|
} catch {
|
|
305
|
-
return
|
|
314
|
+
return requests.map(() => ruleFailure("API_ERROR"));
|
|
306
315
|
}
|
|
307
316
|
}
|
|
317
|
+
function ruleFailure(reason) {
|
|
318
|
+
return { kind: "RULE", status: "FAILED", reason };
|
|
319
|
+
}
|
|
308
320
|
async function evaluateBuiltInBatchWithPort(port, request) {
|
|
309
321
|
try {
|
|
310
322
|
const result = await port.evaluate(request);
|
|
@@ -313,27 +325,118 @@ async function evaluateBuiltInBatchWithPort(port, request) {
|
|
|
313
325
|
return { kind: "BUILT_IN_BATCH", status: "FAILED", reason: "API_ERROR" };
|
|
314
326
|
}
|
|
315
327
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const context = buildReviewContext(input.turn, input.rule, []);
|
|
322
|
-
return { ...context, outcome: "SKIPPED", reason: selection.reason };
|
|
328
|
+
// packages/core/src/evidence/plan-slices.ts
|
|
329
|
+
var HUNK_MARKER = /^@@ [^\n]*@@/gm;
|
|
330
|
+
function planRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
331
|
+
if (hasNoAttributedPatch(turn)) {
|
|
332
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
323
333
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return {
|
|
334
|
+
const applicableFiles = selectApplicableFiles(turn.files, rule.scope, rule.evidence, policy);
|
|
335
|
+
if (applicableFiles.length === 0) {
|
|
336
|
+
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
327
337
|
}
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
const diff = assembleDiff(applicableFiles);
|
|
339
|
+
if (diff.trim() === "") {
|
|
340
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
341
|
+
}
|
|
342
|
+
if (applicableFiles.some((file) => !checkFilePath(file.path, policy).allowed)) {
|
|
343
|
+
return { status: "UNAVAILABLE", reason: "BLOCKED_EVIDENCE" };
|
|
333
344
|
}
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
if (diff.length <= policy.maxDiffLength) {
|
|
346
|
+
return { status: "PLANNED", slices: [wholeSlice(applicableFiles, diff)] };
|
|
347
|
+
}
|
|
348
|
+
return sliceByFile(applicableFiles, policy);
|
|
349
|
+
}
|
|
350
|
+
function wholeSlice(files, diff) {
|
|
351
|
+
const paths = files.map((file) => file.path);
|
|
352
|
+
return {
|
|
353
|
+
index: 0,
|
|
354
|
+
kind: "WHOLE",
|
|
355
|
+
path: paths.length === 1 ? paths[0] ?? null : null,
|
|
356
|
+
hunkOrdinal: null,
|
|
357
|
+
files: paths,
|
|
358
|
+
diff
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function sliceByFile(files, policy) {
|
|
362
|
+
const slices = [];
|
|
363
|
+
for (const file of files) {
|
|
364
|
+
if (file.patch.length <= policy.maxDiffLength) {
|
|
365
|
+
slices.push(fileSlice(slices.length, file));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const hunks = sliceHunks(slices.length, file, policy);
|
|
369
|
+
if (hunks === null) {
|
|
370
|
+
return { status: "UNAVAILABLE", reason: "OVERSIZED_DIFF" };
|
|
371
|
+
}
|
|
372
|
+
slices.push(...hunks);
|
|
373
|
+
}
|
|
374
|
+
return { status: "PLANNED", slices };
|
|
375
|
+
}
|
|
376
|
+
function fileSlice(index, file) {
|
|
377
|
+
return {
|
|
378
|
+
index,
|
|
379
|
+
kind: "FILE",
|
|
380
|
+
path: file.path,
|
|
381
|
+
hunkOrdinal: null,
|
|
382
|
+
files: [file.path],
|
|
383
|
+
diff: file.patch
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function sliceHunks(startIndex, file, policy) {
|
|
387
|
+
const starts = hunkStarts(file.patch);
|
|
388
|
+
if (starts.length === 0) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const firstStart = starts[0];
|
|
392
|
+
if (firstStart === undefined) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const preamble = file.patch.slice(0, firstStart);
|
|
396
|
+
const slices = [];
|
|
397
|
+
for (let ordinal = 0;ordinal < starts.length; ordinal += 1) {
|
|
398
|
+
const start = starts[ordinal];
|
|
399
|
+
if (start === undefined) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
const end = starts[ordinal + 1] ?? file.patch.length;
|
|
403
|
+
const rendered = preamble + file.patch.slice(start, end);
|
|
404
|
+
if (rendered.length > policy.maxDiffLength) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
slices.push({
|
|
408
|
+
index: startIndex + ordinal,
|
|
409
|
+
kind: "HUNK",
|
|
410
|
+
path: file.path,
|
|
411
|
+
hunkOrdinal: ordinal,
|
|
412
|
+
files: [file.path],
|
|
413
|
+
diff: rendered
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return slices;
|
|
417
|
+
}
|
|
418
|
+
function hunkStarts(patch) {
|
|
419
|
+
return Array.from(patch.matchAll(HUNK_MARKER), (match) => match.index ?? 0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// packages/core/src/evidence/slice-budget.ts
|
|
423
|
+
var MAX_SLICES_PER_RULE = 16;
|
|
424
|
+
var MAX_EXTRA_RULE_CALLS_PER_TURN = 32;
|
|
425
|
+
function planTurnRuleSlices(plans) {
|
|
426
|
+
let remainingExtraCalls = MAX_EXTRA_RULE_CALLS_PER_TURN;
|
|
427
|
+
return plans.map((plan) => {
|
|
428
|
+
if (plan.status !== "PLANNED") {
|
|
429
|
+
return plan;
|
|
430
|
+
}
|
|
431
|
+
const extraCalls = plan.slices.length - 1;
|
|
432
|
+
if (plan.slices.length > MAX_SLICES_PER_RULE || extraCalls > remainingExtraCalls) {
|
|
433
|
+
return { status: "SLICE_LIMIT_EXCEEDED", slices: plan.slices };
|
|
434
|
+
}
|
|
435
|
+
remainingExtraCalls -= extraCalls;
|
|
436
|
+
return plan;
|
|
437
|
+
});
|
|
336
438
|
}
|
|
439
|
+
|
|
337
440
|
// packages/core/src/review/aggregate.ts
|
|
338
441
|
var VERDICT_RANK = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
339
442
|
function aggregateReview(turnId, results) {
|
|
@@ -387,28 +490,204 @@ function isSemanticVerdict(outcome) {
|
|
|
387
490
|
return outcome === "PASS" || outcome === "WARN" || outcome === "FAIL";
|
|
388
491
|
}
|
|
389
492
|
|
|
390
|
-
// packages/core/src/evaluation/
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
493
|
+
// packages/core/src/evaluation/assemble-rule.ts
|
|
494
|
+
var VERDICT_RANK2 = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
495
|
+
function assembleSlicedRuleResult(rule, context, slices, evaluations, gateConfig) {
|
|
496
|
+
if (slices.length === 0) {
|
|
497
|
+
return { ...context, outcome: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
498
|
+
}
|
|
499
|
+
const summaries = slices.map(sliceSummary);
|
|
500
|
+
const mode = evidenceMode(slices.length);
|
|
501
|
+
const gates = [];
|
|
502
|
+
const judgments = [];
|
|
503
|
+
let failureIndex = null;
|
|
504
|
+
for (let index = 0;index < slices.length; index += 1) {
|
|
505
|
+
const slice = slices[index];
|
|
506
|
+
const evaluation = evaluations[index];
|
|
507
|
+
if (slice === undefined || evaluation === undefined || evaluation.status === "FAILED") {
|
|
508
|
+
failureIndex = index;
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
const gate = evaluateGate(rule.severity, evaluation.noul.violationProbability, gateConfig);
|
|
512
|
+
if (gate.outcome === "UNAVAILABLE") {
|
|
513
|
+
failureIndex = index;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
gates.push(gate);
|
|
517
|
+
judgments.push({
|
|
518
|
+
index: slice.index,
|
|
519
|
+
probability: gate.violationProbability,
|
|
520
|
+
outcome: gate.outcome
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (failureIndex !== null) {
|
|
524
|
+
return {
|
|
525
|
+
...context,
|
|
526
|
+
outcome: "UNAVAILABLE",
|
|
527
|
+
reason: "JEV_FAILURE",
|
|
528
|
+
evidence: failedMetadata(mode, summaries, failureIndex)
|
|
529
|
+
};
|
|
395
530
|
}
|
|
396
|
-
return
|
|
531
|
+
return {
|
|
532
|
+
...context,
|
|
533
|
+
outcome: mergeVerdict(gates),
|
|
534
|
+
violationProbability: maxProbability(gates),
|
|
535
|
+
evidence: completeMetadata(mode, summaries, judgments)
|
|
536
|
+
};
|
|
397
537
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
538
|
+
function overBudgetMetadata(slices) {
|
|
539
|
+
const summaries = slices.map(sliceSummary);
|
|
540
|
+
return {
|
|
541
|
+
mode: "SLICED",
|
|
542
|
+
plannedSliceCount: summaries.length,
|
|
543
|
+
evaluatedSliceCount: 0,
|
|
544
|
+
failedSliceCount: 0,
|
|
545
|
+
notEvaluatedSliceCount: summaries.length,
|
|
546
|
+
slices: summaries,
|
|
547
|
+
sliceJudgments: [],
|
|
548
|
+
failingSliceIndex: null,
|
|
549
|
+
failureReason: "SLICE_LIMIT_EXCEEDED"
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function sliceSummary(slice) {
|
|
553
|
+
return {
|
|
554
|
+
index: slice.index,
|
|
555
|
+
path: slice.path,
|
|
556
|
+
kind: slice.kind,
|
|
557
|
+
hunkOrdinal: slice.hunkOrdinal
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function evidenceMode(sliceCount) {
|
|
561
|
+
return sliceCount === 1 ? "WHOLE" : "SLICED";
|
|
562
|
+
}
|
|
563
|
+
function mergeVerdict(gates) {
|
|
564
|
+
let verdict = "PASS";
|
|
565
|
+
for (const gate of gates) {
|
|
566
|
+
if (VERDICT_RANK2[gate.outcome] > VERDICT_RANK2[verdict]) {
|
|
567
|
+
verdict = gate.outcome;
|
|
568
|
+
}
|
|
401
569
|
}
|
|
570
|
+
return verdict;
|
|
571
|
+
}
|
|
572
|
+
function maxProbability(gates) {
|
|
573
|
+
return gates.reduce((highest, gate) => Math.max(highest, gate.violationProbability), Number.NEGATIVE_INFINITY);
|
|
574
|
+
}
|
|
575
|
+
function completeMetadata(mode, summaries, judgments) {
|
|
576
|
+
return {
|
|
577
|
+
mode,
|
|
578
|
+
plannedSliceCount: summaries.length,
|
|
579
|
+
evaluatedSliceCount: summaries.length,
|
|
580
|
+
failedSliceCount: 0,
|
|
581
|
+
notEvaluatedSliceCount: 0,
|
|
582
|
+
slices: summaries,
|
|
583
|
+
sliceJudgments: judgments,
|
|
584
|
+
failingSliceIndex: null,
|
|
585
|
+
failureReason: null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function failedMetadata(mode, summaries, failingIndex) {
|
|
589
|
+
return {
|
|
590
|
+
mode,
|
|
591
|
+
plannedSliceCount: summaries.length,
|
|
592
|
+
evaluatedSliceCount: failingIndex,
|
|
593
|
+
failedSliceCount: 1,
|
|
594
|
+
notEvaluatedSliceCount: summaries.length - failingIndex - 1,
|
|
595
|
+
slices: summaries,
|
|
596
|
+
sliceJudgments: [],
|
|
597
|
+
failingSliceIndex: failingIndex,
|
|
598
|
+
failureReason: "JEV_FAILURE"
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// packages/core/src/evaluation/evaluate-rules.ts
|
|
603
|
+
async function evaluateRules(input, dependencies) {
|
|
402
604
|
if (input.gateConfig.status === "INVALID") {
|
|
403
|
-
|
|
605
|
+
const results = input.rules.map((candidate) => candidate.status === "INVALID" ? invalidRuleResult(input.turn.id, candidate) : invalidConfigResult(input.turn, candidate.rule));
|
|
606
|
+
return aggregateReview(input.turn.id, results);
|
|
607
|
+
}
|
|
608
|
+
const gateConfig = input.gateConfig.config;
|
|
609
|
+
const slots = resolveSlots(input);
|
|
610
|
+
const requests = collectRequests(input.turn.task, slots);
|
|
611
|
+
const evaluations = requests.length === 0 ? [] : await evaluateRuleBatchWithPort(dependencies.jev, requests);
|
|
612
|
+
return aggregateReview(input.turn.id, assembleResults(input, slots, evaluations, gateConfig));
|
|
613
|
+
}
|
|
614
|
+
function resolveSlots(input) {
|
|
615
|
+
const planned = input.rules.map((candidate) => candidate.status === "INVALID" ? { kind: "RESULT", result: invalidRuleResult(input.turn.id, candidate) } : {
|
|
616
|
+
kind: "PLAN",
|
|
617
|
+
rule: candidate.rule,
|
|
618
|
+
plan: planRuleEvidence(input.turn, candidate.rule, input.evidencePolicy)
|
|
619
|
+
});
|
|
620
|
+
const budgeted = planTurnRuleSlices(planned.flatMap((slot) => slot.kind === "PLAN" ? [slot.plan] : []));
|
|
621
|
+
let cursor = 0;
|
|
622
|
+
return planned.map((slot) => {
|
|
623
|
+
if (slot.kind === "RESULT") {
|
|
624
|
+
return slot;
|
|
625
|
+
}
|
|
626
|
+
const plan = budgeted[cursor];
|
|
627
|
+
cursor += 1;
|
|
628
|
+
return plan === undefined ? slot : { kind: "PLAN", rule: slot.rule, plan };
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
function collectRequests(task, slots) {
|
|
632
|
+
const requests = [];
|
|
633
|
+
for (const slot of slots) {
|
|
634
|
+
if (slot.kind !== "PLAN" || slot.plan.status !== "PLANNED") {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
for (const slice of slot.plan.slices) {
|
|
638
|
+
requests.push(buildJevRequest(task, slot.rule, { files: slice.files, diff: slice.diff }));
|
|
639
|
+
}
|
|
404
640
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
641
|
+
return requests;
|
|
642
|
+
}
|
|
643
|
+
function assembleResults(input, slots, evaluations, gateConfig) {
|
|
644
|
+
const results = [];
|
|
645
|
+
let evaluationCursor = 0;
|
|
646
|
+
for (const slot of slots) {
|
|
647
|
+
if (slot.kind === "RESULT") {
|
|
648
|
+
results.push(slot.result);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const plan = slot.plan;
|
|
652
|
+
if (plan.status === "SKIPPED") {
|
|
653
|
+
results.push(skippedResult(input.turn, slot.rule, plan.reason));
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (plan.status === "UNAVAILABLE") {
|
|
657
|
+
results.push(unavailableResult(input.turn, slot.rule, plan.reason));
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (plan.status === "SLICE_LIMIT_EXCEEDED") {
|
|
661
|
+
results.push({
|
|
662
|
+
...buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)),
|
|
663
|
+
outcome: "UNAVAILABLE",
|
|
664
|
+
reason: "SLICE_LIMIT_EXCEEDED",
|
|
665
|
+
evidence: overBudgetMetadata(plan.slices)
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const sliceEvaluations = evaluations.slice(evaluationCursor, evaluationCursor + plan.slices.length);
|
|
670
|
+
evaluationCursor += plan.slices.length;
|
|
671
|
+
results.push(assembleSlicedRuleResult(slot.rule, buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)), plan.slices, sliceEvaluations, gateConfig));
|
|
672
|
+
}
|
|
673
|
+
return results;
|
|
674
|
+
}
|
|
675
|
+
function collectSlicePaths(slices) {
|
|
676
|
+
const paths = [];
|
|
677
|
+
for (const slice of slices) {
|
|
678
|
+
for (const path of slice.files) {
|
|
679
|
+
if (!paths.includes(path)) {
|
|
680
|
+
paths.push(path);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return paths;
|
|
685
|
+
}
|
|
686
|
+
function skippedResult(turn, rule, reason) {
|
|
687
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "SKIPPED", reason };
|
|
688
|
+
}
|
|
689
|
+
function unavailableResult(turn, rule, reason) {
|
|
690
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "UNAVAILABLE", reason };
|
|
412
691
|
}
|
|
413
692
|
function invalidRuleResult(turnId, failure) {
|
|
414
693
|
return {
|
|
@@ -450,13 +729,29 @@ function matchSectionHeading(line) {
|
|
|
450
729
|
var METADATA_ENTRY = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/;
|
|
451
730
|
function parseRuleMetadata(lines) {
|
|
452
731
|
let severity = null;
|
|
453
|
-
let
|
|
732
|
+
let evidence = null;
|
|
733
|
+
let scopeDeclared = false;
|
|
734
|
+
const includePatterns = [];
|
|
735
|
+
const excludePatterns = [];
|
|
454
736
|
const seenKeys = new Set;
|
|
455
737
|
for (const line of collectMetadataLines(lines)) {
|
|
456
738
|
const entry = parseMetadataEntry(line);
|
|
457
739
|
if (entry === null) {
|
|
458
740
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
459
741
|
}
|
|
742
|
+
if (entry.key === "scope") {
|
|
743
|
+
const pattern = parseScopePattern(entry.value);
|
|
744
|
+
if (pattern === null) {
|
|
745
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
746
|
+
}
|
|
747
|
+
scopeDeclared = true;
|
|
748
|
+
if (pattern.exclude) {
|
|
749
|
+
excludePatterns.push(pattern.pattern);
|
|
750
|
+
} else {
|
|
751
|
+
includePatterns.push(pattern.pattern);
|
|
752
|
+
}
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
460
755
|
if (seenKeys.has(entry.key)) {
|
|
461
756
|
return { ok: false, code: "DUPLICATE_METADATA" };
|
|
462
757
|
}
|
|
@@ -469,11 +764,12 @@ function parseRuleMetadata(lines) {
|
|
|
469
764
|
severity = parsed;
|
|
470
765
|
continue;
|
|
471
766
|
}
|
|
472
|
-
if (entry.key === "
|
|
473
|
-
|
|
767
|
+
if (entry.key === "evidence") {
|
|
768
|
+
const parsed = parseEvidenceClass(entry.value);
|
|
769
|
+
if (parsed === null) {
|
|
474
770
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
475
771
|
}
|
|
476
|
-
|
|
772
|
+
evidence = parsed;
|
|
477
773
|
continue;
|
|
478
774
|
}
|
|
479
775
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
@@ -481,7 +777,11 @@ function parseRuleMetadata(lines) {
|
|
|
481
777
|
if (severity === null) {
|
|
482
778
|
return { ok: false, code: "MISSING_SEVERITY" };
|
|
483
779
|
}
|
|
484
|
-
|
|
780
|
+
if (scopeDeclared && includePatterns.length === 0) {
|
|
781
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
782
|
+
}
|
|
783
|
+
const scope = scopeDeclared ? { include: includePatterns, exclude: excludePatterns } : null;
|
|
784
|
+
return { ok: true, value: { severity, scope, evidence: evidence ?? "code" } };
|
|
485
785
|
}
|
|
486
786
|
function collectMetadataLines(lines) {
|
|
487
787
|
const collected = [];
|
|
@@ -511,6 +811,26 @@ function parseSeverity(value) {
|
|
|
511
811
|
return null;
|
|
512
812
|
}
|
|
513
813
|
}
|
|
814
|
+
function parseEvidenceClass(value) {
|
|
815
|
+
switch (value) {
|
|
816
|
+
case "code":
|
|
817
|
+
case "docs":
|
|
818
|
+
case "any":
|
|
819
|
+
return value;
|
|
820
|
+
default:
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function parseScopePattern(value) {
|
|
825
|
+
if (value === "") {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
if (value.startsWith("!")) {
|
|
829
|
+
const pattern = value.slice(1).trim();
|
|
830
|
+
return pattern === "" ? null : { pattern, exclude: true };
|
|
831
|
+
}
|
|
832
|
+
return { pattern: value, exclude: false };
|
|
833
|
+
}
|
|
514
834
|
|
|
515
835
|
// packages/core/src/rules/sections.ts
|
|
516
836
|
var SECTION_NAMES = ["Rule", "Violation", "Allowed"];
|
|
@@ -633,6 +953,7 @@ function buildParsedRule(id, metadata, sections) {
|
|
|
633
953
|
id,
|
|
634
954
|
severity: metadata.severity,
|
|
635
955
|
scope: metadata.scope,
|
|
956
|
+
evidence: metadata.evidence,
|
|
636
957
|
description: sections.rule,
|
|
637
958
|
violation: sections.violation,
|
|
638
959
|
allowed: sections.allowed
|
|
@@ -663,6 +984,130 @@ function findDuplicateIds(ids) {
|
|
|
663
984
|
}
|
|
664
985
|
return duplicates;
|
|
665
986
|
}
|
|
987
|
+
// packages/core/src/report/aggregate.ts
|
|
988
|
+
function aggregateReviewHistory(records, skippedLineCount) {
|
|
989
|
+
const outcomes = emptyAccumulator();
|
|
990
|
+
const unavailableReasons = new Map;
|
|
991
|
+
const skippedReasons = new Map;
|
|
992
|
+
const builtIns = new Map;
|
|
993
|
+
const rules = new Map;
|
|
994
|
+
const evaluation = { wholeEvaluations: 0, slicedEvaluations: 0, totalSliceJudgments: 0 };
|
|
995
|
+
let firstTimestamp = null;
|
|
996
|
+
let lastTimestamp = null;
|
|
997
|
+
for (const record of records) {
|
|
998
|
+
firstTimestamp = earlier(firstTimestamp, record.timestamp);
|
|
999
|
+
lastTimestamp = later(lastTimestamp, record.timestamp);
|
|
1000
|
+
for (const result of record.results) {
|
|
1001
|
+
countOutcome(outcomes, result.outcome);
|
|
1002
|
+
if (result.outcome === "UNAVAILABLE") {
|
|
1003
|
+
bump(unavailableReasons, result.reason);
|
|
1004
|
+
}
|
|
1005
|
+
if (result.outcome === "SKIPPED") {
|
|
1006
|
+
bump(skippedReasons, result.reason);
|
|
1007
|
+
}
|
|
1008
|
+
if (result.kind === "BUILT_IN") {
|
|
1009
|
+
bump(builtIns, result.checkId);
|
|
1010
|
+
}
|
|
1011
|
+
if (result.kind === "RULE" && result.ruleId !== null) {
|
|
1012
|
+
countRule(rules, result.ruleId, result.outcome, result.evidence);
|
|
1013
|
+
}
|
|
1014
|
+
if (result.kind === "RULE" && result.evidence !== undefined) {
|
|
1015
|
+
countEvidence(evaluation, result.evidence.mode, result.evidence.sliceJudgments.length);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
entryCount: records.length,
|
|
1021
|
+
skippedLineCount,
|
|
1022
|
+
firstTimestamp,
|
|
1023
|
+
lastTimestamp,
|
|
1024
|
+
outcomes: finalizeAccumulator(outcomes),
|
|
1025
|
+
unavailableReasons: sortedReasons(unavailableReasons),
|
|
1026
|
+
skippedReasons: sortedReasons(skippedReasons),
|
|
1027
|
+
builtIns: sortedBuiltIns(builtIns),
|
|
1028
|
+
rules: sortedRules(rules),
|
|
1029
|
+
evaluation: finalizeEvaluation(evaluation)
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function emptyAccumulator() {
|
|
1033
|
+
return { pass: 0, warn: 0, fail: 0, unavailable: 0, skipped: 0 };
|
|
1034
|
+
}
|
|
1035
|
+
function countOutcome(counts, outcome) {
|
|
1036
|
+
switch (outcome) {
|
|
1037
|
+
case "PASS":
|
|
1038
|
+
counts.pass += 1;
|
|
1039
|
+
return;
|
|
1040
|
+
case "WARN":
|
|
1041
|
+
counts.warn += 1;
|
|
1042
|
+
return;
|
|
1043
|
+
case "FAIL":
|
|
1044
|
+
counts.fail += 1;
|
|
1045
|
+
return;
|
|
1046
|
+
case "UNAVAILABLE":
|
|
1047
|
+
counts.unavailable += 1;
|
|
1048
|
+
return;
|
|
1049
|
+
case "SKIPPED":
|
|
1050
|
+
counts.skipped += 1;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function countRule(rules, ruleId, outcome, evidence) {
|
|
1055
|
+
const accumulator = rules.get(ruleId) ?? { counts: emptyAccumulator(), sliced: 0 };
|
|
1056
|
+
countOutcome(accumulator.counts, outcome);
|
|
1057
|
+
if (evidence?.mode === "SLICED") {
|
|
1058
|
+
accumulator.sliced += 1;
|
|
1059
|
+
}
|
|
1060
|
+
rules.set(ruleId, accumulator);
|
|
1061
|
+
}
|
|
1062
|
+
function countEvidence(coverage, mode, sliceJudgmentCount) {
|
|
1063
|
+
if (mode === "WHOLE") {
|
|
1064
|
+
coverage.wholeEvaluations += 1;
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
coverage.slicedEvaluations += 1;
|
|
1068
|
+
coverage.totalSliceJudgments += sliceJudgmentCount;
|
|
1069
|
+
}
|
|
1070
|
+
function finalizeAccumulator(counts) {
|
|
1071
|
+
return {
|
|
1072
|
+
pass: counts.pass,
|
|
1073
|
+
warn: counts.warn,
|
|
1074
|
+
fail: counts.fail,
|
|
1075
|
+
unavailable: counts.unavailable,
|
|
1076
|
+
skipped: counts.skipped,
|
|
1077
|
+
total: counts.pass + counts.warn + counts.fail + counts.unavailable + counts.skipped
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function finalizeEvaluation(coverage) {
|
|
1081
|
+
return { ...coverage };
|
|
1082
|
+
}
|
|
1083
|
+
function bump(map, key) {
|
|
1084
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
1085
|
+
}
|
|
1086
|
+
function sortedReasons(map) {
|
|
1087
|
+
return [...map.entries()].map(([reason, count]) => ({ reason, count })).sort((left, right) => compareStrings(left.reason, right.reason));
|
|
1088
|
+
}
|
|
1089
|
+
function sortedBuiltIns(map) {
|
|
1090
|
+
return [...map.entries()].map(([checkId, count]) => ({ checkId, count })).sort((left, right) => compareStrings(left.checkId, right.checkId));
|
|
1091
|
+
}
|
|
1092
|
+
function sortedRules(map) {
|
|
1093
|
+
return [...map.entries()].map(([ruleId, accumulator]) => ({
|
|
1094
|
+
ruleId,
|
|
1095
|
+
counts: finalizeAccumulator(accumulator.counts),
|
|
1096
|
+
slicedRuleCount: accumulator.sliced
|
|
1097
|
+
})).sort((left, right) => compareStrings(left.ruleId, right.ruleId));
|
|
1098
|
+
}
|
|
1099
|
+
function earlier(current, candidate) {
|
|
1100
|
+
return current === null || candidate < current ? candidate : current;
|
|
1101
|
+
}
|
|
1102
|
+
function later(current, candidate) {
|
|
1103
|
+
return current === null || candidate > current ? candidate : current;
|
|
1104
|
+
}
|
|
1105
|
+
function compareStrings(left, right) {
|
|
1106
|
+
if (left < right) {
|
|
1107
|
+
return -1;
|
|
1108
|
+
}
|
|
1109
|
+
return left > right ? 1 : 0;
|
|
1110
|
+
}
|
|
666
1111
|
// packages/core/src/builtins/complexity.ts
|
|
667
1112
|
var COMPLEXITY_CHECK_ID = "COMPLEXITY";
|
|
668
1113
|
var COMPLEXITY_GATE_CONFIG = {
|
|
@@ -830,6 +1275,18 @@ function createFifoJevPort(port, config) {
|
|
|
830
1275
|
}
|
|
831
1276
|
}
|
|
832
1277
|
function start(pending) {
|
|
1278
|
+
if (pending.kind === "BATCH") {
|
|
1279
|
+
Promise.resolve().then(() => port.evaluateRuleBatch(pending.requests)).then((results) => {
|
|
1280
|
+
active -= 1;
|
|
1281
|
+
pending.settle(results);
|
|
1282
|
+
drain();
|
|
1283
|
+
}, (error) => {
|
|
1284
|
+
active -= 1;
|
|
1285
|
+
pending.fail(error);
|
|
1286
|
+
drain();
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
833
1290
|
Promise.resolve().then(() => port.evaluate(pending.request)).then((result) => {
|
|
834
1291
|
active -= 1;
|
|
835
1292
|
pending.settle(result);
|
|
@@ -843,7 +1300,13 @@ function createFifoJevPort(port, config) {
|
|
|
843
1300
|
return {
|
|
844
1301
|
evaluate(request) {
|
|
845
1302
|
return new Promise((settle, fail) => {
|
|
846
|
-
queue.push({ request, settle, fail });
|
|
1303
|
+
queue.push({ kind: "SINGLE", request, settle, fail });
|
|
1304
|
+
drain();
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
1307
|
+
evaluateRuleBatch(requests) {
|
|
1308
|
+
return new Promise((settle, fail) => {
|
|
1309
|
+
queue.push({ kind: "BATCH", requests, settle, fail });
|
|
847
1310
|
drain();
|
|
848
1311
|
});
|
|
849
1312
|
}
|
|
@@ -906,7 +1369,7 @@ var REMEDIATION_MAX_TASK_LENGTH = 16384;
|
|
|
906
1369
|
var DEFAULT_REMEDIATION_CONFIG = {
|
|
907
1370
|
autoPropose: true,
|
|
908
1371
|
proposeOn: ["FAIL"],
|
|
909
|
-
model:
|
|
1372
|
+
model: null
|
|
910
1373
|
};
|
|
911
1374
|
|
|
912
1375
|
// packages/core/src/remediation/request.ts
|
|
@@ -996,6 +1459,7 @@ function snapshotRule(rule) {
|
|
|
996
1459
|
// packages/core/src/remediation/config.ts
|
|
997
1460
|
var MODEL_PATTERN = /^[^\s/]+\/[^\s/]+$/;
|
|
998
1461
|
var KNOWN_KEYS = new Set(["auto_propose", "propose_on", "model"]);
|
|
1462
|
+
var INVALID_MODEL = Symbol("invalid-model");
|
|
999
1463
|
function resolveRemediationConfig(value) {
|
|
1000
1464
|
if (value === null || value === undefined) {
|
|
1001
1465
|
return valid(DEFAULT_REMEDIATION_CONFIG);
|
|
@@ -1018,8 +1482,8 @@ function resolveRemediationConfig(value) {
|
|
|
1018
1482
|
if (proposeOn === null) {
|
|
1019
1483
|
return invalid2();
|
|
1020
1484
|
}
|
|
1021
|
-
const model = remediation.model
|
|
1022
|
-
if (
|
|
1485
|
+
const model = resolveModel(remediation.model);
|
|
1486
|
+
if (model === INVALID_MODEL) {
|
|
1023
1487
|
return invalid2();
|
|
1024
1488
|
}
|
|
1025
1489
|
return valid({ autoPropose, proposeOn, model });
|
|
@@ -1034,6 +1498,15 @@ function parseModelSpecifier(model) {
|
|
|
1034
1498
|
modelID: model.slice(separator + 1)
|
|
1035
1499
|
};
|
|
1036
1500
|
}
|
|
1501
|
+
function resolveModel(value) {
|
|
1502
|
+
if (value === undefined) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof value !== "string" || !MODEL_PATTERN.test(value)) {
|
|
1506
|
+
return INVALID_MODEL;
|
|
1507
|
+
}
|
|
1508
|
+
return value;
|
|
1509
|
+
}
|
|
1037
1510
|
function readProposeOn(value) {
|
|
1038
1511
|
if (value === undefined) {
|
|
1039
1512
|
return DEFAULT_REMEDIATION_CONFIG.proposeOn;
|
|
@@ -1510,6 +1983,7 @@ var JEV_MODEL = "jev-latest";
|
|
|
1510
1983
|
|
|
1511
1984
|
// packages/opencode-adapter/src/jev/transport.ts
|
|
1512
1985
|
var DEFAULT_ALLOWED_CRITERION = "The change does not violate the rule's Violation.";
|
|
1986
|
+
var RULE_BATCH_CONCURRENCY = 4;
|
|
1513
1987
|
function buildTypeSafeClientConfig(apiKey) {
|
|
1514
1988
|
return { apiKey, defaultModel: JEV_MODEL, logLevel: "off" };
|
|
1515
1989
|
}
|
|
@@ -1527,9 +2001,48 @@ function createJevTransport(dependencies) {
|
|
|
1527
2001
|
return failedFor(request, "API_ERROR");
|
|
1528
2002
|
}
|
|
1529
2003
|
return evaluateWithClient(client, request);
|
|
2004
|
+
},
|
|
2005
|
+
async evaluateRuleBatch(requests) {
|
|
2006
|
+
if (requests.length === 0) {
|
|
2007
|
+
return [];
|
|
2008
|
+
}
|
|
2009
|
+
const credential = await resolveCredential(dependencies.credentials);
|
|
2010
|
+
if (credential.status === "UNAVAILABLE") {
|
|
2011
|
+
const reason = toFailureReason(credential.reason);
|
|
2012
|
+
return requests.map(() => ruleFailed(reason));
|
|
2013
|
+
}
|
|
2014
|
+
let client;
|
|
2015
|
+
try {
|
|
2016
|
+
client = dependencies.createClient(credential.apiKey);
|
|
2017
|
+
} catch {
|
|
2018
|
+
return requests.map(() => ruleFailed("API_ERROR"));
|
|
2019
|
+
}
|
|
2020
|
+
return runWithConcurrency(requests, RULE_BATCH_CONCURRENCY, (request) => evaluateRuleWithClient(client, request));
|
|
1530
2021
|
}
|
|
1531
2022
|
};
|
|
1532
2023
|
}
|
|
2024
|
+
async function runWithConcurrency(items, cap, worker) {
|
|
2025
|
+
const results = new Array(items.length);
|
|
2026
|
+
let cursor = 0;
|
|
2027
|
+
async function run() {
|
|
2028
|
+
while (true) {
|
|
2029
|
+
const index = cursor;
|
|
2030
|
+
cursor += 1;
|
|
2031
|
+
if (index >= items.length) {
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
const item = items[index];
|
|
2035
|
+
if (item === undefined) {
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
results[index] = await worker(item);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
const workerCount = Math.min(cap, items.length);
|
|
2042
|
+
const workers = Array.from({ length: workerCount }, () => run());
|
|
2043
|
+
await Promise.all(workers);
|
|
2044
|
+
return results;
|
|
2045
|
+
}
|
|
1533
2046
|
function createTypeSafeJevTransport(credentials) {
|
|
1534
2047
|
return createJevTransport({ credentials, createClient: createTypeSafeSystemOneClient });
|
|
1535
2048
|
}
|
|
@@ -1693,6 +2206,217 @@ function batchFailed(reason) {
|
|
|
1693
2206
|
function failedFor(request, reason) {
|
|
1694
2207
|
return request.kind === "RULE" ? ruleFailed(reason) : batchFailed(reason);
|
|
1695
2208
|
}
|
|
2209
|
+
// packages/opencode-adapter/src/opencode-config/confirmation.ts
|
|
2210
|
+
import { createInterface } from "readline/promises";
|
|
2211
|
+
function createConfirmationPrompt() {
|
|
2212
|
+
return {
|
|
2213
|
+
confirm: askForConfirmation
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
async function askForConfirmation(message) {
|
|
2217
|
+
if (process.stdin.isTTY !== true) {
|
|
2218
|
+
return { status: "FAILED", reason: "NOT_A_TTY" };
|
|
2219
|
+
}
|
|
2220
|
+
try {
|
|
2221
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
2222
|
+
try {
|
|
2223
|
+
const answer = await readline.question(`${message} [y/N] `);
|
|
2224
|
+
return isAffirmative(answer) ? { status: "CONFIRMED" } : { status: "DECLINED" };
|
|
2225
|
+
} finally {
|
|
2226
|
+
readline.close();
|
|
2227
|
+
}
|
|
2228
|
+
} catch {
|
|
2229
|
+
return { status: "DECLINED" };
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
function isAffirmative(answer) {
|
|
2233
|
+
const normalized = answer.trim().toLowerCase();
|
|
2234
|
+
return normalized === "y" || normalized === "yes";
|
|
2235
|
+
}
|
|
2236
|
+
// packages/opencode-adapter/src/opencode-config/entry.ts
|
|
2237
|
+
var JEVGUARD_PLUGIN_ENTRY = "@pablozrrrr/jevguard";
|
|
2238
|
+
var CONFIG_SCHEMA_URL = "https://opencode.ai/config.json";
|
|
2239
|
+
function createDefaultConfigDocument() {
|
|
2240
|
+
return { $schema: CONFIG_SCHEMA_URL };
|
|
2241
|
+
}
|
|
2242
|
+
function addPluginEntry(document, entry) {
|
|
2243
|
+
if (!isPlainObject(document)) {
|
|
2244
|
+
return { status: "INVALID_SHAPE" };
|
|
2245
|
+
}
|
|
2246
|
+
const existing = document.plugin;
|
|
2247
|
+
if (existing === undefined) {
|
|
2248
|
+
return { status: "ADDED", document: { ...document, plugin: [entry] } };
|
|
2249
|
+
}
|
|
2250
|
+
if (!isStringArray(existing)) {
|
|
2251
|
+
return { status: "INVALID_SHAPE" };
|
|
2252
|
+
}
|
|
2253
|
+
if (existing.some((value) => isPluginEntryFor(value, entry))) {
|
|
2254
|
+
return { status: "PRESENT" };
|
|
2255
|
+
}
|
|
2256
|
+
return { status: "ADDED", document: { ...document, plugin: [...existing, entry] } };
|
|
2257
|
+
}
|
|
2258
|
+
function isPlainObject(value) {
|
|
2259
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2260
|
+
}
|
|
2261
|
+
function isStringArray(value) {
|
|
2262
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
2263
|
+
}
|
|
2264
|
+
function isPluginEntryFor(value, entry) {
|
|
2265
|
+
return value === entry || value.startsWith(`${entry}@`);
|
|
2266
|
+
}
|
|
2267
|
+
// packages/opencode-adapter/src/opencode-config/file-system.ts
|
|
2268
|
+
import { randomUUID } from "crypto";
|
|
2269
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
2270
|
+
import { dirname, join } from "path";
|
|
2271
|
+
function createNodeConfigFileSystem() {
|
|
2272
|
+
return {
|
|
2273
|
+
readFile: readOptionalFile,
|
|
2274
|
+
fileExists,
|
|
2275
|
+
writeFile: writeConfigFile,
|
|
2276
|
+
ensureParentDirectory
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
async function readOptionalFile(path) {
|
|
2280
|
+
try {
|
|
2281
|
+
return await readFile(path, "utf8");
|
|
2282
|
+
} catch (error) {
|
|
2283
|
+
if (isMissingFileError(error)) {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
throw error;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
async function fileExists(path) {
|
|
2290
|
+
try {
|
|
2291
|
+
await access(path);
|
|
2292
|
+
return true;
|
|
2293
|
+
} catch {
|
|
2294
|
+
return false;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
async function writeConfigFile(path, contents) {
|
|
2298
|
+
const temporaryPath = join(dirname(path), `.opencode.json.jevguard-tmp-${randomUUID()}`);
|
|
2299
|
+
try {
|
|
2300
|
+
await writeFile(temporaryPath, contents, "utf8");
|
|
2301
|
+
await rename(temporaryPath, path);
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
await rm(temporaryPath, { force: true }).catch(() => {
|
|
2304
|
+
return;
|
|
2305
|
+
});
|
|
2306
|
+
throw error;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
async function ensureParentDirectory(path) {
|
|
2310
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2311
|
+
}
|
|
2312
|
+
function isMissingFileError(error) {
|
|
2313
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
2314
|
+
}
|
|
2315
|
+
// packages/opencode-adapter/src/opencode-config/install.ts
|
|
2316
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
2317
|
+
|
|
2318
|
+
// packages/opencode-adapter/src/opencode-config/paths.ts
|
|
2319
|
+
import { isAbsolute, join as join2 } from "path";
|
|
2320
|
+
var PROJECT_CONFIG_FILENAME = "opencode.json";
|
|
2321
|
+
function resolveConfigPath(target, context) {
|
|
2322
|
+
if (target === "project") {
|
|
2323
|
+
return join2(context.cwd, PROJECT_CONFIG_FILENAME);
|
|
2324
|
+
}
|
|
2325
|
+
return join2(resolveConfigHome(context), "opencode", PROJECT_CONFIG_FILENAME);
|
|
2326
|
+
}
|
|
2327
|
+
function resolveConfigHome(context) {
|
|
2328
|
+
const configured = context.environment.XDG_CONFIG_HOME;
|
|
2329
|
+
if (configured !== undefined && configured !== "" && isAbsolute(configured)) {
|
|
2330
|
+
return configured;
|
|
2331
|
+
}
|
|
2332
|
+
return join2(context.homedir, ".config");
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// packages/opencode-adapter/src/opencode-config/install.ts
|
|
2336
|
+
var CONFIRMATION_MESSAGE = "Add JevGuard to the opencode.json plugin list?";
|
|
2337
|
+
async function install(dependencies) {
|
|
2338
|
+
const path = resolveConfigPath(dependencies.target, dependencies);
|
|
2339
|
+
const read = await readConfigDocument(dependencies.fileSystem, path);
|
|
2340
|
+
if (read.status === "FAILED") {
|
|
2341
|
+
return failed(dependencies.target, path, read.reason);
|
|
2342
|
+
}
|
|
2343
|
+
const mutation = addPluginEntry(read.document, JEVGUARD_PLUGIN_ENTRY);
|
|
2344
|
+
if (mutation.status === "INVALID_SHAPE") {
|
|
2345
|
+
return failed(dependencies.target, path, "INVALID_CONFIG_SHAPE");
|
|
2346
|
+
}
|
|
2347
|
+
if (mutation.status === "PRESENT") {
|
|
2348
|
+
return { status: "ALREADY_INSTALLED", target: dependencies.target, path };
|
|
2349
|
+
}
|
|
2350
|
+
const confirmation = await requestConfirmation(dependencies.confirmation);
|
|
2351
|
+
if (confirmation.status === "FAILED") {
|
|
2352
|
+
return failed(dependencies.target, path, confirmation.reason);
|
|
2353
|
+
}
|
|
2354
|
+
if (confirmation.status === "DECLINED") {
|
|
2355
|
+
return { status: "ABORTED", target: dependencies.target, path };
|
|
2356
|
+
}
|
|
2357
|
+
const written = await writeConfigDocument(dependencies.fileSystem, path, mutation.document);
|
|
2358
|
+
if (!written) {
|
|
2359
|
+
return failed(dependencies.target, path, "WRITE_FAILURE");
|
|
2360
|
+
}
|
|
2361
|
+
return { status: "INSTALLED", target: dependencies.target, path };
|
|
2362
|
+
}
|
|
2363
|
+
async function readConfigDocument(fileSystem, path) {
|
|
2364
|
+
const contents = await readOptionalContents(fileSystem, path);
|
|
2365
|
+
if (contents.status === "FAILED") {
|
|
2366
|
+
return contents;
|
|
2367
|
+
}
|
|
2368
|
+
if (contents.value === null) {
|
|
2369
|
+
return readMissingConfigDocument(fileSystem, path);
|
|
2370
|
+
}
|
|
2371
|
+
try {
|
|
2372
|
+
return { status: "READ", document: JSON.parse(contents.value) };
|
|
2373
|
+
} catch {
|
|
2374
|
+
return { status: "FAILED", reason: "INVALID_JSON" };
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
async function readOptionalContents(fileSystem, path) {
|
|
2378
|
+
try {
|
|
2379
|
+
return { status: "READ", value: await fileSystem.readFile(path) };
|
|
2380
|
+
} catch {
|
|
2381
|
+
return { status: "FAILED", reason: "READ_FAILURE" };
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
async function readMissingConfigDocument(fileSystem, path) {
|
|
2385
|
+
try {
|
|
2386
|
+
const hasJsonc = await fileSystem.fileExists(join3(dirname2(path), "opencode.jsonc"));
|
|
2387
|
+
if (hasJsonc) {
|
|
2388
|
+
return { status: "FAILED", reason: "UNSUPPORTED_JSONC" };
|
|
2389
|
+
}
|
|
2390
|
+
return { status: "READ", document: createDefaultConfigDocument() };
|
|
2391
|
+
} catch {
|
|
2392
|
+
return { status: "FAILED", reason: "READ_FAILURE" };
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
async function requestConfirmation(confirmation) {
|
|
2396
|
+
try {
|
|
2397
|
+
const result = await confirmation.confirm(CONFIRMATION_MESSAGE);
|
|
2398
|
+
if (result.status === "FAILED") {
|
|
2399
|
+
return { status: "FAILED", reason: result.reason };
|
|
2400
|
+
}
|
|
2401
|
+
return { status: result.status };
|
|
2402
|
+
} catch {
|
|
2403
|
+
return { status: "FAILED", reason: "NOT_A_TTY" };
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
async function writeConfigDocument(fileSystem, path, document) {
|
|
2407
|
+
const serialized = `${JSON.stringify(document, null, 2)}
|
|
2408
|
+
`;
|
|
2409
|
+
try {
|
|
2410
|
+
await fileSystem.ensureParentDirectory(path);
|
|
2411
|
+
await fileSystem.writeFile(path, serialized);
|
|
2412
|
+
return true;
|
|
2413
|
+
} catch {
|
|
2414
|
+
return false;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
function failed(target, path, reason) {
|
|
2418
|
+
return { status: "FAILED", target, path, reason };
|
|
2419
|
+
}
|
|
1696
2420
|
// packages/opencode-adapter/src/policy/config-yaml.ts
|
|
1697
2421
|
import { parseDocument, visit } from "yaml";
|
|
1698
2422
|
function parsePolicyConfig(text) {
|
|
@@ -1729,16 +2453,16 @@ function invalid3() {
|
|
|
1729
2453
|
return { status: "INVALID", reason: "INVALID_CONFIG" };
|
|
1730
2454
|
}
|
|
1731
2455
|
// packages/opencode-adapter/src/policy/file-source.ts
|
|
1732
|
-
import { readFile } from "fs/promises";
|
|
1733
|
-
import { join } from "path";
|
|
2456
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2457
|
+
import { join as join4 } from "path";
|
|
1734
2458
|
function createPolicyFileLoader(dependencies) {
|
|
1735
2459
|
return {
|
|
1736
2460
|
async load() {
|
|
1737
|
-
const rules = await
|
|
2461
|
+
const rules = await readOptionalFile2(dependencies.fileSystem, join4(dependencies.root, ".jev", "rules.md"));
|
|
1738
2462
|
if (rules.status === "FAILED") {
|
|
1739
2463
|
return { status: "FAILED", reason: "RULES_READ_FAILURE" };
|
|
1740
2464
|
}
|
|
1741
|
-
const config = await
|
|
2465
|
+
const config = await readOptionalFile2(dependencies.fileSystem, join4(dependencies.root, ".jev", "config.yaml"));
|
|
1742
2466
|
if (config.status === "FAILED") {
|
|
1743
2467
|
return { status: "FAILED", reason: "CONFIG_READ_FAILURE" };
|
|
1744
2468
|
}
|
|
@@ -1751,22 +2475,277 @@ function createPolicyFileLoader(dependencies) {
|
|
|
1751
2475
|
}
|
|
1752
2476
|
function createNodePolicyFileSystem() {
|
|
1753
2477
|
return {
|
|
1754
|
-
readFile: (path) =>
|
|
2478
|
+
readFile: (path) => readFile2(path, "utf8")
|
|
1755
2479
|
};
|
|
1756
2480
|
}
|
|
1757
|
-
async function
|
|
2481
|
+
async function readOptionalFile2(fileSystem, path) {
|
|
1758
2482
|
try {
|
|
1759
2483
|
return { status: "READ", value: await fileSystem.readFile(path) };
|
|
1760
2484
|
} catch (error) {
|
|
1761
|
-
if (
|
|
2485
|
+
if (isMissingFileError2(error)) {
|
|
1762
2486
|
return { status: "READ", value: null };
|
|
1763
2487
|
}
|
|
1764
2488
|
return { status: "FAILED" };
|
|
1765
2489
|
}
|
|
1766
2490
|
}
|
|
1767
|
-
function
|
|
2491
|
+
function isMissingFileError2(error) {
|
|
1768
2492
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1769
2493
|
}
|
|
2494
|
+
// packages/opencode-adapter/src/history/local.ts
|
|
2495
|
+
import { isAbsolute as isAbsolute2, join as join5 } from "path";
|
|
2496
|
+
var DATA_DIRECTORY = "jevguard";
|
|
2497
|
+
var HISTORY_FILENAME = "reviews.jsonl";
|
|
2498
|
+
var DEFAULT_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
|
|
2499
|
+
var DEFAULT_HISTORY_MAX_RECORDS = 2000;
|
|
2500
|
+
function createLocalReviewHistory(dependencies) {
|
|
2501
|
+
const filePath = resolveHistoryFilePath(dependencies.context);
|
|
2502
|
+
const maxBytes = dependencies.maxBytes ?? DEFAULT_HISTORY_MAX_BYTES;
|
|
2503
|
+
const maxRecords = dependencies.maxRecords ?? DEFAULT_HISTORY_MAX_RECORDS;
|
|
2504
|
+
let tail = Promise.resolve();
|
|
2505
|
+
async function append(entry) {
|
|
2506
|
+
try {
|
|
2507
|
+
const record = {
|
|
2508
|
+
...entry,
|
|
2509
|
+
timestamp: dependencies.now().toISOString()
|
|
2510
|
+
};
|
|
2511
|
+
await dependencies.fileSystem.ensureParentDirectory(filePath);
|
|
2512
|
+
await dependencies.fileSystem.appendFile(filePath, `${JSON.stringify(record)}
|
|
2513
|
+
`);
|
|
2514
|
+
await boundGrowth();
|
|
2515
|
+
} catch {
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
async function boundGrowth() {
|
|
2520
|
+
try {
|
|
2521
|
+
const size = await dependencies.fileSystem.sizeOf(filePath);
|
|
2522
|
+
if (size === null || size <= maxBytes) {
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const contents = await dependencies.fileSystem.readFile(filePath);
|
|
2526
|
+
if (contents === null) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
const lines = contents.split(`
|
|
2530
|
+
`).filter((line) => line.trim() !== "");
|
|
2531
|
+
const kept = lines.slice(Math.max(0, lines.length - maxRecords));
|
|
2532
|
+
const rewritten = kept.length === 0 ? "" : `${kept.join(`
|
|
2533
|
+
`)}
|
|
2534
|
+
`;
|
|
2535
|
+
await dependencies.fileSystem.writeFileAtomic(filePath, rewritten);
|
|
2536
|
+
} catch {
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
async function read() {
|
|
2541
|
+
try {
|
|
2542
|
+
const contents = await dependencies.fileSystem.readFile(filePath);
|
|
2543
|
+
if (contents === null) {
|
|
2544
|
+
return { records: [], skippedLineCount: 0 };
|
|
2545
|
+
}
|
|
2546
|
+
return parseRecords(contents);
|
|
2547
|
+
} catch {
|
|
2548
|
+
return { records: [], skippedLineCount: 0 };
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return {
|
|
2552
|
+
append: (entry) => {
|
|
2553
|
+
const run = tail.then(() => append(entry));
|
|
2554
|
+
tail = run.then(ignore, ignore);
|
|
2555
|
+
return run;
|
|
2556
|
+
},
|
|
2557
|
+
read
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
function resolveHistoryFilePath(context) {
|
|
2561
|
+
return join5(resolveDataHome(context), DATA_DIRECTORY, HISTORY_FILENAME);
|
|
2562
|
+
}
|
|
2563
|
+
function resolveDataHome(context) {
|
|
2564
|
+
const configured = context.environment.get("XDG_DATA_HOME");
|
|
2565
|
+
if (configured !== undefined && configured !== "" && isAbsolute2(configured)) {
|
|
2566
|
+
return configured;
|
|
2567
|
+
}
|
|
2568
|
+
return join5(context.homedir, ".local", "share");
|
|
2569
|
+
}
|
|
2570
|
+
function parseRecords(contents) {
|
|
2571
|
+
const records = [];
|
|
2572
|
+
let skippedLineCount = 0;
|
|
2573
|
+
for (const line of contents.split(`
|
|
2574
|
+
`)) {
|
|
2575
|
+
if (line.trim() === "") {
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const parsed = parseRecord(line);
|
|
2579
|
+
if (parsed === null) {
|
|
2580
|
+
skippedLineCount += 1;
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
records.push(parsed);
|
|
2584
|
+
}
|
|
2585
|
+
return { records, skippedLineCount };
|
|
2586
|
+
}
|
|
2587
|
+
function parseRecord(line) {
|
|
2588
|
+
try {
|
|
2589
|
+
const value = JSON.parse(line);
|
|
2590
|
+
return isReviewHistoryRecord(value) ? value : null;
|
|
2591
|
+
} catch {
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function isReviewHistoryRecord(value) {
|
|
2596
|
+
if (!isObject(value)) {
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
if (typeof value.timestamp !== "string" || typeof value.turnId !== "string") {
|
|
2600
|
+
return false;
|
|
2601
|
+
}
|
|
2602
|
+
if (!isObject(value.summary) || !Array.isArray(value.results)) {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
return value.results.every(isHistoryResult);
|
|
2606
|
+
}
|
|
2607
|
+
function isHistoryResult(value) {
|
|
2608
|
+
if (!isObject(value)) {
|
|
2609
|
+
return false;
|
|
2610
|
+
}
|
|
2611
|
+
if (value.kind !== "RULE" && value.kind !== "BUILT_IN" && value.kind !== "REVIEW") {
|
|
2612
|
+
return false;
|
|
2613
|
+
}
|
|
2614
|
+
if (!isReviewOutcome(value.outcome)) {
|
|
2615
|
+
return false;
|
|
2616
|
+
}
|
|
2617
|
+
if (value.outcome === "UNAVAILABLE" || value.outcome === "SKIPPED") {
|
|
2618
|
+
return typeof value.reason === "string" && hasValidEvidence(value);
|
|
2619
|
+
}
|
|
2620
|
+
if (typeof value.violationProbability !== "number") {
|
|
2621
|
+
return false;
|
|
2622
|
+
}
|
|
2623
|
+
if (value.kind === "BUILT_IN") {
|
|
2624
|
+
return typeof value.checkId === "string" && hasValidEvidence(value);
|
|
2625
|
+
}
|
|
2626
|
+
if (value.kind === "RULE") {
|
|
2627
|
+
return (value.ruleId === null || typeof value.ruleId === "string") && hasValidEvidence(value);
|
|
2628
|
+
}
|
|
2629
|
+
return true;
|
|
2630
|
+
}
|
|
2631
|
+
function hasValidEvidence(value) {
|
|
2632
|
+
const evidence = value.evidence;
|
|
2633
|
+
if (evidence === undefined) {
|
|
2634
|
+
return true;
|
|
2635
|
+
}
|
|
2636
|
+
if (!isObject(evidence)) {
|
|
2637
|
+
return false;
|
|
2638
|
+
}
|
|
2639
|
+
if (evidence.mode !== "WHOLE" && evidence.mode !== "SLICED") {
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
return Array.isArray(evidence.sliceJudgments);
|
|
2643
|
+
}
|
|
2644
|
+
function isReviewOutcome(value) {
|
|
2645
|
+
return value === "PASS" || value === "WARN" || value === "FAIL" || value === "UNAVAILABLE" || value === "SKIPPED";
|
|
2646
|
+
}
|
|
2647
|
+
function isObject(value) {
|
|
2648
|
+
return typeof value === "object" && value !== null;
|
|
2649
|
+
}
|
|
2650
|
+
function ignore() {
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
// packages/opencode-adapter/src/history/mirrored-sink.ts
|
|
2654
|
+
function createMirroredReviewLogSink(hostSink, history) {
|
|
2655
|
+
return {
|
|
2656
|
+
async write(entry) {
|
|
2657
|
+
const [hostStatus] = await Promise.all([
|
|
2658
|
+
writeHost(hostSink, entry),
|
|
2659
|
+
appendHistory(history, entry)
|
|
2660
|
+
]);
|
|
2661
|
+
return hostStatus;
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
async function writeHost(sink, entry) {
|
|
2666
|
+
try {
|
|
2667
|
+
return await sink.write(entry);
|
|
2668
|
+
} catch {
|
|
2669
|
+
return "FAILED";
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
async function appendHistory(history, entry) {
|
|
2673
|
+
try {
|
|
2674
|
+
await history.append(entry);
|
|
2675
|
+
} catch {
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
// packages/opencode-adapter/src/history/node-file-system.ts
|
|
2680
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2681
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
2682
|
+
import { basename, dirname as dirname3, join as join6 } from "path";
|
|
2683
|
+
function createNodeReviewHistoryFileSystem() {
|
|
2684
|
+
return {
|
|
2685
|
+
readFile: readOptionalFile3,
|
|
2686
|
+
appendFile: appendHistoryFile,
|
|
2687
|
+
sizeOf: sizeOfOptionalFile,
|
|
2688
|
+
writeFileAtomic,
|
|
2689
|
+
ensureParentDirectory: async (path) => {
|
|
2690
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
async function readOptionalFile3(path) {
|
|
2695
|
+
try {
|
|
2696
|
+
return await readFile3(path, "utf8");
|
|
2697
|
+
} catch (error) {
|
|
2698
|
+
if (isMissingFileError3(error)) {
|
|
2699
|
+
return null;
|
|
2700
|
+
}
|
|
2701
|
+
throw error;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
async function appendHistoryFile(path, data) {
|
|
2705
|
+
await appendFile(path, data, "utf8");
|
|
2706
|
+
}
|
|
2707
|
+
async function sizeOfOptionalFile(path) {
|
|
2708
|
+
try {
|
|
2709
|
+
const info = await stat(path);
|
|
2710
|
+
return info.size;
|
|
2711
|
+
} catch (error) {
|
|
2712
|
+
if (isMissingFileError3(error)) {
|
|
2713
|
+
return null;
|
|
2714
|
+
}
|
|
2715
|
+
throw error;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
async function writeFileAtomic(path, data) {
|
|
2719
|
+
const temporaryPath = join6(dirname3(path), `.${basename(path)}.jevguard-tmp-${randomUUID2()}`);
|
|
2720
|
+
try {
|
|
2721
|
+
await writeFile2(temporaryPath, data, "utf8");
|
|
2722
|
+
await rename2(temporaryPath, path);
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
await rm2(temporaryPath, { force: true }).catch(() => {
|
|
2725
|
+
return;
|
|
2726
|
+
});
|
|
2727
|
+
throw error;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
function isMissingFileError3(error) {
|
|
2731
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
2732
|
+
}
|
|
2733
|
+
// packages/opencode-adapter/src/history/process-context.ts
|
|
2734
|
+
import { homedir } from "os";
|
|
2735
|
+
function createProcessReviewHistoryContext() {
|
|
2736
|
+
return {
|
|
2737
|
+
cwd: process.cwd(),
|
|
2738
|
+
environment: processEnvironment(),
|
|
2739
|
+
homedir: homedir()
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
function processEnvironment() {
|
|
2743
|
+
return {
|
|
2744
|
+
get(name) {
|
|
2745
|
+
return process.env[name];
|
|
2746
|
+
}
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
1770
2749
|
// packages/opencode-adapter/src/presentation/display.ts
|
|
1771
2750
|
function displayOutcome(counts) {
|
|
1772
2751
|
if (counts.fail > 0) {
|
|
@@ -1821,12 +2800,35 @@ function logLevelFor(outcome) {
|
|
|
1821
2800
|
function reviewLogMessage(entry) {
|
|
1822
2801
|
return `JevGuard ${displayOutcome(entry.summary.counts)} for turn ${entry.turnId}`;
|
|
1823
2802
|
}
|
|
2803
|
+
function projectEvidence(evidence) {
|
|
2804
|
+
return {
|
|
2805
|
+
mode: evidence.mode,
|
|
2806
|
+
plannedSliceCount: evidence.plannedSliceCount,
|
|
2807
|
+
evaluatedSliceCount: evidence.evaluatedSliceCount,
|
|
2808
|
+
failedSliceCount: evidence.failedSliceCount,
|
|
2809
|
+
notEvaluatedSliceCount: evidence.notEvaluatedSliceCount,
|
|
2810
|
+
slices: evidence.slices.map((slice) => ({
|
|
2811
|
+
index: slice.index,
|
|
2812
|
+
path: slice.path,
|
|
2813
|
+
kind: slice.kind,
|
|
2814
|
+
hunkOrdinal: slice.hunkOrdinal
|
|
2815
|
+
})),
|
|
2816
|
+
sliceJudgments: evidence.sliceJudgments.map((judgment) => ({
|
|
2817
|
+
index: judgment.index,
|
|
2818
|
+
probability: judgment.probability,
|
|
2819
|
+
outcome: judgment.outcome
|
|
2820
|
+
})),
|
|
2821
|
+
failingSliceIndex: evidence.failingSliceIndex,
|
|
2822
|
+
failureReason: evidence.failureReason
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
1824
2825
|
function ruleLogResult(result) {
|
|
1825
2826
|
const identity = {
|
|
1826
2827
|
kind: "RULE",
|
|
1827
2828
|
ruleId: result.ruleId,
|
|
1828
2829
|
severity: result.severity,
|
|
1829
|
-
scopedPaths: [...result.scopedPaths]
|
|
2830
|
+
scopedPaths: [...result.scopedPaths],
|
|
2831
|
+
...result.evidence === undefined ? {} : { evidence: projectEvidence(result.evidence) }
|
|
1830
2832
|
};
|
|
1831
2833
|
switch (result.outcome) {
|
|
1832
2834
|
case "PASS":
|
|
@@ -1872,10 +2874,28 @@ function toReviewToast(review) {
|
|
|
1872
2874
|
const outcome = displayOutcome(review.summary.counts);
|
|
1873
2875
|
return {
|
|
1874
2876
|
title: `JevGuard ${outcome}`,
|
|
1875
|
-
message: countSummary(review.summary.counts)
|
|
2877
|
+
message: `${countSummary(review.summary.counts)}${slicedSummary(review.results)}`,
|
|
1876
2878
|
variant: toastVariantFor(outcome)
|
|
1877
2879
|
};
|
|
1878
2880
|
}
|
|
2881
|
+
function slicedSummary(results) {
|
|
2882
|
+
let slicedRules = 0;
|
|
2883
|
+
let sliceJudgments = 0;
|
|
2884
|
+
for (const result of results) {
|
|
2885
|
+
if (result.kind !== "RULE" || result.evidence === undefined) {
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
if (result.evidence.mode !== "SLICED") {
|
|
2889
|
+
continue;
|
|
2890
|
+
}
|
|
2891
|
+
slicedRules += 1;
|
|
2892
|
+
sliceJudgments += result.evidence.evaluatedSliceCount;
|
|
2893
|
+
}
|
|
2894
|
+
if (slicedRules === 0 && sliceJudgments === 0) {
|
|
2895
|
+
return "";
|
|
2896
|
+
}
|
|
2897
|
+
return ` \xB7 sliced ${slicedRules} rules \xB7 slices ${sliceJudgments}`;
|
|
2898
|
+
}
|
|
1879
2899
|
function toastVariantFor(outcome) {
|
|
1880
2900
|
switch (outcome) {
|
|
1881
2901
|
case "PASS":
|
|
@@ -1994,25 +3014,50 @@ function toOutcomeExtra(result) {
|
|
|
1994
3014
|
}
|
|
1995
3015
|
// packages/opencode-adapter/src/presentation/remediation.ts
|
|
1996
3016
|
var TITLE = "JevGuard";
|
|
3017
|
+
var SERVICE_NAME2 = "jevguard";
|
|
1997
3018
|
var PREPARING_MESSAGE = "JevGuard is preparing a remediation proposal in the child session.";
|
|
1998
3019
|
var READY_MESSAGE = "JevGuard remediation proposal ready.";
|
|
3020
|
+
var FAILED_MESSAGE = "JevGuard could not prepare a remediation proposal.";
|
|
3021
|
+
var FAILURE_LOG_MESSAGE = "JevGuard remediation proposal failed";
|
|
1999
3022
|
function createRemediationNotifier(toast) {
|
|
2000
3023
|
return {
|
|
2001
3024
|
async proposalPreparing() {
|
|
2002
|
-
return showRemediationToast(toast, PREPARING_MESSAGE);
|
|
3025
|
+
return showRemediationToast(toast, PREPARING_MESSAGE, "info");
|
|
2003
3026
|
},
|
|
2004
3027
|
async proposalReady() {
|
|
2005
|
-
return showRemediationToast(toast, READY_MESSAGE);
|
|
3028
|
+
return showRemediationToast(toast, READY_MESSAGE, "info");
|
|
3029
|
+
},
|
|
3030
|
+
async proposalFailed() {
|
|
3031
|
+
return showRemediationToast(toast, FAILED_MESSAGE, "error");
|
|
2006
3032
|
}
|
|
2007
3033
|
};
|
|
2008
3034
|
}
|
|
2009
|
-
async function showRemediationToast(toast, message) {
|
|
3035
|
+
async function showRemediationToast(toast, message, variant) {
|
|
2010
3036
|
try {
|
|
2011
|
-
return await toast.show({ title: TITLE, message, variant
|
|
3037
|
+
return await toast.show({ title: TITLE, message, variant });
|
|
2012
3038
|
} catch {
|
|
2013
3039
|
return "FAILED";
|
|
2014
3040
|
}
|
|
2015
3041
|
}
|
|
3042
|
+
function createRemediationFailureLogSink(client) {
|
|
3043
|
+
return {
|
|
3044
|
+
async write(reason) {
|
|
3045
|
+
try {
|
|
3046
|
+
const result = await client.app.log({
|
|
3047
|
+
body: {
|
|
3048
|
+
service: SERVICE_NAME2,
|
|
3049
|
+
level: "error",
|
|
3050
|
+
message: FAILURE_LOG_MESSAGE,
|
|
3051
|
+
extra: { reason }
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
return result.error === undefined ? "DELIVERED" : "FAILED";
|
|
3055
|
+
} catch {
|
|
3056
|
+
return "FAILED";
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
2016
3061
|
function createOpenCodeSessionNavigator(client) {
|
|
2017
3062
|
return {
|
|
2018
3063
|
async navigate(sessionID) {
|
|
@@ -2063,11 +3108,12 @@ function createOpenCodeProposalSessionFacade(client) {
|
|
|
2063
3108
|
return result.data.id;
|
|
2064
3109
|
},
|
|
2065
3110
|
async prompt(input) {
|
|
3111
|
+
const model = input.model;
|
|
2066
3112
|
const result = await client.session.prompt({
|
|
2067
3113
|
path: { id: input.sessionID },
|
|
2068
3114
|
body: {
|
|
2069
3115
|
agent: input.agent,
|
|
2070
|
-
model: { providerID:
|
|
3116
|
+
...model === null ? {} : { model: { providerID: model.providerID, modelID: model.modelID } },
|
|
2071
3117
|
system: input.system,
|
|
2072
3118
|
tools: { ...input.tools },
|
|
2073
3119
|
parts: [{ type: "text", text: input.text }]
|
|
@@ -2146,21 +3192,28 @@ async function proposeForTurn(request, dependencies) {
|
|
|
2146
3192
|
if (request === null) {
|
|
2147
3193
|
return;
|
|
2148
3194
|
}
|
|
2149
|
-
const model = parseModelSpecifier(request.model);
|
|
2150
|
-
if (model === null) {
|
|
3195
|
+
const model = request.model === null ? null : parseModelSpecifier(request.model);
|
|
3196
|
+
if (request.model !== null && model === null) {
|
|
3197
|
+
await reportProposalFailure("MODEL_SPECIFIER_INVALID", dependencies);
|
|
2151
3198
|
return;
|
|
2152
3199
|
}
|
|
2153
3200
|
if (!dependencies.proposals.claim(request.evaluationId)) {
|
|
2154
3201
|
return;
|
|
2155
3202
|
}
|
|
3203
|
+
let sessionID;
|
|
2156
3204
|
try {
|
|
2157
|
-
|
|
3205
|
+
sessionID = await dependencies.proposalFacade.createChildSession({
|
|
2158
3206
|
parentID: request.sessionID,
|
|
2159
3207
|
title: PROPOSAL_SESSION_TITLE
|
|
2160
3208
|
});
|
|
2161
|
-
|
|
2162
|
-
await
|
|
2163
|
-
|
|
3209
|
+
} catch {
|
|
3210
|
+
await reportProposalFailure("CHILD_SESSION_FAILED", dependencies);
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
dependencies.proposals.registerChildSession(sessionID);
|
|
3214
|
+
await navigateToProposal(dependencies.navigator, sessionID);
|
|
3215
|
+
await notifyProposalPreparing(dependencies.notifier);
|
|
3216
|
+
try {
|
|
2164
3217
|
await dependencies.proposalFacade.prompt({
|
|
2165
3218
|
sessionID,
|
|
2166
3219
|
agent: PROPOSER_AGENT_NAME,
|
|
@@ -2169,7 +3222,26 @@ async function proposeForTurn(request, dependencies) {
|
|
|
2169
3222
|
tools: PROPOSER_TOOLS,
|
|
2170
3223
|
text: buildProposalText(request)
|
|
2171
3224
|
});
|
|
2172
|
-
|
|
3225
|
+
} catch {
|
|
3226
|
+
await reportProposalFailure("PROPOSAL_PROMPT_FAILED", dependencies);
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
await notifyProposalReady(dependencies.notifier);
|
|
3230
|
+
}
|
|
3231
|
+
async function reportProposalFailure(reason, dependencies) {
|
|
3232
|
+
await writeFailureLog(dependencies.failures, reason);
|
|
3233
|
+
await notifyProposalFailed(dependencies.notifier);
|
|
3234
|
+
}
|
|
3235
|
+
async function writeFailureLog(sink, reason) {
|
|
3236
|
+
try {
|
|
3237
|
+
await sink.write(reason);
|
|
3238
|
+
} catch {
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
async function notifyProposalFailed(notifier) {
|
|
3243
|
+
try {
|
|
3244
|
+
await notifier.proposalFailed();
|
|
2173
3245
|
} catch {
|
|
2174
3246
|
return;
|
|
2175
3247
|
}
|
|
@@ -2307,7 +3379,7 @@ function createPluginRuntime(dependencies) {
|
|
|
2307
3379
|
let attributionTail = Promise.resolve();
|
|
2308
3380
|
const backgroundJobs = new Set;
|
|
2309
3381
|
function track(work) {
|
|
2310
|
-
const contained = work.then(
|
|
3382
|
+
const contained = work.then(ignore2, ignore2);
|
|
2311
3383
|
backgroundJobs.add(contained);
|
|
2312
3384
|
contained.then(() => {
|
|
2313
3385
|
backgroundJobs.delete(contained);
|
|
@@ -2315,7 +3387,7 @@ function createPluginRuntime(dependencies) {
|
|
|
2315
3387
|
}
|
|
2316
3388
|
function enqueueAttribution(sessionID) {
|
|
2317
3389
|
const run = attributionTail.then(() => processIdle(sessionID));
|
|
2318
|
-
attributionTail = run.then(
|
|
3390
|
+
attributionTail = run.then(ignore2, ignore2);
|
|
2319
3391
|
}
|
|
2320
3392
|
async function processIdle(sessionID) {
|
|
2321
3393
|
try {
|
|
@@ -2364,7 +3436,7 @@ function createPluginRuntime(dependencies) {
|
|
|
2364
3436
|
drain
|
|
2365
3437
|
};
|
|
2366
3438
|
}
|
|
2367
|
-
function
|
|
3439
|
+
function ignore2() {
|
|
2368
3440
|
return;
|
|
2369
3441
|
}
|
|
2370
3442
|
|
|
@@ -2416,7 +3488,7 @@ function createConfigHook(options) {
|
|
|
2416
3488
|
|
|
2417
3489
|
// packages/plugin/src/skills/skill-directory.ts
|
|
2418
3490
|
import { existsSync } from "fs";
|
|
2419
|
-
import { join as
|
|
3491
|
+
import { join as join7 } from "path";
|
|
2420
3492
|
import { fileURLToPath } from "url";
|
|
2421
3493
|
var BUNDLED_SKILL_NAMES = ["jevguard-rules", "jev-init"];
|
|
2422
3494
|
var SKILL_ENTRY_FILE = "SKILL.md";
|
|
@@ -2424,7 +3496,7 @@ var SKILL_DIRECTORY_PREFIXES = ["./skills/", "../skills/"];
|
|
|
2424
3496
|
function resolveSkillDirectory(moduleUrl, skillName, exists) {
|
|
2425
3497
|
for (const prefix of SKILL_DIRECTORY_PREFIXES) {
|
|
2426
3498
|
const directory = fileURLToPath(new URL(`${prefix}${skillName}`, moduleUrl));
|
|
2427
|
-
if (exists(
|
|
3499
|
+
if (exists(join7(directory, SKILL_ENTRY_FILE))) {
|
|
2428
3500
|
return directory;
|
|
2429
3501
|
}
|
|
2430
3502
|
}
|
|
@@ -2446,6 +3518,11 @@ var DEFAULT_JEV_MAX_CONCURRENCY = 2;
|
|
|
2446
3518
|
var JevGuardPlugin = async (input) => {
|
|
2447
3519
|
const presentationClient = createOpenCodePresentationClient(input.client);
|
|
2448
3520
|
const toast = createOpenCodeToastSink(presentationClient);
|
|
3521
|
+
const history = createLocalReviewHistory({
|
|
3522
|
+
context: createProcessReviewHistoryContext(),
|
|
3523
|
+
fileSystem: createNodeReviewHistoryFileSystem(),
|
|
3524
|
+
now: () => new Date
|
|
3525
|
+
});
|
|
2449
3526
|
const dependencies = {
|
|
2450
3527
|
facade: createOpenCodeSessionFacade(input.client),
|
|
2451
3528
|
deduplicator: new InMemoryTurnDeduplicator,
|
|
@@ -2454,13 +3531,14 @@ var JevGuardPlugin = async (input) => {
|
|
|
2454
3531
|
fileSystem: createNodePolicyFileSystem()
|
|
2455
3532
|
}),
|
|
2456
3533
|
presenter: createReviewPresenter({
|
|
2457
|
-
log: createOpenCodeLogSink(presentationClient),
|
|
3534
|
+
log: createMirroredReviewLogSink(createOpenCodeLogSink(presentationClient), history),
|
|
2458
3535
|
toast
|
|
2459
3536
|
}),
|
|
2460
3537
|
proposals: createRemediationProposalStore(),
|
|
2461
3538
|
proposalFacade: createOpenCodeProposalSessionFacade(input.client),
|
|
2462
3539
|
navigator: createOpenCodeSessionNavigator(presentationClient),
|
|
2463
3540
|
notifier: createRemediationNotifier(toast),
|
|
3541
|
+
failures: createRemediationFailureLogSink(presentationClient),
|
|
2464
3542
|
jev: createFifoJevPort(createTypeSafeJevTransport(createCredentialProvider({
|
|
2465
3543
|
environment: createProcessEnvironment(),
|
|
2466
3544
|
store: createKeyringCredentialStore()
|