@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
@@ -9,6 +9,7 @@ import type { AuthUser } from '@bevel-software/platform-shared';
9
9
  import type { WorkspaceService } from '../../../workspace/workspace.service.js';
10
10
  import { WorkflowHooks } from '../../workflow-hooks.js';
11
11
  import { GitService } from '../git.service.js';
12
+ import { mergeCommitSubject } from '../merge-commit.js';
12
13
 
13
14
  const execFileAsync = promisify(execFile);
14
15
  const BASE = 'current-company-state';
@@ -103,6 +104,9 @@ describe('GitService.mergeChangeRequest', () => {
103
104
  expect(result.kind).toBe('merged');
104
105
  if (result.kind !== 'merged') return;
105
106
  expect(result.sha).toMatch(/^[0-9a-f]{40}$/);
107
+ // A commit of this request's own was written: it IS the state the target is
108
+ // left at here, and it is what the row may record as the merge commit.
109
+ expect(result.mergeCommit).toBe(result.sha);
106
110
 
107
111
  // The merge landed on origin/BASE: a fresh clone sees the feature file and a
108
112
  // merge commit authored by the human triggerer.
@@ -115,6 +119,336 @@ describe('GitService.mergeChangeRequest', () => {
115
119
  expect(log).toContain('Add feature (#1)');
116
120
  });
117
121
 
122
+ // cubic P1 on #347. When the target already contains the source there is
123
+ // nothing to merge, so no commit is written and `sha` is the TARGET TIP —
124
+ // whatever landed on the target last, which in a deployment that lands
125
+ // everything through change requests is usually ANOTHER request's merge commit.
126
+ // Answering `mergeCommit: null` is what stops the row recording it as this
127
+ // request's own: a reader that took it would answer with that other request's
128
+ // files under this request's number. The fixture puts request #1's merge commit
129
+ // on the tip for exactly that reason.
130
+ it('owns NO merge commit when there was nothing to merge, not even the one on the tip', async () => {
131
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
132
+ // Another request lands first, so the target tip is a real merge commit whose
133
+ // subject names #1.
134
+ await pushFeatureBranch(root, upstream, 'bob/first', async (dir) => {
135
+ await fs.writeFile(path.join(dir, 'first.md'), 'first\n');
136
+ });
137
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
138
+ const first = await git.mergeChangeRequest(
139
+ baseWsId, 'bob/first', BASE, { subject: mergeCommitSubject('First', 1), body: 'x' }, USER,
140
+ { appliedChangeNumber: 1 },
141
+ );
142
+ expect(first.kind).toBe('merged');
143
+
144
+ // A branch the target already contains: branched and pushed with no commit of
145
+ // its own, so the merge has nothing to do. (`pushFeatureBranch` always
146
+ // commits, which is the one thing this case must not do.)
147
+ const emptyDir = path.join(root, 'feat-empty');
148
+ await runGit(root, ['clone', '-b', BASE, upstream, emptyDir]);
149
+ await runGit(emptyDir, ['checkout', '-b', 'alice/empty']);
150
+ await runGit(emptyDir, ['push', '-u', 'origin', 'alice/empty']);
151
+
152
+ const result = await git.mergeChangeRequest(
153
+ baseWsId, 'alice/empty', BASE,
154
+ { subject: mergeCommitSubject('Nothing to do', 2), body: 'Merged via Bevel' }, USER,
155
+ { appliedChangeNumber: 2 },
156
+ );
157
+
158
+ expect(result.kind).toBe('merged');
159
+ if (result.kind !== 'merged') return;
160
+ // Asked for #2's own merge commit and there is none — the one on the tip is
161
+ // #1's, and it is not offered in its place.
162
+ expect(result.mergeCommit).toBeNull();
163
+ // The sha it reports is the target tip, which is #1's merge commit.
164
+ const verify = path.join(root, 'verify-empty');
165
+ await runGit(root, ['clone', '-b', BASE, upstream, verify]);
166
+ expect((await gitOut(verify, ['rev-parse', 'HEAD'])).trim()).toBe(result.sha);
167
+ expect(await gitOut(verify, ['log', '-1', '--format=%s'])).toContain('(#1)');
168
+ });
169
+
170
+ /**
171
+ * cubic P2 on #347. The push and the row update are two steps, and a transient
172
+ * database fault between them leaves the merge commit ON the target with the
173
+ * row still open. The retry then finds nothing to merge — and if that answered
174
+ * "no merge commit", the row would record none, and the request would be
175
+ * permanently fileless (author-only) with the commit holding its files sitting
176
+ * on the target unreferenced.
177
+ */
178
+ it('recovers the merge commit a previous attempt pushed but never recorded', async () => {
179
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
180
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
181
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
182
+ });
183
+
184
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
185
+ const merge = () =>
186
+ git.mergeChangeRequest(
187
+ baseWsId, 'alice/add', BASE,
188
+ { subject: mergeCommitSubject('Add feature', 7), body: 'Merged via Bevel' }, USER,
189
+ { appliedChangeNumber: 7 },
190
+ );
191
+
192
+ // Attempt one succeeds in git; imagine the row update failing right here.
193
+ const first = await merge();
194
+ expect(first.kind).toBe('merged');
195
+ if (first.kind !== 'merged') return;
196
+ const pushed = first.mergeCommit;
197
+ expect(pushed).toBe(first.sha);
198
+
199
+ // Attempt two: the target already contains the source, so nothing is staged
200
+ // and no new commit is written — but #7 does own a merge commit, and it is
201
+ // the one the first attempt pushed.
202
+ const second = await merge();
203
+ expect(second.kind).toBe('merged');
204
+ if (second.kind !== 'merged') return;
205
+ expect(second.mergeCommit).toBe(pushed);
206
+
207
+ // Nothing new was pushed: the target is still at that same commit.
208
+ const verify = path.join(root, 'verify-retry');
209
+ await runGit(root, ['clone', '-b', BASE, upstream, verify]);
210
+ expect((await gitOut(verify, ['rev-parse', 'HEAD'])).trim()).toBe(pushed);
211
+ });
212
+
213
+ // The same ownership question, asked WITHOUT a merge — the form the approval
214
+ // gate needs, because the gate refuses the retry before any merge runs. It
215
+ // must hold in the two conditions that are true by then: the source branch is
216
+ // retired, and the clone asking is not the clone that pushed.
217
+ it('finds the merge commit a request owns on the target from another clone, with no source branch left', async () => {
218
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
219
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
220
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
221
+ });
222
+ // Cloned BEFORE the merge, so it can only know the commit by fetching.
223
+ const staleWsId = 'stale';
224
+ const staleRepo = path.join(root, staleWsId, 'knowledge-base');
225
+ await fs.mkdir(path.join(root, staleWsId), { recursive: true });
226
+ await runGit(root, ['clone', '-b', BASE, upstream, staleRepo]);
227
+
228
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
229
+ const merged = await git.mergeChangeRequest(
230
+ baseWsId, 'alice/add', BASE,
231
+ { subject: mergeCommitSubject('Add feature', 7), body: 'Merged via Bevel' }, USER,
232
+ { appliedChangeNumber: 7 },
233
+ );
234
+ expect(merged.kind).toBe('merged');
235
+ if (merged.kind !== 'merged') return;
236
+
237
+ // Retired, as applying a change request retires it.
238
+ await runGit(root, ['-C', upstream, 'branch', '-D', 'alice/add']);
239
+
240
+ const stale = new GitService(stubWorkspaceService(staleWsId, staleRepo), new WorkflowHooks(), testKbContext());
241
+ expect(await stale.appliedMergeCommitOnTarget(staleWsId, BASE, 7)).toBe(merged.mergeCommit);
242
+ // A number nothing on the target was merged under owns nothing.
243
+ expect(await stale.appliedMergeCommitOnTarget(staleWsId, BASE, 8)).toBeNull();
244
+ });
245
+
246
+ it('still finds the commit behind any number of newer merges on a busy target', async () => {
247
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
248
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
249
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
250
+ });
251
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
252
+ const merged = await git.mergeChangeRequest(
253
+ baseWsId, 'alice/add', BASE,
254
+ { subject: mergeCommitSubject('Add feature', 7), body: 'Merged via Bevel' }, USER,
255
+ { appliedChangeNumber: 7 },
256
+ );
257
+ expect(merged.kind).toBe('merged');
258
+ if (merged.kind !== 'merged') return;
259
+
260
+ // Twenty-five merges land on the target after it — more than the fixed
261
+ // count the probe once stopped at, which left a stuck request on a busy
262
+ // branch un-finalizable for good.
263
+ const busy = path.join(root, 'busy');
264
+ await runGit(root, ['clone', '-b', BASE, upstream, busy]);
265
+ for (let i = 0; i < 25; i += 1) {
266
+ await runGit(busy, ['checkout', '-b', `other-${i}`]);
267
+ await fs.writeFile(path.join(busy, `other-${i}.md`), `${i}\n`);
268
+ await runGit(busy, ['add', '-A']);
269
+ await runGit(busy, ['commit', '-m', `work ${i}`]);
270
+ await runGit(busy, ['checkout', BASE]);
271
+ await runGit(busy, ['merge', '--no-ff', '-m', `Other change ${i} (#${100 + i})`, `other-${i}`]);
272
+ }
273
+ await runGit(busy, ['push', 'origin', BASE]);
274
+
275
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 7)).toBe(merged.mergeCommit);
276
+ // Twenty-five merges are a hundred and fifty git processes; slow on a
277
+ // loaded Windows runner, so this one gets its own budget.
278
+ }, 120_000);
279
+
280
+ it('recognises a merge commit an earlier release wrote with the title unflattened, given the stored title', async () => {
281
+ // Before titles were flattened, a title with a blank line put `(#N)` after
282
+ // git's one-paragraph subject, and such rows read as author-only. The row
283
+ // still holds the title, which is what names the commit now.
284
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
285
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
286
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
287
+ });
288
+ const title = 'Add feature\n\nand a note';
289
+ const old = path.join(root, 'old-format');
290
+ await runGit(root, ['clone', '-b', BASE, upstream, old]);
291
+ await runGit(old, ['merge', '--no-ff', '-m', `${title} (#5)`, '-m', 'Merged via Bevel', 'origin/alice/add']);
292
+ await runGit(old, ['push', 'origin', BASE]);
293
+ const sha = (await gitOut(old, ['rev-parse', 'HEAD'])).trim();
294
+ expect((await gitOut(old, ['log', '-1', '--format=%s'])).trim()).toBe('Add feature');
295
+
296
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
297
+ // With the title: the commit is the request's own, on the target and by sha.
298
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 5, title)).toBe(sha);
299
+ expect(await git.appliedChangeShas(baseWsId, { number: 5, mergeSha: sha, title }))
300
+ .toMatchObject({ baseSha: expect.any(String), headSha: expect.any(String) });
301
+ // Without it, nothing vouches for a number that is not where the subject
302
+ // rule looks — and another title does not either.
303
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 5)).toBeNull();
304
+ await expect(git.appliedChangeShas(baseWsId, { number: 5, mergeSha: sha })).rejects.toThrow(/not the merge commit/);
305
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 5, 'Add feature')).toBeNull();
306
+ });
307
+
308
+ it('does not take a current-format commit of another request for one whose title merely contains its number', async () => {
309
+ // Request 43, titled `Fix crash (#42)`, merges as `Fix crash (#42) (#43)`.
310
+ // Asked for request 42 with the stored title `Fix crash`, the old-format
311
+ // rule must refuse it: 42's number is not at the end of the line there.
312
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
313
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
314
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
315
+ });
316
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
317
+ const merged = await git.mergeChangeRequest(
318
+ baseWsId, 'alice/add', BASE,
319
+ { subject: mergeCommitSubject('Fix crash (#42)', 43), body: 'x' }, USER,
320
+ { appliedChangeNumber: 43 },
321
+ );
322
+ expect(merged.kind).toBe('merged');
323
+ if (merged.kind !== 'merged') return;
324
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 43)).toBe(merged.mergeCommit);
325
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 42, 'Fix crash')).toBeNull();
326
+ await expect(git.appliedChangeShas(baseWsId, { number: 42, mergeSha: merged.sha, title: 'Fix crash' })).rejects.toThrow(
327
+ /not the merge commit/,
328
+ );
329
+ });
330
+
331
+ // One variable at a time, because the two halves of the P1 are not the same
332
+ // claim. With the remote REACHABLE, a missing tracking ref is not a hazard at
333
+ // all: the refresh names its destination explicitly, so it recreates the ref
334
+ // and the walk has what it needs. With the remote UNREACHABLE, there is no
335
+ // authority for an answer, and the stale ref the clone still holds must not be
336
+ // scanned — what the caller does with a commit is record it as the request's
337
+ // published state, and a ref it could not refresh may name a commit the
338
+ // published branch no longer carries (cubic P1 on #347).
339
+ it('recreates a missing tracking ref, and refuses to read a stale one', async () => {
340
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
341
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
342
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
343
+ });
344
+
345
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
346
+ const merged = await git.mergeChangeRequest(
347
+ baseWsId, 'alice/add', BASE,
348
+ { subject: mergeCommitSubject('Add feature', 7), body: 'Merged via Bevel' }, USER,
349
+ { appliedChangeNumber: 7 },
350
+ );
351
+ expect(merged.kind).toBe('merged');
352
+ if (merged.kind !== 'merged') return;
353
+ // The commit IS in this clone, and origin/BASE names it.
354
+ expect((await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`])).trim()).toBe(merged.mergeCommit);
355
+
356
+ // Variable 1: no tracking ref, remote reachable. The refresh brings it back.
357
+ await runGit(baseRepo, ['update-ref', '-d', `refs/remotes/origin/${BASE}`]);
358
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 7)).toBe(merged.mergeCommit);
359
+ expect((await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`])).trim()).toBe(merged.mergeCommit);
360
+
361
+ // Variable 2: tracking ref present and naming the commit, remote gone. No
362
+ // authority, no answer — the ref is not scanned.
363
+ await runGit(baseRepo, ['remote', 'set-url', 'origin', path.join(root, 'gone.git')]);
364
+ expect(await git.appliedMergeCommitOnTarget(baseWsId, BASE, 7)).toBeNull();
365
+ });
366
+
367
+ // The merge commit a later attempt recovers is matched on the number alone, so
368
+ // a request that landed a DIFFERENT change under its own number is not offered
369
+ // another request's commit — and `git log --grep` finding the number in a
370
+ // BODY is not enough either, since the subject check still has to pass.
371
+ it('does not mistake a mention of the number in a merge body for the request\'s own commit', async () => {
372
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
373
+ await pushFeatureBranch(root, upstream, 'bob/first', async (dir) => {
374
+ await fs.writeFile(path.join(dir, 'first.md'), 'first\n');
375
+ });
376
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
377
+ // #1's merge commit, whose BODY mentions #9 — the shape `--grep` matches and
378
+ // the subject check must reject.
379
+ await git.mergeChangeRequest(
380
+ baseWsId, 'bob/first', BASE,
381
+ { subject: mergeCommitSubject('First', 1), body: 'Supersedes the work in (#9)' }, USER,
382
+ { appliedChangeNumber: 1 },
383
+ );
384
+
385
+ const emptyDir = path.join(root, 'feat-empty-9');
386
+ await runGit(root, ['clone', '-b', BASE, upstream, emptyDir]);
387
+ await runGit(emptyDir, ['checkout', '-b', 'alice/empty']);
388
+ await runGit(emptyDir, ['push', '-u', 'origin', 'alice/empty']);
389
+
390
+ const result = await git.mergeChangeRequest(
391
+ baseWsId, 'alice/empty', BASE, { subject: mergeCommitSubject('Nothing', 9), body: 'x' }, USER,
392
+ { appliedChangeNumber: 9 },
393
+ );
394
+ expect(result.kind).toBe('merged');
395
+ if (result.kind !== 'merged') return;
396
+ expect(result.mergeCommit).toBeNull();
397
+ });
398
+
399
+ /**
400
+ * `git commit -m` takes the FIRST PARAGRAPH of the message as the subject, so a
401
+ * blank line inside it pushes `(#N)` into the body — where `%s` never reports
402
+ * it and the applied request's reader cannot find it (cubic P2 on #347).
403
+ * `mergeCommitSubject` flattens the title it builds from; this is the guard
404
+ * against any other caller reintroducing the break.
405
+ */
406
+ it('refuses a subject that is more than one paragraph, before touching the clone', async () => {
407
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
408
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
409
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
410
+ });
411
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
412
+ await expect(
413
+ git.mergeChangeRequest(
414
+ baseWsId, 'alice/add', BASE, { subject: 'Add feature\n\nand a note (#3)', body: 'x' }, USER,
415
+ ),
416
+ ).rejects.toThrow(/single paragraph/);
417
+
418
+ // Nothing was merged or pushed.
419
+ const verify = path.join(root, 'verify-subject');
420
+ await runGit(root, ['clone', '-b', BASE, upstream, verify]);
421
+ await expect(fs.readFile(path.join(verify, 'feature.md'), 'utf8')).rejects.toThrow();
422
+ });
423
+
424
+ // A title with a blank line in it reaches here (titles are stored `.trim()`-ed
425
+ // and the tool schema bounds only their length), and the whole read-back of an
426
+ // applied request hangs on the number being ON the subject git reports.
427
+ it('keeps the number on the subject when the title itself spans paragraphs', async () => {
428
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
429
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
430
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
431
+ });
432
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), new WorkflowHooks(), testKbContext());
433
+ const result = await git.mergeChangeRequest(
434
+ baseWsId, 'alice/add', BASE,
435
+ { subject: mergeCommitSubject('Add feature\n\nand a note', 4), body: 'x' }, USER,
436
+ { appliedChangeNumber: 4 },
437
+ );
438
+ expect(result.kind).toBe('merged');
439
+ if (result.kind !== 'merged') return;
440
+
441
+ const verify = path.join(root, 'verify-flat');
442
+ await runGit(root, ['clone', '-b', BASE, upstream, verify]);
443
+ expect((await gitOut(verify, ['log', '-1', '--format=%s'])).trim())
444
+ .toBe('Add feature and a note (#4)');
445
+ // Which is what lets the request be recognised as the owner of this commit:
446
+ // the same read that would otherwise refuse it as "not the merge commit of
447
+ // change request #4" and leave the applied request fileless.
448
+ expect(await git.appliedChangeShas(baseWsId, { number: 4, mergeSha: result.sha }))
449
+ .toMatchObject({ baseSha: expect.any(String), headSha: expect.any(String) });
450
+ });
451
+
118
452
  it('returns the conflicting paths when base and source both changed a file', async () => {
119
453
  const { upstream, baseWsId, baseRepo } = await seed(root, { 'shared.md': 'original\n' });
120
454
  // Source edits shared.md one way…
@@ -5,6 +5,7 @@ import type { Database } from '../../../database/connection.js';
5
5
  import type { WorkspaceService } from '../../../workspace/workspace.service.js';
6
6
  import type { GitService } from '../git.service.js';
7
7
  import type { IAccessControl } from '../../../access/access-control.interface.js';
8
+ import { WorkflowValidationError } from '../../../../shared/domain-errors.js';
8
9
 
9
10
  /**
10
11
  * What a change-request LIST is allowed to cost.
@@ -47,8 +48,13 @@ function harness(rows = [row(1, 'suggestions/a/knowledge'), row(2, 'suggestions/
47
48
  }),
48
49
  }),
49
50
  } as unknown as Database;
50
- const changedPathsForPr = vi.fn(async () => ['Shared/one.pdf']);
51
- const git = { changedPathsForPr } as unknown as GitService;
51
+ // The summary builder asks for the flat paths AND the rename-aware pairs in
52
+ // one call, so this is the method the per-request cost now lands on.
53
+ const changedPathsForPr = vi.fn(async () => ({
54
+ paths: ['Shared/one.pdf'],
55
+ pairs: [{ path: 'Shared/one.pdf' }],
56
+ }));
57
+ const git = { changedPathsAndPairsForPr: changedPathsForPr } as unknown as GitService;
52
58
  const ensureRemotesFetched = vi.fn(async () => undefined);
53
59
  const workspace = {
54
60
  findAnyWorkspaceId: async () => 'ws-main',
@@ -119,3 +125,67 @@ describe('the change-request list refreshes the clone once, not once per request
119
125
  expect(changedPathsForPr).toHaveBeenCalledTimes(2);
120
126
  });
121
127
  });
128
+
129
+ /**
130
+ * What a list of APPLIED requests is allowed to cost. Each is read from its
131
+ * merge commit, which asks git four times per row and never the network. A
132
+ * merge commit cannot change, so the second list must not ask git again.
133
+ */
134
+ describe('the list of applied requests reads each merge commit once', () => {
135
+ const applied = (number: number) => ({
136
+ ...row(number, `suggestions/${number}/knowledge`),
137
+ state: 'merged',
138
+ mergedSha: `${number}`.padStart(40, 'a'),
139
+ closedAt: new Date('2026-09-19T00:00:00Z'),
140
+ });
141
+
142
+ function appliedHarness(rows: ReturnType<typeof applied>[]) {
143
+ const db = {
144
+ select: () => ({
145
+ from: () => ({
146
+ where: () => ({ orderBy: async () => rows, limit: async () => rows }),
147
+ }),
148
+ }),
149
+ } as unknown as Database;
150
+ const ofAppliedChange = vi.fn(async (_w: string, ref: { number: number }) => ({
151
+ paths: [`Shared/${ref.number}.md`],
152
+ pairs: [{ path: `Shared/${ref.number}.md` }],
153
+ }));
154
+ const git = { changedPathsAndPairsOfAppliedChange: ofAppliedChange } as unknown as GitService;
155
+ const ensureRemotesFetched = vi.fn(async () => undefined);
156
+ const workspace = {
157
+ findAnyWorkspaceId: async () => 'ws-main',
158
+ ensureRemotesFetched,
159
+ } as unknown as WorkspaceService;
160
+ const access = {} as unknown as IAccessControl;
161
+ return { svc: new PullRequestService(db, workspace, access, git), ofAppliedChange, ensureRemotesFetched };
162
+ }
163
+
164
+ it('asks git once per request, however often the list is read, and refreshes the clone once per list — never per row', async () => {
165
+ const { svc, ofAppliedChange, ensureRemotesFetched } = appliedHarness([applied(1), applied(2), applied(3)]);
166
+
167
+ const first = await svc.listPrsByState(['merged']);
168
+ const second = await svc.listPrsByState(['merged']);
169
+
170
+ expect(first.map((s) => s.touchedNodePaths)).toEqual([['Shared/1.md'], ['Shared/2.md'], ['Shared/3.md']]);
171
+ expect(second).toEqual(first);
172
+ expect(ofAppliedChange).toHaveBeenCalledTimes(3);
173
+ // One refresh per LIST (TTL-cached underneath), not one per row: a merge
174
+ // commit cannot change, but a clone that has not fetched since the merge
175
+ // does not hold it, and would read every applied request as author-only.
176
+ expect(ensureRemotesFetched).toHaveBeenCalledTimes(2);
177
+ });
178
+
179
+ it('asks again for a merge commit the clone did not hold the first time', async () => {
180
+ const { svc, ofAppliedChange } = appliedHarness([applied(1)]);
181
+ ofAppliedChange.mockRejectedValueOnce(new WorkflowValidationError('commit is not in this clone'));
182
+
183
+ const before = await svc.listPrsByState(['merged']);
184
+ const after = await svc.listPrsByState(['merged']);
185
+
186
+ // Fail-closed while the commit is missing, answered once it is there.
187
+ expect(before[0].touchedNodePaths).toEqual([]);
188
+ expect(after[0].touchedNodePaths).toEqual(['Shared/1.md']);
189
+ expect(ofAppliedChange).toHaveBeenCalledTimes(2);
190
+ });
191
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { isFolderPlaceholder } from '@bevel-software/platform-shared';
2
3
 
3
4
  import { PullRequestService } from '../pull-request.service.js';
4
5
  import type { Database } from '../../../database/connection.js';
@@ -40,8 +41,19 @@ describe('PullRequestService summaries — folder placeholder', () => {
40
41
  }),
41
42
  }),
42
43
  } as unknown as Database;
43
- const changedPathsForPr = vi.fn(async () => paths);
44
- const git = { changedPathsForPr } as unknown as GitService;
44
+ // The real `changedPathPairs` drops the placeholder (it is never a file
45
+ // anybody reads) while the flat list keeps it for the empty-request check,
46
+ // so the double answers both the way the service does. It borrows the
47
+ // production predicate rather than spelling one out: `isFolderPlaceholder`
48
+ // matches on the BASENAME, so a root-level `.gitkeep` is a placeholder too,
49
+ // and both of the real readers drop `roles.yaml`.
50
+ const changedPathsForPr = vi.fn(async () => ({
51
+ paths: paths.filter((p) => p !== 'roles.yaml'),
52
+ pairs: paths
53
+ .filter((p) => !isFolderPlaceholder(p) && p !== 'roles.yaml')
54
+ .map((path) => ({ path })),
55
+ }));
56
+ const git = { changedPathsAndPairsForPr: changedPathsForPr } as unknown as GitService;
45
57
  const workspace = {
46
58
  findAnyWorkspaceId: async () => 'ws-main',
47
59
  ensureRemotesFetched: async () => undefined,
@@ -76,4 +88,14 @@ describe('PullRequestService summaries — folder placeholder', () => {
76
88
  const { svc } = harness(['Elsewhere/.gitkeep']);
77
89
  expect(await svc.listPrsForOwnerEmail('ws-main', 'bob@bevel.software', { fresh: true })).toEqual([]);
78
90
  });
91
+
92
+ it('a placeholder at the ROOT is a placeholder too, on both views of the summary', async () => {
93
+ // `isFolderPlaceholder` matches the basename, so `.gitkeep` with no folder
94
+ // in front of it is the same non-file — the corner a filter spelled
95
+ // `endsWith('/.gitkeep')` would get wrong on both the paths and the pairs.
96
+ const { svc } = harness(['.gitkeep', 'Reports/q3.md']);
97
+ const [summary] = await svc.listOpenPrs({ fresh: true });
98
+ expect(summary.touchedNodePaths).toEqual(['Reports/q3.md']);
99
+ expect(summary.touchedNodeFiles).toEqual([{ path: 'Reports/q3.md' }]);
100
+ });
79
101
  });