@bevel-software/platform-core-backend 0.25.0 → 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 (254) 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/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,705 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type {
3
+ ChangeRequest,
4
+ ChangeRequestDetail,
5
+ FileApproval,
6
+ } from '@bevel-software/platform-shared';
7
+ import { hashEmail } from '../../../../shared/email-identity.js';
8
+ import {
9
+ authorsDescription,
10
+ fileIsReadable,
11
+ isAuthor,
12
+ loginFor,
13
+ matchesAuthor,
14
+ maySeeChangeRequest,
15
+ pageOf,
16
+ pagingOf,
17
+ pathsOf,
18
+ statesFor,
19
+ toCrViewer,
20
+ toCrSummary,
21
+ APPLY_FAILURE_REASON_WITHHELD,
22
+ visibleComments,
23
+ toCrComment,
24
+ toCrFile,
25
+ toCrReviews,
26
+ visibleBlockers,
27
+ } from '../change-request-read-shape.js';
28
+
29
+ const AUTHOR = 'juan@bevel.software';
30
+
31
+ function summary(over: Partial<ChangeRequest> = {}): ChangeRequest {
32
+ return {
33
+ number: 12,
34
+ title: 'Rework the onboarding note',
35
+ authorId: hashEmail(AUTHOR),
36
+ author: { login: loginFor(AUTHOR), name: 'Juan' },
37
+ appAuthor: { name: 'Juan' },
38
+ branch: 'juan/my-draft',
39
+ base: 'main',
40
+ state: 'open',
41
+ createdAt: '2026-09-28T10:00:00.000Z',
42
+ updatedAt: '2026-09-28T10:00:00.000Z',
43
+ touchedNodePaths: ['Knowledge/A.md'],
44
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
45
+ url: 'https://hexis.example.com/change-requests/12',
46
+ ...over,
47
+ };
48
+ }
49
+
50
+ function approval(over: Partial<FileApproval> = {}): FileApproval {
51
+ return {
52
+ path: 'Knowledge/A.md',
53
+ eligibleApprovers: { roles: ['Engineering'], users: [] },
54
+ approvedBy: [],
55
+ eligibilityResolved: true,
56
+ isApproved: false,
57
+ inMergeGate: true,
58
+ viewerCanApprove: false,
59
+ ...over,
60
+ };
61
+ }
62
+
63
+ describe("state is Hexis's own, and only the filter speaks GitHub's", () => {
64
+ // Scenario: WHEN the request is merged THEN `state` says `merged`. GitHub
65
+ // would say `closed` with a `merged` flag beside it; Hexis has the word, so
66
+ // it uses it, and no answer carries the flag (Razvan, 2026-10-02).
67
+ it('reports each of the three states as itself, with no flag to cross-read', () => {
68
+ for (const state of ['open', 'merged', 'closed'] as const) {
69
+ const answer = toCrSummary(summary({ state }), { readable: 1, withheld: 0 });
70
+ expect(answer.state).toBe(state);
71
+ expect(answer).not.toHaveProperty('merged');
72
+ }
73
+ });
74
+
75
+ it('the `closed` filter covers applied and declined alike; `all` covers everything', () => {
76
+ expect(statesFor('open')).toEqual(['open']);
77
+ expect(statesFor('closed').sort()).toEqual(['closed', 'merged']);
78
+ expect(statesFor('all').sort()).toEqual(['closed', 'merged', 'open']);
79
+ });
80
+ });
81
+
82
+ describe('summary mapping', () => {
83
+ it("uses the field names `open_change_request` answers in, `url` first", () => {
84
+ const answer = toCrSummary(summary(), { readable: 1, withheld: 0 });
85
+ expect(answer).toEqual({
86
+ url: 'https://hexis.example.com/change-requests/12',
87
+ number: 12,
88
+ title: 'Rework the onboarding note',
89
+ state: 'open',
90
+ author: { login: loginFor(AUTHOR), name: 'Juan' },
91
+ sourceBranch: 'juan/my-draft',
92
+ targetBranch: 'main',
93
+ createdAt: '2026-09-28T10:00:00.000Z',
94
+ updatedAt: '2026-09-28T10:00:00.000Z',
95
+ changedFiles: 1,
96
+ withheldFiles: 0,
97
+ });
98
+ // `url` first on purpose: it is the field an agent must hand a person, so a
99
+ // truncation of the answer cannot take it. `toEqual` above pins the set of
100
+ // keys; this pins the order.
101
+ expect(Object.keys(answer)[0]).toBe('url');
102
+ });
103
+
104
+ it('carries the note through when the deployment has no public address', () => {
105
+ const cr = summary({ url: '/change-requests/12', urlNote: 'Configure a public address.' });
106
+ expect(toCrSummary(cr, { readable: 1, withheld: 0 })).toMatchObject({
107
+ url: '/change-requests/12',
108
+ urlNote: 'Configure a public address.',
109
+ });
110
+ });
111
+
112
+ it('falls back to the creation time when the row records no later moment', () => {
113
+ const cr = summary();
114
+ delete cr.updatedAt;
115
+ expect(toCrSummary(cr, { readable: 1, withheld: 0 }).updatedAt).toBe(cr.createdAt);
116
+ });
117
+
118
+ it("carries the last apply failure, its reason only for a caller who may read every file of a request that has some", () => {
119
+ const failure = { reason: 'Conflicts in Payroll/Rates.md', conflicts: true, at: '2026-09-29T09:00:00.000Z' };
120
+ const cr = summary({ lastApplyFailure: failure });
121
+ // Every file readable — one is enough: the author's words.
122
+ expect(toCrSummary(cr, { readable: 1, withheld: 0 }).lastApplyFailure).toEqual(failure);
123
+ // One file withheld: the reason could name it, so it is the app's
124
+ // withheld line — the time and the kind are still the caller's to see.
125
+ expect(toCrSummary(cr, { readable: 1, withheld: 1 }).lastApplyFailure).toEqual({
126
+ reason: APPLY_FAILURE_REASON_WITHHELD,
127
+ conflicts: true,
128
+ at: failure.at,
129
+ });
130
+ // No file at all proves nothing, so it grants nothing.
131
+ expect(toCrSummary(cr, { readable: 0, withheld: 0 }).lastApplyFailure?.reason).toBe(APPLY_FAILURE_REASON_WITHHELD);
132
+ // Nothing to report: the key is absent, not null.
133
+ expect(toCrSummary(summary({ lastApplyFailure: null }), { readable: 1, withheld: 0 })).not.toHaveProperty('lastApplyFailure');
134
+ expect(toCrSummary(summary(), { readable: 1, withheld: 0 })).not.toHaveProperty('lastApplyFailure');
135
+ });
136
+ });
137
+
138
+ describe('the author filter', () => {
139
+ it('matches an email through the stored author hash', () => {
140
+ expect(matchesAuthor(summary(), AUTHOR)).toBe(true);
141
+ expect(matchesAuthor(summary(), ' JUAN@Bevel.Software ')).toBe(true);
142
+ expect(matchesAuthor(summary(), 'mia@bevel.software')).toBe(false);
143
+ });
144
+
145
+ it('matches a login by name', () => {
146
+ expect(matchesAuthor(summary(), loginFor(AUTHOR))).toBe(true);
147
+ expect(matchesAuthor(summary(), 'user-000000000000')).toBe(false);
148
+ });
149
+
150
+ it('matches nothing on an empty needle, and nothing on a request with no author hash', () => {
151
+ expect(matchesAuthor(summary(), ' ')).toBe(false);
152
+ expect(matchesAuthor(summary({ authorId: undefined }), AUTHOR)).toBe(false);
153
+ });
154
+
155
+ it('knows the viewer is the author only when the hashes agree', () => {
156
+ expect(isAuthor(summary(), AUTHOR)).toBe(true);
157
+ expect(isAuthor(summary(), 'mia@bevel.software')).toBe(false);
158
+ expect(isAuthor(summary(), undefined)).toBe(false);
159
+ });
160
+ });
161
+
162
+ describe('visibility', () => {
163
+ it('shows a request with at least one readable file', () => {
164
+ expect(maySeeChangeRequest({ readableFiles: 1, isAuthor: false })).toBe(true);
165
+ });
166
+
167
+ // Scenario: WHEN the caller may read none of the request's files and is not
168
+ // its author THEN `get_change_request` answers 404.
169
+ it('hides a request whose every file is closed to a caller who is not its author', () => {
170
+ expect(maySeeChangeRequest({ readableFiles: 0, isAuthor: false })).toBe(false);
171
+ });
172
+
173
+ it('shows the author their own request even when they may read none of it', () => {
174
+ expect(maySeeChangeRequest({ readableFiles: 0, isAuthor: true })).toBe(true);
175
+ });
176
+ });
177
+
178
+ describe('paging', () => {
179
+ it("reads GitHub's defaults and clamps what is out of range", () => {
180
+ expect(pagingOf({})).toEqual({ perPage: 30, page: 1 });
181
+ expect(pagingOf({ per_page: 100, page: 3 })).toEqual({ perPage: 100, page: 3 });
182
+ expect(pagingOf({ per_page: 500 })).toEqual({ perPage: 100, page: 1 });
183
+ expect(pagingOf({ per_page: 0, page: 0 })).toEqual({ perPage: 1, page: 1 });
184
+ expect(pagingOf({ per_page: 'lots', page: null })).toEqual({ perPage: 30, page: 1 });
185
+ });
186
+
187
+ // Scenario: WHEN the request has 40 files THEN the first page holds 30 and
188
+ // says there is a second.
189
+ it('a first page of 40 holds 30 and says there is a second', () => {
190
+ const all = Array.from({ length: 40 }, (_, i) => i);
191
+ const first = pageOf(all, 30, 1);
192
+ expect(first.items).toHaveLength(30);
193
+ expect(first).toMatchObject({ totalCount: 40, page: 1, perPage: 30, hasNextPage: true });
194
+ const second = pageOf(all, 30, 2);
195
+ expect(second.items).toHaveLength(10);
196
+ expect(second.hasNextPage).toBe(false);
197
+ });
198
+
199
+ it('a page past the end is empty rather than an error', () => {
200
+ expect(pageOf([1, 2], 30, 9)).toMatchObject({ items: [], totalCount: 2, hasNextPage: false });
201
+ });
202
+ });
203
+
204
+ describe('files', () => {
205
+ it("names the path `path`, the kind of change `change`, and the old name `previousPath`", () => {
206
+ const file = {
207
+ path: 'Knowledge/A.md',
208
+ previousPath: 'Knowledge/Old.md',
209
+ status: 'renamed' as const,
210
+ additions: 3,
211
+ deletions: 1,
212
+ isBinary: false,
213
+ sha: 'blob-1',
214
+ rawUrl: '',
215
+ patch: '@@ -1 +1 @@',
216
+ };
217
+ const approved = approval({
218
+ approvedBy: [
219
+ { email: 'mia@bevel.software', name: 'Mia', approvedAt: '2026-09-29T08:00:00.000Z', isStale: false, isSelfApproval: false },
220
+ ],
221
+ isApproved: true,
222
+ viewerCanApprove: true,
223
+ });
224
+ expect(toCrFile(file, approved, { patches: false, open: true })).toEqual({
225
+ path: 'Knowledge/A.md',
226
+ previousPath: 'Knowledge/Old.md',
227
+ // git's `renamed` through `open_change_request`'s own `changeKindOf`.
228
+ change: 'moved',
229
+ additions: 3,
230
+ deletions: 1,
231
+ // No `sha`: nothing in Hexis populates a blob sha, so answering one would
232
+ // answer `""` for every file. See `toCrFile`.
233
+ isBinary: false,
234
+ requiredApprovers: { roles: ['Engineering'], users: [] },
235
+ approvedBy: [
236
+ {
237
+ user: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
238
+ approvedAt: '2026-09-29T08:00:00.000Z',
239
+ stale: false,
240
+ selfApproval: false,
241
+ },
242
+ ],
243
+ approved: true,
244
+ inMergeGate: true,
245
+ viewerMayApprove: true,
246
+ });
247
+ });
248
+
249
+ it('never says the viewer may approve a file of a request that is no longer open', () => {
250
+ // A write grant on the file outlives the request: `viewerCanApprove` is
251
+ // still true on a merged or declined request's approval entry, and
252
+ // answering it would offer an approval nothing accepts.
253
+ const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '' };
254
+ const grant = approval({ viewerCanApprove: true });
255
+ expect(toCrFile(file, grant, { patches: false, open: true }).viewerMayApprove).toBe(true);
256
+ expect(toCrFile(file, grant, { patches: false, open: false }).viewerMayApprove).toBe(false);
257
+ });
258
+
259
+ it('returns a patch only when one was asked for', () => {
260
+ const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '', patch: '@@' };
261
+ expect(toCrFile(file, undefined, { patches: false, open: true }).patch).toBeUndefined();
262
+ expect(toCrFile(file, undefined, { patches: true, open: true }).patch).toBe('@@');
263
+ });
264
+
265
+ // `approversUnknown` is PRESENT or absent, never `false` — the same shape
266
+ // `open_change_request` answers in, so the fail-closed reading ("empty means
267
+ // not known") is the one a caller gets by default rather than one they have to
268
+ // look for.
269
+ it('says the approver set is unknown when nothing could answer for the file', () => {
270
+ const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '' };
271
+ expect(toCrFile(file, undefined, { patches: false, open: true })).toMatchObject({
272
+ requiredApprovers: { roles: [], users: [] },
273
+ approversUnknown: true,
274
+ approved: false,
275
+ inMergeGate: false,
276
+ viewerMayApprove: false,
277
+ });
278
+ expect(toCrFile(file, approval({ eligibilityResolved: false }), { patches: false, open: true })
279
+ .approversUnknown).toBe(true);
280
+ // Resolved: the key is absent, not false.
281
+ expect(toCrFile(file, approval(), { patches: false, open: true })).not.toHaveProperty('approversUnknown');
282
+ });
283
+
284
+ it('reads each of git\'s statuses as one of the four words', () => {
285
+ const kind = (status: 'added' | 'removed' | 'renamed' | 'copied' | 'modified' | 'changed' | 'unchanged') =>
286
+ toCrFile(
287
+ { path: 'A.md', status, additions: 0, deletions: 0, isBinary: false, sha: 's', rawUrl: '' },
288
+ undefined,
289
+ { patches: false, open: true },
290
+ ).change;
291
+ expect(kind('added')).toBe('added');
292
+ expect(kind('removed')).toBe('deleted');
293
+ expect(kind('renamed')).toBe('moved');
294
+ expect(kind('copied')).toBe('moved');
295
+ expect(kind('modified')).toBe('changed');
296
+ expect(kind('unchanged')).toBe('changed');
297
+ });
298
+ });
299
+
300
+ describe('reviews, gathered out of the per-file approvals', () => {
301
+ // Scenario: WHEN a reviewer approved two of three files THEN
302
+ // `list_change_request_reviews` names the reviewer and the two files.
303
+ it('names a reviewer once with the files they approved and the latest time', () => {
304
+ const mia = (at: string, isStale = false) => ({
305
+ email: 'mia@bevel.software',
306
+ name: 'Mia',
307
+ approvedAt: at,
308
+ isStale,
309
+ isSelfApproval: false,
310
+ });
311
+ const { reviews, withheldReviews } = toCrReviews([
312
+ approval({ path: 'A.md', approvedBy: [mia('2026-09-29T08:00:00.000Z')] }),
313
+ approval({ path: 'B.md', approvedBy: [mia('2026-09-29T09:00:00.000Z')] }),
314
+ approval({ path: 'C.md', approvedBy: [] }),
315
+ ]);
316
+ expect(reviews).toEqual([
317
+ {
318
+ id: `${loginFor('mia@bevel.software')}:current`,
319
+ reviewer: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
320
+ stale: false,
321
+ submittedAt: '2026-09-29T09:00:00.000Z',
322
+ files: ['A.md', 'B.md'],
323
+ withheldFiles: 0,
324
+ },
325
+ ]);
326
+ expect(withheldReviews).toBe(0);
327
+ });
328
+
329
+ // GitHub's word for this is `DISMISSED`; Hexis's is `isStale`, and the answer
330
+ // uses Hexis's.
331
+ it("reports an approval a later push invalidated as `stale`, separately", () => {
332
+ const mia = (at: string, isStale: boolean) => ({ email: 'mia@x', name: 'Mia', approvedAt: at, isStale, isSelfApproval: false });
333
+ const { reviews } = toCrReviews([
334
+ approval({ path: 'A.md', approvedBy: [mia('2026-09-29T08:00:00.000Z', false)] }),
335
+ approval({ path: 'B.md', approvedBy: [mia('2026-09-28T08:00:00.000Z', true)] }),
336
+ ]);
337
+ expect(reviews.map((r) => [r.stale, r.files])).toEqual([
338
+ [true, ['B.md']],
339
+ [false, ['A.md']],
340
+ ]);
341
+ expect(JSON.stringify(reviews)).not.toContain('DISMISSED');
342
+ });
343
+
344
+ it("keeps a review's readable files and counts the rest, naming none of them", () => {
345
+ const entry = (at: string) => ({ email: 'mia@x', name: 'Mia', approvedAt: at, isStale: false, isSelfApproval: false });
346
+ const { reviews, withheldReviews } = toCrReviews(
347
+ [
348
+ approval({ path: 'Knowledge/A.md', approvedBy: [entry('2026-09-29T08:00:00.000Z')] }),
349
+ approval({ path: 'Secret/Pay.md', approvedBy: [entry('2026-09-29T10:00:00.000Z')] }),
350
+ ],
351
+ (path) => path === 'Knowledge/A.md',
352
+ );
353
+ expect(reviews).toHaveLength(1);
354
+ expect(reviews[0]).toMatchObject({ files: ['Knowledge/A.md'], withheldFiles: 1 });
355
+ // Neither the path nor the moment it was approved leaks out.
356
+ expect(JSON.stringify(reviews)).not.toContain('Secret');
357
+ expect(JSON.stringify(reviews)).not.toContain('10:00:00');
358
+ expect(withheldReviews).toBe(0);
359
+ });
360
+
361
+ it('drops a review whose every file is withheld, and counts it instead', () => {
362
+ const entry = { email: 'mia@x', name: 'Mia', approvedAt: '2026-09-29T08:00:00.000Z', isStale: false, isSelfApproval: false };
363
+ const { reviews, withheldReviews } = toCrReviews(
364
+ [approval({ path: 'Secret/Pay.md', approvedBy: [entry] })],
365
+ () => false,
366
+ );
367
+ expect(reviews).toEqual([]);
368
+ expect(withheldReviews).toBe(1);
369
+ });
370
+
371
+ it('answers with no reviews when nobody has approved anything', () => {
372
+ expect(toCrReviews([approval({ path: 'A.md' })])).toEqual({ reviews: [], withheldReviews: 0 });
373
+ });
374
+ });
375
+
376
+ describe('comments', () => {
377
+ // Scenario: WHEN a reviewer left an inline comment and a reply followed THEN
378
+ // `list_change_request_comments` returns both, the reply with `parentId`.
379
+ it('maps an inline comment and a reply, the reply carrying `parentId`', () => {
380
+ const inline = toCrComment({
381
+ id: 'c-1',
382
+ author: { email: 'mia@bevel.software', name: 'Mia' },
383
+ body: 'This line is out of date.',
384
+ path: 'Knowledge/A.md',
385
+ line: 14,
386
+ headSha: 'head-1',
387
+ createdAt: '2026-09-29T08:00:00.000Z',
388
+ });
389
+ expect(inline).toEqual({
390
+ id: 'c-1',
391
+ author: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
392
+ body: 'This line is out of date.',
393
+ path: 'Knowledge/A.md',
394
+ line: 14,
395
+ headSha: 'head-1',
396
+ createdAt: '2026-09-29T08:00:00.000Z',
397
+ });
398
+ const reply = toCrComment({
399
+ id: 'c-2',
400
+ author: { email: AUTHOR, name: 'Juan' },
401
+ body: 'Fixed.',
402
+ path: 'Knowledge/A.md',
403
+ line: 14,
404
+ headSha: 'head-1',
405
+ parentId: 'c-1',
406
+ createdAt: '2026-09-29T09:00:00.000Z',
407
+ updatedAt: '2026-09-29T09:05:00.000Z',
408
+ });
409
+ expect(reply).toMatchObject({ parentId: 'c-1', updatedAt: '2026-09-29T09:05:00.000Z' });
410
+ });
411
+
412
+ it('leaves `path`, `line` and `parentId` off a general comment', () => {
413
+ const general = toCrComment({
414
+ id: 'c-3',
415
+ author: { email: AUTHOR, name: 'Juan' },
416
+ body: 'Ready for review.',
417
+ headSha: 'head-1',
418
+ createdAt: '2026-09-29T07:00:00.000Z',
419
+ });
420
+ expect(general.path).toBeUndefined();
421
+ expect(general.line).toBeUndefined();
422
+ expect(general.parentId).toBeUndefined();
423
+ });
424
+ });
425
+
426
+ describe('the viewer block — what this caller may do', () => {
427
+ const detail = (over: Partial<ChangeRequestDetail> = {}) =>
428
+ ({
429
+ state: 'open',
430
+ mergeBlockedReasons: [],
431
+ mergeWarnings: [],
432
+ viewerCanBypassMerge: false,
433
+ approvals: [],
434
+ ...over,
435
+ }) as ChangeRequestDetail;
436
+
437
+ it('mayMerge is true when the gate waits on nothing', () => {
438
+ // Only the three verbs. The blockers live beside it on the answer, under
439
+ // `open_change_request`'s own `mergeBlockedReasons`, so this block holds
440
+ // exactly what is about the CALLER and nothing about the request.
441
+ expect(toCrViewer(detail(), false, [])).toEqual({
442
+ mayApprove: false,
443
+ mayMerge: true,
444
+ isAuthor: false,
445
+ });
446
+ });
447
+
448
+ it('mayMerge is false on a missing approval, and true for an admin who may bypass it', () => {
449
+ const waiting = ['Waiting on approval for A.md from Engineering.'];
450
+ expect(
451
+ toCrViewer(detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting }), false, []).mayMerge,
452
+ ).toBe(false);
453
+ expect(
454
+ toCrViewer(
455
+ detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting, viewerCanBypassMerge: true }),
456
+ false,
457
+ [],
458
+ ).mayMerge,
459
+ ).toBe(true);
460
+ });
461
+
462
+ it('mayMerge is false on a hard block, bypass or not', () => {
463
+ const hard = ['This pull request is closed.'];
464
+ expect(
465
+ toCrViewer(detail({ state: 'closed', mergeBlockedReasons: hard, viewerCanBypassMerge: true }), false, []).mayMerge,
466
+ ).toBe(false);
467
+ });
468
+
469
+ it('mayApprove is true when the caller may approve any one file they are shown', () => {
470
+ expect(
471
+ toCrViewer(detail(), false, [
472
+ approval(),
473
+ approval({ path: 'B.md', viewerCanApprove: true }),
474
+ ]).mayApprove,
475
+ ).toBe(true);
476
+ });
477
+
478
+ it('mayApprove is false on a merged or declined request, whatever grant the caller holds', () => {
479
+ const grant = [approval({ viewerCanApprove: true })];
480
+ for (const state of ['merged', 'closed'] as const) {
481
+ expect(toCrViewer(detail({ state }), false, grant).mayApprove, state).toBe(false);
482
+ }
483
+ });
484
+
485
+ it('mayApprove follows exactly the approval set it is given', () => {
486
+ // What this pins is the narrow thing the signature can pin: the answer is a
487
+ // function of the SHOWN approvals and of nothing else. The leak it exists to
488
+ // prevent — a write grant outliving a read refusal, so the only approvable
489
+ // file is one the caller is not shown — cannot be staged here at all, since
490
+ // `toCrViewer` no longer receives the request's whole approval set (its
491
+ // `Pick` excludes `approvals` for exactly that reason). That case is driven
492
+ // end to end by 'answers mayApprove false when the only approvable file is
493
+ // the withheld one' in change-request-read.tools.test.ts.
494
+ const shown = [approval({ path: 'Knowledge/A.md' })];
495
+ expect(toCrViewer(detail(), false, shown).mayApprove).toBe(false);
496
+ expect(
497
+ toCrViewer(detail(), false, [
498
+ ...shown,
499
+ approval({ path: 'Knowledge/A.md', viewerCanApprove: true }),
500
+ ]).mayApprove,
501
+ ).toBe(true);
502
+ });
503
+ });
504
+
505
+ describe('merge blockers never name a file the caller may not read', () => {
506
+ it('withholds and counts a blocker that quotes a withheld path', () => {
507
+ const reasons = [
508
+ 'Waiting on approval for Knowledge/Open.md from Engineering.',
509
+ 'Waiting on approval for Secret/Pay.md from Finance.',
510
+ ];
511
+ const scoped = visibleBlockers(reasons, ['Secret/Pay.md']);
512
+ expect(scoped).toEqual({ visible: [reasons[0]], withheld: 1 });
513
+ expect(JSON.stringify(scoped)).not.toContain('Secret/Pay.md');
514
+ });
515
+
516
+ it('keeps every blocker when nothing is withheld', () => {
517
+ const reasons = ['This pull request has no file changes to approve.'];
518
+ expect(visibleBlockers(reasons, [])).toEqual({ visible: reasons, withheld: 0 });
519
+ });
520
+ });
521
+
522
+ /**
523
+ * The leak Local Testing caught on the first attempt: `openChangeRequest`
524
+ * appends a generated `## Affected owners` block to the body, naming every
525
+ * changed path and its approvers, and the detail returned the body verbatim.
526
+ */
527
+ describe('the body a caller is handed is the author\'s, not the machine\'s', () => {
528
+ const GENERATED = [
529
+ '## Affected owners',
530
+ '',
531
+ '- `KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md` — Admin',
532
+ '- `KnowledgeBase/GTM/Notes.md` — GTM Team',
533
+ ].join('\n');
534
+
535
+ it('drops the generated owners block, keeping what the author typed', () => {
536
+ expect(authorsDescription(`Please take a look.\n\n${GENERATED}`)).toBe('Please take a look.');
537
+ expect(authorsDescription(GENERATED)).toBe('');
538
+ });
539
+
540
+ it('names no path of the generated block, readable or not', () => {
541
+ const out = authorsDescription(`Why this is needed.\n\n${GENERATED}`);
542
+ expect(out).not.toContain('Avi-Checkin');
543
+ expect(out).not.toContain('KnowledgeBase');
544
+ expect(out).not.toContain('Affected owners');
545
+ });
546
+
547
+ it("strips the hidden identity markers older bodies carry, and only those — an author's own HTML comment is prose", () => {
548
+ expect(authorsDescription('<!-- bevel:author:abc123 -->\nMy reason.')).toBe('My reason.');
549
+ expect(authorsDescription('<!--hexis:access-request-level:write-->\nMy reason.')).toBe('My reason.');
550
+ expect(authorsDescription('My reason.\n<!-- TODO: ask Mia about the title -->')).toBe(
551
+ 'My reason.\n<!-- TODO: ask Mia about the title -->',
552
+ );
553
+ });
554
+
555
+ it('leaves a body that is only the author\'s prose alone', () => {
556
+ expect(authorsDescription(' A multi-line\nreason. ')).toBe('A multi-line\nreason.');
557
+ expect(authorsDescription('')).toBe('');
558
+ });
559
+
560
+ // Razvan's review (2026-10-02): the first cut copied the app's dialog, which
561
+ // drops everything from the first `##` heading on. An author who writes their
562
+ // description in sections lost it from that heading onwards, and was never
563
+ // told. The cut is now the generated block and nothing else.
564
+ it("keeps the author's own headings, and everything under them", () => {
565
+ expect(authorsDescription('Lead.\n\n## My own heading\n\nmore')).toBe(
566
+ 'Lead.\n\n## My own heading\n\nmore',
567
+ );
568
+ expect(authorsDescription(`Lead.\n\n## Why now\n\nBecause.\n\n${GENERATED}`)).toBe(
569
+ 'Lead.\n\n## Why now\n\nBecause.',
570
+ );
571
+ });
572
+
573
+ // Hexis appends the block LAST, so the last such line is always the generated
574
+ // one — which is what makes an author who happens to use that heading
575
+ // themselves no worse off than before.
576
+ it('cuts the LAST such heading, so an author may use the words too', () => {
577
+ const out = authorsDescription(`Lead.\n\n## Affected owners\n\nI mean the GTM ones.\n\n${GENERATED}`);
578
+ expect(out).toBe('Lead.\n\n## Affected owners\n\nI mean the GTM ones.');
579
+ // Their sentence is kept; not one path of the generated block is.
580
+ expect(out).not.toContain('Avi-Checkin');
581
+ expect(out).not.toContain('KnowledgeBase');
582
+ });
583
+
584
+ it('cuts an author\'s own owners heading when there is no generated block', () => {
585
+ // Indistinguishable from the generated one with nothing after it, so it is
586
+ // cut — the safe direction, and the only way this can err.
587
+ expect(authorsDescription('Lead.\n\n## Affected owners\n\nI mean the GTM ones.')).toBe('Lead.');
588
+ });
589
+ });
590
+
591
+ /**
592
+ * A rename names TWO paths. The diff of a rename shows the content that was at
593
+ * the old one, so a file renamed out of a folder the caller may not read must
594
+ * not be listed under its new name either.
595
+ */
596
+ describe('a renamed file is judged on both of its names', () => {
597
+ const renamed = {
598
+ path: 'Knowledge/Open.md',
599
+ previousPath: 'Payroll/Rates.md',
600
+ };
601
+
602
+ it('is readable only when both of its names are', () => {
603
+ expect(fileIsReadable(renamed, () => true)).toBe(true);
604
+ expect(fileIsReadable(renamed, (p) => p === 'Knowledge/Open.md')).toBe(false);
605
+ expect(fileIsReadable(renamed, (p) => p === 'Payroll/Rates.md')).toBe(false);
606
+ });
607
+
608
+ it('judges a file that was not renamed on its one name', () => {
609
+ expect(fileIsReadable({ path: 'A.md' }, (p) => p === 'A.md')).toBe(true);
610
+ expect(fileIsReadable({ path: 'A.md' }, () => false)).toBe(false);
611
+ });
612
+
613
+ it('offers both names for the access lookup, and one for a plain file', () => {
614
+ expect(pathsOf(renamed)).toEqual(['Knowledge/Open.md', 'Payroll/Rates.md']);
615
+ expect(pathsOf({ path: 'A.md' })).toEqual(['A.md']);
616
+ });
617
+ });
618
+
619
+ /**
620
+ * A reply is shown only when the comment it replies to is (Razvan's review,
621
+ * 2026-10-02, finding 3). A reply posted without a path of its own — which
622
+ * `post_change_request_comment` accepts, taking `parentId` without `path` — read
623
+ * as a general comment when judged on itself, so a reply to a comment on a
624
+ * withheld file came back with its body and a `parentId` naming a comment the
625
+ * caller cannot see.
626
+ */
627
+ describe('a reply takes its parent\'s verdict, up the chain', () => {
628
+ const c = (id: string, over: { path?: string; parentId?: string } = {}) => ({ id, ...over });
629
+ const mayShow = (path: string) => path.startsWith('Open/');
630
+
631
+ it('withholds a pathless reply to a comment on a file the caller may not read', () => {
632
+ const { visible, withheld } = visibleComments(
633
+ [c('c-1', { path: 'Secret/Pay.md' }), c('c-2', { parentId: 'c-1' })],
634
+ mayShow,
635
+ );
636
+ expect(visible).toEqual([]);
637
+ expect(withheld).toBe(2);
638
+ });
639
+
640
+ it('withholds a reply to that reply, however deep the thread goes', () => {
641
+ const { visible, withheld } = visibleComments(
642
+ [
643
+ c('c-1', { path: 'Secret/Pay.md' }),
644
+ c('c-2', { parentId: 'c-1' }),
645
+ c('c-3', { parentId: 'c-2' }),
646
+ c('c-4', { parentId: 'c-3' }),
647
+ ],
648
+ mayShow,
649
+ );
650
+ expect(visible).toEqual([]);
651
+ expect(withheld).toBe(4);
652
+ });
653
+
654
+ it('keeps a reply whose whole chain is readable', () => {
655
+ const { visible, withheld } = visibleComments(
656
+ [c('c-1', { path: 'Open/A.md' }), c('c-2', { parentId: 'c-1' }), c('c-3', { parentId: 'c-2' })],
657
+ mayShow,
658
+ );
659
+ expect(visible.map((v) => v.id)).toEqual(['c-1', 'c-2', 'c-3']);
660
+ expect(withheld).toBe(0);
661
+ });
662
+
663
+ // Both halves of the rule are needed, and this is the half the inheritance
664
+ // alone would miss: the reply names a withheld file itself.
665
+ it('withholds a reply that names a withheld file under a readable parent', () => {
666
+ const { visible } = visibleComments(
667
+ [c('c-1', { path: 'Open/A.md' }), c('c-2', { path: 'Secret/Pay.md', parentId: 'c-1' })],
668
+ mayShow,
669
+ );
670
+ expect(visible.map((v) => v.id)).toEqual(['c-1']);
671
+ });
672
+
673
+ it('keeps a general comment and a reply to one — neither is about a file', () => {
674
+ const { visible, withheld } = visibleComments([c('c-1'), c('c-2', { parentId: 'c-1' })], mayShow);
675
+ expect(visible.map((v) => v.id)).toEqual(['c-1', 'c-2']);
676
+ expect(withheld).toBe(0);
677
+ });
678
+
679
+ it('withholds a reply whose parent is not in the list at all', () => {
680
+ // Not provably pathless, so it goes: the parent may have been anchored to
681
+ // anything, including a file this caller was refused.
682
+ const { visible, withheld } = visibleComments([c('c-2', { parentId: 'gone' })], mayShow);
683
+ expect(visible).toEqual([]);
684
+ expect(withheld).toBe(1);
685
+ });
686
+
687
+ it('withholds a cycle rather than looping on it', () => {
688
+ const { visible, withheld } = visibleComments(
689
+ [c('c-1', { parentId: 'c-2' }), c('c-2', { parentId: 'c-1' })],
690
+ mayShow,
691
+ );
692
+ expect(visible).toEqual([]);
693
+ expect(withheld).toBe(2);
694
+ });
695
+
696
+ it('resolves a long thread without re-walking it per reply', () => {
697
+ // 500 replies in one chain: memoised, this is linear; unmemoised it is
698
+ // quadratic and the suite would feel it.
699
+ const chain = [c('c-0', { path: 'Open/A.md' })];
700
+ for (let i = 1; i <= 500; i++) chain.push(c(`c-${i}`, { parentId: `c-${i - 1}` }));
701
+ const { visible, withheld } = visibleComments(chain, mayShow);
702
+ expect(visible).toHaveLength(501);
703
+ expect(withheld).toBe(0);
704
+ });
705
+ });