@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
@@ -103,8 +103,12 @@ export interface ChangeRequestSummary {
103
103
  * does with either is look at where the text came from — so both read `moved`.
104
104
  * `unchanged` is a file the request lists without changing (git reports it on
105
105
  * a mode-only change); `changed` is the honest word for it among these four.
106
+ *
107
+ * Exported because `list_change_request_files` answers `change` under this same
108
+ * name: an agent that opens a request and reads its files back must not be told
109
+ * `moved` by one tool and `renamed` by the other.
106
110
  */
107
- function changeKindOf(status: PrFileStatus | undefined): ChangeKind {
111
+ export function changeKindOf(status: PrFileStatus | undefined): ChangeKind {
108
112
  switch (status) {
109
113
  case 'added':
110
114
  return 'added';
@@ -528,6 +528,14 @@ export function registerWorkflowTools(
528
528
  });
529
529
  if (!detail) { throw new ToolError(`Change request #${number} not found.`, 404);
530
530
  }
531
+ // A comment anchors to a commit, and a DECLINED request has none to anchor
532
+ // to: nothing durable records what it proposed, so its detail carries no
533
+ // head (see `changeSourceFor`). Say that, rather than letting the service's
534
+ // `head sha is required` surface as a 400 about an argument the caller
535
+ // never passed. An APPLIED request still takes comments — its head is the
536
+ // source tip its merge commit recorded.
537
+ if (!detail.headSha) { throw new ToolError(`Change request #${number} is ${detail.state} and has no commit to anchor a comment to.`, 409);
538
+ }
531
539
  const comment = await ctx.workflowService.postComment(
532
540
  number,
533
541
  ctx.user,
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
6
+ import { WorkflowHooks } from '../../workflow-hooks.js';
7
+ import { GitService } from '../git.service.js';
8
+ import { WorkflowValidationError } from '../../../../shared/domain-errors.js';
9
+ import { mergeCommitSubject } from '../merge-commit.js';
10
+ import { runGit, gitOut, stubWorkspaceService } from './git-test-helpers.js';
11
+
12
+ /**
13
+ * Reading an APPLIED change request back from its merge commit — the three
14
+ * methods behind Razvan's 2026-10-02 decision, against a real git.
15
+ *
16
+ * Why they exist at all: merging a change request retires its source branch, so
17
+ * the branch pair every other diff method takes no longer resolves. Before this,
18
+ * a merged request answered no files — and since an empty file set proves no read
19
+ * access, that left every applied request readable by its author alone. The merge
20
+ * commit is what is left of the change, it is local, and it cannot move.
21
+ *
22
+ * The fixture is the merge `mergeChangeRequest` makes: `--no-ff`, on the target,
23
+ * so the commit has the target as its first parent and the source tip as its
24
+ * second.
25
+ */
26
+ describe('GitService reading an applied change from its merge commit', () => {
27
+ const workspaceId = 'ws-applied';
28
+ /** The change request the fixture's merge commit belongs to. */
29
+ const CR = 42;
30
+ let root: string;
31
+ let repo: string;
32
+ let git: GitService;
33
+
34
+ /** One clone with a merge commit on `main`, and the shas that went into it. */
35
+ async function seedMerged(): Promise<{
36
+ mergeSha: string;
37
+ targetBefore: string;
38
+ sourceTip: string;
39
+ }> {
40
+ repo = path.join(root, workspaceId, 'knowledge-base');
41
+ await fs.mkdir(repo, { recursive: true });
42
+ await runGit(path.dirname(repo), ['init', '-b', 'main', 'knowledge-base']);
43
+ await runGit(repo, ['config', 'user.email', 'test@bevel.local']);
44
+ await runGit(repo, ['config', 'user.name', 'Test Runner']);
45
+
46
+ await fs.writeFile(path.join(repo, 'base.md'), 'base\n');
47
+ await fs.mkdir(path.join(repo, 'Payroll'), { recursive: true });
48
+ await fs.writeFile(path.join(repo, 'Payroll', 'Rates.md'), 'bands\n');
49
+ await runGit(repo, ['add', '-A']);
50
+ await runGit(repo, ['commit', '-m', 'init']);
51
+
52
+ // The proposal: an add, an edit, a delete and a move out of Payroll.
53
+ await runGit(repo, ['checkout', '-b', 'juan/proposal']);
54
+ await fs.writeFile(path.join(repo, 'Added.md'), 'new\n');
55
+ await fs.writeFile(path.join(repo, 'base.md'), 'base\nmore\n');
56
+ await fs.mkdir(path.join(repo, 'Knowledge'), { recursive: true });
57
+ await fs.rename(path.join(repo, 'Payroll', 'Rates.md'), path.join(repo, 'Knowledge', 'Open.md'));
58
+ await runGit(repo, ['add', '-A']);
59
+ await runGit(repo, ['commit', '-m', 'the proposal']);
60
+ const sourceTip = await gitOut(repo, ['rev-parse', 'HEAD']);
61
+
62
+ // The target moves on while the proposal waits, so a three-dot read of the
63
+ // merge commit would report this commit's file too — and must not.
64
+ await runGit(repo, ['checkout', 'main']);
65
+ await fs.writeFile(path.join(repo, 'OnTarget.md'), 'meanwhile\n');
66
+ await runGit(repo, ['add', '-A']);
67
+ await runGit(repo, ['commit', '-m', 'unrelated work on the target']);
68
+ const targetBefore = await gitOut(repo, ['rev-parse', 'HEAD']);
69
+
70
+ // The subject `mergePr` writes — the reader verifies it, so the fixture must
71
+ // produce it the same way rather than spelling it out.
72
+ await runGit(repo, [
73
+ 'merge', '--no-ff', '-m', mergeCommitSubject('Rework the onboarding note', CR), 'juan/proposal',
74
+ ]);
75
+ const mergeSha = await gitOut(repo, ['rev-parse', 'HEAD']);
76
+ // Applied: the source branch goes, as `mergeChangeRequest` retires it.
77
+ await runGit(repo, ['branch', '-D', 'juan/proposal']);
78
+
79
+ git = new GitService(
80
+ stubWorkspaceService({ [workspaceId]: path.dirname(repo) }),
81
+ new WorkflowHooks(),
82
+ testKbContext(),
83
+ );
84
+ return { mergeSha, targetBefore, sourceTip };
85
+ }
86
+
87
+ /** The row's pointer at its merge commit: the sha AND the number that proves it. */
88
+ const ref = (mergeSha: string, number = CR) => ({ number, mergeSha });
89
+
90
+ beforeEach(async () => {
91
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-applied-'));
92
+ });
93
+ afterEach(async () => {
94
+ await fs.rm(root, { recursive: true, force: true }).catch(() => {});
95
+ });
96
+
97
+ it('answers the files the request applied, with their statuses and counts', async () => {
98
+ const { mergeSha } = await seedMerged();
99
+ const files = await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha));
100
+ const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
101
+ expect(Object.keys(byPath).sort()).toEqual(['Added.md', 'Knowledge/Open.md', 'base.md']);
102
+ expect(byPath['Added.md'].status).toBe('added');
103
+ expect(byPath['base.md'].status).toBe('modified');
104
+ expect(byPath['base.md'].additions).toBe(1);
105
+ // The move is reported as one file with both of its names — which is what
106
+ // the read gate needs to judge it on the folder it LEFT as well.
107
+ expect(byPath['Knowledge/Open.md'].status).toBe('renamed');
108
+ expect(byPath['Knowledge/Open.md'].previousPath).toBe('Payroll/Rates.md');
109
+ });
110
+
111
+ // The reason the range is two-dot from the FIRST parent. A three-dot diff
112
+ // would be read from the merge base of the two parents, so it would re-report
113
+ // everything that landed on the target while the proposal waited.
114
+ it('reports nothing the target did on its own while the request waited', async () => {
115
+ const { mergeSha } = await seedMerged();
116
+ const files = await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha));
117
+ expect(files.map((f) => f.path)).not.toContain('OnTarget.md');
118
+ });
119
+
120
+ it('generates the patches, and skips them entirely at patchCap 0', async () => {
121
+ const { mergeSha } = await seedMerged();
122
+ const withPatches = await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha));
123
+ expect(withPatches.find((f) => f.path === 'base.md')?.patch).toContain('+more');
124
+ const without = await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha), { patchCap: 0 });
125
+ expect(without.every((f) => f.patch === undefined)).toBe(true);
126
+ });
127
+
128
+ it('answers the same change as the two path views a summary needs', async () => {
129
+ const { mergeSha } = await seedMerged();
130
+ const { paths, pairs } = await git.changedPathsAndPairsOfAppliedChange(workspaceId, ref(mergeSha));
131
+ // The flat list offers both sides of the move for an access lookup; the pairs
132
+ // say which old name belongs to which file. The file list above and these
133
+ // pairs must agree, or the list of requests and the detail of one would
134
+ // disagree about what is readable.
135
+ expect(paths.sort()).toEqual(['Added.md', 'Knowledge/Open.md', 'base.md']);
136
+ expect(pairs).toEqual(
137
+ expect.arrayContaining([
138
+ { path: 'Knowledge/Open.md', previousPath: 'Payroll/Rates.md' },
139
+ { path: 'Added.md' },
140
+ { path: 'base.md' },
141
+ ]),
142
+ );
143
+ const files = await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha), { patchCap: 0 });
144
+ expect(pairs.map((p) => p.path).sort()).toEqual(files.map((f) => f.path).sort());
145
+ });
146
+
147
+ it("answers the merge commit's two parents as the request's base and head", async () => {
148
+ const { mergeSha, targetBefore, sourceTip } = await seedMerged();
149
+ expect(await git.appliedChangeShas(workspaceId, ref(mergeSha))).toEqual({
150
+ baseSha: targetBefore,
151
+ headSha: sourceTip,
152
+ });
153
+ });
154
+
155
+ // cubic P1 on #347, and the case that makes the whole verification necessary.
156
+ // When the target already contains the source there is nothing to merge, so the
157
+ // merge writes NO commit and reports the target tip. A row that recorded that
158
+ // tip points at an ordinary commit — whatever landed on the target last — and
159
+ // reading its own change would answer with THAT change's files under this
160
+ // request's number. Refused, so the request falls back to author-only.
161
+ it('refuses a commit with no second parent, which no merge of this request wrote', async () => {
162
+ await seedMerged();
163
+ await fs.writeFile(path.join(repo, 'SomebodyElse.md'), 'unrelated work\n');
164
+ await runGit(repo, ['add', '-A']);
165
+ await runGit(repo, ['commit', '-m', 'unrelated work on the target']);
166
+ const tip = await gitOut(repo, ['rev-parse', 'HEAD']);
167
+ for (const call of [
168
+ () => git.appliedChangeShas(workspaceId, ref(tip)),
169
+ () => git.changedFilesOfAppliedChange(workspaceId, ref(tip)),
170
+ () => git.changedPathsAndPairsOfAppliedChange(workspaceId, ref(tip)),
171
+ ]) {
172
+ await expect(call()).rejects.toThrow(/is not a merge commit/);
173
+ }
174
+ });
175
+
176
+ it('tells a commit the clone does not hold apart from one that is not a merge: the first may arrive, the second never will', async () => {
177
+ await seedMerged();
178
+ const unknown = 'f'.repeat(40);
179
+ await expect(git.appliedChangeShas(workspaceId, ref(unknown))).rejects.toThrow(/is not in this clone/);
180
+ await expect(git.appliedChangeShas(workspaceId, ref(unknown))).rejects.not.toThrow(/is not a merge commit/);
181
+ });
182
+
183
+ // The residual of the same P1, and the reason the NUMBER travels with the sha.
184
+ // An empty request merged while the target tip was ANOTHER request's merge
185
+ // commit recorded a sha that is a merge commit — just not this request's. Two
186
+ // parents alone would accept it and publish the other request's files.
187
+ it('refuses a merge commit whose subject names a different request', async () => {
188
+ const { mergeSha } = await seedMerged();
189
+ for (const call of [
190
+ () => git.appliedChangeShas(workspaceId, ref(mergeSha, CR + 1)),
191
+ () => git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha, CR + 1)),
192
+ () => git.changedPathsAndPairsOfAppliedChange(workspaceId, ref(mergeSha, CR + 1)),
193
+ ]) {
194
+ await expect(call()).rejects.toThrow(/is not the merge commit of change request #43/);
195
+ }
196
+ // The same commit under its OWN number still reads, so what guards is the
197
+ // number and not a blanket refusal.
198
+ expect(
199
+ (await git.changedFilesOfAppliedChange(workspaceId, ref(mergeSha), { patchCap: 0 })).length,
200
+ ).toBe(3);
201
+ });
202
+
203
+ // The number is matched at the END of the subject, where `mergeCommitSubject`
204
+ // puts it, so a title that happens to contain another request's number is not
205
+ // mistaken for that request's merge commit.
206
+ it('matches the number at the end, not anywhere in the title', async () => {
207
+ await seedMerged();
208
+ await runGit(repo, ['checkout', '-b', 'juan/third']);
209
+ await fs.writeFile(path.join(repo, 'Third.md'), 'x\n');
210
+ await runGit(repo, ['add', '-A']);
211
+ await runGit(repo, ['commit', '-m', 'third']);
212
+ await runGit(repo, ['checkout', 'main']);
213
+ await runGit(repo, [
214
+ 'merge', '--no-ff', '-m', mergeCommitSubject('Follow-up to (#99)', 7), 'juan/third',
215
+ ]);
216
+ const sha = await gitOut(repo, ['rev-parse', 'HEAD']);
217
+ await expect(git.appliedChangeShas(workspaceId, ref(sha, 99))).rejects.toThrow(
218
+ /is not the merge commit of change request #99/,
219
+ );
220
+ expect(await git.appliedChangeShas(workspaceId, ref(sha, 7))).toMatchObject({
221
+ baseSha: expect.any(String),
222
+ });
223
+ });
224
+
225
+ // Fail-closed, and this is the case it protects: a clone that has not fetched
226
+ // the merge yet must answer no files, never somebody else's. Every caller reads
227
+ // the rejection as "the file set could not be resolved" and leaves the request
228
+ // to its author.
229
+ it('refuses a commit this clone does not hold, rather than reaching for it', async () => {
230
+ await seedMerged();
231
+ const absent = 'f'.repeat(40);
232
+ for (const call of [
233
+ () => git.changedFilesOfAppliedChange(workspaceId, ref(absent)),
234
+ () => git.changedPathsAndPairsOfAppliedChange(workspaceId, ref(absent)),
235
+ () => git.appliedChangeShas(workspaceId, ref(absent)),
236
+ ]) {
237
+ await expect(call()).rejects.toThrow(WorkflowValidationError);
238
+ }
239
+ });
240
+
241
+ it('refuses anything that is not a sha, so no ref name can be smuggled in', async () => {
242
+ await seedMerged();
243
+ for (const bad of ['main', 'HEAD', '', 'main; rm -rf /', '../../etc/passwd', 'abc']) {
244
+ await expect(
245
+ git.changedFilesOfAppliedChange(workspaceId, ref(bad)),
246
+ ).rejects.toThrow(/invalid commit sha/);
247
+ }
248
+ });
249
+
250
+ it('refuses a root commit, which has no parent to read the change against', async () => {
251
+ await seedMerged();
252
+ const rootCommit = await gitOut(repo, ['rev-list', '--max-parents=0', 'HEAD']);
253
+ await expect(
254
+ git.changedFilesOfAppliedChange(workspaceId, ref(rootCommit)),
255
+ ).rejects.toThrow(/is not a merge commit/);
256
+ });
257
+
258
+ it('filters roles.yaml and the folder placeholder, as the branch-pair diff does', async () => {
259
+ await seedMerged();
260
+ // A request that touches roles.yaml and creates an empty folder: neither is
261
+ // content to review, and a merged request must not start listing them when an
262
+ // open one does not.
263
+ await runGit(repo, ['checkout', '-b', 'juan/second']);
264
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'Admin: [a@b.c]\n');
265
+ await fs.mkdir(path.join(repo, 'Empty'), { recursive: true });
266
+ await fs.writeFile(path.join(repo, 'Empty', '.gitkeep'), '');
267
+ await fs.writeFile(path.join(repo, 'Real.md'), 'content\n');
268
+ await runGit(repo, ['add', '-A']);
269
+ await runGit(repo, ['commit', '-m', 'second proposal']);
270
+ await runGit(repo, ['checkout', 'main']);
271
+ await runGit(repo, [
272
+ 'merge', '--no-ff', '-m', mergeCommitSubject('The second proposal', 43), 'juan/second',
273
+ ]);
274
+ const sha = await gitOut(repo, ['rev-parse', 'HEAD']);
275
+
276
+ const files = await git.changedFilesOfAppliedChange(workspaceId, ref(sha, 43), { patchCap: 0 });
277
+ expect(files.map((f) => f.path)).toEqual(['Real.md']);
278
+ // The paths view keeps the placeholder (a folder-only request must still
279
+ // reach the folder's owners) and drops roles.yaml, exactly as the
280
+ // branch-pair diff does.
281
+ const { paths, pairs } = await git.changedPathsAndPairsOfAppliedChange(workspaceId, ref(sha, 43));
282
+ expect(paths.sort()).toEqual(['Empty/.gitkeep', 'Real.md']);
283
+ expect(pairs.map((p) => p.path)).toEqual(['Real.md']);
284
+ });
285
+ });
@@ -517,3 +517,127 @@ describe('GitService.changedPathsForPr: who pays for the fetch', () => {
517
517
  ).toEqual(['brief.pdf']);
518
518
  });
519
519
  });
520
+
521
+ /**
522
+ * `changedPathsAndPairsForPr` is what a change-request SUMMARY is built from,
523
+ * and `changedFilesForPr` is what its DETAIL is built from. A read gate decides
524
+ * visibility over each — so if they ever describe different files, or pair a
525
+ * rename differently, a list advertises requests its own detail answers 404 for.
526
+ * That happened (ticket log, attempt 2: the list judged the flat path list,
527
+ * which names a rename by its new path alone), so the agreement is asserted
528
+ * here against a real repository rather than assumed.
529
+ */
530
+ describe('GitService.changedPathsAndPairsForPr agrees with changedFilesForPr', () => {
531
+ let root: string;
532
+ const workspaceId = 'current-company-state';
533
+
534
+ beforeEach(async () => {
535
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-pr-pairs-'));
536
+ });
537
+ afterEach(async () => {
538
+ await fs.rm(root, { recursive: true, force: true }).catch(() => {});
539
+ });
540
+
541
+ /** The two file sets, each reduced to `path` + `previousPath`, sorted. */
542
+ async function bothViews(git: GitService, branch: string) {
543
+ const files = await git.changedFilesForPr(workspaceId, 'current-company-state', branch);
544
+ const { paths, pairs } = await git.changedPathsAndPairsForPr(
545
+ workspaceId,
546
+ 'current-company-state',
547
+ branch,
548
+ );
549
+ const key = (f: { path: string; previousPath?: string }) => `${f.path}<=${f.previousPath ?? ''}`;
550
+ return {
551
+ detail: files.map(key).sort(),
552
+ summary: pairs.map(key).sort(),
553
+ paths: paths.sort(),
554
+ };
555
+ }
556
+
557
+ it('pairs a rename the same way, and the flat paths still name only the new side', async () => {
558
+ const { repo } = await seedWorkspace(root, workspaceId);
559
+ await fs.mkdir(path.join(repo, 'Engineering'), { recursive: true });
560
+ await fs.writeFile(path.join(repo, 'Engineering/note.md'), 'one\ntwo\nthree\n');
561
+ await runGit(repo, ['add', '-A']);
562
+ await runGit(repo, ['commit', '-m', 'note']);
563
+ await runGit(repo, ['push', 'origin', 'current-company-state']);
564
+ await runGit(repo, ['checkout', '-b', 'alice/moved']);
565
+ await fs.mkdir(path.join(repo, 'GTM'), { recursive: true });
566
+ await runGit(repo, ['mv', 'Engineering/note.md', 'GTM/note.md']);
567
+ await runGit(repo, ['commit', '-m', 'move it']);
568
+ await runGit(repo, ['push', '-u', 'origin', 'alice/moved']);
569
+
570
+ const git = new GitService(stubWorkspaceService(workspaceId, repo), new WorkflowHooks(), testKbContext());
571
+ const { detail, summary, paths } = await bothViews(git, 'alice/moved');
572
+
573
+ expect(summary).toEqual(['GTM/note.md<=Engineering/note.md']);
574
+ expect(summary).toEqual(detail);
575
+ // And this is exactly why the pairs are needed: the flat list cannot say
576
+ // the file came out of `Engineering/`.
577
+ expect(paths).toEqual(['GTM/note.md']);
578
+ });
579
+
580
+ it('agrees on a mixed change: add, modify, delete and rename together', async () => {
581
+ const { repo } = await seedWorkspace(root, workspaceId);
582
+ await fs.writeFile(path.join(repo, 'old-home.md'), 'a\nb\nc\nd\ne\n');
583
+ await fs.writeFile(path.join(repo, 'doomed.md'), 'goes away\n');
584
+ await runGit(repo, ['add', '-A']);
585
+ await runGit(repo, ['commit', '-m', 'seed more']);
586
+ await runGit(repo, ['push', 'origin', 'current-company-state']);
587
+ await runGit(repo, ['checkout', '-b', 'alice/mixed']);
588
+ await runGit(repo, ['mv', 'old-home.md', 'new-home.md']);
589
+ await fs.rm(path.join(repo, 'doomed.md'));
590
+ await fs.writeFile(path.join(repo, 'base.md'), 'base changed\n');
591
+ await fs.writeFile(path.join(repo, 'fresh.md'), 'new\n');
592
+ await runGit(repo, ['add', '-A']);
593
+ await runGit(repo, ['commit', '-m', 'mixed']);
594
+ await runGit(repo, ['push', '-u', 'origin', 'alice/mixed']);
595
+
596
+ const git = new GitService(stubWorkspaceService(workspaceId, repo), new WorkflowHooks(), testKbContext());
597
+ const { detail, summary } = await bothViews(git, 'alice/mixed');
598
+ expect(summary).toEqual(detail);
599
+ expect(summary).toContain('new-home.md<=old-home.md');
600
+ });
601
+
602
+ it('agrees that roles.yaml and the folder placeholder are not files of the request', async () => {
603
+ const { repo } = await seedWorkspace(root, workspaceId);
604
+ await runGit(repo, ['checkout', '-b', 'alice/config']);
605
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - a@x.io\n');
606
+ await fs.mkdir(path.join(repo, 'Reports'), { recursive: true });
607
+ await fs.writeFile(path.join(repo, 'Reports/.gitkeep'), '');
608
+ await fs.writeFile(path.join(repo, 'Reports/q3.md'), 'one\n');
609
+ await runGit(repo, ['add', '-A']);
610
+ await runGit(repo, ['commit', '-m', 'config + folder']);
611
+ await runGit(repo, ['push', '-u', 'origin', 'alice/config']);
612
+
613
+ const git = new GitService(stubWorkspaceService(workspaceId, repo), new WorkflowHooks(), testKbContext());
614
+ const { detail, summary, paths } = await bothViews(git, 'alice/config');
615
+ expect(summary).toEqual(['Reports/q3.md<=']);
616
+ expect(summary).toEqual(detail);
617
+ // The flat list keeps the placeholder — it is the authoritative
618
+ // empty-request check — which is a third reason not to gate reads on it.
619
+ expect(paths).toContain('Reports/.gitkeep');
620
+ });
621
+
622
+ it('agrees an empty file replaced by the placeholder is the file\'s removal', async () => {
623
+ const { repo } = await seedWorkspace(root, workspaceId);
624
+ await fs.mkdir(path.join(repo, 'Reports'), { recursive: true });
625
+ await fs.writeFile(path.join(repo, 'Reports/empty.md'), '');
626
+ await runGit(repo, ['add', '-A']);
627
+ await runGit(repo, ['commit', '-m', 'empty file']);
628
+ await runGit(repo, ['push', 'origin', 'current-company-state']);
629
+ await runGit(repo, ['checkout', '-b', 'alice/emptied']);
630
+ await fs.rm(path.join(repo, 'Reports/empty.md'));
631
+ await fs.writeFile(path.join(repo, 'Reports/.gitkeep'), '');
632
+ await runGit(repo, ['add', '-A']);
633
+ await runGit(repo, ['commit', '-m', 'emptied']);
634
+ await runGit(repo, ['push', '-u', 'origin', 'alice/emptied']);
635
+
636
+ const git = new GitService(stubWorkspaceService(workspaceId, repo), new WorkflowHooks(), testKbContext());
637
+ const { detail, summary } = await bothViews(git, 'alice/emptied');
638
+ // `withoutPlaceholderRename` runs on both sides, so neither reports a
639
+ // rename onto `.gitkeep` and neither loses the removed file.
640
+ expect(summary).toEqual(['Reports/empty.md<=']);
641
+ expect(summary).toEqual(detail);
642
+ });
643
+ });