@bevel-software/platform-core-backend 0.25.2 → 0.26.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 (248) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  146. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  147. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  148. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  149. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  151. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  153. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  155. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  156. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  157. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  158. package/dist/modules/workspace/workspace.tools.js +211 -18
  159. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  160. package/dist/shared/domain-errors.d.ts +11 -0
  161. package/dist/shared/domain-errors.d.ts.map +1 -1
  162. package/dist/shared/domain-errors.js +14 -0
  163. package/dist/shared/domain-errors.js.map +1 -1
  164. package/dist/shared/hidden-tools.d.ts +44 -0
  165. package/dist/shared/hidden-tools.d.ts.map +1 -0
  166. package/dist/shared/hidden-tools.js +13 -0
  167. package/dist/shared/hidden-tools.js.map +1 -0
  168. package/kb-template/.bevelignore +0 -5
  169. package/package.json +4 -3
  170. package/src/__tests__/kb-layout-config.test.ts +10 -100
  171. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  172. package/src/assets.ts +10 -0
  173. package/src/core/core-ports.ts +11 -0
  174. package/src/core/create-core-server.ts +13 -2
  175. package/src/core/create-core-services.ts +28 -4
  176. package/src/index.ts +2 -2
  177. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  178. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  179. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  180. package/src/modules/access/access-control.interface.ts +15 -0
  181. package/src/modules/access/access-control.service.ts +21 -0
  182. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  183. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  184. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  185. package/src/modules/agent-guide/agent-guide.ts +291 -0
  186. package/src/modules/agent-guide/index.ts +21 -0
  187. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  188. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  189. package/src/modules/agent-instructions/compose.ts +9 -6
  190. package/src/modules/agent-instructions/index.ts +0 -3
  191. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  192. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  193. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  194. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  195. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  196. package/src/modules/mcp/mcp.service.ts +137 -19
  197. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  198. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  199. package/src/modules/plugins/plugins.tools.ts +75 -15
  200. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  201. package/src/modules/settings/deployment-settings.service.ts +13 -54
  202. package/src/modules/settings/setup.routes.ts +3 -6
  203. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  204. package/src/modules/skills/skills.tools.ts +62 -16
  205. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  206. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  207. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  208. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  209. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  210. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  211. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  212. package/src/modules/tool-registry/description-length.ts +24 -26
  213. package/src/modules/tool-registry/guide-first.ts +34 -0
  214. package/src/modules/tool-registry/tool-registry.ts +9 -2
  215. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  216. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  217. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  218. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  219. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  220. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  221. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  222. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  223. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  224. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  225. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  226. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  227. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  228. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  229. package/src/modules/workflow/git/git.service.ts +537 -94
  230. package/src/modules/workflow/git/merge-commit.ts +88 -0
  231. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  232. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  233. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  234. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  235. package/src/modules/workflow/workflow.routes.ts +7 -2
  236. package/src/modules/workflow/workflow.service.ts +7 -0
  237. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  238. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  239. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  240. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  241. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  242. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  243. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  244. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  245. package/src/modules/workspace/workspace.tools.ts +226 -16
  246. package/src/shared/domain-errors.ts +15 -0
  247. package/src/shared/hidden-tools.ts +45 -0
  248. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,1518 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import type {
5
+ ChangeRequest,
6
+ ChangeRequestComment,
7
+ ChangeRequestDetail,
8
+ ChangeRequestState,
9
+ ChangedFile,
10
+ FileApproval,
11
+ } from '@bevel-software/platform-shared';
12
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
13
+ import { hashEmail } from '../../../../shared/email-identity.js';
14
+ import { ToolRegistry } from '../../../tool-registry/tool-registry.js';
15
+ import { InternalTokenService } from '../../../tool-auth/internal-token.service.js';
16
+ import { createToolAuthMiddleware } from '../../../tool-auth/tool-auth.middleware.js';
17
+ import { createToolContextResolver } from '../../../tool-helpers/tool-context.js';
18
+ import { createToolHandlerFactory } from '../../../tool-helpers/tool-handler.js';
19
+ import { registerChangeRequestReadTools } from '../change-request-read.tools.js';
20
+ import { APPLY_FAILURE_REASON_WITHHELD } from '../change-request-read-shape.js';
21
+
22
+ /** The signed-in caller of every test below, unless it says otherwise. */
23
+ const VIEWER = 'mia@bevel.software';
24
+ const AUTHOR = 'juan@bevel.software';
25
+
26
+ const TOOLS = [
27
+ 'list_change_requests',
28
+ 'get_change_request',
29
+ 'list_change_request_files',
30
+ 'list_change_request_reviews',
31
+ 'list_change_request_comments',
32
+ ] as const;
33
+
34
+ // ── The world each test sets up ─────────────────────────────────────────────
35
+
36
+ /** Repo-relative paths THIS caller may read at `origin/<base>`. Null = the access tree does not resolve. */
37
+ let readable: string[] | null = [];
38
+ /** The summaries `listChangeRequestsByState` answers with, per state asked for. */
39
+ let summaries: ChangeRequest[] = [];
40
+ /** The details `getChangeRequestDetail` answers with, by number. */
41
+ let details = new Map<number, ChangeRequestDetail>();
42
+ /** Every service call the tools made, so a test can prove none of them wrote. */
43
+ let calls: unknown[][] = [];
44
+
45
+ function file(path: string, over: Partial<ChangedFile> = {}): ChangedFile {
46
+ return {
47
+ path,
48
+ status: 'modified',
49
+ additions: 2,
50
+ deletions: 1,
51
+ isBinary: false,
52
+ sha: `blob-${path}`,
53
+ rawUrl: '',
54
+ patch: `@@ ${path} @@`,
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ function approval(path: string, over: Partial<FileApproval> = {}): FileApproval {
60
+ return {
61
+ path,
62
+ eligibleApprovers: { roles: ['Engineering'], users: [] },
63
+ approvedBy: [],
64
+ eligibilityResolved: true,
65
+ isApproved: false,
66
+ inMergeGate: true,
67
+ viewerCanApprove: false,
68
+ ...over,
69
+ };
70
+ }
71
+
72
+ /** A comment as the detail carries one. Module-level: the rename suite needs it too. */
73
+ function comment(over: Partial<ChangeRequestComment>): ChangeRequestComment {
74
+ return {
75
+ id: 'c-1',
76
+ author: { email: VIEWER, name: 'Mia' },
77
+ body: 'This line is out of date.',
78
+ headSha: 'head-1',
79
+ createdAt: '2026-09-29T08:00:00.000Z',
80
+ ...over,
81
+ };
82
+ }
83
+
84
+ function approvedBy(email: string, name: string, at: string, isStale = false) {
85
+ return { email, name, approvedAt: at, isStale, isSelfApproval: false };
86
+ }
87
+
88
+ /**
89
+ * A summary as `PullRequestService.summaryOf` builds one — `touchedNodeFiles`
90
+ * included, derived from `touchedNodePaths` unless a test overrides it. A
91
+ * fixture that left it out would be testing a summary no builder produces, and
92
+ * would hide whether the list reads it at all.
93
+ */
94
+ function summary(over: Partial<ChangeRequest> = {}): ChangeRequest {
95
+ const paths = over.touchedNodePaths ?? ['Knowledge/A.md'];
96
+ return {
97
+ touchedNodeFiles: paths.map((path) => ({ path })),
98
+ number: 12,
99
+ title: 'Rework the onboarding note',
100
+ authorId: hashEmail(AUTHOR),
101
+ author: { login: `user-${hashEmail(AUTHOR).slice(0, 12)}`, name: 'Juan' },
102
+ appAuthor: { name: 'Juan' },
103
+ branch: 'juan/my-draft',
104
+ base: 'main',
105
+ state: 'open',
106
+ createdAt: '2026-09-28T10:00:00.000Z',
107
+ updatedAt: '2026-09-28T10:00:00.000Z',
108
+ touchedNodePaths: ['Knowledge/A.md'],
109
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
110
+ url: 'https://hexis.example.com/change-requests/12',
111
+ ...over,
112
+ };
113
+ }
114
+
115
+ function detail(over: Partial<ChangeRequestDetail> = {}): ChangeRequestDetail {
116
+ const files = over.files ?? [file('Knowledge/A.md')];
117
+ return {
118
+ ...summary(over),
119
+ // `getPrDetail` pairs the summary off its real file list, so a detail used
120
+ // as a list row carries the same files its own file tool would answer.
121
+ touchedNodeFiles: files.map((f) => ({
122
+ path: f.path,
123
+ ...(f.previousPath ? { previousPath: f.previousPath } : {}),
124
+ })),
125
+ body: 'Why this change is needed.',
126
+ headSha: 'head-1',
127
+ baseSha: 'base-1',
128
+ files,
129
+ comments: [],
130
+ approvals: files.map((f) => approval(f.path)),
131
+ mergeableInBevel: false,
132
+ mergeBlockedReasons: [],
133
+ mergeWarnings: [],
134
+ viewerCanBypassMerge: false,
135
+ viewerCanCancel: false,
136
+ mergeBaseSha: 'fork-1',
137
+ behind: false,
138
+ needsUpdate: false,
139
+ viewerCanUpdate: false,
140
+ viewerIsAuthor: false,
141
+ viewerCanDelete: false,
142
+ ...over,
143
+ };
144
+ }
145
+
146
+ // ── Harness ─────────────────────────────────────────────────────────────────
147
+
148
+ const externalApiKeyService = {
149
+ looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
150
+ verifyAndLoadToken: async () => null,
151
+ } as never;
152
+
153
+ /** The caller the internal token resolves to — swapped by `asUser`. */
154
+ let callerEmail = VIEWER;
155
+ const authService = {
156
+ getUserById: async (id: string) => ({ id, email: callerEmail, name: 'Caller' }),
157
+ } as never;
158
+
159
+ /** The clone the workspace service knows of — null for a deployment where none has been created yet. */
160
+ let anyWorkspaceId: string | null = 'existing-ws';
161
+ const workspaceService = {
162
+ // The read tools resolve any existing clone: every verdict is read at
163
+ // `origin/<base>`, so no draft has to be cloned to answer.
164
+ findAnyWorkspaceId: async () => anyWorkspaceId,
165
+ getOrCreateForUser: async () => ({ id: 'existing-ws' }),
166
+ getWorkspacePath: async () => '/tmp/ws',
167
+ getOrCreateForBranch: async (b: string) => ({ id: b }),
168
+ } as never;
169
+
170
+ /** The workspace each listing was asked to read in, in order. */
171
+ let listedIn: Array<string | undefined> = [];
172
+ const workflowService = {
173
+ listChangeRequestsByState: async (states: ChangeRequestState[], opts?: { workspaceId?: string }) => {
174
+ calls.push(['listChangeRequestsByState', [...states].sort()]);
175
+ listedIn.push(opts?.workspaceId);
176
+ return summaries.filter((s) => states.includes(s.state));
177
+ },
178
+ getChangeRequestDetail: async (
179
+ number: number,
180
+ opts: { patches?: boolean; viewerEmail?: string },
181
+ ) => {
182
+ calls.push(['getChangeRequestDetail', number, opts.patches, opts.viewerEmail]);
183
+ const found = details.get(number);
184
+ if (!found) return null;
185
+ // Mirror the service: `patches: false` means no patch is generated at all.
186
+ if (opts.patches !== false) return found;
187
+ return {
188
+ ...found,
189
+ files: found.files.map((f) => {
190
+ const withoutPatch = { ...f };
191
+ delete withoutPatch.patch;
192
+ return withoutPatch;
193
+ }),
194
+ };
195
+ },
196
+ } as never;
197
+
198
+ /** Every path the access tree was asked about, across the call under test. */
199
+ let accessAskedFor: string[] = [];
200
+
201
+ const accessControl = {
202
+ canReadBatchAtRef: async (ws: string, ref: string, email: string, paths: string[]) => {
203
+ calls.push(['canReadBatchAtRef', ws, ref, email]);
204
+ accessAskedFor.push(...paths);
205
+ if (readable === null) return null;
206
+ return new Map(paths.map((p) => [p, readable!.includes(p)]));
207
+ },
208
+ };
209
+
210
+ const events = { emit: () => ({}) } as never;
211
+ const internalToken = new InternalTokenService({ secret: 's' });
212
+
213
+ let httpServer: HttpServer | undefined;
214
+ let registryRef: ToolRegistry | undefined;
215
+
216
+ async function start(): Promise<string> {
217
+ const registry = new ToolRegistry();
218
+ registryRef = registry;
219
+ const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
220
+ const resolve = createToolContextResolver({
221
+ authService,
222
+ workspaceService,
223
+ workflowService,
224
+ events,
225
+ kbDirName: 'knowledge-base',
226
+ creatorAccess: {
227
+ planForCreate: async () => null,
228
+ grantInExtractedFile: async () => null,
229
+ noteAccessFileWritten: () => {},
230
+ },
231
+ } as never);
232
+ const toolHandler = createToolHandlerFactory(resolve);
233
+
234
+ const router = express.Router();
235
+ registerChangeRequestReadTools(registry, router, toolAuth, toolHandler, accessControl, testKbContext());
236
+
237
+ const app = express();
238
+ app.use(express.json());
239
+ app.use('/api', router);
240
+ httpServer = await new Promise<HttpServer>((r) => {
241
+ const s = app.listen(0, () => r(s));
242
+ });
243
+ return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
244
+ }
245
+
246
+ const tok = () => internalToken.mint({ userId: 'user-A' });
247
+
248
+ async function call(
249
+ base: string,
250
+ tool: (typeof TOOLS)[number],
251
+ body: unknown = {},
252
+ ): Promise<{ status: number; json: Record<string, never> }> {
253
+ const res = await fetch(`${base}/api/agent/tools/${tool}`, {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${tok()}` },
256
+ body: JSON.stringify(body),
257
+ });
258
+ return { status: res.status, json: (await res.json()) as Record<string, never> };
259
+ }
260
+
261
+ beforeEach(() => {
262
+ readable = ['Knowledge/A.md'];
263
+ summaries = [];
264
+ details = new Map();
265
+ calls = [];
266
+ accessAskedFor = [];
267
+ callerEmail = VIEWER;
268
+ });
269
+ afterEach(async () => {
270
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
271
+ httpServer = undefined;
272
+ registryRef = undefined;
273
+ });
274
+
275
+ // ── The catalog ─────────────────────────────────────────────────────────────
276
+
277
+ describe('the five read tools', () => {
278
+ it('are all registered, on both surfaces, by their GitHub-counterpart names', async () => {
279
+ await start();
280
+ const internal = (await registryRef!.listInternal()).map((t) => t.name);
281
+ const external = (await registryRef!.listExternal()).map((t) => t.name);
282
+ for (const name of TOOLS) {
283
+ expect(internal, name).toContain(name);
284
+ expect(external, name).toContain(name);
285
+ }
286
+ });
287
+
288
+ // "None of the tools changes anything." A `write`-tagged tool is refused to
289
+ // read-scoped callers; these must carry no such tag, because none writes.
290
+ it('carry no `write` tag, so a read-scoped caller may use every one', async () => {
291
+ await start();
292
+ for (const def of await registryRef!.listExternal()) {
293
+ if (!(TOOLS as readonly string[]).includes(def.name)) continue;
294
+ expect(def.tags, def.name).not.toContain('write');
295
+ }
296
+ });
297
+
298
+ it('name no branch: each is keyed by a change-request number, or by nothing', async () => {
299
+ await start();
300
+ for (const def of await registryRef!.listExternal()) {
301
+ if (!(TOOLS as readonly string[]).includes(def.name)) continue;
302
+ const body = (def.inputs as { properties: { body: { properties: Record<string, unknown> } } })
303
+ .properties.body;
304
+ expect(Object.keys(body.properties), def.name).not.toContain('branch');
305
+ }
306
+ });
307
+
308
+ it('call only reads on the workflow service', async () => {
309
+ const base = await start();
310
+ summaries = [summary()];
311
+ details.set(12, detail());
312
+ for (const tool of TOOLS) {
313
+ await call(base, tool, tool === 'list_change_requests' ? {} : { number: 12 });
314
+ }
315
+ const methods = new Set(calls.map((c) => c[0]));
316
+ expect([...methods].sort()).toEqual([
317
+ 'canReadBatchAtRef',
318
+ 'getChangeRequestDetail',
319
+ 'listChangeRequestsByState',
320
+ ]);
321
+ });
322
+ });
323
+
324
+ // ── Scenario: list by state and source branch ───────────────────────────────
325
+
326
+ describe('list_change_requests', () => {
327
+ // WHEN an agent calls `list_change_requests` with `state: open` and
328
+ // `head: juan/my-draft` THEN it gets the open request from that branch — in
329
+ // HEXIS's field names, the ones `open_change_request` answers in: `url`
330
+ // first, `number`, `title`, `state`, `author`, `sourceBranch`,
331
+ // `targetBranch`, `createdAt`, `updatedAt`.
332
+ it("answers the open request from the named source branch, in Hexis's field names", async () => {
333
+ const base = await start();
334
+ summaries = [
335
+ summary(),
336
+ summary({ number: 13, branch: 'juan/other', touchedNodePaths: ['Knowledge/A.md'] }),
337
+ ];
338
+ const { status, json } = await call(base, 'list_change_requests', {
339
+ state: 'open',
340
+ head: 'juan/my-draft',
341
+ });
342
+ expect(status).toBe(200);
343
+ expect(json).toMatchObject({ totalCount: 1, page: 1, perPage: 30, hasNextPage: false });
344
+ expect(json.changeRequests).toEqual([
345
+ {
346
+ url: 'https://hexis.example.com/change-requests/12',
347
+ number: 12,
348
+ title: 'Rework the onboarding note',
349
+ state: 'open',
350
+ author: { login: `user-${hashEmail(AUTHOR).slice(0, 12)}`, name: 'Juan' },
351
+ sourceBranch: 'juan/my-draft',
352
+ targetBranch: 'main',
353
+ createdAt: '2026-09-28T10:00:00.000Z',
354
+ updatedAt: '2026-09-28T10:00:00.000Z',
355
+ changedFiles: 1,
356
+ withheldFiles: 0,
357
+ },
358
+ ]);
359
+ // No GitHub field names anywhere in the answer, and no `merged` flag: the
360
+ // state says it.
361
+ const row = json.changeRequests[0] as Record<string, unknown>;
362
+ for (const gh of ['html_url', 'user', 'head', 'base', 'merged', 'created_at', 'changed_files']) {
363
+ expect(row).not.toHaveProperty(gh);
364
+ }
365
+ // `url` is the first key, so a truncated answer still carries the link.
366
+ expect(Object.keys(row)[0]).toBe('url');
367
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['open']]);
368
+ });
369
+
370
+ it('defaults to the open ones, as GitHub does', async () => {
371
+ const base = await start();
372
+ summaries = [summary(), summary({ number: 13, state: 'merged' })];
373
+ await call(base, 'list_change_requests', {});
374
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['open']]);
375
+ });
376
+
377
+ it('`state: closed` asks for the applied and the declined alike; `all` for everything', async () => {
378
+ const base = await start();
379
+ await call(base, 'list_change_requests', { state: 'closed' });
380
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged']]);
381
+ calls = [];
382
+ await call(base, 'list_change_requests', { state: 'all' });
383
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged', 'open']]);
384
+ });
385
+
386
+ it('filters by target branch and by author', async () => {
387
+ const base = await start();
388
+ summaries = [
389
+ summary(),
390
+ summary({ number: 13, base: 'release', touchedNodePaths: ['Knowledge/A.md'] }),
391
+ summary({
392
+ number: 14,
393
+ authorId: hashEmail('ana@bevel.software'),
394
+ author: { login: 'user-ana', name: 'Ana' },
395
+ appAuthor: { name: 'Ana' },
396
+ touchedNodePaths: ['Knowledge/A.md'],
397
+ }),
398
+ ];
399
+ const byBase = await call(base, 'list_change_requests', { base: 'release' });
400
+ expect(byBase.json.changeRequests).toHaveLength(1);
401
+ expect((byBase.json.changeRequests as unknown as { number: number }[])[0].number).toBe(13);
402
+
403
+ const byAuthor = await call(base, 'list_change_requests', { author: AUTHOR });
404
+ expect((byAuthor.json.changeRequests as unknown as { number: number }[]).map((c) => c.number))
405
+ .toEqual([12, 13]);
406
+ });
407
+
408
+ it('resolves each target branch once, not each request', async () => {
409
+ const base = await start();
410
+ summaries = [
411
+ summary({ number: 12 }),
412
+ summary({ number: 13 }),
413
+ summary({ number: 14, base: 'release' }),
414
+ ];
415
+ await call(base, 'list_change_requests', { state: 'open' });
416
+ const refs = calls.filter((c) => c[0] === 'canReadBatchAtRef').map((c) => c[2]);
417
+ expect(refs.sort()).toEqual(['origin/main', 'origin/release']);
418
+ });
419
+
420
+ it('leaves out a request whose every file is closed to the caller', async () => {
421
+ const base = await start();
422
+ readable = [];
423
+ summaries = [summary()];
424
+ const { json } = await call(base, 'list_change_requests', {});
425
+ expect(json.changeRequests).toEqual([]);
426
+ expect(json.totalCount).toBe(0);
427
+ });
428
+
429
+ it('lists in the same clone the by-number tools read in — the default one when none exists yet', async () => {
430
+ // The by-number tools fall back to the default workspace; a listing that
431
+ // fell back to nothing built every file list empty and hid every request
432
+ // the caller did not author, while `get_change_request` served them. One
433
+ // resolution, handed to both.
434
+ const base = await start();
435
+ summaries = [summary()];
436
+ listedIn = [];
437
+ await call(base, 'list_change_requests', {});
438
+ expect(listedIn).toEqual(['existing-ws']);
439
+ anyWorkspaceId = null;
440
+ try {
441
+ listedIn = [];
442
+ await call(base, 'list_change_requests', {});
443
+ expect(listedIn).toEqual([testKbContext().defaultWorkspaceId()]);
444
+ } finally {
445
+ anyWorkspaceId = 'existing-ws';
446
+ }
447
+ });
448
+
449
+ it('still shows the author their own request when they may read none of it', async () => {
450
+ const base = await start();
451
+ readable = [];
452
+ callerEmail = AUTHOR;
453
+ summaries = [summary()];
454
+ const { json } = await call(base, 'list_change_requests', {});
455
+ expect(json.changeRequests).toHaveLength(1);
456
+ expect((json.changeRequests as unknown as { withheldFiles: number }[])[0].withheldFiles).toBe(1);
457
+ });
458
+
459
+ it('counts the files it withheld without naming them', async () => {
460
+ const base = await start();
461
+ readable = ['Knowledge/A.md'];
462
+ summaries = [summary({ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'] })];
463
+ const { json } = await call(base, 'list_change_requests', {});
464
+ expect(json.changeRequests).toHaveLength(1);
465
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 1, withheldFiles: 1 });
466
+ expect(JSON.stringify(json)).not.toContain('Payroll');
467
+ });
468
+
469
+ it('tells why the last Apply failed, in its own words only when every file is readable', async () => {
470
+ const base = await start();
471
+ const failure = { reason: 'Conflicts in Payroll/Rates.md', conflicts: true, at: '2026-09-29T09:00:00.000Z' };
472
+ readable = ['Knowledge/A.md'];
473
+ summaries = [summary({ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'], lastApplyFailure: failure })];
474
+ const withheld = await call(base, 'list_change_requests', {});
475
+ expect(withheld.json.changeRequests[0]).toMatchObject({
476
+ lastApplyFailure: { reason: APPLY_FAILURE_REASON_WITHHELD, conflicts: true, at: failure.at },
477
+ });
478
+ expect(JSON.stringify(withheld.json)).not.toContain('Payroll');
479
+ // The detail withholds on the same verdict, through its own path.
480
+ details.set(
481
+ 12,
482
+ detail({
483
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
484
+ touchedNodePaths: ['Knowledge/A.md', 'Payroll/Rates.md'],
485
+ lastApplyFailure: failure,
486
+ }),
487
+ );
488
+ const detailWithheld = (await call(base, 'get_change_request', { number: 12 })).json;
489
+ expect(detailWithheld).toMatchObject({ lastApplyFailure: { reason: APPLY_FAILURE_REASON_WITHHELD } });
490
+ expect(JSON.stringify(detailWithheld)).not.toContain('Payroll');
491
+ readable = ['Knowledge/A.md', 'Payroll/Rates.md'];
492
+ const shown = await call(base, 'list_change_requests', {});
493
+ expect(shown.json.changeRequests[0]).toMatchObject({ lastApplyFailure: failure });
494
+ expect((await call(base, 'get_change_request', { number: 12 })).json).toMatchObject({ lastApplyFailure: failure });
495
+ });
496
+
497
+ it('pages after the access filter, so a page length says nothing about what was withheld', async () => {
498
+ const base = await start();
499
+ readable = ['Knowledge/A.md'];
500
+ summaries = Array.from({ length: 40 }, (_, i) =>
501
+ summary({ number: i + 1, touchedNodePaths: i % 2 === 0 ? ['Knowledge/A.md'] : ['Payroll/Rates.md'] }),
502
+ );
503
+ const { json } = await call(base, 'list_change_requests', {});
504
+ // 20 of the 40 are visible — a full page of 30 would have been the giveaway.
505
+ expect(json.totalCount).toBe(20);
506
+ expect(json.changeRequests).toHaveLength(20);
507
+ expect(json.hasNextPage).toBe(false);
508
+ });
509
+ });
510
+
511
+ // ── Scenario: read one request ──────────────────────────────────────────────
512
+
513
+ describe('get_change_request', () => {
514
+ // WHEN it calls `get_change_request` with that number THEN it also gets
515
+ // `body`, `mergeable`, the blockers and the `viewer` block — unwrapped, the
516
+ // way `open_change_request` answers since #349.
517
+ it('adds body, mergeable, the blockers and the viewer block', async () => {
518
+ const base = await start();
519
+ const waiting = ['Waiting on approval for Knowledge/A.md from Engineering.'];
520
+ details.set(
521
+ 12,
522
+ detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting, mergeableInBevel: false }),
523
+ );
524
+ const { status, json } = await call(base, 'get_change_request', { number: 12 });
525
+ expect(status).toBe(200);
526
+ expect(json).toMatchObject({
527
+ url: 'https://hexis.example.com/change-requests/12',
528
+ number: 12,
529
+ title: 'Rework the onboarding note',
530
+ state: 'open',
531
+ body: 'Why this change is needed.',
532
+ sourceBranch: 'juan/my-draft',
533
+ targetBranch: 'main',
534
+ headSha: 'head-1',
535
+ baseSha: 'base-1',
536
+ mergeable: false,
537
+ mergeBlockedReasons: waiting,
538
+ withheldMergeBlockedReasons: 0,
539
+ viewer: { mayApprove: false, mayMerge: false, isAuthor: false },
540
+ });
541
+ // Not wrapped in `change_request`, and `url` still first.
542
+ expect(json).not.toHaveProperty('change_request');
543
+ expect(Object.keys(json)[0]).toBe('url');
544
+ });
545
+
546
+ it('tells the author it is theirs, and an approver that they may approve', async () => {
547
+ const base = await start();
548
+ callerEmail = AUTHOR;
549
+ details.set(12, detail({ approvals: [approval('Knowledge/A.md', { viewerCanApprove: true })] }));
550
+ const { json } = await call(base, 'get_change_request', { number: 12 });
551
+ expect(json).toMatchObject({ viewer: { isAuthor: true, mayApprove: true } });
552
+ });
553
+
554
+ // WHEN the request is merged THEN `state` says `merged` — Hexis's own state,
555
+ // not GitHub's `closed` plus a flag a reader has to look for.
556
+ it('reports a merged request as merged, with no flag to cross-read', async () => {
557
+ const base = await start();
558
+ details.set(
559
+ 12,
560
+ detail({
561
+ state: 'merged',
562
+ mergeBlockedReasons: ['This pull request has already been merged.'],
563
+ }),
564
+ );
565
+ const { json } = await call(base, 'get_change_request', { number: 12 });
566
+ expect(json).toMatchObject({ state: 'merged', viewer: { mayMerge: false } });
567
+ expect(json).not.toHaveProperty('merged');
568
+ });
569
+
570
+ // An applied request is normally read from its merge commit (the 2026-10-02
571
+ // decision), so this is the corner that is left: a row whose file set could not
572
+ // be resolved AT ALL — no `merged_sha`, or a clone that does not hold the
573
+ // commit yet. No file of it can be proven readable, so fail-closed leaves it to
574
+ // its author alone: "we cannot tell what it touched" is not "you may see it".
575
+ it('leaves an applied request whose files could not be resolved at all to its author', async () => {
576
+ const base = await start();
577
+ details.set(12, detail({ state: 'merged', files: [], approvals: [] }));
578
+ expect((await call(base, 'get_change_request', { number: 12 })).status).toBe(404);
579
+
580
+ callerEmail = AUTHOR;
581
+ const mine = await call(base, 'get_change_request', { number: 12 });
582
+ expect(mine.status).toBe(200);
583
+ expect(mine.json).toMatchObject({
584
+ state: 'merged',
585
+ changedFiles: 0,
586
+ withheldFiles: 0,
587
+ viewer: { isAuthor: true, mayMerge: false },
588
+ });
589
+ });
590
+
591
+ // The AUTHOR reads it, because nobody else can: a declined request is read
592
+ // from nothing (no sha is recorded, and its branch now holds later work), so
593
+ // its file set is empty and an empty file set proves no read access. Asking as
594
+ // a stranger would pin the state mapping on a combination the service cannot
595
+ // produce — a non-author holding a readable file list for a declined request.
596
+ it('reports a declined request as closed, which in Hexis means declined', async () => {
597
+ const base = await start();
598
+ callerEmail = AUTHOR;
599
+ details.set(12, detail({ state: 'closed', files: [], approvals: [] }));
600
+ const { json } = await call(base, 'get_change_request', { number: 12 });
601
+ expect(json).toMatchObject({ state: 'closed', changedFiles: 0, viewer: { isAuthor: true } });
602
+ expect(json).not.toHaveProperty('merged');
603
+ });
604
+
605
+ // WHEN the caller may read none of the request's files and is not its author
606
+ // THEN `get_change_request` answers 404.
607
+ it('answers not found when the caller may read none of its files', async () => {
608
+ const base = await start();
609
+ readable = [];
610
+ details.set(12, detail());
611
+ const { status, json } = await call(base, 'get_change_request', { number: 12 });
612
+ expect(status).toBe(404);
613
+ // Word for word what a number that was never issued gets: the answer is a
614
+ // function of the number alone, so a probe learns nothing from the
615
+ // difference between a request withheld and a request that never existed.
616
+ expect(json.error).toBe('Change request #12 not found.');
617
+ const absent = await call(base, 'get_change_request', { number: 99 });
618
+ expect(absent.status).toBe(404);
619
+ expect(absent.json.error).toBe('Change request #99 not found.');
620
+ });
621
+
622
+ it('answers not found when the access tree at the target cannot be resolved', async () => {
623
+ const base = await start();
624
+ readable = null;
625
+ details.set(12, detail());
626
+ expect((await call(base, 'get_change_request', { number: 12 })).status).toBe(404);
627
+ });
628
+
629
+ it('withholds a merge blocker that would name a file the caller may not read', async () => {
630
+ const base = await start();
631
+ readable = ['Knowledge/A.md'];
632
+ const reasons = [
633
+ 'Waiting on approval for Knowledge/A.md from Engineering.',
634
+ 'Waiting on approval for Payroll/Rates.md from Finance.',
635
+ ];
636
+ details.set(
637
+ 12,
638
+ detail({
639
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
640
+ mergeBlockedReasons: reasons,
641
+ mergeWarnings: reasons,
642
+ }),
643
+ );
644
+ const { json } = await call(base, 'get_change_request', { number: 12 });
645
+ expect(json).toMatchObject({
646
+ changedFiles: 1,
647
+ withheldFiles: 1,
648
+ mergeBlockedReasons: [reasons[0]],
649
+ withheldMergeBlockedReasons: 1,
650
+ });
651
+ expect(JSON.stringify(json)).not.toContain('Payroll');
652
+ });
653
+
654
+ it('refuses a number that is not a positive integer', async () => {
655
+ const base = await start();
656
+ // 2^53 and above are integers JSON can spell that a double cannot hold
657
+ // exactly: refused like a fraction, rather than rounded on the way to
658
+ // the database or turned into its error.
659
+ for (const number of [0, -3, 1.5, 2 ** 53, 1e300, 'twelve', undefined]) {
660
+ expect((await call(base, 'get_change_request', { number })).status, String(number)).toBe(400);
661
+ }
662
+ });
663
+ });
664
+
665
+ // ── Scenario: files, approvals and patches ──────────────────────────────────
666
+
667
+ describe('list_change_request_files', () => {
668
+ // WHEN a reviewer approved two of three files THEN `list_change_request_files`
669
+ // shows `approvedBy` on those two and the missing approver on the third.
670
+ it('shows approvedBy on the approved files and the missing approver on the rest', async () => {
671
+ const base = await start();
672
+ readable = ['A.md', 'B.md', 'C.md'];
673
+ const mia = approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z');
674
+ details.set(
675
+ 12,
676
+ detail({
677
+ files: [file('A.md'), file('B.md'), file('C.md')],
678
+ approvals: [
679
+ approval('A.md', { approvedBy: [mia], isApproved: true }),
680
+ approval('B.md', { approvedBy: [mia], isApproved: true }),
681
+ approval('C.md', {
682
+ eligibleApprovers: { roles: [], users: [{ name: 'Ana', email: 'ana@bevel.software' }] },
683
+ }),
684
+ ],
685
+ }),
686
+ );
687
+ const { status, json } = await call(base, 'list_change_request_files', { number: 12 });
688
+ expect(status).toBe(200);
689
+ const files = json.files as unknown as {
690
+ path: string;
691
+ approved: boolean;
692
+ approvedBy: { user: { name: string } }[];
693
+ requiredApprovers: { roles: string[]; users: { email: string }[] };
694
+ }[];
695
+ expect(files.map((f) => f.path)).toEqual(['A.md', 'B.md', 'C.md']);
696
+ expect(files[0].approvedBy.map((a) => a.user.name)).toEqual(['Mia']);
697
+ expect(files[1].approvedBy.map((a) => a.user.name)).toEqual(['Mia']);
698
+ expect(files[2].approvedBy).toEqual([]);
699
+ expect(files[2].approved).toBe(false);
700
+ expect(files[2].requiredApprovers.users.map((u) => u.email)).toEqual(['ana@bevel.software']);
701
+ });
702
+
703
+ // WHEN the request touches one file in a folder the caller may not read THEN
704
+ // the files list leaves it out and `withheldFiles` is 1.
705
+ it('leaves out a file in a folder the caller may not read, and counts it', async () => {
706
+ const base = await start();
707
+ readable = ['Knowledge/A.md'];
708
+ details.set(12, detail({ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')] }));
709
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
710
+ expect((json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
711
+ 'Knowledge/A.md',
712
+ ]);
713
+ expect(json.withheldFiles).toBe(1);
714
+ expect(json.totalCount).toBe(1);
715
+ expect(JSON.stringify(json)).not.toContain('Payroll');
716
+ });
717
+
718
+ // WHEN the request has 40 files THEN the first page holds 30 and says there
719
+ // is a second.
720
+ it('pages 40 files into 30 and a second page', async () => {
721
+ const base = await start();
722
+ const paths = Array.from({ length: 40 }, (_, i) => `Knowledge/F${i}.md`);
723
+ readable = paths;
724
+ details.set(12, detail({ files: paths.map((p) => file(p)) }));
725
+ const first = await call(base, 'list_change_request_files', { number: 12 });
726
+ expect(first.json.files).toHaveLength(30);
727
+ expect(first.json).toMatchObject({ totalCount: 40, page: 1, perPage: 30, hasNextPage: true });
728
+ const second = await call(base, 'list_change_request_files', { number: 12, page: 2 });
729
+ expect(second.json.files).toHaveLength(10);
730
+ expect(second.json.hasNextPage).toBe(false);
731
+ });
732
+
733
+ it('returns no patch unless `include: ["patches"]` asks for one', async () => {
734
+ const base = await start();
735
+ details.set(12, detail());
736
+ const without = await call(base, 'list_change_request_files', { number: 12 });
737
+ expect((without.json.files as unknown as { patch?: string }[])[0].patch).toBeUndefined();
738
+ expect(calls).toContainEqual(['getChangeRequestDetail', 12, false, VIEWER]);
739
+
740
+ calls = [];
741
+ const withPatches = await call(base, 'list_change_request_files', {
742
+ number: 12,
743
+ include: ['patches'],
744
+ });
745
+ expect((withPatches.json.files as unknown as { patch?: string }[])[0].patch).toBe(
746
+ '@@ Knowledge/A.md @@',
747
+ );
748
+ expect(calls).toContainEqual(['getChangeRequestDetail', 12, true, VIEWER]);
749
+ });
750
+
751
+ it('answers not found when the caller may read none of its files', async () => {
752
+ const base = await start();
753
+ readable = [];
754
+ details.set(12, detail());
755
+ expect((await call(base, 'list_change_request_files', { number: 12 })).status).toBe(404);
756
+ });
757
+ });
758
+
759
+ // ── Scenario: reviews ───────────────────────────────────────────────────────
760
+
761
+ describe('list_change_request_reviews', () => {
762
+ // WHEN a reviewer approved two of three files THEN
763
+ // `list_change_request_reviews` names the reviewer and the two files.
764
+ it('names the reviewer and the two files they approved', async () => {
765
+ const base = await start();
766
+ readable = ['A.md', 'B.md', 'C.md'];
767
+ details.set(
768
+ 12,
769
+ detail({
770
+ files: [file('A.md'), file('B.md'), file('C.md')],
771
+ approvals: [
772
+ approval('A.md', { approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z')], isApproved: true }),
773
+ approval('B.md', { approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-29T09:00:00.000Z')], isApproved: true }),
774
+ approval('C.md'),
775
+ ],
776
+ }),
777
+ );
778
+ const { status, json } = await call(base, 'list_change_request_reviews', { number: 12 });
779
+ expect(status).toBe(200);
780
+ expect(json.reviews).toHaveLength(1);
781
+ expect(json.reviews[0]).toMatchObject({
782
+ reviewer: { name: 'Mia', email: VIEWER },
783
+ stale: false,
784
+ submittedAt: '2026-09-29T09:00:00.000Z',
785
+ files: ['A.md', 'B.md'],
786
+ withheldFiles: 0,
787
+ });
788
+ // Hexis's own word for an approval that still stands, not GitHub's review
789
+ // state: nothing here says APPROVED or DISMISSED.
790
+ expect(JSON.stringify(json)).not.toContain('APPROVED');
791
+ });
792
+
793
+ it("counts a reviewer's withheld files without naming them", async () => {
794
+ const base = await start();
795
+ readable = ['Knowledge/A.md'];
796
+ const mia = approvedBy(VIEWER, 'Mia', '2026-09-29T08:00:00.000Z');
797
+ details.set(
798
+ 12,
799
+ detail({
800
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
801
+ approvals: [
802
+ approval('Knowledge/A.md', { approvedBy: [mia], isApproved: true }),
803
+ approval('Payroll/Rates.md', { approvedBy: [mia], isApproved: true }),
804
+ ],
805
+ }),
806
+ );
807
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
808
+ expect(json.reviews[0]).toMatchObject({ files: ['Knowledge/A.md'], withheldFiles: 1 });
809
+ expect(json.withheldReviews).toBe(0);
810
+ expect(JSON.stringify(json)).not.toContain('Payroll');
811
+ });
812
+
813
+ it('drops a review about files the caller may not read, and counts it instead', async () => {
814
+ const base = await start();
815
+ readable = ['Knowledge/A.md'];
816
+ details.set(
817
+ 12,
818
+ detail({
819
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
820
+ approvals: [
821
+ approval('Knowledge/A.md'),
822
+ approval('Payroll/Rates.md', {
823
+ approvedBy: [approvedBy('ana@bevel.software', 'Ana', '2026-09-29T08:00:00.000Z')],
824
+ isApproved: true,
825
+ }),
826
+ ],
827
+ }),
828
+ );
829
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
830
+ expect(json.reviews).toEqual([]);
831
+ expect(json.withheldReviews).toBe(1);
832
+ // Neither the reviewer nor the file they approved is named.
833
+ expect(JSON.stringify(json)).not.toContain('Ana');
834
+ expect(JSON.stringify(json)).not.toContain('Payroll');
835
+ });
836
+
837
+ it('reports an approval a later push invalidated as stale, in Hexis\'s own word', async () => {
838
+ const base = await start();
839
+ details.set(
840
+ 12,
841
+ detail({
842
+ approvals: [
843
+ approval('Knowledge/A.md', {
844
+ approvedBy: [approvedBy(VIEWER, 'Mia', '2026-09-28T08:00:00.000Z', true)],
845
+ }),
846
+ ],
847
+ }),
848
+ );
849
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
850
+ expect(json.reviews[0]).toMatchObject({ stale: true, files: ['Knowledge/A.md'] });
851
+ expect(JSON.stringify(json)).not.toContain('DISMISSED');
852
+ });
853
+
854
+ it('answers an empty list when nobody has approved anything yet', async () => {
855
+ const base = await start();
856
+ details.set(12, detail());
857
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
858
+ expect(json).toMatchObject({ reviews: [], withheldReviews: 0, totalCount: 0, hasNextPage: false });
859
+ });
860
+ });
861
+
862
+ // ── Scenario: comments ──────────────────────────────────────────────────────
863
+
864
+ describe('list_change_request_comments', () => {
865
+ // WHEN a reviewer left an inline comment and a reply followed THEN
866
+ // `list_change_request_comments` returns both, the reply with `parentId`.
867
+ it('returns the inline comment and its reply, the reply carrying parentId', async () => {
868
+ const base = await start();
869
+ details.set(
870
+ 12,
871
+ detail({
872
+ comments: [
873
+ comment({ id: 'c-1', path: 'Knowledge/A.md', line: 14 }),
874
+ comment({
875
+ id: 'c-2',
876
+ author: { email: AUTHOR, name: 'Juan' },
877
+ body: 'Fixed.',
878
+ path: 'Knowledge/A.md',
879
+ line: 14,
880
+ parentId: 'c-1',
881
+ createdAt: '2026-09-29T09:00:00.000Z',
882
+ }),
883
+ ],
884
+ }),
885
+ );
886
+ const { status, json } = await call(base, 'list_change_request_comments', { number: 12 });
887
+ expect(status).toBe(200);
888
+ expect(json.comments).toHaveLength(2);
889
+ expect(json.comments[0]).toMatchObject({
890
+ id: 'c-1',
891
+ author: { name: 'Mia', email: VIEWER },
892
+ body: 'This line is out of date.',
893
+ path: 'Knowledge/A.md',
894
+ line: 14,
895
+ headSha: 'head-1',
896
+ createdAt: '2026-09-29T08:00:00.000Z',
897
+ });
898
+ expect(json.comments[1]).toMatchObject({ id: 'c-2', parentId: 'c-1' });
899
+ expect(json.withheldComments).toBe(0);
900
+ });
901
+
902
+ it('keeps a general comment, which belongs to the request rather than a file', async () => {
903
+ const base = await start();
904
+ details.set(12, detail({ comments: [comment({ id: 'c-9', body: 'Ready for review.' })] }));
905
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
906
+ expect(json.comments).toHaveLength(1);
907
+ expect((json.comments as unknown as { path?: string }[])[0].path).toBeUndefined();
908
+ });
909
+
910
+ it('leaves out a comment on a file the caller may not read, and counts it', async () => {
911
+ const base = await start();
912
+ readable = ['Knowledge/A.md'];
913
+ details.set(
914
+ 12,
915
+ detail({
916
+ files: [file('Knowledge/A.md'), file('Payroll/Rates.md')],
917
+ comments: [
918
+ comment({ id: 'c-1', path: 'Knowledge/A.md', line: 1 }),
919
+ comment({ id: 'c-2', path: 'Payroll/Rates.md', line: 2, body: 'The band is wrong.' }),
920
+ ],
921
+ }),
922
+ );
923
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
924
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-1']);
925
+ expect(json.withheldComments).toBe(1);
926
+ expect(JSON.stringify(json)).not.toContain('Payroll');
927
+ expect(JSON.stringify(json)).not.toContain('band is wrong');
928
+ });
929
+
930
+ it('keeps a comment on a file the request no longer changes but the caller may read', async () => {
931
+ const base = await start();
932
+ readable = ['Knowledge/A.md', 'Knowledge/Gone.md'];
933
+ details.set(12, detail({ comments: [comment({ id: 'c-7', path: 'Knowledge/Gone.md', line: 3 })] }));
934
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
935
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-7']);
936
+ expect(json.withheldComments).toBe(0);
937
+ });
938
+
939
+ it('pages the comments', async () => {
940
+ const base = await start();
941
+ details.set(
942
+ 12,
943
+ detail({
944
+ comments: Array.from({ length: 40 }, (_, i) =>
945
+ comment({ id: `c-${i}`, createdAt: `2026-09-29T08:00:${String(i).padStart(2, '0')}.000Z` }),
946
+ ),
947
+ }),
948
+ );
949
+ const first = await call(base, 'list_change_request_comments', { number: 12 });
950
+ expect(first.json.comments).toHaveLength(30);
951
+ expect(first.json.hasNextPage).toBe(true);
952
+ const second = await call(base, 'list_change_request_comments', { number: 12, page: 2 });
953
+ expect(second.json.comments).toHaveLength(10);
954
+ expect(second.json.hasNextPage).toBe(false);
955
+ });
956
+
957
+ it('answers not found when the caller may read none of its files', async () => {
958
+ const base = await start();
959
+ readable = [];
960
+ details.set(12, detail({ comments: [comment({ id: 'c-1' })] }));
961
+ expect((await call(base, 'list_change_request_comments', { number: 12 })).status).toBe(404);
962
+ });
963
+ });
964
+
965
+ /**
966
+ * Reproductions of what Local Testing found on sha ae2f49d9, through the HTTP
967
+ * tool surface it found them on — not just the pure mapper underneath.
968
+ */
969
+ describe('a mixed-access caller is never handed a path they may not read', () => {
970
+ /** A body exactly as `openChangeRequest` builds it: prose, then the block. */
971
+ const bodyWithOwnersBlock = [
972
+ 'Please review the check-in note.',
973
+ '',
974
+ '## Affected owners',
975
+ '',
976
+ '- `KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md` — Admin',
977
+ '- `KnowledgeBase/GTM/Notes.md` — GTM Team',
978
+ ].join('\n');
979
+
980
+ /** The reader of one folder of a two-folder request — john.newcomer's case. */
981
+ function mixedAccessRequest(): void {
982
+ readable = ['KnowledgeBase/GTM/Notes.md'];
983
+ const waiting = [
984
+ 'Waiting on approval for KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md from Admin.',
985
+ 'Waiting on approval for KnowledgeBase/GTM/Notes.md from GTM Team.',
986
+ ];
987
+ details.set(
988
+ 1,
989
+ detail({
990
+ number: 1,
991
+ body: bodyWithOwnersBlock,
992
+ files: [
993
+ file('KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md'),
994
+ file('KnowledgeBase/GTM/Notes.md'),
995
+ ],
996
+ approvals: [
997
+ approval('KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md', {
998
+ eligibleApprovers: { roles: ['Admin'], users: [] },
999
+ }),
1000
+ approval('KnowledgeBase/GTM/Notes.md', {
1001
+ eligibleApprovers: { roles: ['GTM Team'], users: [] },
1002
+ }),
1003
+ ],
1004
+ mergeBlockedReasons: waiting,
1005
+ mergeWarnings: waiting,
1006
+ }),
1007
+ );
1008
+ }
1009
+
1010
+ it('get_change_request answers the author\'s reason and names no withheld file anywhere', async () => {
1011
+ const base = await start();
1012
+ mixedAccessRequest();
1013
+ const { status, json } = await call(base, 'get_change_request', { number: 1 });
1014
+ expect(status).toBe(200);
1015
+ expect(json).toMatchObject({
1016
+ body: 'Please review the check-in note.',
1017
+ changedFiles: 1,
1018
+ withheldFiles: 1,
1019
+ });
1020
+ // The whole payload, not just `body` — this is the assertion whose absence
1021
+ // let the leak through the first time.
1022
+ const whole = JSON.stringify(json);
1023
+ expect(whole).not.toContain('Avi-Checkin');
1024
+ expect(whole).not.toContain('KnowledgeBase/Engineering');
1025
+ expect(whole).not.toContain('Affected owners');
1026
+ // The file they CAN read is still named, and its blocker still readable.
1027
+ expect(whole).toContain('KnowledgeBase/GTM/Notes.md');
1028
+ expect(json).toMatchObject({ withheldMergeBlockedReasons: 1 });
1029
+ });
1030
+
1031
+ it('no tool of the five names the withheld file, in any field', async () => {
1032
+ const base = await start();
1033
+ mixedAccessRequest();
1034
+ summaries = [details.get(1)!];
1035
+ for (const tool of TOOLS) {
1036
+ const { status, json } = await call(
1037
+ base,
1038
+ tool,
1039
+ tool === 'list_change_requests' ? {} : { number: 1, include: ['patches'] },
1040
+ );
1041
+ expect(status, tool).toBe(200);
1042
+ const whole = JSON.stringify(json);
1043
+ expect(whole, tool).not.toContain('Avi-Checkin');
1044
+ expect(whole, tool).not.toContain('KnowledgeBase/Engineering');
1045
+ }
1046
+ });
1047
+
1048
+ it('the author sees their own body too — the cut is the same for everyone', async () => {
1049
+ const base = await start();
1050
+ mixedAccessRequest();
1051
+ callerEmail = AUTHOR;
1052
+ readable = [
1053
+ 'KnowledgeBase/GTM/Notes.md',
1054
+ 'KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md',
1055
+ ];
1056
+ const { json } = await call(base, 'get_change_request', { number: 1 });
1057
+ // A caller who may read everything gets the same author text, not the
1058
+ // machine block — one body nobody has to reason about.
1059
+ expect(json).toMatchObject({
1060
+ body: 'Please review the check-in note.',
1061
+ changedFiles: 2,
1062
+ withheldFiles: 0,
1063
+ });
1064
+ expect(JSON.stringify(json)).not.toContain('Affected owners');
1065
+ });
1066
+ });
1067
+
1068
+ describe('a file renamed out of a folder the caller may not read', () => {
1069
+ /**
1070
+ * Two files: one renamed out of a closed folder, one plainly readable. The
1071
+ * readable one keeps the REQUEST visible, so what this suite measures is the
1072
+ * rename's own treatment rather than the 404 that an all-withheld request
1073
+ * already gets (covered above).
1074
+ */
1075
+ function renameRequest(): void {
1076
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md'];
1077
+ details.set(
1078
+ 12,
1079
+ detail({
1080
+ files: [
1081
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1082
+ file('Knowledge/Plain.md'),
1083
+ ],
1084
+ approvals: [approval('Knowledge/Open.md'), approval('Knowledge/Plain.md')],
1085
+ }),
1086
+ );
1087
+ }
1088
+
1089
+ it('is withheld whole, so `previousPath` can never name the closed path', async () => {
1090
+ const base = await start();
1091
+ renameRequest();
1092
+ const { json } = await call(base, 'list_change_request_files', {
1093
+ number: 12,
1094
+ include: ['patches'],
1095
+ });
1096
+ // `Knowledge/Open.md` is readable by its new name, but its diff shows what
1097
+ // was at `Payroll/Rates.md` — so it is withheld, counted, and neither of
1098
+ // its two names appears. The plainly readable file is unaffected.
1099
+ expect((json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
1100
+ 'Knowledge/Plain.md',
1101
+ ]);
1102
+ expect(json.withheldFiles).toBe(1);
1103
+ const whole = JSON.stringify(json);
1104
+ expect(whole).not.toContain('Payroll');
1105
+ expect(whole).not.toContain('Rates');
1106
+ expect(whole).not.toContain('Knowledge/Open.md');
1107
+ });
1108
+
1109
+ it('is listed, with `previousPath`, once both of its names are readable', async () => {
1110
+ const base = await start();
1111
+ renameRequest();
1112
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md', 'Payroll/Rates.md'];
1113
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
1114
+ expect(json.files).toHaveLength(2);
1115
+ expect(json.files[0]).toMatchObject({
1116
+ path: 'Knowledge/Open.md',
1117
+ previousPath: 'Payroll/Rates.md',
1118
+ // `open_change_request`'s word for it, from the same `changeKindOf`:
1119
+ // git's `renamed` and `copied` both read `moved`.
1120
+ change: 'moved',
1121
+ });
1122
+ expect(json.files[0]).not.toHaveProperty('status');
1123
+ expect(json.withheldFiles).toBe(0);
1124
+ });
1125
+
1126
+ it('keeps a comment anchored to the withheld rename out of the comment list', async () => {
1127
+ const base = await start();
1128
+ renameRequest();
1129
+ details.set(
1130
+ 12,
1131
+ detail({
1132
+ files: [
1133
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1134
+ file('Knowledge/Plain.md'),
1135
+ ],
1136
+ approvals: [approval('Knowledge/Open.md'), approval('Knowledge/Plain.md')],
1137
+ comments: [
1138
+ comment({ id: 'c-1', path: 'Knowledge/Plain.md', line: 4 }),
1139
+ comment({ id: 'c-2', path: 'Knowledge/Open.md', line: 9, body: 'Rate band looks off.' }),
1140
+ ],
1141
+ }),
1142
+ );
1143
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1144
+ // `Knowledge/Open.md` passes a bare read check — it is the file's new name —
1145
+ // but the file is withheld whole, so a comment on it is withheld too. Were
1146
+ // it kept, it would print the name the files tool refuses to print.
1147
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual(['c-1']);
1148
+ expect(json.withheldComments).toBe(1);
1149
+ const whole = JSON.stringify(json);
1150
+ expect(whole).not.toContain('Knowledge/Open.md');
1151
+ expect(whole).not.toContain('Payroll');
1152
+ expect(whole).not.toContain('Rate band');
1153
+ });
1154
+
1155
+ it('keeps the withheld rename out of the files a review names, and counts it', async () => {
1156
+ const base = await start();
1157
+ renameRequest();
1158
+ details.set(
1159
+ 12,
1160
+ detail({
1161
+ files: [
1162
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1163
+ file('Knowledge/Plain.md'),
1164
+ ],
1165
+ approvals: [
1166
+ approval('Knowledge/Open.md', {
1167
+ approvedBy: [approvedBy('ali@bevel.software', 'Ali', '2026-09-30T11:00:00.000Z')],
1168
+ }),
1169
+ approval('Knowledge/Plain.md', {
1170
+ approvedBy: [approvedBy('ali@bevel.software', 'Ali', '2026-09-30T09:00:00.000Z')],
1171
+ }),
1172
+ ],
1173
+ }),
1174
+ );
1175
+ const { json } = await call(base, 'list_change_request_reviews', { number: 12 });
1176
+ expect(json.reviews).toHaveLength(1);
1177
+ expect(json.reviews[0]).toMatchObject({
1178
+ reviewer: { name: 'Ali' },
1179
+ files: ['Knowledge/Plain.md'],
1180
+ withheldFiles: 1,
1181
+ // Taken from the readable approval alone — the later one is withheld.
1182
+ submittedAt: '2026-09-30T09:00:00.000Z',
1183
+ });
1184
+ const whole = JSON.stringify(json);
1185
+ expect(whole).not.toContain('Knowledge/Open.md');
1186
+ expect(whole).not.toContain('Payroll');
1187
+ expect(whole).not.toContain('11:00:00');
1188
+ });
1189
+
1190
+ it('answers mayApprove false when the only approvable file is the withheld one', async () => {
1191
+ const base = await start();
1192
+ renameRequest();
1193
+ details.set(
1194
+ 12,
1195
+ detail({
1196
+ files: [
1197
+ file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' }),
1198
+ file('Knowledge/Plain.md'),
1199
+ ],
1200
+ // A write grant at `origin/<base>` can hold for a file the read verdict
1201
+ // withholds, so the request's whole approval set says the caller may
1202
+ // approve something — but not anything they are shown.
1203
+ approvals: [
1204
+ approval('Knowledge/Open.md', { viewerCanApprove: true }),
1205
+ approval('Knowledge/Plain.md', { viewerCanApprove: false }),
1206
+ ],
1207
+ }),
1208
+ );
1209
+ const first = await call(base, 'get_change_request', { number: 12 });
1210
+ expect(first.json).toMatchObject({ withheldFiles: 1 });
1211
+ expect((first.json as unknown as { viewer: { mayApprove: boolean } }).viewer.mayApprove).toBe(false);
1212
+ // Readable, and the answer turns true — the filter is the read verdict, not
1213
+ // a blanket false.
1214
+ readable = ['Knowledge/Open.md', 'Knowledge/Plain.md', 'Payroll/Rates.md'];
1215
+ const second = await call(base, 'get_change_request', { number: 12 });
1216
+ expect((second.json as unknown as { viewer: { mayApprove: boolean } }).viewer.mayApprove).toBe(true);
1217
+ });
1218
+
1219
+ it('counts a withheld rename ONCE, though it goes by two names', async () => {
1220
+ const base = await start();
1221
+ readable = [];
1222
+ details.set(
1223
+ 12,
1224
+ detail({
1225
+ files: [file('Knowledge/Open.md', { status: 'renamed', previousPath: 'Payroll/Rates.md' })],
1226
+ approvals: [approval('Knowledge/Open.md')],
1227
+ }),
1228
+ );
1229
+ callerEmail = AUTHOR;
1230
+ const { json } = await call(base, 'list_change_request_files', { number: 12 });
1231
+ expect(json.withheldFiles).toBe(1);
1232
+ });
1233
+ });
1234
+
1235
+ /**
1236
+ * The contradiction Local Testing found on sha 45f18939: `list_change_requests`
1237
+ * advertised change request #3 to a caller for whom all four by-number tools
1238
+ * answered 404, and reported `withheldFiles: 0` where it was 1. The list judged
1239
+ * the flat `touchedNodePaths`, which names a rename by its new path alone.
1240
+ */
1241
+ describe('the list and the by-number tools never disagree about a request', () => {
1242
+ const OLD = 'KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md';
1243
+ const NEW = 'KnowledgeBase/GTM/Knowledge/Moved-From-Engineering.md';
1244
+
1245
+ /**
1246
+ * CR#3 from the report: one file, renamed out of Engineering (which john
1247
+ * cannot read) into GTM (which he can). `touchedNodePaths` carries the new
1248
+ * path only — exactly as `changedPathsForPr` reports a detected rename.
1249
+ */
1250
+ function renamedOutOfReach(): ChangeRequest {
1251
+ return summary({
1252
+ number: 3,
1253
+ title: 'Local test: rename out of a folder john cannot read',
1254
+ touchedNodePaths: [NEW],
1255
+ touchedNodeFiles: [{ path: NEW, previousPath: OLD }],
1256
+ });
1257
+ }
1258
+
1259
+ beforeEach(() => {
1260
+ readable = [NEW];
1261
+ });
1262
+
1263
+ it('does not list a request whose every file the by-number tools withhold', async () => {
1264
+ const base = await start();
1265
+ const cr = renamedOutOfReach();
1266
+ summaries = [cr];
1267
+ details.set(3, detail({ ...cr, files: [file(NEW, { status: 'renamed', previousPath: OLD })] }));
1268
+
1269
+ const listed = await call(base, 'list_change_requests', {});
1270
+ expect(listed.json.changeRequests).toEqual([]);
1271
+ expect(listed.json.totalCount).toBe(0);
1272
+
1273
+ // ...and the four by-number tools agree, as they already did.
1274
+ for (const tool of TOOLS.filter((t) => t !== 'list_change_requests')) {
1275
+ const { status, json } = await call(base, tool, { number: 3 });
1276
+ expect(status, tool).toBe(404);
1277
+ expect(json.error, tool).toBe('Change request #3 not found.');
1278
+ }
1279
+ });
1280
+
1281
+ it('names neither side of the rename in the list it does answer', async () => {
1282
+ const base = await start();
1283
+ summaries = [renamedOutOfReach(), summary({ number: 2, touchedNodePaths: [NEW] })];
1284
+ const { json } = await call(base, 'list_change_requests', {});
1285
+ // #2 touches the readable file plainly, so it is listed; #3 is not, and
1286
+ // nothing of it — not its title, not either of its paths — comes back.
1287
+ expect((json.changeRequests as unknown as { number: number }[]).map((c) => c.number)).toEqual([2]);
1288
+ const whole = JSON.stringify(json);
1289
+ expect(whole).not.toContain('Avi-Checkin');
1290
+ expect(whole).not.toContain('rename out of a folder');
1291
+ });
1292
+
1293
+ it('counts the withheld rename as one file for its author, who may see it', async () => {
1294
+ const base = await start();
1295
+ callerEmail = AUTHOR;
1296
+ summaries = [renamedOutOfReach()];
1297
+ const { json } = await call(base, 'list_change_requests', {});
1298
+ expect(json.changeRequests).toHaveLength(1);
1299
+ // The author may SEE their request; they still may not read the file, and
1300
+ // the count says so — one file, not two, though it goes by two names.
1301
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 0, withheldFiles: 1 });
1302
+ expect(JSON.stringify(json)).not.toContain('Avi-Checkin');
1303
+ });
1304
+
1305
+ it('lists the request once both of the rename\'s names are readable', async () => {
1306
+ const base = await start();
1307
+ readable = [NEW, OLD];
1308
+ summaries = [renamedOutOfReach()];
1309
+ const { json } = await call(base, 'list_change_requests', {});
1310
+ expect(json.changeRequests).toHaveLength(1);
1311
+ expect(json.changeRequests[0]).toMatchObject({ changedFiles: 1, withheldFiles: 0 });
1312
+ });
1313
+
1314
+ it('asks the access tree about both of a rename\'s names', async () => {
1315
+ const base = await start();
1316
+ summaries = [renamedOutOfReach()];
1317
+ callerEmail = AUTHOR;
1318
+ await call(base, 'list_change_requests', {});
1319
+ // One lookup for the target branch, carrying both paths — the old side is
1320
+ // what the previous implementation never asked about.
1321
+ const asked = calls.filter((c) => c[0] === 'canReadBatchAtRef');
1322
+ expect(asked).toHaveLength(1);
1323
+ expect(asked[0][2]).toBe('origin/main');
1324
+ expect(accessAskedFor).toEqual(expect.arrayContaining([NEW, OLD]));
1325
+ });
1326
+
1327
+ /**
1328
+ * A summary from somewhere that never filled `touchedNodeFiles` proves
1329
+ * nothing, rather than silently falling back to the flat paths — the fallback
1330
+ * is what this whole class of bug was.
1331
+ */
1332
+ it('treats a summary with no paired files as proving no read access', async () => {
1333
+ const base = await start();
1334
+ const bare = summary({ number: 5 });
1335
+ delete bare.touchedNodeFiles;
1336
+ summaries = [bare];
1337
+ const strangers = await call(base, 'list_change_requests', {});
1338
+ expect(strangers.json.changeRequests).toEqual([]);
1339
+
1340
+ callerEmail = AUTHOR;
1341
+ const mine = await call(base, 'list_change_requests', {});
1342
+ expect(mine.json.changeRequests).toHaveLength(1);
1343
+ });
1344
+ });
1345
+
1346
+ /**
1347
+ * Scenario (Razvan's decision, 2026-10-02): a MERGED request's files are
1348
+ * recovered from its merge commit and filtered by access like an open one, so a
1349
+ * reviewer can read back what happened. A DECLINED one has no merge commit and
1350
+ * stays readable by its author alone.
1351
+ *
1352
+ * The recovery itself is the service's (`getPrDetail` of an applied request, and
1353
+ * `changedFilesAtCommit` under it, both tested there). What these pin is the part
1354
+ * the tools own: a merged request with a resolved file set is read like any
1355
+ * other — same access filter, same withholding, same 404 — and nothing about
1356
+ * being applied makes it either more or less visible.
1357
+ */
1358
+ describe('reading a request that is no longer open', () => {
1359
+ const applied = (over: Partial<ChangeRequestDetail> = {}) =>
1360
+ detail({
1361
+ number: 20,
1362
+ state: 'merged',
1363
+ files: [file('KnowledgeBase/GTM/Notes.md'), file('Payroll/Rates.md')],
1364
+ mergeBlockedReasons: ['This pull request has already been merged.'],
1365
+ ...over,
1366
+ });
1367
+
1368
+ it('shows a non-author the applied files they may read, withholding the rest', async () => {
1369
+ const base = await start();
1370
+ readable = ['KnowledgeBase/GTM/Notes.md'];
1371
+ const cr = applied();
1372
+ details.set(20, cr);
1373
+ summaries = [cr];
1374
+
1375
+ const got = await call(base, 'get_change_request', { number: 20 });
1376
+ expect(got.status).toBe(200);
1377
+ expect(got.json).toMatchObject({ state: 'merged', changedFiles: 1, withheldFiles: 1 });
1378
+
1379
+ const files = await call(base, 'list_change_request_files', { number: 20 });
1380
+ expect((files.json.files as unknown as { path: string }[]).map((f) => f.path)).toEqual([
1381
+ 'KnowledgeBase/GTM/Notes.md',
1382
+ ]);
1383
+ expect(files.json.withheldFiles).toBe(1);
1384
+ // Applied or not, the withheld file is never named.
1385
+ expect(JSON.stringify(files.json)).not.toContain('Payroll');
1386
+ });
1387
+
1388
+ it('lists it under `state: closed`, which covers applied and declined alike', async () => {
1389
+ const base = await start();
1390
+ readable = ['KnowledgeBase/GTM/Notes.md'];
1391
+ summaries = [applied()];
1392
+ const { json } = await call(base, 'list_change_requests', { state: 'closed' });
1393
+ expect(calls).toContainEqual(['listChangeRequestsByState', ['closed', 'merged']]);
1394
+ expect(json.changeRequests).toHaveLength(1);
1395
+ // And the row says WHICH of the two it is, where GitHub would say `closed`.
1396
+ expect(json.changeRequests[0]).toMatchObject({
1397
+ number: 20,
1398
+ state: 'merged',
1399
+ changedFiles: 1,
1400
+ withheldFiles: 1,
1401
+ });
1402
+ });
1403
+
1404
+ it('still answers 404 to a caller who may read none of the applied files', async () => {
1405
+ const base = await start();
1406
+ readable = [];
1407
+ summaries = [applied()];
1408
+ details.set(20, applied());
1409
+ expect((await call(base, 'get_change_request', { number: 20 })).status).toBe(404);
1410
+ expect((await call(base, 'list_change_requests', { state: 'all' })).json.changeRequests).toEqual([]);
1411
+ });
1412
+
1413
+ // A declined request records no sha, so the service reads it from nothing and
1414
+ // resolves no files for it — and an empty file set proves no read access.
1415
+ //
1416
+ // That precondition is the SERVICE's to keep, and this test cannot check it:
1417
+ // it hands the tools a detail directly. It used to be false. Declining does
1418
+ // not retire the source branch, so `getPrDetail` resolved a declined request's
1419
+ // branch pair and published its files, while the list — which asks git nothing
1420
+ // about a declined row — hid the same request from the same caller. What the
1421
+ // tools are shown here is now what the service produces, pinned by
1422
+ // `PullRequestService.getPrDetail of a declined request whose branch still
1423
+ // resolves`. Deliberately not re-asserted in the tool layer: a second copy of
1424
+ // the rule is what let the two surfaces disagree in the first place.
1425
+ it('leaves a declined request, whose files cannot be resolved, to its author', async () => {
1426
+ const base = await start();
1427
+ const declined = detail({ number: 21, state: 'closed', files: [], approvals: [] });
1428
+ details.set(21, declined);
1429
+ summaries = [declined];
1430
+ expect((await call(base, 'get_change_request', { number: 21 })).status).toBe(404);
1431
+ expect((await call(base, 'list_change_requests', { state: 'closed' })).json.changeRequests).toEqual([]);
1432
+
1433
+ callerEmail = AUTHOR;
1434
+ const mine = await call(base, 'get_change_request', { number: 21 });
1435
+ expect(mine.status).toBe(200);
1436
+ expect(mine.json).toMatchObject({ state: 'closed', changedFiles: 0, viewer: { isAuthor: true } });
1437
+ });
1438
+ });
1439
+
1440
+ /**
1441
+ * Scenario (Razvan's review, 2026-10-02, finding 3): a reply is shown only when
1442
+ * the comment it replies to is.
1443
+ *
1444
+ * `post_change_request_comment` takes `parentId` without requiring `path`, so a
1445
+ * reply to a comment on a withheld file has no path of its own — and judged on
1446
+ * itself it looked like a general comment about the whole request. It came back
1447
+ * with its body and a `parentId` naming a comment the caller cannot see.
1448
+ */
1449
+ describe('a reply is shown only when the comment it replies to is', () => {
1450
+ /** A request whose two files the caller can read one of. */
1451
+ function threadRequest(comments: ChangeRequestComment[]) {
1452
+ readable = ['Knowledge/Open.md'];
1453
+ details.set(
1454
+ 12,
1455
+ detail({
1456
+ files: [file('Knowledge/Open.md'), file('Payroll/Rates.md')],
1457
+ approvals: [approval('Knowledge/Open.md'), approval('Payroll/Rates.md')],
1458
+ comments,
1459
+ }),
1460
+ );
1461
+ }
1462
+
1463
+ it('withholds a pathless reply to a comment on a file the caller may not read', async () => {
1464
+ const base = await start();
1465
+ threadRequest([
1466
+ comment({ id: 'c-1', path: 'Payroll/Rates.md', line: 3, body: 'This band is wrong.' }),
1467
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Agreed, the band moved in April.' }),
1468
+ ]);
1469
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1470
+ expect(json.comments).toEqual([]);
1471
+ expect(json.withheldComments).toBe(2);
1472
+ const whole = JSON.stringify(json);
1473
+ // Neither the parent's path, nor the reply's body, nor the id it answers.
1474
+ expect(whole).not.toContain('Payroll');
1475
+ expect(whole).not.toContain('band moved');
1476
+ expect(whole).not.toContain('c-1');
1477
+ });
1478
+
1479
+ it('withholds every reply down the thread, however deep it runs', async () => {
1480
+ const base = await start();
1481
+ threadRequest([
1482
+ comment({ id: 'c-1', path: 'Payroll/Rates.md', body: 'This band is wrong.' }),
1483
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Which one?' }),
1484
+ comment({ id: 'c-3', parentId: 'c-2', body: 'The senior one.' }),
1485
+ ]);
1486
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1487
+ expect(json.comments).toEqual([]);
1488
+ expect(json.withheldComments).toBe(3);
1489
+ expect(JSON.stringify(json)).not.toContain('senior');
1490
+ });
1491
+
1492
+ it('keeps a thread on a file the caller may read, replies and all', async () => {
1493
+ const base = await start();
1494
+ threadRequest([
1495
+ comment({ id: 'c-1', path: 'Knowledge/Open.md', line: 4, body: 'Out of date.' }),
1496
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Fixed.' }),
1497
+ comment({ id: 'c-3', parentId: 'c-2', body: 'Thanks.' }),
1498
+ ]);
1499
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1500
+ expect((json.comments as unknown as { id: string }[]).map((c) => c.id)).toEqual([
1501
+ 'c-1',
1502
+ 'c-2',
1503
+ 'c-3',
1504
+ ]);
1505
+ expect(json.withheldComments).toBe(0);
1506
+ });
1507
+
1508
+ it('keeps a general comment and its replies — neither is about a file', async () => {
1509
+ const base = await start();
1510
+ threadRequest([
1511
+ comment({ id: 'c-1', body: 'Ready for review.' }),
1512
+ comment({ id: 'c-2', parentId: 'c-1', body: 'Looking now.' }),
1513
+ ]);
1514
+ const { json } = await call(base, 'list_change_request_comments', { number: 12 });
1515
+ expect(json.comments).toHaveLength(2);
1516
+ expect(json.withheldComments).toBe(0);
1517
+ });
1518
+ });