@pablozrrrr/jevguard 0.2.2 → 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/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
- allowedExtensions: [
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 (!policy.allowedExtensions.includes(extension)) {
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 matchesScope(path, scope) {
545
- return globToRegExp(scope).test(normalizePath(path));
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
- if (scope === null) {
610
- return files;
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
611
  }
612
- return files.filter((file) => matchesScope(file.path, scope));
612
+ const safety = checkFilePath(path, policy);
613
+ if (!safety.allowed && safety.reason === "DENIED_SENSITIVE_PATH") {
614
+ return true;
615
+ }
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 evaluateWithPort(port, request) {
659
+ async function evaluateRuleBatchWithPort(port, requests) {
654
660
  try {
655
- const result = await port.evaluate(request);
656
- return result.kind === "RULE" ? result : { kind: "RULE", status: "FAILED", reason: "INVALID_RESPONSE" };
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 { kind: "RULE", status: "FAILED", reason: "API_ERROR" };
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
- // packages/core/src/evaluation/evaluate-rule.ts
671
- async function evaluateRule(input, dependencies) {
672
- const selection = selectRuleEvidence(input.turn, input.rule, input.evidencePolicy);
673
- if (selection.status === "SKIPPED") {
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
- if (selection.status === "UNAVAILABLE") {
678
- const context = buildReviewContext(input.turn, input.rule, []);
679
- return { ...context, outcome: "UNAVAILABLE", reason: selection.reason };
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 context = buildReviewContext(input.turn, input.rule, selection.evidence.files);
682
- const request = buildJevRequest(input.turn.task, input.rule, selection.evidence);
683
- const evaluation = await evaluateWithPort(dependencies.jev, request);
684
- if (evaluation.status === "FAILED") {
685
- return { ...context, outcome: "UNAVAILABLE", reason: "JEV_FAILURE" };
691
+ const diff = assembleDiff(applicableFiles);
692
+ if (diff.trim() === "") {
693
+ return { status: "SKIPPED", reason: "NO_ATTRIBUTED_PATCH" };
686
694
  }
687
- const gate = evaluateGate(input.rule.severity, evaluation.noul.violationProbability, input.gateConfig);
688
- return { ...context, ...gate };
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)] };
700
+ }
701
+ return sliceByFile(applicableFiles, policy);
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
+ };
689
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/evaluate-rules.ts
744
- async function evaluateRules(input, dependencies) {
745
- const results = [];
746
- for (const rule of input.rules) {
747
- results.push(await evaluateRuleResult(input, rule, dependencies));
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
- return aggregateReview(input.turn.id, results);
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
- async function evaluateRuleResult(input, rule, dependencies) {
752
- if (rule.status === "INVALID") {
753
- return invalidRuleResult(input.turn.id, rule);
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
- return invalidConfigResult(input.turn, rule.rule);
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
- const ruleInput = {
759
- turn: input.turn,
760
- rule: rule.rule,
761
- gateConfig: input.gateConfig.config,
762
- evidencePolicy: input.evidencePolicy
763
- };
764
- return evaluateRule(ruleInput, dependencies);
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 scope = null;
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 === "scope") {
826
- if (entry.value === "") {
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
- scope = entry.value;
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
- return { ok: true, value: { severity, scope } };
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
  }
@@ -1522,6 +1985,7 @@ var JEV_MODEL = "jev-latest";
1522
1985
 
1523
1986
  // packages/opencode-adapter/src/jev/transport.ts
1524
1987
  var DEFAULT_ALLOWED_CRITERION = "The change does not violate the rule's Violation.";
1988
+ var RULE_BATCH_CONCURRENCY = 4;
1525
1989
  function buildTypeSafeClientConfig(apiKey) {
1526
1990
  return { apiKey, defaultModel: JEV_MODEL, logLevel: "off" };
1527
1991
  }
@@ -1539,9 +2003,48 @@ function createJevTransport(dependencies) {
1539
2003
  return failedFor(request, "API_ERROR");
1540
2004
  }
1541
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));
1542
2023
  }
1543
2024
  };
1544
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
+ }
1545
2048
  function createTypeSafeJevTransport(credentials) {
1546
2049
  return createJevTransport({ credentials, createClient: createTypeSafeSystemOneClient });
1547
2050
  }
@@ -1990,6 +2493,261 @@ async function readOptionalFile2(fileSystem, path) {
1990
2493
  function isMissingFileError2(error) {
1991
2494
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1992
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
+ }
1993
2751
  // packages/opencode-adapter/src/presentation/display.ts
1994
2752
  function displayOutcome(counts) {
1995
2753
  if (counts.fail > 0) {
@@ -2044,12 +2802,35 @@ function logLevelFor(outcome) {
2044
2802
  function reviewLogMessage(entry) {
2045
2803
  return `JevGuard ${displayOutcome(entry.summary.counts)} for turn ${entry.turnId}`;
2046
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
+ }
2047
2827
  function ruleLogResult(result) {
2048
2828
  const identity = {
2049
2829
  kind: "RULE",
2050
2830
  ruleId: result.ruleId,
2051
2831
  severity: result.severity,
2052
- scopedPaths: [...result.scopedPaths]
2832
+ scopedPaths: [...result.scopedPaths],
2833
+ ...result.evidence === undefined ? {} : { evidence: projectEvidence(result.evidence) }
2053
2834
  };
2054
2835
  switch (result.outcome) {
2055
2836
  case "PASS":
@@ -2095,10 +2876,28 @@ function toReviewToast(review) {
2095
2876
  const outcome = displayOutcome(review.summary.counts);
2096
2877
  return {
2097
2878
  title: `JevGuard ${outcome}`,
2098
- message: countSummary(review.summary.counts),
2879
+ message: `${countSummary(review.summary.counts)}${slicedSummary(review.results)}`,
2099
2880
  variant: toastVariantFor(outcome)
2100
2881
  };
2101
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
+ }
2102
2901
  function toastVariantFor(outcome) {
2103
2902
  switch (outcome) {
2104
2903
  case "PASS":
@@ -2329,7 +3128,7 @@ function createOpenCodeProposalSessionFacade(client) {
2329
3128
  };
2330
3129
  }
2331
3130
  // packages/plugin/src/cli/install.ts
2332
- import { homedir } from "os";
3131
+ import { homedir as homedir2 } from "os";
2333
3132
  function performInstall(target) {
2334
3133
  return install({
2335
3134
  confirmation: createConfirmationPrompt(),
@@ -2337,7 +3136,7 @@ function performInstall(target) {
2337
3136
  target,
2338
3137
  cwd: process.cwd(),
2339
3138
  environment: process.env,
2340
- homedir: homedir()
3139
+ homedir: homedir2()
2341
3140
  });
2342
3141
  }
2343
3142
 
@@ -2346,16 +3145,30 @@ function performLogin() {
2346
3145
  return login({ prompt: createSecretPrompt(), store: createKeyringCredentialStore() });
2347
3146
  }
2348
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
+
2349
3159
  // packages/plugin/src/cli/messages.ts
2350
3160
  var USAGE = [
2351
3161
  "Usage: jevguard login",
2352
3162
  " jevguard install [--project]",
3163
+ " jevguard report [--json]",
2353
3164
  "",
2354
3165
  "`jev-init` and `jevguard-rules` are OpenCode skills, not CLI commands. Register",
2355
3166
  "the plugin in opencode.json (or run `jevguard install`) and invoke them from",
2356
3167
  "OpenCode."
2357
3168
  ].join(`
2358
3169
  `);
3170
+ var EMPTY_HISTORY_MESSAGE = "No review history yet.";
3171
+ var REPORT_READ_FAILURE_MESSAGE = "Could not read the review history.";
2359
3172
  var LOGIN_SAVED_MESSAGE = "Credential saved to the OS credential store.";
2360
3173
  var LOGIN_ABORTED_MESSAGE = "Login cancelled. No credential was saved.";
2361
3174
  var MANUAL_INSTALL_SNIPPET = `{ "plugin": ["${JEVGUARD_PLUGIN_ENTRY}"] }`;
@@ -2400,6 +3213,57 @@ function manualSnippetFailure(message) {
2400
3213
  return [message, "Add this to the plugin array manually:", MANUAL_INSTALL_SNIPPET].join(`
2401
3214
  `);
2402
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
+ }
2403
3267
 
2404
3268
  // packages/plugin/src/cli/run-cli.ts
2405
3269
  async function runCli(argv, io, dependencies) {
@@ -2412,6 +3276,12 @@ async function runCli(argv, io, dependencies) {
2412
3276
  if (argv.length === 2 && argv[0] === "install" && argv[1] === "--project") {
2413
3277
  return runInstall(io, dependencies, "project");
2414
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
+ }
2415
3285
  io.writeErr(USAGE);
2416
3286
  return 1;
2417
3287
  }
@@ -2448,6 +3318,16 @@ function reportInstall(io, outcome) {
2448
3318
  io.writeErr(describeInstallFailure(outcome.reason, outcome.path));
2449
3319
  return 1;
2450
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
+ }
2451
3331
 
2452
3332
  // packages/plugin/src/cli/main.ts
2453
3333
  var io = {
@@ -2458,6 +3338,7 @@ var io = {
2458
3338
  };
2459
3339
  var exitCode = await runCli(process.argv.slice(2), io, {
2460
3340
  login: performLogin,
2461
- install: performInstall
3341
+ install: performInstall,
3342
+ report: performReport
2462
3343
  });
2463
3344
  process.exitCode = exitCode;