@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/cli/main.js
CHANGED
|
@@ -388,7 +388,7 @@ function buildReviewContext(turn, rule, scopedPaths) {
|
|
|
388
388
|
var DEFAULT_MAX_DIFF_LENGTH = 1e5;
|
|
389
389
|
var DEFAULT_EVIDENCE_POLICY = {
|
|
390
390
|
maxDiffLength: DEFAULT_MAX_DIFF_LENGTH,
|
|
391
|
-
|
|
391
|
+
codeExtensions: [
|
|
392
392
|
".ts",
|
|
393
393
|
".tsx",
|
|
394
394
|
".mts",
|
|
@@ -399,9 +399,6 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
399
399
|
".cjs",
|
|
400
400
|
".json",
|
|
401
401
|
".jsonc",
|
|
402
|
-
".md",
|
|
403
|
-
".mdx",
|
|
404
|
-
".txt",
|
|
405
402
|
".yaml",
|
|
406
403
|
".yml",
|
|
407
404
|
".toml",
|
|
@@ -457,6 +454,7 @@ var DEFAULT_EVIDENCE_POLICY = {
|
|
|
457
454
|
".diff",
|
|
458
455
|
".patch"
|
|
459
456
|
],
|
|
457
|
+
docsExtensions: [".md", ".mdx", ".txt"],
|
|
460
458
|
deniedFileNames: [
|
|
461
459
|
".env",
|
|
462
460
|
".envrc",
|
|
@@ -531,18 +529,26 @@ function checkFilePath(path, policy) {
|
|
|
531
529
|
if (policy.deniedExtensions.includes(extension)) {
|
|
532
530
|
return DENIED_SENSITIVE;
|
|
533
531
|
}
|
|
534
|
-
if (!
|
|
532
|
+
if (!isAllowlistedExtension(extension, policy)) {
|
|
535
533
|
return DENIED_EXTENSION;
|
|
536
534
|
}
|
|
537
535
|
return { allowed: true };
|
|
538
536
|
}
|
|
537
|
+
function isAllowlistedExtension(extension, policy) {
|
|
538
|
+
return policy.codeExtensions.includes(extension) || policy.docsExtensions.includes(extension);
|
|
539
|
+
}
|
|
539
540
|
function matchesDeniedFileName(basename, policy) {
|
|
540
541
|
return policy.deniedFileNames.some((name) => name.startsWith(".") ? basename === name || basename.startsWith(`${name}.`) : basename === name);
|
|
541
542
|
}
|
|
542
543
|
|
|
543
544
|
// packages/core/src/evidence/scope.ts
|
|
544
|
-
function
|
|
545
|
-
|
|
545
|
+
function matchesRuleScope(path, scope) {
|
|
546
|
+
const normalized = normalizePath(path);
|
|
547
|
+
const included = scope.include.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
548
|
+
if (!included) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
return !scope.exclude.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
546
552
|
}
|
|
547
553
|
function globToRegExp(glob) {
|
|
548
554
|
const source = glob.replace(/\\/g, "/");
|
|
@@ -569,16 +575,6 @@ function globToRegExp(glob) {
|
|
|
569
575
|
}
|
|
570
576
|
|
|
571
577
|
// packages/core/src/evidence/select-evidence.ts
|
|
572
|
-
function selectRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
573
|
-
if (hasNoAttributedPatch(turn)) {
|
|
574
|
-
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
575
|
-
}
|
|
576
|
-
const applicableFiles = selectApplicableFiles(turn.files, rule.scope);
|
|
577
|
-
if (applicableFiles.length === 0) {
|
|
578
|
-
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
579
|
-
}
|
|
580
|
-
return mountEvidence(applicableFiles, policy);
|
|
581
|
-
}
|
|
582
578
|
function selectTurnEvidence(turn, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
583
579
|
if (hasNoAttributedPatch(turn)) {
|
|
584
580
|
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
@@ -605,11 +601,21 @@ function mountEvidence(files, policy) {
|
|
|
605
601
|
function hasNoAttributedPatch(turn) {
|
|
606
602
|
return turn.files.every((file) => file.patch.trim() === "");
|
|
607
603
|
}
|
|
608
|
-
function selectApplicableFiles(files, scope) {
|
|
609
|
-
|
|
610
|
-
|
|
604
|
+
function selectApplicableFiles(files, scope, evidenceClass, policy) {
|
|
605
|
+
const scoped = scope === null ? files : files.filter((file) => matchesRuleScope(file.path, scope));
|
|
606
|
+
return scoped.filter((file) => isInEvidenceClass(file.path, evidenceClass, policy));
|
|
607
|
+
}
|
|
608
|
+
function isInEvidenceClass(path, evidenceClass, policy) {
|
|
609
|
+
if (evidenceClass === "any") {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
const safety = checkFilePath(path, policy);
|
|
613
|
+
if (!safety.allowed && safety.reason === "DENIED_SENSITIVE_PATH") {
|
|
614
|
+
return true;
|
|
611
615
|
}
|
|
612
|
-
|
|
616
|
+
const extension = fileExtension(path);
|
|
617
|
+
const otherClass = evidenceClass === "code" ? policy.docsExtensions : policy.codeExtensions;
|
|
618
|
+
return !otherClass.includes(extension);
|
|
613
619
|
}
|
|
614
620
|
function assembleDiff(files) {
|
|
615
621
|
return files.map((file) => file.patch).join(`
|
|
@@ -650,14 +656,20 @@ function evaluateWarningGate(probability, thresholds) {
|
|
|
650
656
|
}
|
|
651
657
|
|
|
652
658
|
// packages/core/src/evaluation/evaluate-with-port.ts
|
|
653
|
-
async function
|
|
659
|
+
async function evaluateRuleBatchWithPort(port, requests) {
|
|
654
660
|
try {
|
|
655
|
-
const
|
|
656
|
-
|
|
661
|
+
const results = await port.evaluateRuleBatch(requests);
|
|
662
|
+
if (results.length !== requests.length) {
|
|
663
|
+
return requests.map(() => ruleFailure("INVALID_RESPONSE"));
|
|
664
|
+
}
|
|
665
|
+
return results.map((result) => result.kind === "RULE" ? result : ruleFailure("INVALID_RESPONSE"));
|
|
657
666
|
} catch {
|
|
658
|
-
return
|
|
667
|
+
return requests.map(() => ruleFailure("API_ERROR"));
|
|
659
668
|
}
|
|
660
669
|
}
|
|
670
|
+
function ruleFailure(reason) {
|
|
671
|
+
return { kind: "RULE", status: "FAILED", reason };
|
|
672
|
+
}
|
|
661
673
|
async function evaluateBuiltInBatchWithPort(port, request) {
|
|
662
674
|
try {
|
|
663
675
|
const result = await port.evaluate(request);
|
|
@@ -666,27 +678,118 @@ async function evaluateBuiltInBatchWithPort(port, request) {
|
|
|
666
678
|
return { kind: "BUILT_IN_BATCH", status: "FAILED", reason: "API_ERROR" };
|
|
667
679
|
}
|
|
668
680
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const context = buildReviewContext(input.turn, input.rule, []);
|
|
675
|
-
return { ...context, outcome: "SKIPPED", reason: selection.reason };
|
|
681
|
+
// packages/core/src/evidence/plan-slices.ts
|
|
682
|
+
var HUNK_MARKER = /^@@ [^\n]*@@/gm;
|
|
683
|
+
function planRuleEvidence(turn, rule, policy = DEFAULT_EVIDENCE_POLICY) {
|
|
684
|
+
if (hasNoAttributedPatch(turn)) {
|
|
685
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
676
686
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
return {
|
|
687
|
+
const applicableFiles = selectApplicableFiles(turn.files, rule.scope, rule.evidence, policy);
|
|
688
|
+
if (applicableFiles.length === 0) {
|
|
689
|
+
return { status: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
680
690
|
}
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
691
|
+
const diff = assembleDiff(applicableFiles);
|
|
692
|
+
if (diff.trim() === "") {
|
|
693
|
+
return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
|
|
694
|
+
}
|
|
695
|
+
if (applicableFiles.some((file) => !checkFilePath(file.path, policy).allowed)) {
|
|
696
|
+
return { status: "UNAVAILABLE", reason: "BLOCKED_EVIDENCE" };
|
|
697
|
+
}
|
|
698
|
+
if (diff.length <= policy.maxDiffLength) {
|
|
699
|
+
return { status: "PLANNED", slices: [wholeSlice(applicableFiles, diff)] };
|
|
686
700
|
}
|
|
687
|
-
|
|
688
|
-
return { ...context, ...gate };
|
|
701
|
+
return sliceByFile(applicableFiles, policy);
|
|
689
702
|
}
|
|
703
|
+
function wholeSlice(files, diff) {
|
|
704
|
+
const paths = files.map((file) => file.path);
|
|
705
|
+
return {
|
|
706
|
+
index: 0,
|
|
707
|
+
kind: "WHOLE",
|
|
708
|
+
path: paths.length === 1 ? paths[0] ?? null : null,
|
|
709
|
+
hunkOrdinal: null,
|
|
710
|
+
files: paths,
|
|
711
|
+
diff
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function sliceByFile(files, policy) {
|
|
715
|
+
const slices = [];
|
|
716
|
+
for (const file of files) {
|
|
717
|
+
if (file.patch.length <= policy.maxDiffLength) {
|
|
718
|
+
slices.push(fileSlice(slices.length, file));
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const hunks = sliceHunks(slices.length, file, policy);
|
|
722
|
+
if (hunks === null) {
|
|
723
|
+
return { status: "UNAVAILABLE", reason: "OVERSIZED_DIFF" };
|
|
724
|
+
}
|
|
725
|
+
slices.push(...hunks);
|
|
726
|
+
}
|
|
727
|
+
return { status: "PLANNED", slices };
|
|
728
|
+
}
|
|
729
|
+
function fileSlice(index, file) {
|
|
730
|
+
return {
|
|
731
|
+
index,
|
|
732
|
+
kind: "FILE",
|
|
733
|
+
path: file.path,
|
|
734
|
+
hunkOrdinal: null,
|
|
735
|
+
files: [file.path],
|
|
736
|
+
diff: file.patch
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function sliceHunks(startIndex, file, policy) {
|
|
740
|
+
const starts = hunkStarts(file.patch);
|
|
741
|
+
if (starts.length === 0) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const firstStart = starts[0];
|
|
745
|
+
if (firstStart === undefined) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
const preamble = file.patch.slice(0, firstStart);
|
|
749
|
+
const slices = [];
|
|
750
|
+
for (let ordinal = 0;ordinal < starts.length; ordinal += 1) {
|
|
751
|
+
const start = starts[ordinal];
|
|
752
|
+
if (start === undefined) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const end = starts[ordinal + 1] ?? file.patch.length;
|
|
756
|
+
const rendered = preamble + file.patch.slice(start, end);
|
|
757
|
+
if (rendered.length > policy.maxDiffLength) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
slices.push({
|
|
761
|
+
index: startIndex + ordinal,
|
|
762
|
+
kind: "HUNK",
|
|
763
|
+
path: file.path,
|
|
764
|
+
hunkOrdinal: ordinal,
|
|
765
|
+
files: [file.path],
|
|
766
|
+
diff: rendered
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return slices;
|
|
770
|
+
}
|
|
771
|
+
function hunkStarts(patch) {
|
|
772
|
+
return Array.from(patch.matchAll(HUNK_MARKER), (match) => match.index ?? 0);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// packages/core/src/evidence/slice-budget.ts
|
|
776
|
+
var MAX_SLICES_PER_RULE = 16;
|
|
777
|
+
var MAX_EXTRA_RULE_CALLS_PER_TURN = 32;
|
|
778
|
+
function planTurnRuleSlices(plans) {
|
|
779
|
+
let remainingExtraCalls = MAX_EXTRA_RULE_CALLS_PER_TURN;
|
|
780
|
+
return plans.map((plan) => {
|
|
781
|
+
if (plan.status !== "PLANNED") {
|
|
782
|
+
return plan;
|
|
783
|
+
}
|
|
784
|
+
const extraCalls = plan.slices.length - 1;
|
|
785
|
+
if (plan.slices.length > MAX_SLICES_PER_RULE || extraCalls > remainingExtraCalls) {
|
|
786
|
+
return { status: "SLICE_LIMIT_EXCEEDED", slices: plan.slices };
|
|
787
|
+
}
|
|
788
|
+
remainingExtraCalls -= extraCalls;
|
|
789
|
+
return plan;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
690
793
|
// packages/core/src/review/aggregate.ts
|
|
691
794
|
var VERDICT_RANK = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
692
795
|
function aggregateReview(turnId, results) {
|
|
@@ -740,28 +843,204 @@ function isSemanticVerdict(outcome) {
|
|
|
740
843
|
return outcome === "PASS" || outcome === "WARN" || outcome === "FAIL";
|
|
741
844
|
}
|
|
742
845
|
|
|
743
|
-
// packages/core/src/evaluation/
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
846
|
+
// packages/core/src/evaluation/assemble-rule.ts
|
|
847
|
+
var VERDICT_RANK2 = { PASS: 0, WARN: 1, FAIL: 2 };
|
|
848
|
+
function assembleSlicedRuleResult(rule, context, slices, evaluations, gateConfig) {
|
|
849
|
+
if (slices.length === 0) {
|
|
850
|
+
return { ...context, outcome: "SKIPPED", reason: "NO_SCOPE_MATCH" };
|
|
851
|
+
}
|
|
852
|
+
const summaries = slices.map(sliceSummary);
|
|
853
|
+
const mode = evidenceMode(slices.length);
|
|
854
|
+
const gates = [];
|
|
855
|
+
const judgments = [];
|
|
856
|
+
let failureIndex = null;
|
|
857
|
+
for (let index = 0;index < slices.length; index += 1) {
|
|
858
|
+
const slice = slices[index];
|
|
859
|
+
const evaluation = evaluations[index];
|
|
860
|
+
if (slice === undefined || evaluation === undefined || evaluation.status === "FAILED") {
|
|
861
|
+
failureIndex = index;
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
const gate = evaluateGate(rule.severity, evaluation.noul.violationProbability, gateConfig);
|
|
865
|
+
if (gate.outcome === "UNAVAILABLE") {
|
|
866
|
+
failureIndex = index;
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
gates.push(gate);
|
|
870
|
+
judgments.push({
|
|
871
|
+
index: slice.index,
|
|
872
|
+
probability: gate.violationProbability,
|
|
873
|
+
outcome: gate.outcome
|
|
874
|
+
});
|
|
748
875
|
}
|
|
749
|
-
|
|
876
|
+
if (failureIndex !== null) {
|
|
877
|
+
return {
|
|
878
|
+
...context,
|
|
879
|
+
outcome: "UNAVAILABLE",
|
|
880
|
+
reason: "JEV_FAILURE",
|
|
881
|
+
evidence: failedMetadata(mode, summaries, failureIndex)
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
...context,
|
|
886
|
+
outcome: mergeVerdict(gates),
|
|
887
|
+
violationProbability: maxProbability(gates),
|
|
888
|
+
evidence: completeMetadata(mode, summaries, judgments)
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function overBudgetMetadata(slices) {
|
|
892
|
+
const summaries = slices.map(sliceSummary);
|
|
893
|
+
return {
|
|
894
|
+
mode: "SLICED",
|
|
895
|
+
plannedSliceCount: summaries.length,
|
|
896
|
+
evaluatedSliceCount: 0,
|
|
897
|
+
failedSliceCount: 0,
|
|
898
|
+
notEvaluatedSliceCount: summaries.length,
|
|
899
|
+
slices: summaries,
|
|
900
|
+
sliceJudgments: [],
|
|
901
|
+
failingSliceIndex: null,
|
|
902
|
+
failureReason: "SLICE_LIMIT_EXCEEDED"
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function sliceSummary(slice) {
|
|
906
|
+
return {
|
|
907
|
+
index: slice.index,
|
|
908
|
+
path: slice.path,
|
|
909
|
+
kind: slice.kind,
|
|
910
|
+
hunkOrdinal: slice.hunkOrdinal
|
|
911
|
+
};
|
|
750
912
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
913
|
+
function evidenceMode(sliceCount) {
|
|
914
|
+
return sliceCount === 1 ? "WHOLE" : "SLICED";
|
|
915
|
+
}
|
|
916
|
+
function mergeVerdict(gates) {
|
|
917
|
+
let verdict = "PASS";
|
|
918
|
+
for (const gate of gates) {
|
|
919
|
+
if (VERDICT_RANK2[gate.outcome] > VERDICT_RANK2[verdict]) {
|
|
920
|
+
verdict = gate.outcome;
|
|
921
|
+
}
|
|
754
922
|
}
|
|
923
|
+
return verdict;
|
|
924
|
+
}
|
|
925
|
+
function maxProbability(gates) {
|
|
926
|
+
return gates.reduce((highest, gate) => Math.max(highest, gate.violationProbability), Number.NEGATIVE_INFINITY);
|
|
927
|
+
}
|
|
928
|
+
function completeMetadata(mode, summaries, judgments) {
|
|
929
|
+
return {
|
|
930
|
+
mode,
|
|
931
|
+
plannedSliceCount: summaries.length,
|
|
932
|
+
evaluatedSliceCount: summaries.length,
|
|
933
|
+
failedSliceCount: 0,
|
|
934
|
+
notEvaluatedSliceCount: 0,
|
|
935
|
+
slices: summaries,
|
|
936
|
+
sliceJudgments: judgments,
|
|
937
|
+
failingSliceIndex: null,
|
|
938
|
+
failureReason: null
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function failedMetadata(mode, summaries, failingIndex) {
|
|
942
|
+
return {
|
|
943
|
+
mode,
|
|
944
|
+
plannedSliceCount: summaries.length,
|
|
945
|
+
evaluatedSliceCount: failingIndex,
|
|
946
|
+
failedSliceCount: 1,
|
|
947
|
+
notEvaluatedSliceCount: summaries.length - failingIndex - 1,
|
|
948
|
+
slices: summaries,
|
|
949
|
+
sliceJudgments: [],
|
|
950
|
+
failingSliceIndex: failingIndex,
|
|
951
|
+
failureReason: "JEV_FAILURE"
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// packages/core/src/evaluation/evaluate-rules.ts
|
|
956
|
+
async function evaluateRules(input, dependencies) {
|
|
755
957
|
if (input.gateConfig.status === "INVALID") {
|
|
756
|
-
|
|
958
|
+
const results = input.rules.map((candidate) => candidate.status === "INVALID" ? invalidRuleResult(input.turn.id, candidate) : invalidConfigResult(input.turn, candidate.rule));
|
|
959
|
+
return aggregateReview(input.turn.id, results);
|
|
960
|
+
}
|
|
961
|
+
const gateConfig = input.gateConfig.config;
|
|
962
|
+
const slots = resolveSlots(input);
|
|
963
|
+
const requests = collectRequests(input.turn.task, slots);
|
|
964
|
+
const evaluations = requests.length === 0 ? [] : await evaluateRuleBatchWithPort(dependencies.jev, requests);
|
|
965
|
+
return aggregateReview(input.turn.id, assembleResults(input, slots, evaluations, gateConfig));
|
|
966
|
+
}
|
|
967
|
+
function resolveSlots(input) {
|
|
968
|
+
const planned = input.rules.map((candidate) => candidate.status === "INVALID" ? { kind: "RESULT", result: invalidRuleResult(input.turn.id, candidate) } : {
|
|
969
|
+
kind: "PLAN",
|
|
970
|
+
rule: candidate.rule,
|
|
971
|
+
plan: planRuleEvidence(input.turn, candidate.rule, input.evidencePolicy)
|
|
972
|
+
});
|
|
973
|
+
const budgeted = planTurnRuleSlices(planned.flatMap((slot) => slot.kind === "PLAN" ? [slot.plan] : []));
|
|
974
|
+
let cursor = 0;
|
|
975
|
+
return planned.map((slot) => {
|
|
976
|
+
if (slot.kind === "RESULT") {
|
|
977
|
+
return slot;
|
|
978
|
+
}
|
|
979
|
+
const plan = budgeted[cursor];
|
|
980
|
+
cursor += 1;
|
|
981
|
+
return plan === undefined ? slot : { kind: "PLAN", rule: slot.rule, plan };
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
function collectRequests(task, slots) {
|
|
985
|
+
const requests = [];
|
|
986
|
+
for (const slot of slots) {
|
|
987
|
+
if (slot.kind !== "PLAN" || slot.plan.status !== "PLANNED") {
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
for (const slice of slot.plan.slices) {
|
|
991
|
+
requests.push(buildJevRequest(task, slot.rule, { files: slice.files, diff: slice.diff }));
|
|
992
|
+
}
|
|
757
993
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
994
|
+
return requests;
|
|
995
|
+
}
|
|
996
|
+
function assembleResults(input, slots, evaluations, gateConfig) {
|
|
997
|
+
const results = [];
|
|
998
|
+
let evaluationCursor = 0;
|
|
999
|
+
for (const slot of slots) {
|
|
1000
|
+
if (slot.kind === "RESULT") {
|
|
1001
|
+
results.push(slot.result);
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const plan = slot.plan;
|
|
1005
|
+
if (plan.status === "SKIPPED") {
|
|
1006
|
+
results.push(skippedResult(input.turn, slot.rule, plan.reason));
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (plan.status === "UNAVAILABLE") {
|
|
1010
|
+
results.push(unavailableResult(input.turn, slot.rule, plan.reason));
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (plan.status === "SLICE_LIMIT_EXCEEDED") {
|
|
1014
|
+
results.push({
|
|
1015
|
+
...buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)),
|
|
1016
|
+
outcome: "UNAVAILABLE",
|
|
1017
|
+
reason: "SLICE_LIMIT_EXCEEDED",
|
|
1018
|
+
evidence: overBudgetMetadata(plan.slices)
|
|
1019
|
+
});
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const sliceEvaluations = evaluations.slice(evaluationCursor, evaluationCursor + plan.slices.length);
|
|
1023
|
+
evaluationCursor += plan.slices.length;
|
|
1024
|
+
results.push(assembleSlicedRuleResult(slot.rule, buildReviewContext(input.turn, slot.rule, collectSlicePaths(plan.slices)), plan.slices, sliceEvaluations, gateConfig));
|
|
1025
|
+
}
|
|
1026
|
+
return results;
|
|
1027
|
+
}
|
|
1028
|
+
function collectSlicePaths(slices) {
|
|
1029
|
+
const paths = [];
|
|
1030
|
+
for (const slice of slices) {
|
|
1031
|
+
for (const path of slice.files) {
|
|
1032
|
+
if (!paths.includes(path)) {
|
|
1033
|
+
paths.push(path);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return paths;
|
|
1038
|
+
}
|
|
1039
|
+
function skippedResult(turn, rule, reason) {
|
|
1040
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "SKIPPED", reason };
|
|
1041
|
+
}
|
|
1042
|
+
function unavailableResult(turn, rule, reason) {
|
|
1043
|
+
return { ...buildReviewContext(turn, rule, []), outcome: "UNAVAILABLE", reason };
|
|
765
1044
|
}
|
|
766
1045
|
function invalidRuleResult(turnId, failure) {
|
|
767
1046
|
return {
|
|
@@ -803,13 +1082,29 @@ function matchSectionHeading(line) {
|
|
|
803
1082
|
var METADATA_ENTRY = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/;
|
|
804
1083
|
function parseRuleMetadata(lines) {
|
|
805
1084
|
let severity = null;
|
|
806
|
-
let
|
|
1085
|
+
let evidence = null;
|
|
1086
|
+
let scopeDeclared = false;
|
|
1087
|
+
const includePatterns = [];
|
|
1088
|
+
const excludePatterns = [];
|
|
807
1089
|
const seenKeys = new Set;
|
|
808
1090
|
for (const line of collectMetadataLines(lines)) {
|
|
809
1091
|
const entry = parseMetadataEntry(line);
|
|
810
1092
|
if (entry === null) {
|
|
811
1093
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
812
1094
|
}
|
|
1095
|
+
if (entry.key === "scope") {
|
|
1096
|
+
const pattern = parseScopePattern(entry.value);
|
|
1097
|
+
if (pattern === null) {
|
|
1098
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
1099
|
+
}
|
|
1100
|
+
scopeDeclared = true;
|
|
1101
|
+
if (pattern.exclude) {
|
|
1102
|
+
excludePatterns.push(pattern.pattern);
|
|
1103
|
+
} else {
|
|
1104
|
+
includePatterns.push(pattern.pattern);
|
|
1105
|
+
}
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
813
1108
|
if (seenKeys.has(entry.key)) {
|
|
814
1109
|
return { ok: false, code: "DUPLICATE_METADATA" };
|
|
815
1110
|
}
|
|
@@ -822,11 +1117,12 @@ function parseRuleMetadata(lines) {
|
|
|
822
1117
|
severity = parsed;
|
|
823
1118
|
continue;
|
|
824
1119
|
}
|
|
825
|
-
if (entry.key === "
|
|
826
|
-
|
|
1120
|
+
if (entry.key === "evidence") {
|
|
1121
|
+
const parsed = parseEvidenceClass(entry.value);
|
|
1122
|
+
if (parsed === null) {
|
|
827
1123
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
828
1124
|
}
|
|
829
|
-
|
|
1125
|
+
evidence = parsed;
|
|
830
1126
|
continue;
|
|
831
1127
|
}
|
|
832
1128
|
return { ok: false, code: "MALFORMED_METADATA" };
|
|
@@ -834,7 +1130,11 @@ function parseRuleMetadata(lines) {
|
|
|
834
1130
|
if (severity === null) {
|
|
835
1131
|
return { ok: false, code: "MISSING_SEVERITY" };
|
|
836
1132
|
}
|
|
837
|
-
|
|
1133
|
+
if (scopeDeclared && includePatterns.length === 0) {
|
|
1134
|
+
return { ok: false, code: "MALFORMED_METADATA" };
|
|
1135
|
+
}
|
|
1136
|
+
const scope = scopeDeclared ? { include: includePatterns, exclude: excludePatterns } : null;
|
|
1137
|
+
return { ok: true, value: { severity, scope, evidence: evidence ?? "code" } };
|
|
838
1138
|
}
|
|
839
1139
|
function collectMetadataLines(lines) {
|
|
840
1140
|
const collected = [];
|
|
@@ -864,6 +1164,26 @@ function parseSeverity(value) {
|
|
|
864
1164
|
return null;
|
|
865
1165
|
}
|
|
866
1166
|
}
|
|
1167
|
+
function parseEvidenceClass(value) {
|
|
1168
|
+
switch (value) {
|
|
1169
|
+
case "code":
|
|
1170
|
+
case "docs":
|
|
1171
|
+
case "any":
|
|
1172
|
+
return value;
|
|
1173
|
+
default:
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
function parseScopePattern(value) {
|
|
1178
|
+
if (value === "") {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
if (value.startsWith("!")) {
|
|
1182
|
+
const pattern = value.slice(1).trim();
|
|
1183
|
+
return pattern === "" ? null : { pattern, exclude: true };
|
|
1184
|
+
}
|
|
1185
|
+
return { pattern: value, exclude: false };
|
|
1186
|
+
}
|
|
867
1187
|
|
|
868
1188
|
// packages/core/src/rules/sections.ts
|
|
869
1189
|
var SECTION_NAMES = ["Rule", "Violation", "Allowed"];
|
|
@@ -986,6 +1306,7 @@ function buildParsedRule(id, metadata, sections) {
|
|
|
986
1306
|
id,
|
|
987
1307
|
severity: metadata.severity,
|
|
988
1308
|
scope: metadata.scope,
|
|
1309
|
+
evidence: metadata.evidence,
|
|
989
1310
|
description: sections.rule,
|
|
990
1311
|
violation: sections.violation,
|
|
991
1312
|
allowed: sections.allowed
|
|
@@ -1016,6 +1337,130 @@ function findDuplicateIds(ids) {
|
|
|
1016
1337
|
}
|
|
1017
1338
|
return duplicates;
|
|
1018
1339
|
}
|
|
1340
|
+
// packages/core/src/report/aggregate.ts
|
|
1341
|
+
function aggregateReviewHistory(records, skippedLineCount) {
|
|
1342
|
+
const outcomes = emptyAccumulator();
|
|
1343
|
+
const unavailableReasons = new Map;
|
|
1344
|
+
const skippedReasons = new Map;
|
|
1345
|
+
const builtIns = new Map;
|
|
1346
|
+
const rules = new Map;
|
|
1347
|
+
const evaluation = { wholeEvaluations: 0, slicedEvaluations: 0, totalSliceJudgments: 0 };
|
|
1348
|
+
let firstTimestamp = null;
|
|
1349
|
+
let lastTimestamp = null;
|
|
1350
|
+
for (const record of records) {
|
|
1351
|
+
firstTimestamp = earlier(firstTimestamp, record.timestamp);
|
|
1352
|
+
lastTimestamp = later(lastTimestamp, record.timestamp);
|
|
1353
|
+
for (const result of record.results) {
|
|
1354
|
+
countOutcome(outcomes, result.outcome);
|
|
1355
|
+
if (result.outcome === "UNAVAILABLE") {
|
|
1356
|
+
bump(unavailableReasons, result.reason);
|
|
1357
|
+
}
|
|
1358
|
+
if (result.outcome === "SKIPPED") {
|
|
1359
|
+
bump(skippedReasons, result.reason);
|
|
1360
|
+
}
|
|
1361
|
+
if (result.kind === "BUILT_IN") {
|
|
1362
|
+
bump(builtIns, result.checkId);
|
|
1363
|
+
}
|
|
1364
|
+
if (result.kind === "RULE" && result.ruleId !== null) {
|
|
1365
|
+
countRule(rules, result.ruleId, result.outcome, result.evidence);
|
|
1366
|
+
}
|
|
1367
|
+
if (result.kind === "RULE" && result.evidence !== undefined) {
|
|
1368
|
+
countEvidence(evaluation, result.evidence.mode, result.evidence.sliceJudgments.length);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
entryCount: records.length,
|
|
1374
|
+
skippedLineCount,
|
|
1375
|
+
firstTimestamp,
|
|
1376
|
+
lastTimestamp,
|
|
1377
|
+
outcomes: finalizeAccumulator(outcomes),
|
|
1378
|
+
unavailableReasons: sortedReasons(unavailableReasons),
|
|
1379
|
+
skippedReasons: sortedReasons(skippedReasons),
|
|
1380
|
+
builtIns: sortedBuiltIns(builtIns),
|
|
1381
|
+
rules: sortedRules(rules),
|
|
1382
|
+
evaluation: finalizeEvaluation(evaluation)
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function emptyAccumulator() {
|
|
1386
|
+
return { pass: 0, warn: 0, fail: 0, unavailable: 0, skipped: 0 };
|
|
1387
|
+
}
|
|
1388
|
+
function countOutcome(counts, outcome) {
|
|
1389
|
+
switch (outcome) {
|
|
1390
|
+
case "PASS":
|
|
1391
|
+
counts.pass += 1;
|
|
1392
|
+
return;
|
|
1393
|
+
case "WARN":
|
|
1394
|
+
counts.warn += 1;
|
|
1395
|
+
return;
|
|
1396
|
+
case "FAIL":
|
|
1397
|
+
counts.fail += 1;
|
|
1398
|
+
return;
|
|
1399
|
+
case "UNAVAILABLE":
|
|
1400
|
+
counts.unavailable += 1;
|
|
1401
|
+
return;
|
|
1402
|
+
case "SKIPPED":
|
|
1403
|
+
counts.skipped += 1;
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function countRule(rules, ruleId, outcome, evidence) {
|
|
1408
|
+
const accumulator = rules.get(ruleId) ?? { counts: emptyAccumulator(), sliced: 0 };
|
|
1409
|
+
countOutcome(accumulator.counts, outcome);
|
|
1410
|
+
if (evidence?.mode === "SLICED") {
|
|
1411
|
+
accumulator.sliced += 1;
|
|
1412
|
+
}
|
|
1413
|
+
rules.set(ruleId, accumulator);
|
|
1414
|
+
}
|
|
1415
|
+
function countEvidence(coverage, mode, sliceJudgmentCount) {
|
|
1416
|
+
if (mode === "WHOLE") {
|
|
1417
|
+
coverage.wholeEvaluations += 1;
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
coverage.slicedEvaluations += 1;
|
|
1421
|
+
coverage.totalSliceJudgments += sliceJudgmentCount;
|
|
1422
|
+
}
|
|
1423
|
+
function finalizeAccumulator(counts) {
|
|
1424
|
+
return {
|
|
1425
|
+
pass: counts.pass,
|
|
1426
|
+
warn: counts.warn,
|
|
1427
|
+
fail: counts.fail,
|
|
1428
|
+
unavailable: counts.unavailable,
|
|
1429
|
+
skipped: counts.skipped,
|
|
1430
|
+
total: counts.pass + counts.warn + counts.fail + counts.unavailable + counts.skipped
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
function finalizeEvaluation(coverage) {
|
|
1434
|
+
return { ...coverage };
|
|
1435
|
+
}
|
|
1436
|
+
function bump(map, key) {
|
|
1437
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
1438
|
+
}
|
|
1439
|
+
function sortedReasons(map) {
|
|
1440
|
+
return [...map.entries()].map(([reason, count]) => ({ reason, count })).sort((left, right) => compareStrings(left.reason, right.reason));
|
|
1441
|
+
}
|
|
1442
|
+
function sortedBuiltIns(map) {
|
|
1443
|
+
return [...map.entries()].map(([checkId, count]) => ({ checkId, count })).sort((left, right) => compareStrings(left.checkId, right.checkId));
|
|
1444
|
+
}
|
|
1445
|
+
function sortedRules(map) {
|
|
1446
|
+
return [...map.entries()].map(([ruleId, accumulator]) => ({
|
|
1447
|
+
ruleId,
|
|
1448
|
+
counts: finalizeAccumulator(accumulator.counts),
|
|
1449
|
+
slicedRuleCount: accumulator.sliced
|
|
1450
|
+
})).sort((left, right) => compareStrings(left.ruleId, right.ruleId));
|
|
1451
|
+
}
|
|
1452
|
+
function earlier(current, candidate) {
|
|
1453
|
+
return current === null || candidate < current ? candidate : current;
|
|
1454
|
+
}
|
|
1455
|
+
function later(current, candidate) {
|
|
1456
|
+
return current === null || candidate > current ? candidate : current;
|
|
1457
|
+
}
|
|
1458
|
+
function compareStrings(left, right) {
|
|
1459
|
+
if (left < right) {
|
|
1460
|
+
return -1;
|
|
1461
|
+
}
|
|
1462
|
+
return left > right ? 1 : 0;
|
|
1463
|
+
}
|
|
1019
1464
|
// packages/core/src/builtins/complexity.ts
|
|
1020
1465
|
var COMPLEXITY_CHECK_ID = "COMPLEXITY";
|
|
1021
1466
|
var COMPLEXITY_GATE_CONFIG = {
|
|
@@ -1183,6 +1628,18 @@ function createFifoJevPort(port, config) {
|
|
|
1183
1628
|
}
|
|
1184
1629
|
}
|
|
1185
1630
|
function start(pending) {
|
|
1631
|
+
if (pending.kind === "BATCH") {
|
|
1632
|
+
Promise.resolve().then(() => port.evaluateRuleBatch(pending.requests)).then((results) => {
|
|
1633
|
+
active -= 1;
|
|
1634
|
+
pending.settle(results);
|
|
1635
|
+
drain();
|
|
1636
|
+
}, (error) => {
|
|
1637
|
+
active -= 1;
|
|
1638
|
+
pending.fail(error);
|
|
1639
|
+
drain();
|
|
1640
|
+
});
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1186
1643
|
Promise.resolve().then(() => port.evaluate(pending.request)).then((result) => {
|
|
1187
1644
|
active -= 1;
|
|
1188
1645
|
pending.settle(result);
|
|
@@ -1196,7 +1653,13 @@ function createFifoJevPort(port, config) {
|
|
|
1196
1653
|
return {
|
|
1197
1654
|
evaluate(request) {
|
|
1198
1655
|
return new Promise((settle, fail) => {
|
|
1199
|
-
queue.push({ request, settle, fail });
|
|
1656
|
+
queue.push({ kind: "SINGLE", request, settle, fail });
|
|
1657
|
+
drain();
|
|
1658
|
+
});
|
|
1659
|
+
},
|
|
1660
|
+
evaluateRuleBatch(requests) {
|
|
1661
|
+
return new Promise((settle, fail) => {
|
|
1662
|
+
queue.push({ kind: "BATCH", requests, settle, fail });
|
|
1200
1663
|
drain();
|
|
1201
1664
|
});
|
|
1202
1665
|
}
|
|
@@ -1259,7 +1722,7 @@ var REMEDIATION_MAX_TASK_LENGTH = 16384;
|
|
|
1259
1722
|
var DEFAULT_REMEDIATION_CONFIG = {
|
|
1260
1723
|
autoPropose: true,
|
|
1261
1724
|
proposeOn: ["FAIL"],
|
|
1262
|
-
model:
|
|
1725
|
+
model: null
|
|
1263
1726
|
};
|
|
1264
1727
|
|
|
1265
1728
|
// packages/core/src/remediation/request.ts
|
|
@@ -1349,6 +1812,7 @@ function snapshotRule(rule) {
|
|
|
1349
1812
|
// packages/core/src/remediation/config.ts
|
|
1350
1813
|
var MODEL_PATTERN = /^[^\s/]+\/[^\s/]+$/;
|
|
1351
1814
|
var KNOWN_KEYS = new Set(["auto_propose", "propose_on", "model"]);
|
|
1815
|
+
var INVALID_MODEL = Symbol("invalid-model");
|
|
1352
1816
|
function resolveRemediationConfig(value) {
|
|
1353
1817
|
if (value === null || value === undefined) {
|
|
1354
1818
|
return valid(DEFAULT_REMEDIATION_CONFIG);
|
|
@@ -1371,8 +1835,8 @@ function resolveRemediationConfig(value) {
|
|
|
1371
1835
|
if (proposeOn === null) {
|
|
1372
1836
|
return invalid2();
|
|
1373
1837
|
}
|
|
1374
|
-
const model = remediation.model
|
|
1375
|
-
if (
|
|
1838
|
+
const model = resolveModel(remediation.model);
|
|
1839
|
+
if (model === INVALID_MODEL) {
|
|
1376
1840
|
return invalid2();
|
|
1377
1841
|
}
|
|
1378
1842
|
return valid({ autoPropose, proposeOn, model });
|
|
@@ -1387,6 +1851,15 @@ function parseModelSpecifier(model) {
|
|
|
1387
1851
|
modelID: model.slice(separator + 1)
|
|
1388
1852
|
};
|
|
1389
1853
|
}
|
|
1854
|
+
function resolveModel(value) {
|
|
1855
|
+
if (value === undefined) {
|
|
1856
|
+
return null;
|
|
1857
|
+
}
|
|
1858
|
+
if (typeof value !== "string" || !MODEL_PATTERN.test(value)) {
|
|
1859
|
+
return INVALID_MODEL;
|
|
1860
|
+
}
|
|
1861
|
+
return value;
|
|
1862
|
+
}
|
|
1390
1863
|
function readProposeOn(value) {
|
|
1391
1864
|
if (value === undefined) {
|
|
1392
1865
|
return DEFAULT_REMEDIATION_CONFIG.proposeOn;
|
|
@@ -1512,6 +1985,7 @@ var JEV_MODEL = "jev-latest";
|
|
|
1512
1985
|
|
|
1513
1986
|
// packages/opencode-adapter/src/jev/transport.ts
|
|
1514
1987
|
var DEFAULT_ALLOWED_CRITERION = "The change does not violate the rule's Violation.";
|
|
1988
|
+
var RULE_BATCH_CONCURRENCY = 4;
|
|
1515
1989
|
function buildTypeSafeClientConfig(apiKey) {
|
|
1516
1990
|
return { apiKey, defaultModel: JEV_MODEL, logLevel: "off" };
|
|
1517
1991
|
}
|
|
@@ -1529,9 +2003,48 @@ function createJevTransport(dependencies) {
|
|
|
1529
2003
|
return failedFor(request, "API_ERROR");
|
|
1530
2004
|
}
|
|
1531
2005
|
return evaluateWithClient(client, request);
|
|
2006
|
+
},
|
|
2007
|
+
async evaluateRuleBatch(requests) {
|
|
2008
|
+
if (requests.length === 0) {
|
|
2009
|
+
return [];
|
|
2010
|
+
}
|
|
2011
|
+
const credential = await resolveCredential(dependencies.credentials);
|
|
2012
|
+
if (credential.status === "UNAVAILABLE") {
|
|
2013
|
+
const reason = toFailureReason(credential.reason);
|
|
2014
|
+
return requests.map(() => ruleFailed(reason));
|
|
2015
|
+
}
|
|
2016
|
+
let client;
|
|
2017
|
+
try {
|
|
2018
|
+
client = dependencies.createClient(credential.apiKey);
|
|
2019
|
+
} catch {
|
|
2020
|
+
return requests.map(() => ruleFailed("API_ERROR"));
|
|
2021
|
+
}
|
|
2022
|
+
return runWithConcurrency(requests, RULE_BATCH_CONCURRENCY, (request) => evaluateRuleWithClient(client, request));
|
|
1532
2023
|
}
|
|
1533
2024
|
};
|
|
1534
2025
|
}
|
|
2026
|
+
async function runWithConcurrency(items, cap, worker) {
|
|
2027
|
+
const results = new Array(items.length);
|
|
2028
|
+
let cursor = 0;
|
|
2029
|
+
async function run() {
|
|
2030
|
+
while (true) {
|
|
2031
|
+
const index = cursor;
|
|
2032
|
+
cursor += 1;
|
|
2033
|
+
if (index >= items.length) {
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const item = items[index];
|
|
2037
|
+
if (item === undefined) {
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
results[index] = await worker(item);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
const workerCount = Math.min(cap, items.length);
|
|
2044
|
+
const workers = Array.from({ length: workerCount }, () => run());
|
|
2045
|
+
await Promise.all(workers);
|
|
2046
|
+
return results;
|
|
2047
|
+
}
|
|
1535
2048
|
function createTypeSafeJevTransport(credentials) {
|
|
1536
2049
|
return createJevTransport({ credentials, createClient: createTypeSafeSystemOneClient });
|
|
1537
2050
|
}
|
|
@@ -1695,6 +2208,217 @@ function batchFailed(reason) {
|
|
|
1695
2208
|
function failedFor(request, reason) {
|
|
1696
2209
|
return request.kind === "RULE" ? ruleFailed(reason) : batchFailed(reason);
|
|
1697
2210
|
}
|
|
2211
|
+
// packages/opencode-adapter/src/opencode-config/confirmation.ts
|
|
2212
|
+
import { createInterface } from "readline/promises";
|
|
2213
|
+
function createConfirmationPrompt() {
|
|
2214
|
+
return {
|
|
2215
|
+
confirm: askForConfirmation
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
async function askForConfirmation(message) {
|
|
2219
|
+
if (process.stdin.isTTY !== true) {
|
|
2220
|
+
return { status: "FAILED", reason: "NOT_A_TTY" };
|
|
2221
|
+
}
|
|
2222
|
+
try {
|
|
2223
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
2224
|
+
try {
|
|
2225
|
+
const answer = await readline.question(`${message} [y/N] `);
|
|
2226
|
+
return isAffirmative(answer) ? { status: "CONFIRMED" } : { status: "DECLINED" };
|
|
2227
|
+
} finally {
|
|
2228
|
+
readline.close();
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
return { status: "DECLINED" };
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
function isAffirmative(answer) {
|
|
2235
|
+
const normalized = answer.trim().toLowerCase();
|
|
2236
|
+
return normalized === "y" || normalized === "yes";
|
|
2237
|
+
}
|
|
2238
|
+
// packages/opencode-adapter/src/opencode-config/entry.ts
|
|
2239
|
+
var JEVGUARD_PLUGIN_ENTRY = "@pablozrrrr/jevguard";
|
|
2240
|
+
var CONFIG_SCHEMA_URL = "https://opencode.ai/config.json";
|
|
2241
|
+
function createDefaultConfigDocument() {
|
|
2242
|
+
return { $schema: CONFIG_SCHEMA_URL };
|
|
2243
|
+
}
|
|
2244
|
+
function addPluginEntry(document, entry) {
|
|
2245
|
+
if (!isPlainObject(document)) {
|
|
2246
|
+
return { status: "INVALID_SHAPE" };
|
|
2247
|
+
}
|
|
2248
|
+
const existing = document.plugin;
|
|
2249
|
+
if (existing === undefined) {
|
|
2250
|
+
return { status: "ADDED", document: { ...document, plugin: [entry] } };
|
|
2251
|
+
}
|
|
2252
|
+
if (!isStringArray(existing)) {
|
|
2253
|
+
return { status: "INVALID_SHAPE" };
|
|
2254
|
+
}
|
|
2255
|
+
if (existing.some((value) => isPluginEntryFor(value, entry))) {
|
|
2256
|
+
return { status: "PRESENT" };
|
|
2257
|
+
}
|
|
2258
|
+
return { status: "ADDED", document: { ...document, plugin: [...existing, entry] } };
|
|
2259
|
+
}
|
|
2260
|
+
function isPlainObject(value) {
|
|
2261
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2262
|
+
}
|
|
2263
|
+
function isStringArray(value) {
|
|
2264
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
2265
|
+
}
|
|
2266
|
+
function isPluginEntryFor(value, entry) {
|
|
2267
|
+
return value === entry || value.startsWith(`${entry}@`);
|
|
2268
|
+
}
|
|
2269
|
+
// packages/opencode-adapter/src/opencode-config/file-system.ts
|
|
2270
|
+
import { randomUUID } from "crypto";
|
|
2271
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
2272
|
+
import { dirname, join } from "path";
|
|
2273
|
+
function createNodeConfigFileSystem() {
|
|
2274
|
+
return {
|
|
2275
|
+
readFile: readOptionalFile,
|
|
2276
|
+
fileExists,
|
|
2277
|
+
writeFile: writeConfigFile,
|
|
2278
|
+
ensureParentDirectory
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
async function readOptionalFile(path) {
|
|
2282
|
+
try {
|
|
2283
|
+
return await readFile(path, "utf8");
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
if (isMissingFileError(error)) {
|
|
2286
|
+
return null;
|
|
2287
|
+
}
|
|
2288
|
+
throw error;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
async function fileExists(path) {
|
|
2292
|
+
try {
|
|
2293
|
+
await access(path);
|
|
2294
|
+
return true;
|
|
2295
|
+
} catch {
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async function writeConfigFile(path, contents) {
|
|
2300
|
+
const temporaryPath = join(dirname(path), `.opencode.json.jevguard-tmp-${randomUUID()}`);
|
|
2301
|
+
try {
|
|
2302
|
+
await writeFile(temporaryPath, contents, "utf8");
|
|
2303
|
+
await rename(temporaryPath, path);
|
|
2304
|
+
} catch (error) {
|
|
2305
|
+
await rm(temporaryPath, { force: true }).catch(() => {
|
|
2306
|
+
return;
|
|
2307
|
+
});
|
|
2308
|
+
throw error;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
async function ensureParentDirectory(path) {
|
|
2312
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2313
|
+
}
|
|
2314
|
+
function isMissingFileError(error) {
|
|
2315
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
2316
|
+
}
|
|
2317
|
+
// packages/opencode-adapter/src/opencode-config/install.ts
|
|
2318
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
2319
|
+
|
|
2320
|
+
// packages/opencode-adapter/src/opencode-config/paths.ts
|
|
2321
|
+
import { isAbsolute, join as join2 } from "path";
|
|
2322
|
+
var PROJECT_CONFIG_FILENAME = "opencode.json";
|
|
2323
|
+
function resolveConfigPath(target, context) {
|
|
2324
|
+
if (target === "project") {
|
|
2325
|
+
return join2(context.cwd, PROJECT_CONFIG_FILENAME);
|
|
2326
|
+
}
|
|
2327
|
+
return join2(resolveConfigHome(context), "opencode", PROJECT_CONFIG_FILENAME);
|
|
2328
|
+
}
|
|
2329
|
+
function resolveConfigHome(context) {
|
|
2330
|
+
const configured = context.environment.XDG_CONFIG_HOME;
|
|
2331
|
+
if (configured !== undefined && configured !== "" && isAbsolute(configured)) {
|
|
2332
|
+
return configured;
|
|
2333
|
+
}
|
|
2334
|
+
return join2(context.homedir, ".config");
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// packages/opencode-adapter/src/opencode-config/install.ts
|
|
2338
|
+
var CONFIRMATION_MESSAGE = "Add JevGuard to the opencode.json plugin list?";
|
|
2339
|
+
async function install(dependencies) {
|
|
2340
|
+
const path = resolveConfigPath(dependencies.target, dependencies);
|
|
2341
|
+
const read = await readConfigDocument(dependencies.fileSystem, path);
|
|
2342
|
+
if (read.status === "FAILED") {
|
|
2343
|
+
return failed(dependencies.target, path, read.reason);
|
|
2344
|
+
}
|
|
2345
|
+
const mutation = addPluginEntry(read.document, JEVGUARD_PLUGIN_ENTRY);
|
|
2346
|
+
if (mutation.status === "INVALID_SHAPE") {
|
|
2347
|
+
return failed(dependencies.target, path, "INVALID_CONFIG_SHAPE");
|
|
2348
|
+
}
|
|
2349
|
+
if (mutation.status === "PRESENT") {
|
|
2350
|
+
return { status: "ALREADY_INSTALLED", target: dependencies.target, path };
|
|
2351
|
+
}
|
|
2352
|
+
const confirmation = await requestConfirmation(dependencies.confirmation);
|
|
2353
|
+
if (confirmation.status === "FAILED") {
|
|
2354
|
+
return failed(dependencies.target, path, confirmation.reason);
|
|
2355
|
+
}
|
|
2356
|
+
if (confirmation.status === "DECLINED") {
|
|
2357
|
+
return { status: "ABORTED", target: dependencies.target, path };
|
|
2358
|
+
}
|
|
2359
|
+
const written = await writeConfigDocument(dependencies.fileSystem, path, mutation.document);
|
|
2360
|
+
if (!written) {
|
|
2361
|
+
return failed(dependencies.target, path, "WRITE_FAILURE");
|
|
2362
|
+
}
|
|
2363
|
+
return { status: "INSTALLED", target: dependencies.target, path };
|
|
2364
|
+
}
|
|
2365
|
+
async function readConfigDocument(fileSystem, path) {
|
|
2366
|
+
const contents = await readOptionalContents(fileSystem, path);
|
|
2367
|
+
if (contents.status === "FAILED") {
|
|
2368
|
+
return contents;
|
|
2369
|
+
}
|
|
2370
|
+
if (contents.value === null) {
|
|
2371
|
+
return readMissingConfigDocument(fileSystem, path);
|
|
2372
|
+
}
|
|
2373
|
+
try {
|
|
2374
|
+
return { status: "READ", document: JSON.parse(contents.value) };
|
|
2375
|
+
} catch {
|
|
2376
|
+
return { status: "FAILED", reason: "INVALID_JSON" };
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
async function readOptionalContents(fileSystem, path) {
|
|
2380
|
+
try {
|
|
2381
|
+
return { status: "READ", value: await fileSystem.readFile(path) };
|
|
2382
|
+
} catch {
|
|
2383
|
+
return { status: "FAILED", reason: "READ_FAILURE" };
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
async function readMissingConfigDocument(fileSystem, path) {
|
|
2387
|
+
try {
|
|
2388
|
+
const hasJsonc = await fileSystem.fileExists(join3(dirname2(path), "opencode.jsonc"));
|
|
2389
|
+
if (hasJsonc) {
|
|
2390
|
+
return { status: "FAILED", reason: "UNSUPPORTED_JSONC" };
|
|
2391
|
+
}
|
|
2392
|
+
return { status: "READ", document: createDefaultConfigDocument() };
|
|
2393
|
+
} catch {
|
|
2394
|
+
return { status: "FAILED", reason: "READ_FAILURE" };
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
async function requestConfirmation(confirmation) {
|
|
2398
|
+
try {
|
|
2399
|
+
const result = await confirmation.confirm(CONFIRMATION_MESSAGE);
|
|
2400
|
+
if (result.status === "FAILED") {
|
|
2401
|
+
return { status: "FAILED", reason: result.reason };
|
|
2402
|
+
}
|
|
2403
|
+
return { status: result.status };
|
|
2404
|
+
} catch {
|
|
2405
|
+
return { status: "FAILED", reason: "NOT_A_TTY" };
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
async function writeConfigDocument(fileSystem, path, document) {
|
|
2409
|
+
const serialized = `${JSON.stringify(document, null, 2)}
|
|
2410
|
+
`;
|
|
2411
|
+
try {
|
|
2412
|
+
await fileSystem.ensureParentDirectory(path);
|
|
2413
|
+
await fileSystem.writeFile(path, serialized);
|
|
2414
|
+
return true;
|
|
2415
|
+
} catch {
|
|
2416
|
+
return false;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
function failed(target, path, reason) {
|
|
2420
|
+
return { status: "FAILED", target, path, reason };
|
|
2421
|
+
}
|
|
1698
2422
|
// packages/opencode-adapter/src/policy/config-yaml.ts
|
|
1699
2423
|
import { parseDocument, visit } from "yaml";
|
|
1700
2424
|
function parsePolicyConfig(text) {
|
|
@@ -1731,16 +2455,16 @@ function invalid3() {
|
|
|
1731
2455
|
return { status: "INVALID", reason: "INVALID_CONFIG" };
|
|
1732
2456
|
}
|
|
1733
2457
|
// packages/opencode-adapter/src/policy/file-source.ts
|
|
1734
|
-
import { readFile } from "fs/promises";
|
|
1735
|
-
import { join } from "path";
|
|
2458
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2459
|
+
import { join as join4 } from "path";
|
|
1736
2460
|
function createPolicyFileLoader(dependencies) {
|
|
1737
2461
|
return {
|
|
1738
2462
|
async load() {
|
|
1739
|
-
const rules = await
|
|
2463
|
+
const rules = await readOptionalFile2(dependencies.fileSystem, join4(dependencies.root, ".jev", "rules.md"));
|
|
1740
2464
|
if (rules.status === "FAILED") {
|
|
1741
2465
|
return { status: "FAILED", reason: "RULES_READ_FAILURE" };
|
|
1742
2466
|
}
|
|
1743
|
-
const config = await
|
|
2467
|
+
const config = await readOptionalFile2(dependencies.fileSystem, join4(dependencies.root, ".jev", "config.yaml"));
|
|
1744
2468
|
if (config.status === "FAILED") {
|
|
1745
2469
|
return { status: "FAILED", reason: "CONFIG_READ_FAILURE" };
|
|
1746
2470
|
}
|
|
@@ -1753,22 +2477,277 @@ function createPolicyFileLoader(dependencies) {
|
|
|
1753
2477
|
}
|
|
1754
2478
|
function createNodePolicyFileSystem() {
|
|
1755
2479
|
return {
|
|
1756
|
-
readFile: (path) =>
|
|
2480
|
+
readFile: (path) => readFile2(path, "utf8")
|
|
1757
2481
|
};
|
|
1758
2482
|
}
|
|
1759
|
-
async function
|
|
2483
|
+
async function readOptionalFile2(fileSystem, path) {
|
|
1760
2484
|
try {
|
|
1761
2485
|
return { status: "READ", value: await fileSystem.readFile(path) };
|
|
1762
2486
|
} catch (error) {
|
|
1763
|
-
if (
|
|
2487
|
+
if (isMissingFileError2(error)) {
|
|
1764
2488
|
return { status: "READ", value: null };
|
|
1765
2489
|
}
|
|
1766
2490
|
return { status: "FAILED" };
|
|
1767
2491
|
}
|
|
1768
2492
|
}
|
|
1769
|
-
function
|
|
2493
|
+
function isMissingFileError2(error) {
|
|
1770
2494
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1771
2495
|
}
|
|
2496
|
+
// packages/opencode-adapter/src/history/local.ts
|
|
2497
|
+
import { isAbsolute as isAbsolute2, join as join5 } from "path";
|
|
2498
|
+
var DATA_DIRECTORY = "jevguard";
|
|
2499
|
+
var HISTORY_FILENAME = "reviews.jsonl";
|
|
2500
|
+
var DEFAULT_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
|
|
2501
|
+
var DEFAULT_HISTORY_MAX_RECORDS = 2000;
|
|
2502
|
+
function createLocalReviewHistory(dependencies) {
|
|
2503
|
+
const filePath = resolveHistoryFilePath(dependencies.context);
|
|
2504
|
+
const maxBytes = dependencies.maxBytes ?? DEFAULT_HISTORY_MAX_BYTES;
|
|
2505
|
+
const maxRecords = dependencies.maxRecords ?? DEFAULT_HISTORY_MAX_RECORDS;
|
|
2506
|
+
let tail = Promise.resolve();
|
|
2507
|
+
async function append(entry) {
|
|
2508
|
+
try {
|
|
2509
|
+
const record = {
|
|
2510
|
+
...entry,
|
|
2511
|
+
timestamp: dependencies.now().toISOString()
|
|
2512
|
+
};
|
|
2513
|
+
await dependencies.fileSystem.ensureParentDirectory(filePath);
|
|
2514
|
+
await dependencies.fileSystem.appendFile(filePath, `${JSON.stringify(record)}
|
|
2515
|
+
`);
|
|
2516
|
+
await boundGrowth();
|
|
2517
|
+
} catch {
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
async function boundGrowth() {
|
|
2522
|
+
try {
|
|
2523
|
+
const size = await dependencies.fileSystem.sizeOf(filePath);
|
|
2524
|
+
if (size === null || size <= maxBytes) {
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
const contents = await dependencies.fileSystem.readFile(filePath);
|
|
2528
|
+
if (contents === null) {
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
const lines = contents.split(`
|
|
2532
|
+
`).filter((line) => line.trim() !== "");
|
|
2533
|
+
const kept = lines.slice(Math.max(0, lines.length - maxRecords));
|
|
2534
|
+
const rewritten = kept.length === 0 ? "" : `${kept.join(`
|
|
2535
|
+
`)}
|
|
2536
|
+
`;
|
|
2537
|
+
await dependencies.fileSystem.writeFileAtomic(filePath, rewritten);
|
|
2538
|
+
} catch {
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
async function read() {
|
|
2543
|
+
try {
|
|
2544
|
+
const contents = await dependencies.fileSystem.readFile(filePath);
|
|
2545
|
+
if (contents === null) {
|
|
2546
|
+
return { records: [], skippedLineCount: 0 };
|
|
2547
|
+
}
|
|
2548
|
+
return parseRecords(contents);
|
|
2549
|
+
} catch {
|
|
2550
|
+
return { records: [], skippedLineCount: 0 };
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return {
|
|
2554
|
+
append: (entry) => {
|
|
2555
|
+
const run = tail.then(() => append(entry));
|
|
2556
|
+
tail = run.then(ignore, ignore);
|
|
2557
|
+
return run;
|
|
2558
|
+
},
|
|
2559
|
+
read
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
function resolveHistoryFilePath(context) {
|
|
2563
|
+
return join5(resolveDataHome(context), DATA_DIRECTORY, HISTORY_FILENAME);
|
|
2564
|
+
}
|
|
2565
|
+
function resolveDataHome(context) {
|
|
2566
|
+
const configured = context.environment.get("XDG_DATA_HOME");
|
|
2567
|
+
if (configured !== undefined && configured !== "" && isAbsolute2(configured)) {
|
|
2568
|
+
return configured;
|
|
2569
|
+
}
|
|
2570
|
+
return join5(context.homedir, ".local", "share");
|
|
2571
|
+
}
|
|
2572
|
+
function parseRecords(contents) {
|
|
2573
|
+
const records = [];
|
|
2574
|
+
let skippedLineCount = 0;
|
|
2575
|
+
for (const line of contents.split(`
|
|
2576
|
+
`)) {
|
|
2577
|
+
if (line.trim() === "") {
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const parsed = parseRecord(line);
|
|
2581
|
+
if (parsed === null) {
|
|
2582
|
+
skippedLineCount += 1;
|
|
2583
|
+
continue;
|
|
2584
|
+
}
|
|
2585
|
+
records.push(parsed);
|
|
2586
|
+
}
|
|
2587
|
+
return { records, skippedLineCount };
|
|
2588
|
+
}
|
|
2589
|
+
function parseRecord(line) {
|
|
2590
|
+
try {
|
|
2591
|
+
const value = JSON.parse(line);
|
|
2592
|
+
return isReviewHistoryRecord(value) ? value : null;
|
|
2593
|
+
} catch {
|
|
2594
|
+
return null;
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
function isReviewHistoryRecord(value) {
|
|
2598
|
+
if (!isObject(value)) {
|
|
2599
|
+
return false;
|
|
2600
|
+
}
|
|
2601
|
+
if (typeof value.timestamp !== "string" || typeof value.turnId !== "string") {
|
|
2602
|
+
return false;
|
|
2603
|
+
}
|
|
2604
|
+
if (!isObject(value.summary) || !Array.isArray(value.results)) {
|
|
2605
|
+
return false;
|
|
2606
|
+
}
|
|
2607
|
+
return value.results.every(isHistoryResult);
|
|
2608
|
+
}
|
|
2609
|
+
function isHistoryResult(value) {
|
|
2610
|
+
if (!isObject(value)) {
|
|
2611
|
+
return false;
|
|
2612
|
+
}
|
|
2613
|
+
if (value.kind !== "RULE" && value.kind !== "BUILT_IN" && value.kind !== "REVIEW") {
|
|
2614
|
+
return false;
|
|
2615
|
+
}
|
|
2616
|
+
if (!isReviewOutcome(value.outcome)) {
|
|
2617
|
+
return false;
|
|
2618
|
+
}
|
|
2619
|
+
if (value.outcome === "UNAVAILABLE" || value.outcome === "SKIPPED") {
|
|
2620
|
+
return typeof value.reason === "string" && hasValidEvidence(value);
|
|
2621
|
+
}
|
|
2622
|
+
if (typeof value.violationProbability !== "number") {
|
|
2623
|
+
return false;
|
|
2624
|
+
}
|
|
2625
|
+
if (value.kind === "BUILT_IN") {
|
|
2626
|
+
return typeof value.checkId === "string" && hasValidEvidence(value);
|
|
2627
|
+
}
|
|
2628
|
+
if (value.kind === "RULE") {
|
|
2629
|
+
return (value.ruleId === null || typeof value.ruleId === "string") && hasValidEvidence(value);
|
|
2630
|
+
}
|
|
2631
|
+
return true;
|
|
2632
|
+
}
|
|
2633
|
+
function hasValidEvidence(value) {
|
|
2634
|
+
const evidence = value.evidence;
|
|
2635
|
+
if (evidence === undefined) {
|
|
2636
|
+
return true;
|
|
2637
|
+
}
|
|
2638
|
+
if (!isObject(evidence)) {
|
|
2639
|
+
return false;
|
|
2640
|
+
}
|
|
2641
|
+
if (evidence.mode !== "WHOLE" && evidence.mode !== "SLICED") {
|
|
2642
|
+
return false;
|
|
2643
|
+
}
|
|
2644
|
+
return Array.isArray(evidence.sliceJudgments);
|
|
2645
|
+
}
|
|
2646
|
+
function isReviewOutcome(value) {
|
|
2647
|
+
return value === "PASS" || value === "WARN" || value === "FAIL" || value === "UNAVAILABLE" || value === "SKIPPED";
|
|
2648
|
+
}
|
|
2649
|
+
function isObject(value) {
|
|
2650
|
+
return typeof value === "object" && value !== null;
|
|
2651
|
+
}
|
|
2652
|
+
function ignore() {
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
// packages/opencode-adapter/src/history/mirrored-sink.ts
|
|
2656
|
+
function createMirroredReviewLogSink(hostSink, history) {
|
|
2657
|
+
return {
|
|
2658
|
+
async write(entry) {
|
|
2659
|
+
const [hostStatus] = await Promise.all([
|
|
2660
|
+
writeHost(hostSink, entry),
|
|
2661
|
+
appendHistory(history, entry)
|
|
2662
|
+
]);
|
|
2663
|
+
return hostStatus;
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
async function writeHost(sink, entry) {
|
|
2668
|
+
try {
|
|
2669
|
+
return await sink.write(entry);
|
|
2670
|
+
} catch {
|
|
2671
|
+
return "FAILED";
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
async function appendHistory(history, entry) {
|
|
2675
|
+
try {
|
|
2676
|
+
await history.append(entry);
|
|
2677
|
+
} catch {
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
// packages/opencode-adapter/src/history/node-file-system.ts
|
|
2682
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2683
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
2684
|
+
import { basename, dirname as dirname3, join as join6 } from "path";
|
|
2685
|
+
function createNodeReviewHistoryFileSystem() {
|
|
2686
|
+
return {
|
|
2687
|
+
readFile: readOptionalFile3,
|
|
2688
|
+
appendFile: appendHistoryFile,
|
|
2689
|
+
sizeOf: sizeOfOptionalFile,
|
|
2690
|
+
writeFileAtomic,
|
|
2691
|
+
ensureParentDirectory: async (path) => {
|
|
2692
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
2693
|
+
}
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
async function readOptionalFile3(path) {
|
|
2697
|
+
try {
|
|
2698
|
+
return await readFile3(path, "utf8");
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
if (isMissingFileError3(error)) {
|
|
2701
|
+
return null;
|
|
2702
|
+
}
|
|
2703
|
+
throw error;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
async function appendHistoryFile(path, data) {
|
|
2707
|
+
await appendFile(path, data, "utf8");
|
|
2708
|
+
}
|
|
2709
|
+
async function sizeOfOptionalFile(path) {
|
|
2710
|
+
try {
|
|
2711
|
+
const info = await stat(path);
|
|
2712
|
+
return info.size;
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
if (isMissingFileError3(error)) {
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
throw error;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
async function writeFileAtomic(path, data) {
|
|
2721
|
+
const temporaryPath = join6(dirname3(path), `.${basename(path)}.jevguard-tmp-${randomUUID2()}`);
|
|
2722
|
+
try {
|
|
2723
|
+
await writeFile2(temporaryPath, data, "utf8");
|
|
2724
|
+
await rename2(temporaryPath, path);
|
|
2725
|
+
} catch (error) {
|
|
2726
|
+
await rm2(temporaryPath, { force: true }).catch(() => {
|
|
2727
|
+
return;
|
|
2728
|
+
});
|
|
2729
|
+
throw error;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function isMissingFileError3(error) {
|
|
2733
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
2734
|
+
}
|
|
2735
|
+
// packages/opencode-adapter/src/history/process-context.ts
|
|
2736
|
+
import { homedir } from "os";
|
|
2737
|
+
function createProcessReviewHistoryContext() {
|
|
2738
|
+
return {
|
|
2739
|
+
cwd: process.cwd(),
|
|
2740
|
+
environment: processEnvironment(),
|
|
2741
|
+
homedir: homedir()
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
function processEnvironment() {
|
|
2745
|
+
return {
|
|
2746
|
+
get(name) {
|
|
2747
|
+
return process.env[name];
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
1772
2751
|
// packages/opencode-adapter/src/presentation/display.ts
|
|
1773
2752
|
function displayOutcome(counts) {
|
|
1774
2753
|
if (counts.fail > 0) {
|
|
@@ -1823,12 +2802,35 @@ function logLevelFor(outcome) {
|
|
|
1823
2802
|
function reviewLogMessage(entry) {
|
|
1824
2803
|
return `JevGuard ${displayOutcome(entry.summary.counts)} for turn ${entry.turnId}`;
|
|
1825
2804
|
}
|
|
2805
|
+
function projectEvidence(evidence) {
|
|
2806
|
+
return {
|
|
2807
|
+
mode: evidence.mode,
|
|
2808
|
+
plannedSliceCount: evidence.plannedSliceCount,
|
|
2809
|
+
evaluatedSliceCount: evidence.evaluatedSliceCount,
|
|
2810
|
+
failedSliceCount: evidence.failedSliceCount,
|
|
2811
|
+
notEvaluatedSliceCount: evidence.notEvaluatedSliceCount,
|
|
2812
|
+
slices: evidence.slices.map((slice) => ({
|
|
2813
|
+
index: slice.index,
|
|
2814
|
+
path: slice.path,
|
|
2815
|
+
kind: slice.kind,
|
|
2816
|
+
hunkOrdinal: slice.hunkOrdinal
|
|
2817
|
+
})),
|
|
2818
|
+
sliceJudgments: evidence.sliceJudgments.map((judgment) => ({
|
|
2819
|
+
index: judgment.index,
|
|
2820
|
+
probability: judgment.probability,
|
|
2821
|
+
outcome: judgment.outcome
|
|
2822
|
+
})),
|
|
2823
|
+
failingSliceIndex: evidence.failingSliceIndex,
|
|
2824
|
+
failureReason: evidence.failureReason
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
1826
2827
|
function ruleLogResult(result) {
|
|
1827
2828
|
const identity = {
|
|
1828
2829
|
kind: "RULE",
|
|
1829
2830
|
ruleId: result.ruleId,
|
|
1830
2831
|
severity: result.severity,
|
|
1831
|
-
scopedPaths: [...result.scopedPaths]
|
|
2832
|
+
scopedPaths: [...result.scopedPaths],
|
|
2833
|
+
...result.evidence === undefined ? {} : { evidence: projectEvidence(result.evidence) }
|
|
1832
2834
|
};
|
|
1833
2835
|
switch (result.outcome) {
|
|
1834
2836
|
case "PASS":
|
|
@@ -1874,10 +2876,28 @@ function toReviewToast(review) {
|
|
|
1874
2876
|
const outcome = displayOutcome(review.summary.counts);
|
|
1875
2877
|
return {
|
|
1876
2878
|
title: `JevGuard ${outcome}`,
|
|
1877
|
-
message: countSummary(review.summary.counts)
|
|
2879
|
+
message: `${countSummary(review.summary.counts)}${slicedSummary(review.results)}`,
|
|
1878
2880
|
variant: toastVariantFor(outcome)
|
|
1879
2881
|
};
|
|
1880
2882
|
}
|
|
2883
|
+
function slicedSummary(results) {
|
|
2884
|
+
let slicedRules = 0;
|
|
2885
|
+
let sliceJudgments = 0;
|
|
2886
|
+
for (const result of results) {
|
|
2887
|
+
if (result.kind !== "RULE" || result.evidence === undefined) {
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2890
|
+
if (result.evidence.mode !== "SLICED") {
|
|
2891
|
+
continue;
|
|
2892
|
+
}
|
|
2893
|
+
slicedRules += 1;
|
|
2894
|
+
sliceJudgments += result.evidence.evaluatedSliceCount;
|
|
2895
|
+
}
|
|
2896
|
+
if (slicedRules === 0 && sliceJudgments === 0) {
|
|
2897
|
+
return "";
|
|
2898
|
+
}
|
|
2899
|
+
return ` \xB7 sliced ${slicedRules} rules \xB7 slices ${sliceJudgments}`;
|
|
2900
|
+
}
|
|
1881
2901
|
function toastVariantFor(outcome) {
|
|
1882
2902
|
switch (outcome) {
|
|
1883
2903
|
case "PASS":
|
|
@@ -1996,25 +3016,50 @@ function toOutcomeExtra(result) {
|
|
|
1996
3016
|
}
|
|
1997
3017
|
// packages/opencode-adapter/src/presentation/remediation.ts
|
|
1998
3018
|
var TITLE = "JevGuard";
|
|
3019
|
+
var SERVICE_NAME2 = "jevguard";
|
|
1999
3020
|
var PREPARING_MESSAGE = "JevGuard is preparing a remediation proposal in the child session.";
|
|
2000
3021
|
var READY_MESSAGE = "JevGuard remediation proposal ready.";
|
|
3022
|
+
var FAILED_MESSAGE = "JevGuard could not prepare a remediation proposal.";
|
|
3023
|
+
var FAILURE_LOG_MESSAGE = "JevGuard remediation proposal failed";
|
|
2001
3024
|
function createRemediationNotifier(toast) {
|
|
2002
3025
|
return {
|
|
2003
3026
|
async proposalPreparing() {
|
|
2004
|
-
return showRemediationToast(toast, PREPARING_MESSAGE);
|
|
3027
|
+
return showRemediationToast(toast, PREPARING_MESSAGE, "info");
|
|
2005
3028
|
},
|
|
2006
3029
|
async proposalReady() {
|
|
2007
|
-
return showRemediationToast(toast, READY_MESSAGE);
|
|
3030
|
+
return showRemediationToast(toast, READY_MESSAGE, "info");
|
|
3031
|
+
},
|
|
3032
|
+
async proposalFailed() {
|
|
3033
|
+
return showRemediationToast(toast, FAILED_MESSAGE, "error");
|
|
2008
3034
|
}
|
|
2009
3035
|
};
|
|
2010
3036
|
}
|
|
2011
|
-
async function showRemediationToast(toast, message) {
|
|
3037
|
+
async function showRemediationToast(toast, message, variant) {
|
|
2012
3038
|
try {
|
|
2013
|
-
return await toast.show({ title: TITLE, message, variant
|
|
3039
|
+
return await toast.show({ title: TITLE, message, variant });
|
|
2014
3040
|
} catch {
|
|
2015
3041
|
return "FAILED";
|
|
2016
3042
|
}
|
|
2017
3043
|
}
|
|
3044
|
+
function createRemediationFailureLogSink(client) {
|
|
3045
|
+
return {
|
|
3046
|
+
async write(reason) {
|
|
3047
|
+
try {
|
|
3048
|
+
const result = await client.app.log({
|
|
3049
|
+
body: {
|
|
3050
|
+
service: SERVICE_NAME2,
|
|
3051
|
+
level: "error",
|
|
3052
|
+
message: FAILURE_LOG_MESSAGE,
|
|
3053
|
+
extra: { reason }
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
return result.error === undefined ? "DELIVERED" : "FAILED";
|
|
3057
|
+
} catch {
|
|
3058
|
+
return "FAILED";
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
2018
3063
|
function createOpenCodeSessionNavigator(client) {
|
|
2019
3064
|
return {
|
|
2020
3065
|
async navigate(sessionID) {
|
|
@@ -2065,11 +3110,12 @@ function createOpenCodeProposalSessionFacade(client) {
|
|
|
2065
3110
|
return result.data.id;
|
|
2066
3111
|
},
|
|
2067
3112
|
async prompt(input) {
|
|
3113
|
+
const model = input.model;
|
|
2068
3114
|
const result = await client.session.prompt({
|
|
2069
3115
|
path: { id: input.sessionID },
|
|
2070
3116
|
body: {
|
|
2071
3117
|
agent: input.agent,
|
|
2072
|
-
model: { providerID:
|
|
3118
|
+
...model === null ? {} : { model: { providerID: model.providerID, modelID: model.modelID } },
|
|
2073
3119
|
system: input.system,
|
|
2074
3120
|
tools: { ...input.tools },
|
|
2075
3121
|
parts: [{ type: "text", text: input.text }]
|
|
@@ -2081,15 +3127,51 @@ function createOpenCodeProposalSessionFacade(client) {
|
|
|
2081
3127
|
}
|
|
2082
3128
|
};
|
|
2083
3129
|
}
|
|
3130
|
+
// packages/plugin/src/cli/install.ts
|
|
3131
|
+
import { homedir as homedir2 } from "os";
|
|
3132
|
+
function performInstall(target) {
|
|
3133
|
+
return install({
|
|
3134
|
+
confirmation: createConfirmationPrompt(),
|
|
3135
|
+
fileSystem: createNodeConfigFileSystem(),
|
|
3136
|
+
target,
|
|
3137
|
+
cwd: process.cwd(),
|
|
3138
|
+
environment: process.env,
|
|
3139
|
+
homedir: homedir2()
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
2084
3143
|
// packages/plugin/src/cli/login.ts
|
|
2085
3144
|
function performLogin() {
|
|
2086
3145
|
return login({ prompt: createSecretPrompt(), store: createKeyringCredentialStore() });
|
|
2087
3146
|
}
|
|
2088
3147
|
|
|
3148
|
+
// packages/plugin/src/cli/report.ts
|
|
3149
|
+
async function performReport() {
|
|
3150
|
+
const history = createLocalReviewHistory({
|
|
3151
|
+
context: createProcessReviewHistoryContext(),
|
|
3152
|
+
fileSystem: createNodeReviewHistoryFileSystem(),
|
|
3153
|
+
now: () => new Date
|
|
3154
|
+
});
|
|
3155
|
+
const { records, skippedLineCount } = await history.read();
|
|
3156
|
+
return aggregateReviewHistory(records, skippedLineCount);
|
|
3157
|
+
}
|
|
3158
|
+
|
|
2089
3159
|
// packages/plugin/src/cli/messages.ts
|
|
2090
|
-
var USAGE =
|
|
3160
|
+
var USAGE = [
|
|
3161
|
+
"Usage: jevguard login",
|
|
3162
|
+
" jevguard install [--project]",
|
|
3163
|
+
" jevguard report [--json]",
|
|
3164
|
+
"",
|
|
3165
|
+
"`jev-init` and `jevguard-rules` are OpenCode skills, not CLI commands. Register",
|
|
3166
|
+
"the plugin in opencode.json (or run `jevguard install`) and invoke them from",
|
|
3167
|
+
"OpenCode."
|
|
3168
|
+
].join(`
|
|
3169
|
+
`);
|
|
3170
|
+
var EMPTY_HISTORY_MESSAGE = "No review history yet.";
|
|
3171
|
+
var REPORT_READ_FAILURE_MESSAGE = "Could not read the review history.";
|
|
2091
3172
|
var LOGIN_SAVED_MESSAGE = "Credential saved to the OS credential store.";
|
|
2092
3173
|
var LOGIN_ABORTED_MESSAGE = "Login cancelled. No credential was saved.";
|
|
3174
|
+
var MANUAL_INSTALL_SNIPPET = `{ "plugin": ["${JEVGUARD_PLUGIN_ENTRY}"] }`;
|
|
2093
3175
|
function describeLoginFailure(reason) {
|
|
2094
3176
|
switch (reason) {
|
|
2095
3177
|
case "NOT_A_TTY":
|
|
@@ -2102,13 +3184,108 @@ function describeLoginFailure(reason) {
|
|
|
2102
3184
|
return "Could not save the credential to the OS credential store. Check that a credential store is available and try again.";
|
|
2103
3185
|
}
|
|
2104
3186
|
}
|
|
3187
|
+
function describeInstallInstalled(target, path) {
|
|
3188
|
+
return `Registered JevGuard for the ${target} target in ${path}. Restart OpenCode to load it.`;
|
|
3189
|
+
}
|
|
3190
|
+
function describeInstallAlreadyInstalled(target, path) {
|
|
3191
|
+
return `JevGuard is already registered for the ${target} target in ${path}.`;
|
|
3192
|
+
}
|
|
3193
|
+
function describeInstallAborted(path) {
|
|
3194
|
+
return `Install cancelled. ${path} was not changed.`;
|
|
3195
|
+
}
|
|
3196
|
+
function describeInstallFailure(reason, path) {
|
|
3197
|
+
switch (reason) {
|
|
3198
|
+
case "NOT_A_TTY":
|
|
3199
|
+
return manualSnippetFailure(`${path} cannot be confirmed without an interactive terminal.`);
|
|
3200
|
+
case "UNSUPPORTED_JSONC":
|
|
3201
|
+
return manualSnippetFailure(`${path} is absent, but a sibling opencode.jsonc exists. JevGuard never edits .jsonc files.`);
|
|
3202
|
+
case "INVALID_JSON":
|
|
3203
|
+
return manualSnippetFailure(`Could not parse ${path} as JSON.`);
|
|
3204
|
+
case "INVALID_CONFIG_SHAPE":
|
|
3205
|
+
return manualSnippetFailure(`The plugin entry in ${path} is not an array of strings, so JevGuard did not change it.`);
|
|
3206
|
+
case "READ_FAILURE":
|
|
3207
|
+
return `Could not read ${path}. Check the file permissions and try again.`;
|
|
3208
|
+
case "WRITE_FAILURE":
|
|
3209
|
+
return `Could not write ${path}. Check the file permissions and try again.`;
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
function manualSnippetFailure(message) {
|
|
3213
|
+
return [message, "Add this to the plugin array manually:", MANUAL_INSTALL_SNIPPET].join(`
|
|
3214
|
+
`);
|
|
3215
|
+
}
|
|
3216
|
+
function serializeReport(report) {
|
|
3217
|
+
return JSON.stringify(report, null, 2);
|
|
3218
|
+
}
|
|
3219
|
+
function describeReport(report) {
|
|
3220
|
+
if (report.entryCount === 0) {
|
|
3221
|
+
return EMPTY_HISTORY_MESSAGE;
|
|
3222
|
+
}
|
|
3223
|
+
return [
|
|
3224
|
+
"JevGuard review history",
|
|
3225
|
+
`Turns: ${report.entryCount} \xB7 skipped malformed lines: ${report.skippedLineCount}`,
|
|
3226
|
+
`First: ${report.firstTimestamp ?? "unknown"}`,
|
|
3227
|
+
`Last: ${report.lastTimestamp ?? "unknown"}`,
|
|
3228
|
+
`Outcomes: ${describeTotals(report.outcomes)}`,
|
|
3229
|
+
"",
|
|
3230
|
+
"UNAVAILABLE reasons:",
|
|
3231
|
+
...describeReasons(report.unavailableReasons),
|
|
3232
|
+
"",
|
|
3233
|
+
"SKIPPED reasons:",
|
|
3234
|
+
...describeReasons(report.skippedReasons),
|
|
3235
|
+
"",
|
|
3236
|
+
"Built-ins:",
|
|
3237
|
+
...describeBuiltIns(report.builtIns),
|
|
3238
|
+
"",
|
|
3239
|
+
"Rules:",
|
|
3240
|
+
...describeRules(report.rules),
|
|
3241
|
+
"",
|
|
3242
|
+
`Evaluation: whole ${report.evaluation.wholeEvaluations} \xB7 sliced ${report.evaluation.slicedEvaluations} \xB7 slice judgments ${report.evaluation.totalSliceJudgments}`
|
|
3243
|
+
].join(`
|
|
3244
|
+
`);
|
|
3245
|
+
}
|
|
3246
|
+
function describeTotals(totals) {
|
|
3247
|
+
return `pass ${totals.pass} \xB7 warn ${totals.warn} \xB7 fail ${totals.fail} \xB7 unavailable ${totals.unavailable} \xB7 skipped ${totals.skipped}`;
|
|
3248
|
+
}
|
|
3249
|
+
function describeReasons(reasons) {
|
|
3250
|
+
if (reasons.length === 0) {
|
|
3251
|
+
return [" (none)"];
|
|
3252
|
+
}
|
|
3253
|
+
return reasons.map((entry) => ` ${entry.reason}: ${entry.count}`);
|
|
3254
|
+
}
|
|
3255
|
+
function describeBuiltIns(checks) {
|
|
3256
|
+
if (checks.length === 0) {
|
|
3257
|
+
return [" (none)"];
|
|
3258
|
+
}
|
|
3259
|
+
return checks.map((entry) => ` ${entry.checkId}: ${entry.count}`);
|
|
3260
|
+
}
|
|
3261
|
+
function describeRules(rules) {
|
|
3262
|
+
if (rules.length === 0) {
|
|
3263
|
+
return [" (none)"];
|
|
3264
|
+
}
|
|
3265
|
+
return rules.map((entry) => ` ${entry.ruleId}: ${describeTotals(entry.counts)} \xB7 sliced ${entry.slicedRuleCount}`);
|
|
3266
|
+
}
|
|
2105
3267
|
|
|
2106
3268
|
// packages/plugin/src/cli/run-cli.ts
|
|
2107
3269
|
async function runCli(argv, io, dependencies) {
|
|
2108
|
-
if (argv.length
|
|
2109
|
-
io
|
|
2110
|
-
return 1;
|
|
3270
|
+
if (argv.length === 1 && argv[0] === "login") {
|
|
3271
|
+
return runLogin(io, dependencies);
|
|
2111
3272
|
}
|
|
3273
|
+
if (argv.length === 1 && argv[0] === "install") {
|
|
3274
|
+
return runInstall(io, dependencies, "global");
|
|
3275
|
+
}
|
|
3276
|
+
if (argv.length === 2 && argv[0] === "install" && argv[1] === "--project") {
|
|
3277
|
+
return runInstall(io, dependencies, "project");
|
|
3278
|
+
}
|
|
3279
|
+
if (argv.length === 1 && argv[0] === "report") {
|
|
3280
|
+
return runReport(io, dependencies, false);
|
|
3281
|
+
}
|
|
3282
|
+
if (argv.length === 2 && argv[0] === "report" && argv[1] === "--json") {
|
|
3283
|
+
return runReport(io, dependencies, true);
|
|
3284
|
+
}
|
|
3285
|
+
io.writeErr(USAGE);
|
|
3286
|
+
return 1;
|
|
3287
|
+
}
|
|
3288
|
+
async function runLogin(io, dependencies) {
|
|
2112
3289
|
const outcome = await dependencies.login();
|
|
2113
3290
|
if (outcome.status === "SAVED") {
|
|
2114
3291
|
io.writeOut(LOGIN_SAVED_MESSAGE);
|
|
@@ -2121,6 +3298,36 @@ async function runCli(argv, io, dependencies) {
|
|
|
2121
3298
|
io.writeErr(describeLoginFailure(outcome.reason));
|
|
2122
3299
|
return 1;
|
|
2123
3300
|
}
|
|
3301
|
+
async function runInstall(io, dependencies, target) {
|
|
3302
|
+
const outcome = await dependencies.install(target);
|
|
3303
|
+
return reportInstall(io, outcome);
|
|
3304
|
+
}
|
|
3305
|
+
function reportInstall(io, outcome) {
|
|
3306
|
+
if (outcome.status === "INSTALLED") {
|
|
3307
|
+
io.writeOut(describeInstallInstalled(outcome.target, outcome.path));
|
|
3308
|
+
return 0;
|
|
3309
|
+
}
|
|
3310
|
+
if (outcome.status === "ALREADY_INSTALLED") {
|
|
3311
|
+
io.writeOut(describeInstallAlreadyInstalled(outcome.target, outcome.path));
|
|
3312
|
+
return 0;
|
|
3313
|
+
}
|
|
3314
|
+
if (outcome.status === "ABORTED") {
|
|
3315
|
+
io.writeErr(describeInstallAborted(outcome.path));
|
|
3316
|
+
return 1;
|
|
3317
|
+
}
|
|
3318
|
+
io.writeErr(describeInstallFailure(outcome.reason, outcome.path));
|
|
3319
|
+
return 1;
|
|
3320
|
+
}
|
|
3321
|
+
async function runReport(io, dependencies, json) {
|
|
3322
|
+
try {
|
|
3323
|
+
const report = await dependencies.report();
|
|
3324
|
+
io.writeOut(json ? serializeReport(report) : describeReport(report));
|
|
3325
|
+
return 0;
|
|
3326
|
+
} catch {
|
|
3327
|
+
io.writeErr(REPORT_READ_FAILURE_MESSAGE);
|
|
3328
|
+
return 1;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
2124
3331
|
|
|
2125
3332
|
// packages/plugin/src/cli/main.ts
|
|
2126
3333
|
var io = {
|
|
@@ -2129,5 +3336,9 @@ var io = {
|
|
|
2129
3336
|
writeErr: (message) => process.stderr.write(`${message}
|
|
2130
3337
|
`)
|
|
2131
3338
|
};
|
|
2132
|
-
var exitCode = await runCli(process.argv.slice(2), io, {
|
|
3339
|
+
var exitCode = await runCli(process.argv.slice(2), io, {
|
|
3340
|
+
login: performLogin,
|
|
3341
|
+
install: performInstall,
|
|
3342
|
+
report: performReport
|
|
3343
|
+
});
|
|
2133
3344
|
process.exitCode = exitCode;
|