@bevel-software/platform-core-backend 0.30.0 → 0.32.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 (210) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +16 -3
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +1 -5
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access-model/index.d.ts +14 -0
  19. package/dist/modules/access-model/index.d.ts.map +1 -0
  20. package/dist/modules/access-model/index.js +13 -0
  21. package/dist/modules/access-model/index.js.map +1 -0
  22. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  23. package/dist/modules/audit/agent-audit.service.js +66 -6
  24. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  25. package/dist/modules/audit/audit.contract.d.ts +52 -8
  26. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  27. package/dist/modules/audit/audit.contract.js +37 -1
  28. package/dist/modules/audit/audit.contract.js.map +1 -1
  29. package/dist/modules/audit/audit.routes.d.ts +1 -1
  30. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  31. package/dist/modules/audit/audit.routes.js +15 -13
  32. package/dist/modules/audit/audit.routes.js.map +1 -1
  33. package/dist/modules/audit/request-audit.d.ts +21 -10
  34. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  35. package/dist/modules/audit/request-audit.js +26 -10
  36. package/dist/modules/audit/request-audit.js.map +1 -1
  37. package/dist/modules/auth/account-hooks.d.ts +4 -6
  38. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  39. package/dist/modules/auth/account-hooks.js +7 -17
  40. package/dist/modules/auth/account-hooks.js.map +1 -1
  41. package/dist/modules/auth/auth.service.d.ts +9 -8
  42. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.service.js +19 -18
  44. package/dist/modules/auth/auth.service.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +43 -4
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +31 -7
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/kb-fs/index.d.ts +1 -0
  50. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  51. package/dist/modules/kb-fs/index.js +1 -0
  52. package/dist/modules/kb-fs/index.js.map +1 -1
  53. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  54. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  55. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  56. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  57. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/mutex.js +14 -0
  59. package/dist/modules/kb-fs/mutex.js.map +1 -1
  60. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  61. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  62. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  63. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  64. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  65. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  66. package/dist/modules/mcp/mcp.routes.js +67 -0
  67. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  68. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  69. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  70. package/dist/modules/mcp/mcp.service.js +257 -50
  71. package/dist/modules/mcp/mcp.service.js.map +1 -1
  72. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  73. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  74. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  75. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  76. package/dist/modules/mcp/request-context.d.ts +25 -0
  77. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  78. package/dist/modules/mcp/request-context.js +11 -0
  79. package/dist/modules/mcp/request-context.js.map +1 -0
  80. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  81. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  82. package/dist/modules/mcp/shared-definitions.js +117 -0
  83. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  84. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  85. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  86. package/dist/modules/mcp/surface-cache.js +104 -0
  87. package/dist/modules/mcp/surface-cache.js.map +1 -0
  88. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  89. package/dist/modules/tool-helpers/tool-context.js +33 -12
  90. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  91. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  92. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  93. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  94. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  95. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  96. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  97. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  98. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  99. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  100. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  101. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  102. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  103. package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
  104. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  105. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  106. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  107. package/dist/modules/workflow/change-request-hooks.js +15 -0
  108. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  109. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  110. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  111. package/dist/modules/workflow/git/git.service.js +470 -106
  112. package/dist/modules/workflow/git/git.service.js.map +1 -1
  113. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  114. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  116. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  117. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  118. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  119. package/dist/modules/workflow/read-on-branch.js +58 -0
  120. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  121. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  122. package/dist/modules/workflow/workflow.routes.js +22 -41
  123. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  124. package/dist/modules/workflow/workflow.service.d.ts +124 -24
  125. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  126. package/dist/modules/workflow/workflow.service.js +326 -48
  127. package/dist/modules/workflow/workflow.service.js.map +1 -1
  128. package/dist/modules/workspace/index.d.ts +2 -0
  129. package/dist/modules/workspace/index.d.ts.map +1 -1
  130. package/dist/modules/workspace/index.js +4 -0
  131. package/dist/modules/workspace/index.js.map +1 -1
  132. package/dist/shared/git.contract.d.ts +7 -0
  133. package/dist/shared/git.contract.d.ts.map +1 -1
  134. package/dist/shared/git.contract.js +9 -0
  135. package/dist/shared/git.contract.js.map +1 -1
  136. package/dist/shared/listener-seam.d.ts +34 -0
  137. package/dist/shared/listener-seam.d.ts.map +1 -0
  138. package/dist/shared/listener-seam.js +42 -0
  139. package/dist/shared/listener-seam.js.map +1 -0
  140. package/dist/shared/no-link-below.d.ts +10 -0
  141. package/dist/shared/no-link-below.d.ts.map +1 -0
  142. package/dist/shared/no-link-below.js +30 -0
  143. package/dist/shared/no-link-below.js.map +1 -0
  144. package/migrations/0018_agent_events_page.sql +11 -0
  145. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  146. package/migrations/meta/0018_snapshot.json +2429 -0
  147. package/migrations/meta/0019_snapshot.json +2435 -0
  148. package/migrations/meta/_journal.json +15 -1
  149. package/package.json +3 -3
  150. package/src/core/catalog-revision.ts +3 -3
  151. package/src/core/create-core-server.ts +2 -1
  152. package/src/core/create-core-services.ts +21 -2
  153. package/src/index.ts +7 -0
  154. package/src/modules/access/access-control.service.ts +1 -6
  155. package/src/modules/access-model/index.ts +13 -0
  156. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  157. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  158. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  159. package/src/modules/audit/agent-audit.service.ts +66 -6
  160. package/src/modules/audit/audit.contract.ts +62 -10
  161. package/src/modules/audit/audit.routes.ts +14 -12
  162. package/src/modules/audit/request-audit.ts +28 -13
  163. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  164. package/src/modules/auth/account-hooks.ts +13 -21
  165. package/src/modules/auth/auth.service.ts +19 -18
  166. package/src/modules/database/core-schema.ts +31 -7
  167. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  168. package/src/modules/kb-fs/index.ts +1 -0
  169. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  170. package/src/modules/kb-fs/mutex.ts +16 -0
  171. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  172. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  173. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  174. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  175. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  176. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  177. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  178. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  179. package/src/modules/mcp/mcp.routes.ts +68 -0
  180. package/src/modules/mcp/mcp.service.ts +315 -51
  181. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  182. package/src/modules/mcp/request-context.ts +34 -0
  183. package/src/modules/mcp/shared-definitions.ts +121 -0
  184. package/src/modules/mcp/surface-cache.ts +154 -0
  185. package/src/modules/tool-helpers/tool-context.ts +37 -18
  186. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  187. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  188. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  189. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  190. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  191. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  192. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  193. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
  194. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
  195. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  196. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  197. package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
  198. package/src/modules/workflow/change-request-hooks.ts +96 -0
  199. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  200. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  201. package/src/modules/workflow/git/git.service.ts +537 -106
  202. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  203. package/src/modules/workflow/read-on-branch.ts +79 -0
  204. package/src/modules/workflow/workflow.routes.ts +21 -39
  205. package/src/modules/workflow/workflow.service.ts +390 -55
  206. package/src/modules/workspace/index.ts +11 -0
  207. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  208. package/src/shared/git.contract.ts +10 -0
  209. package/src/shared/listener-seam.ts +52 -0
  210. package/src/shared/no-link-below.ts +29 -0
@@ -58,9 +58,11 @@ import type {
58
58
  IWorkflowService,
59
59
  MergeBranchOutcome,
60
60
  MergeChangeRequestOutcome,
61
+ MergeResolution,
61
62
  OpenChangeRequestInput,
62
63
  PostChangeRequestCommentInput,
63
64
  RemoteSyncPullResult,
65
+ UpdateBranchOutcome,
64
66
  } from '@bevel-software/platform-shared';
65
67
  import {
66
68
  isFolderPlaceholder,
@@ -68,6 +70,7 @@ import {
68
70
  isPlatformRestoreShape,
69
71
  } from '@bevel-software/platform-shared';
70
72
  import type { KbContext } from '../../shared/kb-context.js';
73
+ import { readableAtCommits, ReadOnBranchDeniedError } from './read-on-branch.js';
71
74
  import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
72
75
  import type { Database } from '../database/connection.js';
73
76
  import { changeRequests } from '../database/schema.js';
@@ -86,6 +89,11 @@ import type { WorkflowEventBus } from './event-bus.js';
86
89
  import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
87
90
  import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
88
91
  import { WorkflowHooks } from './workflow-hooks.js';
92
+ import {
93
+ ChangeRequestHooks,
94
+ type ChangeRequestPerson,
95
+ type IChangeRequestHooks,
96
+ } from './change-request-hooks.js';
89
97
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
90
98
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
91
99
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
@@ -105,8 +113,7 @@ import {
105
113
  WorkflowValidationError,
106
114
  } from '../../shared/domain-errors.js';
107
115
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
108
- import { AccessDeniedError } from '../access-model/access-errors.js';
109
- import type { IChangeReadGate } from '../access-model/change-gate.js';
116
+ import { AccessDeniedError, EVERYONE_CANONICAL, type IChangeReadGate } from '../access-model/index.js';
110
117
  import { printable } from '../../shared/printable.js';
111
118
 
112
119
  const execFileAsync = promisify(execFile);
@@ -246,6 +253,34 @@ export function formatAffectedOwnersBlock(
246
253
  return out.join('\n');
247
254
  }
248
255
 
256
+ /** What the access layer answers for the changed paths of a request, or null when it cannot. */
257
+ type EligibleWritersAtRef = Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>>;
258
+
259
+ /**
260
+ * The `## Affected owners` markdown block appended to the CR body, from the
261
+ * eligibility already resolved. The per-path list is capped (see
262
+ * {@link formatAffectedOwnersBlock}) so the body stays within the
263
+ * `gh pr create --body` arg limit on large changesets. The empty string when
264
+ * nothing was resolvable — keeps the body clean rather than printing a stub
265
+ * heading.
266
+ */
267
+ function buildAffectedOwnersBlock(paths: string[], resolved: EligibleWritersAtRef): string {
268
+ if (!resolved || resolved.size === 0) return '';
269
+ return formatAffectedOwnersBlock(paths, resolved);
270
+ }
271
+
272
+ /**
273
+ * The one question the open asks of accounts: which of these addresses have
274
+ * a live account. `AuthService.findActiveAccountsByEmail` answers it in a
275
+ * single indexed read; the shape is spelled here so the workflow module
276
+ * imports nothing of auth's. Keyed by the canonical address asked for.
277
+ */
278
+ export interface ApproverAccounts {
279
+ findActiveAccountsByEmail(
280
+ emails: readonly string[],
281
+ ): Promise<Map<string, { id: string; email: string; name: string }>>;
282
+ }
283
+
249
284
  /** Git commit subjects are capped at 200 characters (`GitService.commitFile`). */
250
285
  const MAX_COMMIT_SUBJECT = 200;
251
286
 
@@ -340,6 +375,19 @@ export class WorkflowService implements IWorkflowService {
340
375
  * hooks) unchanged.
341
376
  */
342
377
  public readonly hooks: WorkflowHooks = new WorkflowHooks(),
378
+ /**
379
+ * The seam a deployment's notification extends when a request is opened
380
+ * (see `change-request-hooks.ts`). The composition root passes the SAME
381
+ * instance it exposes as `changeRequestHooks`; the default registers
382
+ * nobody, so a test construction notifies nobody and says so.
383
+ */
384
+ private readonly changeRequestHooks: IChangeRequestHooks = new ChangeRequestHooks(),
385
+ /**
386
+ * Who turns the addresses the access model names into accounts, for the
387
+ * opened event's `approvers`. Absent (a harness without accounts), every
388
+ * approver counts as unresolved.
389
+ */
390
+ private readonly accounts?: ApproverAccounts,
343
391
  ) {
344
392
  // The type already requires it; this holds for a caller the compiler did
345
393
  // not see (plain JavaScript, a positional slip in a harness).
@@ -421,22 +469,29 @@ export class WorkflowService implements IWorkflowService {
421
469
  * opposite directions (`A -> B` and `B -> A`), and nothing is ever held
422
470
  * while waiting for the other key.
423
471
  *
424
- * A PROTECTED target's key is NOT reserved. No path in the system can
425
- * delete a protected branch (`git.deleteBranch` throws
472
+ * A PROTECTED branch's key is NOT reserved, at either end. No path in the
473
+ * system can delete a protected branch (`git.deleteBranch` throws
426
474
  * `ProtectedBranchError` before any lock, `retireMergedSourceBranch`
427
475
  * returns early on one), so the key would exclude nothing. It would only
428
- * cost: nearly every request targets the protected default branch, so one
429
- * shared key there would serialize every open on the hottest path for the
430
- * duration of a fetch + merge + push + diff. The source needs no such
431
- * carve-out; `openChangeRequest` refuses protected sources before locking.
476
+ * cost: nearly every request targets the protected default branch, and
477
+ * every draft refreshes FROM it (`updateBranch`, the merge tool's sync),
478
+ * so one shared key there would serialize every open on the hottest path for the
479
+ * duration of a fetch + merge + push + diff. A protected SOURCE takes no
480
+ * key for the same reason: `openChangeRequest` refuses protected sources
481
+ * before locking, and a draft refreshing from the default branch is
482
+ * exactly a protected source.
432
483
  */
433
484
  private runOnBranchPair<T>(
434
485
  source: string,
435
486
  target: string,
436
487
  fn: () => Promise<T>,
437
488
  ): Promise<T> {
438
- const keys = [`branch:${source}`];
439
- if (!this.kb.isProtectedBranch(target)) keys.push(`branch:${target}`);
489
+ // A PROTECTED branch's key is not reserved at EITHER end (see above):
490
+ // nothing can delete one, so the key would exclude nothing and only
491
+ // serialize every draft that refreshes from the default branch behind
492
+ // every other. The same branch at both ends (a draft syncing its own
493
+ // shared state) takes its one key.
494
+ const keys = [...new Set([source, target].filter((b) => !this.kb.isProtectedBranch(b)).map((b) => `branch:${b}`))];
440
495
  return this.branchLifecycle.runAll(keys, fn);
441
496
  }
442
497
 
@@ -968,7 +1023,17 @@ export class WorkflowService implements IWorkflowService {
968
1023
  workspaceId: string,
969
1024
  opts: { preserveMerges?: boolean } = {},
970
1025
  ): Promise<boolean> {
971
- const { treeChanged } = await this.git.pull(workspaceId, opts);
1026
+ return this.pullWorkspaceVia(() => this.git.pull(workspaceId, opts), workspaceId);
1027
+ }
1028
+
1029
+ /**
1030
+ * `pullWorkspace` over the pull it is given: the workspace's own
1031
+ * (`git.pull`, which takes the workspace's reservation) or one a caller
1032
+ * already holding that reservation hands in (a merge's publish). The
1033
+ * announcement is the same either way.
1034
+ */
1035
+ private async pullWorkspaceVia(pull: () => Promise<{ treeChanged: boolean }>, workspaceId: string): Promise<boolean> {
1036
+ const { treeChanged } = await pull();
972
1037
  const defaultBranch = this.kb.defaultBranch;
973
1038
  if (treeChanged && branchForWorkspaceId(workspaceId) === defaultBranch) {
974
1039
  this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: defaultBranch });
@@ -1259,8 +1324,9 @@ export class WorkflowService implements IWorkflowService {
1259
1324
  path: string,
1260
1325
  fromBranch: string,
1261
1326
  toBranch: string,
1262
- ): Promise<string> {
1263
- return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
1327
+ authorize: (sides: { from: string[]; to: string[] }) => Promise<void>,
1328
+ ): Promise<{ diff: string; from: string; to: string }> {
1329
+ return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch, authorize);
1264
1330
  }
1265
1331
 
1266
1332
  fileAtForkPoint(
@@ -1893,8 +1959,33 @@ export class WorkflowService implements IWorkflowService {
1893
1959
  ): Promise<void> {
1894
1960
  // No explicit `undefined` third argument when there are no options: the
1895
1961
  // call shape stays the bare `push(workspaceId, user)` every caller made.
1896
- const push = () =>
1897
- opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user);
1962
+ return this.pushWithRecoveryVia(
1963
+ {
1964
+ push: () => (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user)),
1965
+ pull: () => this.pullWorkspace(workspaceId, { preserveMerges: true }),
1966
+ },
1967
+ workspaceId,
1968
+ branch,
1969
+ targetPath,
1970
+ user,
1971
+ );
1972
+ }
1973
+
1974
+ /**
1975
+ * `pushWithRecovery`'s policy over the two moves it is made of — the push,
1976
+ * and the pull-rebase that keeps a stranded merge a merge — so that a
1977
+ * caller already holding the workspace's reservation (a merge publishing
1978
+ * itself, `updateBranch`) runs the SAME policy over its own unlocked moves,
1979
+ * and a push the host refuses answers one way there too.
1980
+ */
1981
+ private async pushWithRecoveryVia(
1982
+ ops: { push: () => Promise<void>; pull: () => Promise<unknown> },
1983
+ workspaceId: string,
1984
+ branch: string,
1985
+ targetPath: string,
1986
+ user: AuthUser,
1987
+ ): Promise<void> {
1988
+ const push = ops.push;
1898
1989
  try {
1899
1990
  await push();
1900
1991
  this.noteGitSyncOk(workspaceId, branch);
@@ -1915,7 +2006,7 @@ export class WorkflowService implements IWorkflowService {
1915
2006
  // with whatever push of the branch comes next — a save, a share —
1916
2007
  // and a plain rebase would flatten it into cherry-picks. With no
1917
2008
  // merge among the local commits the two spellings replay the same.
1918
- await this.pullWorkspace(workspaceId, { preserveMerges: true });
2009
+ await ops.pull();
1919
2010
  try {
1920
2011
  await push();
1921
2012
  } catch (retryErr) {
@@ -2224,12 +2315,16 @@ export class WorkflowService implements IWorkflowService {
2224
2315
  * account, so no hidden body marker). The DB-assigned number is the CR id.
2225
2316
  * 6. Re-fetch the full detail via the PR service so the response shape
2226
2317
  * matches every other CR endpoint.
2318
+ * 7. Tell the deployment's change-request listeners (see
2319
+ * `change-request-hooks.ts`); `notified` in the answer says which
2320
+ * channels reached the approvers. Once per request, here, whether a
2321
+ * person or an agent opened it — both take this road.
2227
2322
  */
2228
2323
  async openChangeRequest(
2229
2324
  workspaceId: string,
2230
2325
  user: AuthUser,
2231
2326
  input: OpenChangeRequestInput,
2232
- ): Promise<ChangeRequestDetail> {
2327
+ ): Promise<ChangeRequestDetail & { notified: string[] }> {
2233
2328
  if (!input.sourceBranch) throw new WorkflowValidationError('sourceBranch is required');
2234
2329
  if (!input.targetBranch) throw new WorkflowValidationError('targetBranch is required');
2235
2330
  if (input.sourceBranch === input.targetBranch) {
@@ -2254,7 +2349,19 @@ export class WorkflowService implements IWorkflowService {
2254
2349
  // its branches is retired inside the window, and holding only the source
2255
2350
  // left a `Y -> X` request open to X being deleted under it — the very
2256
2351
  // failure the widened guards exist to prevent.
2257
- return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
2352
+ //
2353
+ // The deployment's listeners are told AFTER the locks are released: they
2354
+ // are awaited (the answer's `notified` is true to what happened), and a
2355
+ // slow mailer must hold up this answer alone, never a delete or a merge
2356
+ // on either branch. So the locked part answers what it made — the detail,
2357
+ // or the refusal — and the telling follows.
2358
+ const outcome = await this.runOnBranchPair(input.sourceBranch, input.targetBranch, async (): Promise<
2359
+ { number: number; eligible: EligibleWritersAtRef } & (
2360
+ | { detail: ChangeRequestDetail; pushRefused: null; detailError: null }
2361
+ | { detail: null; pushRefused: PushNeedsAgentResolutionError; detailError: null }
2362
+ | { detail: null; pushRefused: null; detailError: Error }
2363
+ )
2364
+ > => {
2258
2365
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
2259
2366
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
2260
2367
  // force-fresh to catch a CR that opened just now.
@@ -2320,11 +2427,10 @@ export class WorkflowService implements IWorkflowService {
2320
2427
  input.targetBranch,
2321
2428
  input.sourceBranch,
2322
2429
  );
2323
- const affectedOwnersBlock = await this.buildAffectedOwnersBlock(
2324
- workspaceId,
2325
- input.targetBranch,
2326
- touchedPaths,
2327
- );
2430
+ // Resolved once: the body lists this eligibility, and the opened event
2431
+ // hands the same people to the deployment's listeners.
2432
+ const eligible = await this.eligibleWritersFor(workspaceId, input.targetBranch, touchedPaths);
2433
+ const affectedOwnersBlock = buildAffectedOwnersBlock(touchedPaths, eligible);
2328
2434
 
2329
2435
  // The change request is a plain DB row now — no provider PR. Author is a
2330
2436
  // real column (no hidden body marker needed, since nothing runs as a shared
@@ -2407,24 +2513,119 @@ export class WorkflowService implements IWorkflowService {
2407
2513
  // refused push never moved — answering the refusal is what the caller
2408
2514
  // needs, not a detail read that may fail on a branch the host lacks.
2409
2515
  this.events?.emit(opened);
2410
- throw pushRefused;
2516
+ return { number: prNumber, eligible, detail: null, pushRefused, detailError: null };
2411
2517
  }
2412
2518
 
2413
- const detail = await this.prs.getPrDetail(prNumber, {
2414
- fresh: true,
2415
- workspaceId,
2416
- viewerEmail: user.email,
2417
- });
2418
- if (!detail) {
2419
- throw new WorkflowValidationError(
2420
- `Created change request #${prNumber} but could not fetch its detail`,
2421
- );
2422
- }
2519
+ // The row exists and the push landed: every tab is told, whatever the
2520
+ // read-back below does.
2423
2521
  this.events?.emit(opened);
2424
- return detail;
2522
+ // Reading the detail back is the caller's answer, not the open: a read
2523
+ // that fails is reported, but it is not allowed to cost the reviewers
2524
+ // their message — the request stands, and a retry of the open meets the
2525
+ // duplicate check, so there is no second chance to tell them.
2526
+ let detail: ChangeRequestDetail | null = null;
2527
+ let detailError: Error | null = null;
2528
+ try {
2529
+ detail = await this.prs.getPrDetail(prNumber, {
2530
+ fresh: true,
2531
+ workspaceId,
2532
+ viewerEmail: user.email,
2533
+ });
2534
+ } catch (err) {
2535
+ detailError = err instanceof Error ? err : new Error(String(err));
2536
+ }
2537
+ if (detail) return { number: prNumber, eligible, detail, pushRefused: null, detailError: null };
2538
+ return {
2539
+ number: prNumber,
2540
+ eligible,
2541
+ detail: null,
2542
+ pushRefused: null,
2543
+ detailError:
2544
+ detailError ?? new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`),
2545
+ };
2546
+ });
2547
+
2548
+ // The request exists in every branch of the outcome — the listeners are
2549
+ // told all the same when the push was refused (`pushPending` says so:
2550
+ // the request is there to find, its commits reach the host with the
2551
+ // branch's next push, and what that is worth to a reviewer is each
2552
+ // listener's call) and when its detail could not be read back. Only
2553
+ // then is the refusal or the failed read the caller's answer.
2554
+ const notified = await this.notifyOpened(outcome.number, input, user, outcome.eligible, outcome.pushRefused !== null);
2555
+ if (outcome.pushRefused) throw outcome.pushRefused;
2556
+ if (outcome.detailError) throw outcome.detailError;
2557
+ return { ...outcome.detail, notified };
2558
+ }
2559
+
2560
+ /**
2561
+ * Tell the deployment's listeners the request was opened (see
2562
+ * {@link IChangeRequestHooks}); the channels that reached the approvers.
2563
+ * Never throws: a listener's failure is the hooks' to log, and a failed
2564
+ * read of the approvers' accounts hands the listeners none of them, counted
2565
+ * as unresolved.
2566
+ */
2567
+ private async notifyOpened(
2568
+ number: number,
2569
+ input: OpenChangeRequestInput,
2570
+ user: AuthUser,
2571
+ eligible: EligibleWritersAtRef,
2572
+ pushPending: boolean,
2573
+ ): Promise<string[]> {
2574
+ const { approvers, unresolvedApprovers } = await this.resolveApprovers(eligible);
2575
+ // Only an address a person can open from outside is handed over; the
2576
+ // relative in-app path (no `PUBLIC_FRONTEND_URL`) is the listener's to
2577
+ // complete.
2578
+ const link = this.prs.linkFor(number);
2579
+ return this.changeRequestHooks.runChangeRequestOpened({
2580
+ number,
2581
+ title: input.title.trim(),
2582
+ sourceBranch: input.sourceBranch,
2583
+ targetBranch: input.targetBranch,
2584
+ openedBy: { id: user.id, email: user.email, name: user.name },
2585
+ approvers,
2586
+ unresolvedApprovers,
2587
+ ...(link.urlNote === undefined ? { url: link.url } : {}),
2588
+ pushPending,
2425
2589
  });
2426
2590
  }
2427
2591
 
2592
+ /**
2593
+ * The distinct people eligible to approve some changed path, as accounts.
2594
+ * Each path's eligibility already expands its roles and groups to the
2595
+ * addresses the access model names (`emails`), so a person is one address
2596
+ * however many paths or roles name them; the addresses are looked up as
2597
+ * live accounts in one read. An address with no account, or with one
2598
+ * switched off, and the built-in `everyone` (anyone signed in may approve;
2599
+ * nobody can be listed for that), are counted, not handed over.
2600
+ */
2601
+ private async resolveApprovers(
2602
+ eligible: EligibleWritersAtRef,
2603
+ ): Promise<{ approvers: ChangeRequestPerson[]; unresolvedApprovers: number }> {
2604
+ const addresses = new Set<string>();
2605
+ let everyone = false;
2606
+ for (const info of eligible?.values() ?? []) {
2607
+ for (const email of info.emails) addresses.add(canonicalEmail(email));
2608
+ if (info.roles.includes(EVERYONE_CANONICAL)) everyone = true;
2609
+ }
2610
+ let accounts = new Map<string, { id: string; email: string; name: string }>();
2611
+ if (addresses.size > 0 && this.accounts) {
2612
+ try {
2613
+ accounts = await this.accounts.findActiveAccountsByEmail([...addresses]);
2614
+ } catch (err) {
2615
+ crLog.warn('the approvers of a change request could not be read as accounts; the listeners are handed none:', { err });
2616
+ }
2617
+ }
2618
+ const approvers: ChangeRequestPerson[] = [];
2619
+ let unresolvedApprovers = everyone ? 1 : 0;
2620
+ for (const address of addresses) {
2621
+ const account = accounts.get(address);
2622
+ if (account) approvers.push({ id: account.id, email: account.email, name: account.name });
2623
+ else unresolvedApprovers++;
2624
+ }
2625
+ approvers.sort((a, b) => a.email.localeCompare(b.email));
2626
+ return { approvers, unresolvedApprovers };
2627
+ }
2628
+
2428
2629
  /**
2429
2630
  * Refresh an existing change request from its target — re-runs the
2430
2631
  * `target → source` auto-merge so the CR diff reflects what the merge
@@ -4182,8 +4383,8 @@ export class WorkflowService implements IWorkflowService {
4182
4383
  // while they are held: a bare read would let a person open the request
4183
4384
  // mid-merge and the agent would land its content anyway, which is the one
4184
4385
  // thing this tool exists to prevent. `runOnBranchPair` drops a protected
4185
- // target's key; the source key is held either way, and every competing
4186
- // request from this source takes it too.
4386
+ // branch's key at either end; a draft source's key is held, and every
4387
+ // competing request from this source takes it too.
4187
4388
  return this.runOnBranchPair(sourceBranch, targetBranch, async () => {
4188
4389
  // A person merges a change request. Only the request's OWN direction is
4189
4390
  // blocked: the target merged into the source is how a draft under review
@@ -4648,37 +4849,171 @@ export class WorkflowService implements IWorkflowService {
4648
4849
  }
4649
4850
 
4650
4851
  /**
4651
- * Build the `## Affected owners` markdown block that gets appended to
4652
- * the CR body. Lists the eligible approvers (roles + named users) per
4653
- * changed path, per the access tree on `origin/<targetBranch>` — the same
4654
- * tree the approval gate uses server-side, so what the body advertises
4655
- * matches who can actually approve at merge time. The per-path list is
4656
- * capped (see {@link formatAffectedOwnersBlock}) so the body stays within
4657
- * the `gh pr create --body` arg limit on large changesets.
4658
- *
4659
- * Returns the empty string when there are no touched paths or no
4660
- * resolvable eligibility — keeps the body clean rather than printing
4661
- * a stub heading.
4852
+ * The eligible approvers (roles + named users, and the addresses they
4853
+ * expand to) per changed path, per the access tree on
4854
+ * `origin/<targetBranch>` — the same tree the approval gate uses
4855
+ * server-side, so what the body advertises and the listeners are told
4856
+ * matches who can actually approve at merge time. Null when there are no
4857
+ * touched paths or no resolvable eligibility: a missing affected-owners
4858
+ * block is cosmetic, not a reason to abort the open.
4662
4859
  */
4663
- private async buildAffectedOwnersBlock(
4860
+ private async eligibleWritersFor(
4664
4861
  workspaceId: string,
4665
4862
  targetBranch: string,
4666
4863
  paths: string[],
4667
- ): Promise<string> {
4668
- if (paths.length === 0) return '';
4669
- let resolved: Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>> = null;
4864
+ ): Promise<EligibleWritersAtRef> {
4865
+ if (paths.length === 0) return null;
4670
4866
  try {
4671
- resolved = await this.accessControl.eligibleWritersForPathsAtRef(
4867
+ return await this.accessControl.eligibleWritersForPathsAtRef(
4672
4868
  workspaceId,
4673
4869
  `origin/${targetBranch}`,
4674
4870
  paths,
4675
4871
  );
4676
4872
  } catch (err) {
4677
4873
  log.warn('eligibleWritersForPathsAtRef failed:', { err });
4874
+ return null;
4678
4875
  }
4679
- if (!resolved || resolved.size === 0) return '';
4876
+ }
4680
4877
 
4681
- return formatAffectedOwnersBlock(paths, resolved);
4878
+ async updateBranch(
4879
+ workspaceId: string,
4880
+ user: AuthUser,
4881
+ opts: { from?: string } & (
4882
+ | { resolutions?: undefined; validateWrite?: undefined }
4883
+ | { resolutions: MergeResolution[]; validateWrite: (wsPath: string, content: string) => void | Promise<void> }
4884
+ ) = {},
4885
+ ): Promise<UpdateBranchOutcome> {
4886
+ const branch = branchForWorkspaceId(workspaceId);
4887
+ const from = opts.from ?? branch;
4888
+ // A resolution writes on the draft: it is somebody's to gate, and a
4889
+ // caller that brings none gates nothing (closed) — the type says so for
4890
+ // a compiled caller, this for any other.
4891
+ if ((opts.resolutions?.length ?? 0) > 0 && typeof opts.validateWrite !== 'function') {
4892
+ throw new Error('updateBranch: resolutions need a `validateWrite` to be asked before the merge');
4893
+ }
4894
+ if (this.kb.isProtectedBranch(branch)) {
4895
+ throw new WorkflowDomainError(
4896
+ `"${branch}" is protected: it is changed through change requests, never merged into directly.`,
4897
+ 403,
4898
+ { kind: 'protected-update-target', branch },
4899
+ );
4900
+ }
4901
+ // The one merge a person makes: a change request's. An update that would
4902
+ // land an OPEN request from `from` into this draft is that merge by
4903
+ // another name, and `mergeBranch`'s gate refuses it here too — asked once
4904
+ // before waiting, and again under the locks below, where the answer
4905
+ // counts. (The other direction, the request's target into its source, is
4906
+ // how a draft under review stays current, and is never blocked.)
4907
+ if (from !== branch) {
4908
+ const openBefore = await this.openRequestBlockingMerge(from, branch);
4909
+ if (openBefore !== null) throw new OpenChangeRequestBlocksMergeError(from, branch, openBefore);
4910
+ }
4911
+ // The merge takes what the REMOTE holds of `from`, and the push publishes
4912
+ // the branch: writes still being committed on either would be missed by
4913
+ // the first or swept up by the second. Waited for, as `mergeBranch` waits
4914
+ // — outside the lifecycle locks, which must not be held for seconds of
4915
+ // waiting on a queue.
4916
+ for (const b of from === branch ? [branch] : [from, branch]) {
4917
+ const settled = await this.awaitPendingCommits(b);
4918
+ if (!settled.ok) {
4919
+ const message = settled.needsAttention
4920
+ ? `A commit queued on "${b}" failed and needs a person: ${settled.needsAttention} ` +
4921
+ 'Nothing was merged. Tell the user; update again once it has been resolved.'
4922
+ : `${settled.pending} write(s) on "${b}" are still being committed. Nothing was merged. Retry shortly.`;
4923
+ return {
4924
+ kind: 'pending-commits',
4925
+ branch: b,
4926
+ pending: settled.pending,
4927
+ ...(settled.needsAttention ? { needsAttention: settled.needsAttention } : {}),
4928
+ message,
4929
+ };
4930
+ }
4931
+ }
4932
+ // Under the lifecycle lock of both branches, as `mergeBranch` and
4933
+ // `openChangeRequest` run: the "no open request" answer is only sound
4934
+ // while nobody can open one mid-merge, and a branch being merged into is
4935
+ // not one to delete or recreate meanwhile. A protected `from` takes no
4936
+ // key, and the branch's own shared state (from = branch) takes the one.
4937
+ return this.runOnBranchPair(from, branch, async () => {
4938
+ if (from !== branch) {
4939
+ const open = await this.openRequestBlockingMerge(from, branch);
4940
+ if (open !== null) throw new OpenChangeRequestBlocksMergeError(from, branch, open);
4941
+ }
4942
+ if (!(await this.git.remoteBranchExists(workspaceId, from))) {
4943
+ throw new WorkflowValidationError(`No branch named "${from}" on the shared repository.`);
4944
+ }
4945
+ const resolutions = opts.resolutions ?? [];
4946
+ const subject = from === branch ? `Merge the shared ${branch} into the local one` : `Merge ${from} into ${branch}`;
4947
+ const body =
4948
+ (from === branch
4949
+ ? `The branch had moved on the shared repository while this workspace held commits of its own; both are kept.`
4950
+ : `Brings the latest ${from} into ${branch}.`) +
4951
+ (resolutions.length > 0
4952
+ ? ` Conflicts resolved by ${user.name} <${user.email}> in: ${resolutions.map((r) => r.path).join(', ')}.`
4953
+ : '');
4954
+ // Published inside the merge's own reservation: nothing lands between
4955
+ // the merge commit and its push, and the push is EVERY workflow push's
4956
+ // `pushWithRecovery`, run over the merge's own unlocked moves — a
4957
+ // rejection replays the merge (kept as a merge) onto the moved branch
4958
+ // and pushes again; a replay that conflicts or a host that refuses
4959
+ // answers as every refused share answers, the banner noted and the
4960
+ // typed hand-off thrown, naming the branch (no single file is at issue)
4961
+ // and never a conflicted path. The merge commit stays local for the
4962
+ // next push of the branch to carry.
4963
+ return this.git.mergeIntoBranch(workspaceId, branch, from, user, {
4964
+ subject,
4965
+ body,
4966
+ resolutions,
4967
+ publish: (ops) =>
4968
+ this.pushWithRecoveryVia(
4969
+ { push: ops.push, pull: () => this.pullWorkspaceVia(ops.pullRebase, workspaceId) },
4970
+ workspaceId,
4971
+ branch,
4972
+ branch,
4973
+ user,
4974
+ ),
4975
+ // A resolution writes a file on the draft from what both branches
4976
+ // hold of it. It is authorized inside the merge's reservation, after
4977
+ // the fetch and before the merge touches the checkout — the draft's
4978
+ // tree clean at its own commit, so the rules read from it are the
4979
+ // draft's, never a rule file mid-merge: the caller must read the
4980
+ // file at the draft's commit and at the source commit the merge is
4981
+ // of, and may change it on the draft by the one rule every draft
4982
+ // write takes — the change gate `acquireLock` asks, not a write
4983
+ // permission a draft never requires.
4984
+ authorizeResolutions: async ({ ours, theirs }) => {
4985
+ for (const r of resolutions) {
4986
+ const rel = r.path.startsWith(`${this.kbDirName}/`) ? r.path.slice(this.kbDirName.length + 1) : r.path;
4987
+ // At the two COMMITS the merge is of — pinned by the merge itself,
4988
+ // never at a ref a concurrent fetch can move between this verdict
4989
+ // and the merge — by the one rule the tool shows a conflict by.
4990
+ if (!(await readableAtCommits(this.accessControl, workspaceId, user.email, rel, [ours, theirs]))) {
4991
+ throw new ReadOnBranchDeniedError(rel, from);
4992
+ }
4993
+ // The gate takes the WORKSPACE path, as `acquireLock` hands it
4994
+ // one: a repository-relative path reads as outside the knowledge
4995
+ // base there, which the gate waves through.
4996
+ await this.changeGate.assertMayChange(workspaceId, user.email, `${this.kbDirName}/${rel}`, 'file');
4997
+ // Content the resolution brings takes the caller's pre-disk write
4998
+ // gate, as its file writes do before a byte lands; a side kept
4999
+ // whole is a commit's own bytes and brings none.
5000
+ if (r.content !== undefined && opts.validateWrite) await opts.validateWrite(`${this.kbDirName}/${rel}`, r.content);
5001
+ }
5002
+ },
5003
+ });
5004
+ });
4682
5005
  }
4683
5006
 
5007
+ fileAtBranch(
5008
+ workspaceId: string,
5009
+ path: string,
5010
+ branch: string,
5011
+ authorize: (commits: string[]) => Promise<void>,
5012
+ ): Promise<{ sha: string; ref: string; content: string | null }> {
5013
+ return this.git.readFileAtBranch(workspaceId, path, branch, authorize);
5014
+ }
5015
+
5016
+ changedPathsBetweenBranches(workspaceId: string, base: string, branch: string): Promise<string[]> {
5017
+ return this.git.changedPathsForPr(workspaceId, base, branch);
5018
+ }
4684
5019
  }
@@ -24,3 +24,14 @@ export {
24
24
  type WorkingCopyShape,
25
25
  } from './kb-repository.js';
26
26
  export type { AddWorktreeOptions, FetchOptions, IKbRepository } from './kb-repository.contract.js';
27
+ // The agent-access gate: what a module's agent tools take to tell the
28
+ // deployment's hooks of a read or a write, and the session input they declare.
29
+ export {
30
+ assertAgentWriteAllowed,
31
+ notifyAgentRead,
32
+ SESSION_ID_DESCRIPTION,
33
+ SESSION_ID_INPUT,
34
+ ToolDescriptionNotes,
35
+ type AgentAccessGate,
36
+ } from './agent-access.gate.js';
37
+ export { SpillStore } from './spill-store.js';
@@ -0,0 +1,45 @@
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 { linkedAncestor } from '../no-link-below.js';
6
+
7
+ /**
8
+ * `linkedAncestor`: the one judge of "is this path reached through a link
9
+ * below the base". A link on the way is named; a sibling of the base that
10
+ * merely shares its spelling is not below it and is never looked at.
11
+ */
12
+ describe('linkedAncestor', () => {
13
+ let tmp: string;
14
+ beforeEach(async () => {
15
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'no-link-below-'));
16
+ });
17
+ afterEach(async () => {
18
+ await fs.rm(tmp, { recursive: true, force: true });
19
+ });
20
+
21
+ it('answers null for a plain path below the base, and the base itself', async () => {
22
+ const base = path.join(tmp, 'repo');
23
+ await fs.mkdir(path.join(base, 'a', 'b'), { recursive: true });
24
+ expect(await linkedAncestor(base, path.join(base, 'a', 'b', 'c.md'))).toBeNull();
25
+ expect(await linkedAncestor(base, base)).toBeNull();
26
+ });
27
+
28
+ // Symbolic links need a privilege on Windows that a test runner does not have.
29
+ it.skipIf(process.platform === 'win32')('names the first link on the way below the base', async () => {
30
+ const base = path.join(tmp, 'repo');
31
+ await fs.mkdir(path.join(base, 'real'), { recursive: true });
32
+ await fs.symlink(path.join(base, 'real'), path.join(base, 'via'));
33
+ expect(await linkedAncestor(base, path.join(base, 'via', 'x.md'))).toBe(path.join(base, 'via'));
34
+ });
35
+
36
+ it.skipIf(process.platform === 'win32')('treats a sibling that shares the base\'s spelling as outside: not below it, never walked', async () => {
37
+ const base = path.join(tmp, 'repo');
38
+ await fs.mkdir(base, { recursive: true });
39
+ await fs.mkdir(path.join(tmp, 'elsewhere'));
40
+ // `<tmp>/repository` is a LINK, and a sibling of `<tmp>/repo`: a prefix
41
+ // match without a separator would walk it and name it as an ancestor.
42
+ await fs.symlink(path.join(tmp, 'elsewhere'), path.join(tmp, 'repository'));
43
+ expect(await linkedAncestor(base, path.join(tmp, 'repository', 'x.md'))).toBeNull();
44
+ });
45
+ });