popeye-cli 2.2.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/dist/adapters/gemini.d.ts +14 -0
  2. package/dist/adapters/gemini.d.ts.map +1 -1
  3. package/dist/adapters/gemini.js +41 -6
  4. package/dist/adapters/gemini.js.map +1 -1
  5. package/dist/adapters/grok.d.ts +14 -0
  6. package/dist/adapters/grok.d.ts.map +1 -1
  7. package/dist/adapters/grok.js +42 -6
  8. package/dist/adapters/grok.js.map +1 -1
  9. package/dist/adapters/openai.d.ts +10 -0
  10. package/dist/adapters/openai.d.ts.map +1 -1
  11. package/dist/adapters/openai.js +44 -5
  12. package/dist/adapters/openai.js.map +1 -1
  13. package/dist/cli/commands/create.js +1 -1
  14. package/dist/cli/commands/create.js.map +1 -1
  15. package/dist/cli/interactive.d.ts.map +1 -1
  16. package/dist/cli/interactive.js +324 -20
  17. package/dist/cli/interactive.js.map +1 -1
  18. package/dist/generators/all.d.ts.map +1 -1
  19. package/dist/generators/all.js +3 -2
  20. package/dist/generators/all.js.map +1 -1
  21. package/dist/generators/doc-parser.d.ts +21 -6
  22. package/dist/generators/doc-parser.d.ts.map +1 -1
  23. package/dist/generators/doc-parser.js +55 -4
  24. package/dist/generators/doc-parser.js.map +1 -1
  25. package/dist/generators/templates/fullstack.js +1 -1
  26. package/dist/generators/templates/website-components.js +1 -1
  27. package/dist/generators/templates/website-components.js.map +1 -1
  28. package/dist/generators/templates/website-config.d.ts +4 -1
  29. package/dist/generators/templates/website-config.d.ts.map +1 -1
  30. package/dist/generators/templates/website-config.js +17 -11
  31. package/dist/generators/templates/website-config.js.map +1 -1
  32. package/dist/generators/templates/website-conversion.js +1 -1
  33. package/dist/generators/templates/website-conversion.js.map +1 -1
  34. package/dist/generators/templates/website-landing.js +1 -1
  35. package/dist/generators/templates/website-landing.js.map +1 -1
  36. package/dist/generators/templates/website-layout.d.ts +36 -4
  37. package/dist/generators/templates/website-layout.d.ts.map +1 -1
  38. package/dist/generators/templates/website-layout.js +466 -23
  39. package/dist/generators/templates/website-layout.js.map +1 -1
  40. package/dist/generators/templates/website-pricing.js +1 -1
  41. package/dist/generators/templates/website-pricing.js.map +1 -1
  42. package/dist/generators/templates/website-sections.js +1 -1
  43. package/dist/generators/templates/website-sections.js.map +1 -1
  44. package/dist/generators/templates/website-seo.d.ts.map +1 -1
  45. package/dist/generators/templates/website-seo.js +4 -1
  46. package/dist/generators/templates/website-seo.js.map +1 -1
  47. package/dist/generators/templates/website.d.ts +1 -1
  48. package/dist/generators/templates/website.d.ts.map +1 -1
  49. package/dist/generators/templates/website.js +1 -1
  50. package/dist/generators/templates/website.js.map +1 -1
  51. package/dist/generators/website-content-ai.d.ts +52 -0
  52. package/dist/generators/website-content-ai.d.ts.map +1 -0
  53. package/dist/generators/website-content-ai.js +141 -0
  54. package/dist/generators/website-content-ai.js.map +1 -0
  55. package/dist/generators/website-content-scanner.d.ts +1 -1
  56. package/dist/generators/website-content-scanner.d.ts.map +1 -1
  57. package/dist/generators/website-content-scanner.js +98 -1
  58. package/dist/generators/website-content-scanner.js.map +1 -1
  59. package/dist/generators/website-context.d.ts +34 -1
  60. package/dist/generators/website-context.d.ts.map +1 -1
  61. package/dist/generators/website-context.js +131 -9
  62. package/dist/generators/website-context.js.map +1 -1
  63. package/dist/generators/website-debug.d.ts +12 -0
  64. package/dist/generators/website-debug.d.ts.map +1 -1
  65. package/dist/generators/website-debug.js +16 -0
  66. package/dist/generators/website-debug.js.map +1 -1
  67. package/dist/generators/website.d.ts.map +1 -1
  68. package/dist/generators/website.js +26 -4
  69. package/dist/generators/website.js.map +1 -1
  70. package/dist/pipeline/auto-recovery.d.ts +56 -0
  71. package/dist/pipeline/auto-recovery.d.ts.map +1 -0
  72. package/dist/pipeline/auto-recovery.js +185 -0
  73. package/dist/pipeline/auto-recovery.js.map +1 -0
  74. package/dist/pipeline/change-request.d.ts +39 -0
  75. package/dist/pipeline/change-request.d.ts.map +1 -1
  76. package/dist/pipeline/change-request.js +40 -1
  77. package/dist/pipeline/change-request.js.map +1 -1
  78. package/dist/pipeline/check-runner.d.ts +30 -1
  79. package/dist/pipeline/check-runner.d.ts.map +1 -1
  80. package/dist/pipeline/check-runner.js +122 -1
  81. package/dist/pipeline/check-runner.js.map +1 -1
  82. package/dist/pipeline/command-resolver.d.ts.map +1 -1
  83. package/dist/pipeline/command-resolver.js +33 -2
  84. package/dist/pipeline/command-resolver.js.map +1 -1
  85. package/dist/pipeline/consensus/arbitrator-query.d.ts +22 -0
  86. package/dist/pipeline/consensus/arbitrator-query.d.ts.map +1 -0
  87. package/dist/pipeline/consensus/arbitrator-query.js +70 -0
  88. package/dist/pipeline/consensus/arbitrator-query.js.map +1 -0
  89. package/dist/pipeline/consensus/consensus-runner.d.ts +131 -7
  90. package/dist/pipeline/consensus/consensus-runner.d.ts.map +1 -1
  91. package/dist/pipeline/consensus/consensus-runner.js +809 -35
  92. package/dist/pipeline/consensus/consensus-runner.js.map +1 -1
  93. package/dist/pipeline/cr-lifecycle.d.ts +42 -0
  94. package/dist/pipeline/cr-lifecycle.d.ts.map +1 -0
  95. package/dist/pipeline/cr-lifecycle.js +89 -0
  96. package/dist/pipeline/cr-lifecycle.js.map +1 -0
  97. package/dist/pipeline/gate-engine.d.ts +1 -0
  98. package/dist/pipeline/gate-engine.d.ts.map +1 -1
  99. package/dist/pipeline/gate-engine.js +26 -7
  100. package/dist/pipeline/gate-engine.js.map +1 -1
  101. package/dist/pipeline/orchestrator.d.ts +1 -1
  102. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  103. package/dist/pipeline/orchestrator.js +306 -16
  104. package/dist/pipeline/orchestrator.js.map +1 -1
  105. package/dist/pipeline/packets/consensus-packet-builder.d.ts +15 -4
  106. package/dist/pipeline/packets/consensus-packet-builder.d.ts.map +1 -1
  107. package/dist/pipeline/packets/consensus-packet-builder.js +29 -17
  108. package/dist/pipeline/packets/consensus-packet-builder.js.map +1 -1
  109. package/dist/pipeline/phases/architecture.d.ts.map +1 -1
  110. package/dist/pipeline/phases/architecture.js +5 -3
  111. package/dist/pipeline/phases/architecture.js.map +1 -1
  112. package/dist/pipeline/phases/audit.d.ts.map +1 -1
  113. package/dist/pipeline/phases/audit.js +5 -3
  114. package/dist/pipeline/phases/audit.js.map +1 -1
  115. package/dist/pipeline/phases/consensus-architecture.d.ts.map +1 -1
  116. package/dist/pipeline/phases/consensus-architecture.js +10 -1
  117. package/dist/pipeline/phases/consensus-architecture.js.map +1 -1
  118. package/dist/pipeline/phases/consensus-master-plan.d.ts.map +1 -1
  119. package/dist/pipeline/phases/consensus-master-plan.js +10 -3
  120. package/dist/pipeline/phases/consensus-master-plan.js.map +1 -1
  121. package/dist/pipeline/phases/consensus-role-plans.d.ts.map +1 -1
  122. package/dist/pipeline/phases/consensus-role-plans.js +10 -1
  123. package/dist/pipeline/phases/consensus-role-plans.js.map +1 -1
  124. package/dist/pipeline/phases/done.d.ts.map +1 -1
  125. package/dist/pipeline/phases/done.js +9 -4
  126. package/dist/pipeline/phases/done.js.map +1 -1
  127. package/dist/pipeline/phases/intake.d.ts.map +1 -1
  128. package/dist/pipeline/phases/intake.js +7 -3
  129. package/dist/pipeline/phases/intake.js.map +1 -1
  130. package/dist/pipeline/phases/phase-context.d.ts +2 -0
  131. package/dist/pipeline/phases/phase-context.d.ts.map +1 -1
  132. package/dist/pipeline/phases/phase-context.js +3 -1
  133. package/dist/pipeline/phases/phase-context.js.map +1 -1
  134. package/dist/pipeline/phases/production-gate.d.ts.map +1 -1
  135. package/dist/pipeline/phases/production-gate.js +28 -3
  136. package/dist/pipeline/phases/production-gate.js.map +1 -1
  137. package/dist/pipeline/phases/qa-validation.d.ts.map +1 -1
  138. package/dist/pipeline/phases/qa-validation.js +38 -5
  139. package/dist/pipeline/phases/qa-validation.js.map +1 -1
  140. package/dist/pipeline/phases/recovery-loop.d.ts +2 -0
  141. package/dist/pipeline/phases/recovery-loop.d.ts.map +1 -1
  142. package/dist/pipeline/phases/recovery-loop.js +200 -6
  143. package/dist/pipeline/phases/recovery-loop.js.map +1 -1
  144. package/dist/pipeline/phases/review.d.ts.map +1 -1
  145. package/dist/pipeline/phases/review.js +58 -28
  146. package/dist/pipeline/phases/review.js.map +1 -1
  147. package/dist/pipeline/phases/role-planning.d.ts.map +1 -1
  148. package/dist/pipeline/phases/role-planning.js +18 -2
  149. package/dist/pipeline/phases/role-planning.js.map +1 -1
  150. package/dist/pipeline/phases/stuck.d.ts.map +1 -1
  151. package/dist/pipeline/phases/stuck.js +10 -0
  152. package/dist/pipeline/phases/stuck.js.map +1 -1
  153. package/dist/pipeline/repo-snapshot.d.ts.map +1 -1
  154. package/dist/pipeline/repo-snapshot.js +3 -0
  155. package/dist/pipeline/repo-snapshot.js.map +1 -1
  156. package/dist/pipeline/role-execution-adapter.d.ts +2 -1
  157. package/dist/pipeline/role-execution-adapter.d.ts.map +1 -1
  158. package/dist/pipeline/role-execution-adapter.js +22 -7
  159. package/dist/pipeline/role-execution-adapter.js.map +1 -1
  160. package/dist/pipeline/skill-loader.d.ts +19 -0
  161. package/dist/pipeline/skill-loader.d.ts.map +1 -1
  162. package/dist/pipeline/skill-loader.js +22 -0
  163. package/dist/pipeline/skill-loader.js.map +1 -1
  164. package/dist/pipeline/skills/coverage-gate.d.ts +44 -0
  165. package/dist/pipeline/skills/coverage-gate.d.ts.map +1 -0
  166. package/dist/pipeline/skills/coverage-gate.js +143 -0
  167. package/dist/pipeline/skills/coverage-gate.js.map +1 -0
  168. package/dist/pipeline/skills/usage-registry.d.ts +48 -0
  169. package/dist/pipeline/skills/usage-registry.d.ts.map +1 -0
  170. package/dist/pipeline/skills/usage-registry.js +55 -0
  171. package/dist/pipeline/skills/usage-registry.js.map +1 -0
  172. package/dist/pipeline/strategy-context.d.ts +20 -0
  173. package/dist/pipeline/strategy-context.d.ts.map +1 -0
  174. package/dist/pipeline/strategy-context.js +55 -0
  175. package/dist/pipeline/strategy-context.js.map +1 -0
  176. package/dist/pipeline/type-defs/artifacts.d.ts +25 -5
  177. package/dist/pipeline/type-defs/artifacts.d.ts.map +1 -1
  178. package/dist/pipeline/type-defs/artifacts.js +4 -0
  179. package/dist/pipeline/type-defs/artifacts.js.map +1 -1
  180. package/dist/pipeline/type-defs/audit.d.ts +25 -13
  181. package/dist/pipeline/type-defs/audit.d.ts.map +1 -1
  182. package/dist/pipeline/type-defs/checks.d.ts +18 -8
  183. package/dist/pipeline/type-defs/checks.d.ts.map +1 -1
  184. package/dist/pipeline/type-defs/checks.js +4 -0
  185. package/dist/pipeline/type-defs/checks.js.map +1 -1
  186. package/dist/pipeline/type-defs/packets.d.ts +104 -18
  187. package/dist/pipeline/type-defs/packets.d.ts.map +1 -1
  188. package/dist/pipeline/type-defs/packets.js +17 -1
  189. package/dist/pipeline/type-defs/packets.js.map +1 -1
  190. package/dist/pipeline/type-defs/state.d.ts +160 -16
  191. package/dist/pipeline/type-defs/state.d.ts.map +1 -1
  192. package/dist/pipeline/type-defs/state.js +26 -1
  193. package/dist/pipeline/type-defs/state.js.map +1 -1
  194. package/dist/shared/text-utils.d.ts +23 -0
  195. package/dist/shared/text-utils.d.ts.map +1 -0
  196. package/dist/shared/text-utils.js +66 -0
  197. package/dist/shared/text-utils.js.map +1 -0
  198. package/dist/shared/website-strategy-format.d.ts +18 -0
  199. package/dist/shared/website-strategy-format.d.ts.map +1 -0
  200. package/dist/shared/website-strategy-format.js +47 -0
  201. package/dist/shared/website-strategy-format.js.map +1 -0
  202. package/dist/state/index.d.ts +2 -0
  203. package/dist/state/index.d.ts.map +1 -1
  204. package/dist/state/index.js +57 -8
  205. package/dist/state/index.js.map +1 -1
  206. package/dist/types/consensus.d.ts +1 -0
  207. package/dist/types/consensus.d.ts.map +1 -1
  208. package/dist/types/consensus.js.map +1 -1
  209. package/dist/types/website-strategy.d.ts +1 -1
  210. package/dist/types/workflow.d.ts +447 -0
  211. package/dist/types/workflow.d.ts.map +1 -1
  212. package/dist/types/workflow.js +3 -0
  213. package/dist/types/workflow.js.map +1 -1
  214. package/dist/upgrade/handlers.d.ts.map +1 -1
  215. package/dist/upgrade/handlers.js +6 -3
  216. package/dist/upgrade/handlers.js.map +1 -1
  217. package/dist/workflow/consensus.d.ts.map +1 -1
  218. package/dist/workflow/consensus.js +1 -0
  219. package/dist/workflow/consensus.js.map +1 -1
  220. package/dist/workflow/website-strategy.d.ts.map +1 -1
  221. package/dist/workflow/website-strategy.js +2 -29
  222. package/dist/workflow/website-strategy.js.map +1 -1
  223. package/dist/workflow/website-updater.d.ts.map +1 -1
  224. package/dist/workflow/website-updater.js +3 -2
  225. package/dist/workflow/website-updater.js.map +1 -1
  226. package/package.json +1 -1
  227. package/src/adapters/gemini.ts +51 -6
  228. package/src/adapters/grok.ts +51 -6
  229. package/src/adapters/openai.ts +53 -5
  230. package/src/cli/commands/create.ts +1 -1
  231. package/src/cli/interactive.ts +333 -19
  232. package/src/generators/all.ts +3 -2
  233. package/src/generators/doc-parser.ts +75 -15
  234. package/src/generators/templates/fullstack.ts +1 -1
  235. package/src/generators/templates/website-components.ts +1 -1
  236. package/src/generators/templates/website-config.ts +23 -11
  237. package/src/generators/templates/website-conversion.ts +1 -1
  238. package/src/generators/templates/website-landing.ts +1 -1
  239. package/src/generators/templates/website-layout.ts +491 -23
  240. package/src/generators/templates/website-pricing.ts +1 -1
  241. package/src/generators/templates/website-sections.ts +1 -1
  242. package/src/generators/templates/website-seo.ts +4 -1
  243. package/src/generators/templates/website.ts +3 -0
  244. package/src/generators/website-content-ai.ts +186 -0
  245. package/src/generators/website-content-scanner.ts +113 -1
  246. package/src/generators/website-context.ts +151 -12
  247. package/src/generators/website-debug.ts +26 -0
  248. package/src/generators/website.ts +28 -3
  249. package/src/pipeline/auto-recovery.ts +283 -0
  250. package/src/pipeline/change-request.ts +63 -1
  251. package/src/pipeline/check-runner.ts +141 -2
  252. package/src/pipeline/command-resolver.ts +34 -2
  253. package/src/pipeline/consensus/arbitrator-query.ts +101 -0
  254. package/src/pipeline/consensus/consensus-runner.ts +1099 -42
  255. package/src/pipeline/cr-lifecycle.ts +103 -0
  256. package/src/pipeline/gate-engine.ts +35 -7
  257. package/src/pipeline/orchestrator.ts +361 -16
  258. package/src/pipeline/packets/consensus-packet-builder.ts +44 -18
  259. package/src/pipeline/phases/architecture.ts +6 -3
  260. package/src/pipeline/phases/audit.ts +6 -3
  261. package/src/pipeline/phases/consensus-architecture.ts +10 -1
  262. package/src/pipeline/phases/consensus-master-plan.ts +10 -3
  263. package/src/pipeline/phases/consensus-role-plans.ts +10 -1
  264. package/src/pipeline/phases/done.ts +15 -4
  265. package/src/pipeline/phases/intake.ts +7 -3
  266. package/src/pipeline/phases/phase-context.ts +6 -1
  267. package/src/pipeline/phases/production-gate.ts +41 -3
  268. package/src/pipeline/phases/qa-validation.ts +51 -5
  269. package/src/pipeline/phases/recovery-loop.ts +229 -7
  270. package/src/pipeline/phases/review.ts +73 -30
  271. package/src/pipeline/phases/role-planning.ts +21 -2
  272. package/src/pipeline/phases/stuck.ts +10 -0
  273. package/src/pipeline/repo-snapshot.ts +3 -0
  274. package/src/pipeline/role-execution-adapter.ts +30 -4
  275. package/src/pipeline/skill-loader.ts +33 -0
  276. package/src/pipeline/skills/coverage-gate.ts +199 -0
  277. package/src/pipeline/skills/usage-registry.ts +87 -0
  278. package/src/pipeline/strategy-context.ts +60 -0
  279. package/src/pipeline/type-defs/artifacts.ts +4 -0
  280. package/src/pipeline/type-defs/checks.ts +4 -0
  281. package/src/pipeline/type-defs/packets.ts +18 -1
  282. package/src/pipeline/type-defs/state.ts +26 -1
  283. package/src/shared/text-utils.ts +70 -0
  284. package/src/shared/website-strategy-format.ts +56 -0
  285. package/src/state/index.ts +60 -8
  286. package/src/types/consensus.ts +1 -0
  287. package/src/types/workflow.ts +6 -0
  288. package/src/upgrade/handlers.ts +9 -3
  289. package/src/workflow/consensus.ts +1 -0
  290. package/src/workflow/website-strategy.ts +2 -36
  291. package/src/workflow/website-updater.ts +4 -2
  292. package/tests/adapters/gemini.test.ts +165 -0
  293. package/tests/adapters/grok.test.ts +137 -0
  294. package/tests/adapters/openai.test.ts +128 -0
  295. package/tests/generators/doc-parser.test.ts +88 -9
  296. package/tests/generators/quality-gate.test.ts +19 -3
  297. package/tests/generators/website-components.test.ts +34 -0
  298. package/tests/generators/website-content-ai.test.ts +308 -0
  299. package/tests/generators/website-content-scanner.test.ts +86 -0
  300. package/tests/generators/website-context.test.ts +3 -2
  301. package/tests/integration/smokestack-scaffold.test.ts +385 -0
  302. package/tests/pipeline/auto-recovery.test.ts +337 -0
  303. package/tests/pipeline/change-request.test.ts +70 -0
  304. package/tests/pipeline/command-resolver.test.ts +42 -0
  305. package/tests/pipeline/consensus/arbitrator-query.test.ts +107 -0
  306. package/tests/pipeline/consensus-runner.test.ts +1333 -10
  307. package/tests/pipeline/consensus-scoring.test.ts +602 -18
  308. package/tests/pipeline/gate-engine.test.ts +34 -0
  309. package/tests/pipeline/install-check.test.ts +261 -0
  310. package/tests/pipeline/orchestrator.test.ts +1506 -15
  311. package/tests/pipeline/packets/builders.test.ts +29 -6
  312. package/tests/pipeline/phases/role-planning.strategy.test.ts +204 -0
  313. package/tests/pipeline/pipeline-persistence.test.ts +230 -0
  314. package/tests/pipeline/recovery-loop-guidance.test.ts +280 -0
  315. package/tests/pipeline/role-execution-adapter.test.ts +88 -0
  316. package/tests/pipeline/skills/coverage-gate.test.ts +370 -0
  317. package/tests/pipeline/skills/usage-registry.test.ts +114 -0
  318. package/tests/pipeline/strategy-context.test.ts +148 -0
  319. package/tests/shared/text-utils.test.ts +155 -0
  320. package/tests/state/progress-analysis.test.ts +375 -0
  321. package/tests/upgrade/handlers.test.ts +33 -2
  322. package/tests/workflow/consensus.test.ts +6 -0
  323. package/tsconfig.json +1 -1
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Consensus Scoring tests — weighted scoring, CONDITIONAL handling,
3
- * blocking issues, confidence weights, backward compat.
2
+ * Consensus Scoring tests — Option B scoring, normalization, REJECT guard,
3
+ * force-zero semantics, backward compat.
4
4
  */
5
5
 
6
6
  import { describe, it, expect } from 'vitest';
7
7
  import { computeConsensusScore } from '../../src/pipeline/packets/consensus-packet-builder.js';
8
8
  import { buildConsensusPacket } from '../../src/pipeline/packets/consensus-packet-builder.js';
9
+ import { normalizeVoteBlockers } from '../../src/pipeline/consensus/consensus-runner.js';
9
10
  import type { ReviewerVote, ArtifactRef } from '../../src/pipeline/types.js';
10
11
 
11
12
  function makeVote(overrides: Partial<ReviewerVote> = {}): ReviewerVote {
@@ -32,7 +33,7 @@ const mockRef: ArtifactRef = {
32
33
  type: 'master_plan',
33
34
  };
34
35
 
35
- describe('computeConsensusScore', () => {
36
+ describe('computeConsensusScore (Option B)', () => {
36
37
  it('should return 0 for empty votes', () => {
37
38
  const result = computeConsensusScore([]);
38
39
  expect(result.score).toBe(0);
@@ -46,7 +47,8 @@ describe('computeConsensusScore', () => {
46
47
  ];
47
48
  const result = computeConsensusScore(votes);
48
49
  expect(result.score).toBe(1.0);
49
- expect(result.weighted_score).toBe(1.0);
50
+ // Option B: (1.0*0.9 + 1.0*0.8) / 2 = 0.85
51
+ expect(result.weighted_score).toBeCloseTo(0.85, 3);
50
52
  });
51
53
 
52
54
  it('should return 0 for all REJECT votes', () => {
@@ -65,33 +67,111 @@ describe('computeConsensusScore', () => {
65
67
  ];
66
68
  const result = computeConsensusScore(votes);
67
69
  expect(result.score).toBe(0); // Simple: 0 approves / 1 total
68
- expect(result.weighted_score).toBe(0.5); // Weighted: 0.5 * 1.0 / 1.0
70
+ // Option B: (0.5 * 1.0) / 1 = 0.5
71
+ expect(result.weighted_score).toBe(0.5);
69
72
  });
70
73
 
71
- it('should weight by confidence', () => {
74
+ it('should average per reviewer (Option B)', () => {
72
75
  const votes = [
73
76
  makeVote({ vote: 'APPROVE', confidence: 1.0, reviewer_id: 'r1' }),
74
77
  makeVote({ vote: 'REJECT', confidence: 0.1, reviewer_id: 'r2' }),
75
78
  ];
76
79
  const result = computeConsensusScore(votes);
77
80
  expect(result.score).toBe(0.5); // Simple: 1/2
78
- // Weighted: (1.0*1.0 + 0.0*0.1) / (1.0+0.1) = 1.0/1.1 ≈ 0.909
79
- expect(result.weighted_score).toBeCloseTo(1.0 / 1.1, 3);
81
+ // Option B: (1.0*1.0 + 0.0*0.1) / 2 = 0.5
82
+ expect(result.weighted_score).toBe(0.5);
80
83
  });
81
84
 
82
- it('should force weighted_score to 0 when blocking issues exist', () => {
85
+ it('should return honest weighted_score when REJECT vote has real blocking issues (v2.4.2: no force-zero)', () => {
83
86
  const votes = [
84
87
  makeVote({ vote: 'APPROVE', confidence: 1.0, reviewer_id: 'r1' }),
85
88
  makeVote({
86
- vote: 'CONDITIONAL',
87
- confidence: 0.9,
89
+ vote: 'REJECT',
90
+ confidence: 0.5,
88
91
  blocking_issues: ['Critical bug found'],
89
92
  reviewer_id: 'r2',
90
93
  }),
91
94
  ];
92
95
  const result = computeConsensusScore(votes);
93
96
  expect(result.score).toBe(0.5); // Simple score unaffected
94
- expect(result.weighted_score).toBe(0); // Forced to 0 by blocking issues
97
+ // v2.4.2: honest score (1.0*1.0 + 0.0*0.5) / 2 = 0.5
98
+ expect(result.weighted_score).toBeCloseTo(0.5, 3);
99
+ expect(result.has_true_blockers).toBe(true);
100
+ });
101
+
102
+ it('should NOT force-zero when only non-REJECT votes have blocking_issues', () => {
103
+ // After normalization, APPROVE/CONDITIONAL votes never have blocking_issues.
104
+ // But the scorer must still handle pre-normalization edge cases gracefully.
105
+ const votes = [
106
+ makeVote({ vote: 'APPROVE', confidence: 1.0, reviewer_id: 'r1' }),
107
+ makeVote({
108
+ vote: 'CONDITIONAL',
109
+ confidence: 0.9,
110
+ blocking_issues: ['Consider auth approach'],
111
+ reviewer_id: 'r2',
112
+ }),
113
+ ];
114
+ const result = computeConsensusScore(votes);
115
+ // Force-zero only fires for REJECT votes with real blockers
116
+ expect(result.weighted_score).toBeGreaterThan(0);
117
+ });
118
+
119
+ it('should NOT force weighted_score to 0 when blocking_issues contains only none-variants (defense-in-depth)', () => {
120
+ const votes = [
121
+ makeVote({ vote: 'APPROVE', confidence: 1.0, reviewer_id: 'r1' }),
122
+ makeVote({
123
+ vote: 'REJECT',
124
+ confidence: 0.5,
125
+ blocking_issues: ['No blocking issues found'],
126
+ reviewer_id: 'r2',
127
+ }),
128
+ ];
129
+ const result = computeConsensusScore(votes);
130
+ expect(result.score).toBe(0.5);
131
+ // Defense-in-depth: none-variant filtered, so score is NOT zeroed
132
+ expect(result.weighted_score).toBeGreaterThan(0);
133
+ });
134
+
135
+ it('REJECT vote with real blocking issues returns honest score + has_true_blockers=true (v2.4.2)', () => {
136
+ const votes = [
137
+ makeVote({
138
+ vote: 'REJECT',
139
+ confidence: 0.95,
140
+ blocking_issues: ['SQL injection vulnerability'],
141
+ reviewer_id: 'r1',
142
+ }),
143
+ makeVote({ vote: 'APPROVE', confidence: 0.9, reviewer_id: 'r2' }),
144
+ ];
145
+ const result = computeConsensusScore(votes);
146
+ // v2.4.2: honest score (0.0*0.95 + 1.0*0.9) / 2 = 0.45
147
+ expect(result.weighted_score).toBeCloseTo(0.45, 3);
148
+ expect(result.has_true_blockers).toBe(true);
149
+ });
150
+
151
+ it('REJECT vote with no blockers: does NOT force-zero (scores 0 by weight, not by force)', () => {
152
+ const votes = [
153
+ makeVote({
154
+ vote: 'REJECT',
155
+ confidence: 0.5,
156
+ blocking_issues: [],
157
+ reviewer_id: 'r1',
158
+ }),
159
+ makeVote({ vote: 'APPROVE', confidence: 0.96, reviewer_id: 'r2' }),
160
+ ];
161
+ const result = computeConsensusScore(votes);
162
+ // Option B: (0*0.5 + 1.0*0.96) / 2 = 0.48
163
+ expect(result.weighted_score).toBeCloseTo(0.48, 3);
164
+ });
165
+
166
+ it('APPROVE votes after normalization (no blockers left): not force-zeroed', () => {
167
+ const votes = [
168
+ makeVote({ vote: 'APPROVE', confidence: 0.96, blocking_issues: [], reviewer_id: 'r1' }),
169
+ makeVote({ vote: 'APPROVE', confidence: 0.97, blocking_issues: [], reviewer_id: 'r2' }),
170
+ ];
171
+ const result = computeConsensusScore(votes);
172
+ // Option B: (1.0*0.96 + 1.0*0.97) / 2 = 0.965
173
+ expect(result.weighted_score).toBeCloseTo(0.965, 3);
174
+ expect(result.weighted_score).toBeGreaterThan(0);
95
175
  });
96
176
 
97
177
  it('should handle mixed votes with varied confidence', () => {
@@ -102,8 +182,491 @@ describe('computeConsensusScore', () => {
102
182
  ];
103
183
  const result = computeConsensusScore(votes);
104
184
  expect(result.score).toBeCloseTo(1 / 3, 3); // 1 approve / 3 total
105
- // Weighted: (1.0*0.8 + 0.5*0.6 + 0.0*0.4) / (0.8+0.6+0.4) = 1.1/1.8 ≈ 0.611
106
- expect(result.weighted_score).toBeCloseTo(1.1 / 1.8, 3);
185
+ // Option B: (1.0*0.8 + 0.5*0.6 + 0.0*0.4) / 3 = 1.1/3 ≈ 0.367
186
+ expect(result.weighted_score).toBeCloseTo(1.1 / 3, 3);
187
+ });
188
+
189
+ it('should NOT force weighted_score to 0 when concerns exist but no blocking issues', () => {
190
+ const votes = [
191
+ makeVote({ vote: 'APPROVE', confidence: 0.9, blocking_issues: [], suggestions: ['improve naming'], reviewer_id: 'r1' }),
192
+ makeVote({ vote: 'APPROVE', confidence: 0.85, blocking_issues: [], suggestions: ['add caching'], reviewer_id: 'r2' }),
193
+ ];
194
+ const result = computeConsensusScore(votes);
195
+ expect(result.weighted_score).toBeGreaterThan(0.8);
196
+ });
197
+
198
+ it('CONDITIONAL-only votes should produce correct weighted_score', () => {
199
+ const votes = [
200
+ makeVote({ vote: 'CONDITIONAL', confidence: 0.90, reviewer_id: 'r1' }),
201
+ makeVote({ vote: 'CONDITIONAL', confidence: 0.875, reviewer_id: 'r2' }),
202
+ ];
203
+ const result = computeConsensusScore(votes);
204
+ // Option B: (0.5*0.90 + 0.5*0.875) / 2 = 0.44375
205
+ expect(result.weighted_score).toBeCloseTo(0.44375, 3);
206
+ });
207
+
208
+ it('mixed APPROVE+CONDITIONAL should produce correct weighted_score', () => {
209
+ const votes = [
210
+ makeVote({ vote: 'APPROVE', confidence: 0.96, reviewer_id: 'r1' }),
211
+ makeVote({ vote: 'CONDITIONAL', confidence: 0.90, reviewer_id: 'r2' }),
212
+ ];
213
+ const result = computeConsensusScore(votes);
214
+ // Option B: (1.0*0.96 + 0.5*0.90) / 2 = 1.41/2 = 0.705
215
+ expect(result.weighted_score).toBeCloseTo(0.705, 3);
216
+ });
217
+
218
+ it('APPROVE with suggestions but no blocking_issues should NOT zero score', () => {
219
+ const votes = [
220
+ makeVote({
221
+ vote: 'APPROVE', confidence: 0.96,
222
+ blocking_issues: [], suggestions: ['consider caching', 'improve naming'],
223
+ reviewer_id: 'r1',
224
+ }),
225
+ makeVote({
226
+ vote: 'APPROVE', confidence: 0.95,
227
+ blocking_issues: [], suggestions: ['add monitoring'],
228
+ reviewer_id: 'r2',
229
+ }),
230
+ ];
231
+ const result = computeConsensusScore(votes);
232
+ // Option B: (1.0*0.96 + 1.0*0.95) / 2 = 0.955
233
+ expect(result.weighted_score).toBeCloseTo(0.955, 3);
234
+ });
235
+
236
+ // v2.4.2: has_true_blockers tests
237
+ it('computeConsensusScore returns has_true_blockers=true when REJECT has real blockers', () => {
238
+ const votes = [
239
+ makeVote({ vote: 'APPROVE', confidence: 0.97, reviewer_id: 'r1' }),
240
+ makeVote({ vote: 'REJECT', confidence: 0.3, blocking_issues: ['Missing auth'], reviewer_id: 'r2' }),
241
+ ];
242
+ const result = computeConsensusScore(votes);
243
+ expect(result.has_true_blockers).toBe(true);
244
+ });
245
+
246
+ it('computeConsensusScore returns has_true_blockers=false when no REJECT has blockers', () => {
247
+ const votes = [
248
+ makeVote({ vote: 'APPROVE', confidence: 0.97, reviewer_id: 'r1' }),
249
+ makeVote({ vote: 'REJECT', confidence: 0.3, blocking_issues: [], reviewer_id: 'r2' }),
250
+ ];
251
+ const result = computeConsensusScore(votes);
252
+ expect(result.has_true_blockers).toBe(false);
253
+ });
254
+
255
+ it('computeConsensusScore returns has_true_blockers=false for empty votes', () => {
256
+ const result = computeConsensusScore([]);
257
+ expect(result.has_true_blockers).toBe(false);
258
+ });
259
+ });
260
+
261
+ describe('normalizeVoteBlockers', () => {
262
+ it('APPROVE + soft blockers -> moved to suggestions, blocking_issues cleared', () => {
263
+ const votes = [makeVote({
264
+ vote: 'APPROVE',
265
+ confidence: 0.96,
266
+ blocking_issues: ['Consider auth approach', 'Add caching layer'],
267
+ suggestions: ['Improve naming'],
268
+ reviewer_id: 'r1',
269
+ })];
270
+ const { votes: norm } = normalizeVoteBlockers(votes);
271
+ expect(norm[0].blocking_issues).toEqual([]);
272
+ expect(norm[0].suggestions).toContain('Consider auth approach');
273
+ expect(norm[0].suggestions).toContain('Add caching layer');
274
+ expect(norm[0].suggestions).toContain('Improve naming');
275
+ });
276
+
277
+ it('CONDITIONAL + soft blockers -> moved to required_changes, blocking_issues cleared', () => {
278
+ const votes = [makeVote({
279
+ vote: 'CONDITIONAL',
280
+ confidence: 0.88,
281
+ blocking_issues: ['Add error handling', 'Need input validation'],
282
+ suggestions: ['Improve docs'],
283
+ reviewer_id: 'r1',
284
+ })];
285
+ const { votes: norm } = normalizeVoteBlockers(votes);
286
+ expect(norm[0].blocking_issues).toEqual([]);
287
+ expect(norm[0].required_changes).toContain('Add error handling');
288
+ expect(norm[0].required_changes).toContain('Need input validation');
289
+ expect(norm[0].suggestions).toContain('Improve docs');
290
+ });
291
+
292
+ it('REJECT + blockers -> unchanged', () => {
293
+ const votes = [makeVote({
294
+ vote: 'REJECT',
295
+ confidence: 0.5,
296
+ blocking_issues: ['Critical security flaw'],
297
+ suggestions: ['Rewrite auth module'],
298
+ reviewer_id: 'r1',
299
+ })];
300
+ const { votes: norm } = normalizeVoteBlockers(votes);
301
+ expect(norm[0].blocking_issues).toContain('Critical security flaw');
302
+ expect(norm[0].vote).toBe('REJECT');
303
+ });
304
+
305
+ it('APPROVE + [BLOCKER] tagged issue -> vote forced to REJECT, reviewer_inconsistency=true', () => {
306
+ const votes = [makeVote({
307
+ vote: 'APPROVE',
308
+ confidence: 0.96,
309
+ blocking_issues: ['[BLOCKER] Missing authentication layer'],
310
+ suggestions: [],
311
+ reviewer_id: 'r1',
312
+ })];
313
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
314
+ expect(norm[0].vote).toBe('REJECT');
315
+ expect(norm[0].reviewer_inconsistency).toBe(true);
316
+ expect(norm[0].blocking_issues).toContain('Missing authentication layer');
317
+ expect(summary.forced_rejects).toBe(1);
318
+ });
319
+
320
+ it('APPROVE + "SQL injection" pattern -> vote forced to REJECT', () => {
321
+ const votes = [makeVote({
322
+ vote: 'APPROVE',
323
+ confidence: 0.96,
324
+ blocking_issues: ['The code has SQL injection vulnerabilities'],
325
+ suggestions: [],
326
+ reviewer_id: 'r1',
327
+ })];
328
+ const { votes: norm } = normalizeVoteBlockers(votes);
329
+ expect(norm[0].vote).toBe('REJECT');
330
+ expect(norm[0].reviewer_inconsistency).toBe(true);
331
+ });
332
+
333
+ it('Mixed votes: only REJECT retains blockers after normalization', () => {
334
+ const votes = [
335
+ makeVote({
336
+ vote: 'APPROVE',
337
+ confidence: 0.96,
338
+ blocking_issues: ['Consider caching'],
339
+ reviewer_id: 'r1',
340
+ }),
341
+ makeVote({
342
+ vote: 'REJECT',
343
+ confidence: 0.5,
344
+ blocking_issues: ['Critical data loss issue'],
345
+ reviewer_id: 'r2',
346
+ }),
347
+ ];
348
+ const { votes: norm } = normalizeVoteBlockers(votes);
349
+ expect(norm[0].blocking_issues).toEqual([]);
350
+ expect(norm[1].blocking_issues).toContain('Critical data loss issue');
351
+ });
352
+
353
+ it('Tag prefixes stripped when moving issues', () => {
354
+ const votes = [makeVote({
355
+ vote: 'CONDITIONAL',
356
+ confidence: 0.88,
357
+ blocking_issues: ['[REQUIRED] Add error handling'],
358
+ suggestions: ['[SUGGESTION] Improve docs'],
359
+ reviewer_id: 'r1',
360
+ })];
361
+ const { votes: norm } = normalizeVoteBlockers(votes);
362
+ expect(norm[0].required_changes).toContain('Add error handling');
363
+ expect(norm[0].suggestions).toContain('Improve docs');
364
+ // Tags should be stripped
365
+ expect(norm[0].required_changes?.some(r => r.includes('[REQUIRED]'))).toBe(false);
366
+ expect(norm[0].suggestions.some(s => s.includes('[SUGGESTION]'))).toBe(false);
367
+ });
368
+
369
+ it('Idempotency: normalizeVoteBlockers(normalizeVoteBlockers(votes)) deep-equals single pass', () => {
370
+ const votes = [makeVote({
371
+ vote: 'CONDITIONAL',
372
+ confidence: 0.88,
373
+ blocking_issues: ['Add error handling', 'Need validation'],
374
+ suggestions: ['Improve docs'],
375
+ reviewer_id: 'r1',
376
+ })];
377
+ const { votes: first } = normalizeVoteBlockers(votes);
378
+ const { votes: second } = normalizeVoteBlockers(first);
379
+ expect(second[0].blocking_issues).toEqual(first[0].blocking_issues);
380
+ expect(second[0].required_changes).toEqual(first[0].required_changes);
381
+ expect(second[0].suggestions).toEqual(first[0].suggestions);
382
+ expect(second[0].vote).toEqual(first[0].vote);
383
+ });
384
+
385
+ it('Tags override vote: APPROVE with [BLOCKER] tag -> forced REJECT + blocker in output', () => {
386
+ const votes = [makeVote({
387
+ vote: 'APPROVE',
388
+ confidence: 0.98,
389
+ blocking_issues: [],
390
+ suggestions: ['[BLOCKER] XSS vulnerability in form handler'],
391
+ reviewer_id: 'r1',
392
+ })];
393
+ const { votes: norm } = normalizeVoteBlockers(votes);
394
+ expect(norm[0].vote).toBe('REJECT');
395
+ expect(norm[0].blocking_issues).toContain('XSS vulnerability in form handler');
396
+ expect(norm[0].reviewer_inconsistency).toBe(true);
397
+ });
398
+
399
+ it('Hard pattern in suggestions triggers forced REJECT: APPROVE with "SQL injection" in suggestions', () => {
400
+ const votes = [makeVote({
401
+ vote: 'APPROVE',
402
+ confidence: 0.97,
403
+ blocking_issues: [],
404
+ suggestions: ['Watch out for SQL injection in the user input handler'],
405
+ reviewer_id: 'r1',
406
+ })];
407
+ const { votes: norm } = normalizeVoteBlockers(votes);
408
+ expect(norm[0].vote).toBe('REJECT');
409
+ expect(norm[0].reviewer_inconsistency).toBe(true);
410
+ });
411
+
412
+ // v2.4.4: Vote-aware contradiction guard tests
413
+ it('CONDITIONAL + hard pattern in suggestions only -> NOT forced REJECT', () => {
414
+ const votes = [makeVote({
415
+ vote: 'CONDITIONAL',
416
+ confidence: 0.88,
417
+ blocking_issues: [],
418
+ suggestions: ['Watch out for SQL injection in the user input handler'],
419
+ reviewer_id: 'r1',
420
+ })];
421
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
422
+ // CONDITIONAL with hard pattern only in suggestions stays CONDITIONAL
423
+ expect(norm[0].vote).toBe('CONDITIONAL');
424
+ expect(summary.forced_rejects).toBe(0);
425
+ });
426
+
427
+ it('CONDITIONAL + hard pattern in blocking_issues (untagged) -> forced REJECT', () => {
428
+ const votes = [makeVote({
429
+ vote: 'CONDITIONAL',
430
+ confidence: 0.88,
431
+ blocking_issues: ['SQL injection vulnerability in user input'],
432
+ suggestions: [],
433
+ reviewer_id: 'r1',
434
+ })];
435
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
436
+ expect(norm[0].vote).toBe('REJECT');
437
+ expect(norm[0].reviewer_inconsistency).toBe(true);
438
+ expect(summary.forced_rejects).toBe(1);
439
+ });
440
+
441
+ it('CONDITIONAL + [BLOCKER] tagged item -> forced REJECT', () => {
442
+ const votes = [makeVote({
443
+ vote: 'CONDITIONAL',
444
+ confidence: 0.88,
445
+ blocking_issues: ['[BLOCKER] Critical data loss vulnerability'],
446
+ suggestions: [],
447
+ reviewer_id: 'r1',
448
+ })];
449
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
450
+ expect(norm[0].vote).toBe('REJECT');
451
+ expect(norm[0].reviewer_inconsistency).toBe(true);
452
+ expect(summary.forced_rejects).toBe(1);
453
+ });
454
+
455
+ it('CONDITIONAL + [REQUIRED] SQL injection in suggestions -> NOT forced REJECT', () => {
456
+ const votes = [makeVote({
457
+ vote: 'CONDITIONAL',
458
+ confidence: 0.88,
459
+ blocking_issues: [],
460
+ suggestions: ['[REQUIRED] SQL injection needs parameterized queries'],
461
+ reviewer_id: 'r1',
462
+ })];
463
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
464
+ // [REQUIRED] tag reclassifies to required, NOT blocker. Hard pattern only in
465
+ // required_changes (not blocker-origin), so CONDITIONAL stays.
466
+ expect(norm[0].vote).toBe('CONDITIONAL');
467
+ expect(summary.forced_rejects).toBe(0);
468
+ });
469
+
470
+ it('APPROVE + hard pattern in suggestions -> forced REJECT (genuinely inconsistent)', () => {
471
+ const votes = [makeVote({
472
+ vote: 'APPROVE',
473
+ confidence: 0.97,
474
+ blocking_issues: [],
475
+ suggestions: ['Beware of XSS vulnerabilities in form handler'],
476
+ reviewer_id: 'r1',
477
+ })];
478
+ const { votes: norm, summary } = normalizeVoteBlockers(votes);
479
+ // APPROVE scans ALL text for hard patterns -> forced REJECT
480
+ expect(norm[0].vote).toBe('REJECT');
481
+ expect(norm[0].reviewer_inconsistency).toBe(true);
482
+ expect(summary.forced_rejects).toBe(1);
483
+ });
484
+ });
485
+
486
+ describe('normalizeVoteBlockers + computeConsensusScore (integration)', () => {
487
+ it('APPROVE with "consider auth" -> normalize -> weighted_score not force-zeroed', () => {
488
+ const votes = [
489
+ makeVote({
490
+ vote: 'APPROVE',
491
+ confidence: 0.96,
492
+ blocking_issues: ['Consider auth approach'],
493
+ reviewer_id: 'r1',
494
+ }),
495
+ makeVote({ vote: 'APPROVE', confidence: 0.97, reviewer_id: 'r2' }),
496
+ ];
497
+ const { votes: norm } = normalizeVoteBlockers(votes);
498
+ const result = computeConsensusScore(norm);
499
+ expect(result.weighted_score).toBeGreaterThan(0);
500
+ // Option B: (1.0*0.96 + 1.0*0.97) / 2 = 0.965
501
+ expect(result.weighted_score).toBeCloseTo(0.965, 3);
502
+ });
503
+
504
+ it('APPROVE with "SQL injection" -> normalize -> forced REJECT -> has_true_blockers (v2.4.2)', () => {
505
+ const votes = [
506
+ makeVote({
507
+ vote: 'APPROVE',
508
+ confidence: 0.96,
509
+ blocking_issues: ['SQL injection vulnerability found'],
510
+ reviewer_id: 'r1',
511
+ }),
512
+ makeVote({ vote: 'APPROVE', confidence: 0.97, reviewer_id: 'r2' }),
513
+ ];
514
+ const { votes: norm } = normalizeVoteBlockers(votes);
515
+ // First vote should have been forced to REJECT
516
+ expect(norm[0].vote).toBe('REJECT');
517
+ expect(norm[0].blocking_issues.length).toBeGreaterThan(0);
518
+
519
+ const result = computeConsensusScore(norm);
520
+ // v2.4.2: honest score > 0, has_true_blockers flags the issue
521
+ expect(result.weighted_score).toBeGreaterThan(0);
522
+ expect(result.has_true_blockers).toBe(true);
523
+ });
524
+ });
525
+
526
+ describe('REJECT guard in buildConsensusPacket', () => {
527
+ it('REJECT with true blockers prevents APPROVED final_status', () => {
528
+ // Even if somehow score threshold is met, REJECT with blockers blocks APPROVED
529
+ const votes = [
530
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r1' }),
531
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r2' }),
532
+ makeVote({
533
+ vote: 'REJECT',
534
+ confidence: 0.3,
535
+ blocking_issues: ['Critical security flaw'],
536
+ reviewer_id: 'r3',
537
+ }),
538
+ ];
539
+ const packet = buildConsensusPacket({
540
+ planPacketRef: mockRef,
541
+ votes,
542
+ rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
543
+ });
544
+ // Force-zero kicks in from scorer, plus REJECT guard
545
+ expect(packet.final_status).toBe('REJECTED');
546
+ });
547
+
548
+ it('REJECT without blockers does NOT prevent APPROVED final_status', () => {
549
+ const votes = [
550
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r1' }),
551
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r2' }),
552
+ makeVote({
553
+ vote: 'REJECT',
554
+ confidence: 0.3,
555
+ blocking_issues: [],
556
+ reviewer_id: 'r3',
557
+ }),
558
+ ];
559
+ const packet = buildConsensusPacket({
560
+ planPacketRef: mockRef,
561
+ votes,
562
+ // Low threshold so raw score passes
563
+ rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
564
+ });
565
+ // Option B: (0.99 + 0.99 + 0) / 3 = 0.66 >= 0.5
566
+ expect(packet.consensus_result.weighted_score).toBeGreaterThan(0);
567
+ expect(packet.final_status).toBe('APPROVED');
568
+ });
569
+
570
+ it('governance guard prevents APPROVED even when honest weighted_score passes threshold (v2.4.2)', () => {
571
+ // v2.4.2: with force-zero removed, weighted_score may exceed threshold
572
+ // but governance guard still blocks APPROVED when REJECT has real blockers
573
+ const votes = [
574
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r1' }),
575
+ makeVote({ vote: 'APPROVE', confidence: 0.99, reviewer_id: 'r2' }),
576
+ makeVote({
577
+ vote: 'REJECT',
578
+ confidence: 0.3,
579
+ blocking_issues: ['Critical security flaw'],
580
+ reviewer_id: 'r3',
581
+ }),
582
+ ];
583
+ const packet = buildConsensusPacket({
584
+ planPacketRef: mockRef,
585
+ votes,
586
+ rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
587
+ });
588
+ // v2.4.2: weighted_score is honest (>0) but governance guard blocks APPROVED
589
+ expect(packet.consensus_result.weighted_score).toBeGreaterThan(0);
590
+ expect(packet.consensus_result.has_true_blockers).toBe(true);
591
+ expect(packet.final_status).toBe('REJECTED');
592
+ });
593
+
594
+ it('REJECT guard does not affect ARBITRATED status', () => {
595
+ const votes = [
596
+ makeVote({
597
+ vote: 'REJECT',
598
+ confidence: 0.3,
599
+ blocking_issues: ['Critical bug'],
600
+ reviewer_id: 'r1',
601
+ }),
602
+ ];
603
+ const packet = buildConsensusPacket({
604
+ planPacketRef: mockRef,
605
+ votes,
606
+ rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
607
+ arbitratorResult: { decision: 'Override: plan is acceptable with amendments' },
608
+ });
609
+ // Arbitration takes precedence over REJECT guard
610
+ expect(packet.final_status).toBe('ARBITRATED');
611
+ });
612
+ });
613
+
614
+ describe('normalization summary', () => {
615
+ it('summary counts tagged_blockers_demoted_to_required for CONDITIONAL votes', () => {
616
+ const votes = [makeVote({
617
+ vote: 'CONDITIONAL',
618
+ confidence: 0.88,
619
+ blocking_issues: ['[BLOCKER] Need error handling', 'Add validation'],
620
+ reviewer_id: 'r1',
621
+ })];
622
+ // Note: [BLOCKER] tag on CONDITIONAL triggers forced REJECT, NOT demotion
623
+ // Let's test with untagged blockers instead
624
+ const votesClean = [makeVote({
625
+ vote: 'CONDITIONAL',
626
+ confidence: 0.88,
627
+ blocking_issues: ['Need error handling', 'Add validation'],
628
+ suggestions: [],
629
+ reviewer_id: 'r1',
630
+ })];
631
+ const { summary } = normalizeVoteBlockers(votesClean);
632
+ expect(summary.untagged_from_blocking_routed_to_required).toBe(2);
633
+ });
634
+
635
+ it('summary counts tagged_blockers_demoted_to_suggestions for APPROVE votes', () => {
636
+ // APPROVE with tagged [BLOCKER] triggers forced REJECT, not demotion.
637
+ // Test with untagged (soft) blockers that get demoted to suggestions.
638
+ const votes = [makeVote({
639
+ vote: 'APPROVE',
640
+ confidence: 0.96,
641
+ blocking_issues: ['Consider caching', 'Maybe add retry logic'],
642
+ suggestions: [],
643
+ reviewer_id: 'r1',
644
+ })];
645
+ const { summary } = normalizeVoteBlockers(votes);
646
+ // Untagged items from blocking → suggestions for APPROVE
647
+ // tagged_blockers_demoted_to_suggestions is for [BLOCKER]-tagged items specifically
648
+ expect(summary.tagged_blockers_demoted_to_suggestions).toBe(0);
649
+ });
650
+
651
+ it('summary counts forced_rejects for contradiction guard triggers', () => {
652
+ const votes = [
653
+ makeVote({
654
+ vote: 'APPROVE',
655
+ confidence: 0.96,
656
+ blocking_issues: ['[BLOCKER] Missing auth'],
657
+ reviewer_id: 'r1',
658
+ }),
659
+ makeVote({
660
+ vote: 'CONDITIONAL',
661
+ confidence: 0.88,
662
+ suggestions: ['The code has SQL injection issues'],
663
+ reviewer_id: 'r2',
664
+ }),
665
+ ];
666
+ const { summary } = normalizeVoteBlockers(votes);
667
+ // v2.4.4: Only APPROVE is forced to REJECT (tagged [BLOCKER]).
668
+ // CONDITIONAL with hard pattern only in suggestions is NOT forced.
669
+ expect(summary.forced_rejects).toBe(1);
107
670
  });
108
671
  });
109
672
 
@@ -115,7 +678,8 @@ describe('buildConsensusPacket', () => {
115
678
  rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
116
679
  });
117
680
  expect(packet.consensus_result.weighted_score).toBeDefined();
118
- expect(packet.consensus_result.weighted_score).toBe(1.0);
681
+ // Option B: (1.0 * 0.9) / 1 = 0.9
682
+ expect(packet.consensus_result.weighted_score).toBeCloseTo(0.9, 3);
119
683
  });
120
684
 
121
685
  it('should maintain backward-compatible simple score', () => {
@@ -130,16 +694,20 @@ describe('buildConsensusPacket', () => {
130
694
  expect(packet.consensus_result.score).toBe(0.5);
131
695
  });
132
696
 
133
- it('should APPROVE when score meets threshold', () => {
697
+ it('should APPROVE when weighted_score meets threshold', () => {
134
698
  const packet = buildConsensusPacket({
135
699
  planPacketRef: mockRef,
136
- votes: [makeVote({ vote: 'APPROVE' })],
700
+ votes: [
701
+ makeVote({ vote: 'APPROVE', confidence: 0.96 }),
702
+ makeVote({ vote: 'APPROVE', confidence: 0.97, reviewer_id: 'r2' }),
703
+ ],
137
704
  rules: { threshold: 0.95, quorum: 1, min_reviewers: 1 },
138
705
  });
706
+ // Option B: (0.96 + 0.97) / 2 = 0.965 >= 0.95
139
707
  expect(packet.final_status).toBe('APPROVED');
140
708
  });
141
709
 
142
- it('should REJECT when score below threshold', () => {
710
+ it('should REJECT when weighted_score below threshold', () => {
143
711
  const packet = buildConsensusPacket({
144
712
  planPacketRef: mockRef,
145
713
  votes: [
@@ -160,4 +728,20 @@ describe('buildConsensusPacket', () => {
160
728
  });
161
729
  expect(packet.final_status).toBe('ARBITRATED');
162
730
  });
731
+
732
+ it('should include normalization_moves when provided', () => {
733
+ const packet = buildConsensusPacket({
734
+ planPacketRef: mockRef,
735
+ votes: [makeVote({ vote: 'APPROVE', confidence: 0.96 })],
736
+ rules: { threshold: 0.5, quorum: 1, min_reviewers: 1 },
737
+ normalizationMoves: {
738
+ tagged_blockers_demoted_to_suggestions: 2,
739
+ tagged_blockers_demoted_to_required: 1,
740
+ untagged_from_blocking_routed_to_required: 3,
741
+ forced_rejects: 0,
742
+ },
743
+ });
744
+ expect(packet.normalization_moves).toBeDefined();
745
+ expect(packet.normalization_moves?.tagged_blockers_demoted_to_suggestions).toBe(2);
746
+ });
163
747
  });