opencastle 0.26.1 → 0.27.1

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 (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,103 @@
1
+ import { hostname as getHostname } from 'node:os'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+
4
+ export class EngineAlreadyRunningError extends Error {
5
+ constructor(public readonly pid: number, public readonly hostname: string) {
6
+ super(
7
+ `Another opencastle process (PID ${pid} on ${hostname}) is already running against this database.`,
8
+ )
9
+ this.name = 'EngineAlreadyRunningError'
10
+ }
11
+ }
12
+
13
+ type LockRow = { pid: number; hostname: string; last_heartbeat: string }
14
+
15
+ function checkStaleness(row: LockRow): boolean {
16
+ const heartbeatAge = Date.now() - new Date(row.last_heartbeat).getTime()
17
+ if (heartbeatAge <= 30_000) return false
18
+ if (row.hostname !== getHostname()) return true
19
+ try {
20
+ process.kill(row.pid, 0)
21
+ return false // PID is alive on this host
22
+ } catch {
23
+ return true // PID is dead
24
+ }
25
+ }
26
+
27
+ export function isLockStale(db: DatabaseSync): boolean {
28
+ const row = db
29
+ .prepare('SELECT pid, hostname, last_heartbeat FROM engine_lock WHERE id = 1')
30
+ .get() as LockRow | undefined
31
+ if (!row) return true
32
+ return checkStaleness(row)
33
+ }
34
+
35
+ export function releaseEngineLock(db: DatabaseSync): void {
36
+ db.exec('DELETE FROM engine_lock WHERE id = 1')
37
+ }
38
+
39
+ export function acquireEngineLock(
40
+ db: DatabaseSync,
41
+ _dbPath: string,
42
+ ): {
43
+ release: () => void
44
+ startHeartbeat: () => NodeJS.Timeout
45
+ } {
46
+ // BEGIN IMMEDIATE acquires a write lock upfront, preventing concurrent writers
47
+ try {
48
+ db.exec('BEGIN IMMEDIATE')
49
+ } catch (err) {
50
+ const msg = (err as Error).message ?? ''
51
+ if (msg.includes('SQLITE_BUSY') || msg.includes('database is locked')) {
52
+ throw new EngineAlreadyRunningError(0, 'unknown')
53
+ }
54
+ throw err
55
+ }
56
+
57
+ const existing = db
58
+ .prepare('SELECT pid, hostname, last_heartbeat FROM engine_lock WHERE id = 1')
59
+ .get() as LockRow | undefined
60
+
61
+ if (existing) {
62
+ const stale = checkStaleness(existing)
63
+ if (!stale) {
64
+ db.exec('ROLLBACK')
65
+ throw new EngineAlreadyRunningError(existing.pid, existing.hostname)
66
+ }
67
+ }
68
+
69
+ const now = new Date().toISOString()
70
+ db.prepare(
71
+ 'INSERT OR REPLACE INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
72
+ ).run(process.pid, getHostname(), now, now)
73
+ db.exec('COMMIT')
74
+
75
+ let heartbeatInterval: NodeJS.Timeout | undefined
76
+
77
+ function startHeartbeat(): NodeJS.Timeout {
78
+ heartbeatInterval = setInterval(() => {
79
+ try {
80
+ db.prepare('UPDATE engine_lock SET last_heartbeat = ? WHERE id = 1').run(
81
+ new Date().toISOString(),
82
+ )
83
+ } catch {
84
+ // Ignore errors — DB may have been closed
85
+ }
86
+ }, 10_000)
87
+ return heartbeatInterval
88
+ }
89
+
90
+ function release(): void {
91
+ if (heartbeatInterval !== undefined) {
92
+ clearInterval(heartbeatInterval)
93
+ heartbeatInterval = undefined
94
+ }
95
+ try {
96
+ db.exec('DELETE FROM engine_lock WHERE id = 1')
97
+ } catch {
98
+ // Ignore errors — DB may have been closed
99
+ }
100
+ }
101
+
102
+ return { release, startHeartbeat }
103
+ }
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { execFile as execFileCb } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
- import { createMergeQueue } from './merge.js'
7
+ import { createMergeQueue, MergeConflictError } from './merge.js'
8
8
  import type { MergeQueue } from './merge.js'
9
9
 
10
10
  const execFile = promisify(execFileCb)
@@ -103,7 +103,7 @@ describe('merge - no changes', () => {
103
103
  // ── merge conflict ────────────────────────────────────────────────────────────
104
104
 
105
105
  describe('merge - conflict', () => {
106
- it('returns conflicted: true and aborts when two worktrees edit the same file', async () => {
106
+ it('throws MergeConflictError and aborts when two worktrees edit the same file', async () => {
107
107
  const worktree1 = await addWorktree(repoPath, 'worker1', featureBranch)
108
108
  const worktree2 = await addWorktree(repoPath, 'worker2', featureBranch)
109
109
 
@@ -113,10 +113,8 @@ describe('merge - conflict', () => {
113
113
  const first = await queue.merge(worktree1, 'convoy-worker1', featureBranch)
114
114
  expect(first).toEqual({ success: true, conflicted: false, message: 'Merged successfully' })
115
115
 
116
- const second = await queue.merge(worktree2, 'convoy-worker2', featureBranch)
117
- expect(second.success).toBe(false)
118
- expect(second.conflicted).toBe(true)
119
- expect(second.message).toContain('conflict')
116
+ await expect(queue.merge(worktree2, 'convoy-worker2', featureBranch))
117
+ .rejects.toThrow(MergeConflictError)
120
118
  })
121
119
 
122
120
  it('leaves the repo in a clean state (no pending merge) after aborting a conflict', async () => {
@@ -127,7 +125,8 @@ describe('merge - conflict', () => {
127
125
  writeFileSync(join(worktree2, 'shared.txt'), 'content from worker 2')
128
126
 
129
127
  await queue.merge(worktree1, 'convoy-worker1', featureBranch)
130
- await queue.merge(worktree2, 'convoy-worker2', featureBranch)
128
+ await expect(queue.merge(worktree2, 'convoy-worker2', featureBranch))
129
+ .rejects.toBeInstanceOf(MergeConflictError)
131
130
 
132
131
  // --untracked-files=no excludes the .opencastle/worktrees/ dir from the check;
133
132
  // we only want to verify there is no pending merge (no staged/modified tracked files).
@@ -10,6 +10,16 @@ export interface MergeResult {
10
10
  message: string
11
11
  }
12
12
 
13
+ export class MergeConflictError extends Error {
14
+ constructor(
15
+ public readonly conflictingFiles: string[],
16
+ message?: string,
17
+ ) {
18
+ super(message ?? `Merge conflict in: ${conflictingFiles.join(', ')}`)
19
+ this.name = 'MergeConflictError'
20
+ }
21
+ }
22
+
13
23
  export interface MergeQueue {
14
24
  /**
15
25
  * Merge a single worktree's changes back onto the target branch.
@@ -78,8 +88,16 @@ export function createMergeQueue(repoPath: string): MergeQueue {
78
88
  error.code === 1 &&
79
89
  ((error.stderr ?? '').includes('CONFLICT') || (error.stdout ?? '').includes('CONFLICT'))
80
90
  if (isConflict) {
91
+ // Collect conflicting files before aborting
92
+ let conflictingFiles: string[] = []
93
+ try {
94
+ const { stdout: conflictOut } = await execFile('git', [
95
+ '-C', repoPath, 'diff', '--name-only', '--diff-filter=U',
96
+ ])
97
+ conflictingFiles = conflictOut.split('\n').filter(Boolean)
98
+ } catch { /* ignore — we still abort */ }
81
99
  await execFile('git', ['-C', repoPath, 'merge', '--abort'])
82
- return { success: false, conflicted: true, message: 'Merge conflict detected; merge aborted' }
100
+ throw new MergeConflictError(conflictingFiles)
83
101
  }
84
102
  throw err
85
103
  }
@@ -0,0 +1,423 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import type { Stats } from 'node:fs'
3
+
4
+ // ── Mock node:fs so all tests run without touching disk ───────────────────────
5
+ // The spy-per-test pattern (afterEach restoreAllMocks) keeps tests isolated.
6
+
7
+ vi.mock('node:fs', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('node:fs')>()
9
+ return {
10
+ ...actual,
11
+ writeFileSync: vi.fn(),
12
+ statSync: vi.fn(),
13
+ rmSync: vi.fn(),
14
+ realpathSync: vi.fn(),
15
+ lstatSync: vi.fn(),
16
+ }
17
+ })
18
+
19
+ // Import after vi.mock so we receive the mocked module
20
+ import * as fs from 'node:fs'
21
+ import {
22
+ normalizePath,
23
+ pathsOverlap,
24
+ validateFilePartitions,
25
+ determineFsCaseSensitivity,
26
+ scanSymlinks,
27
+ scanNewSymlinks,
28
+ } from './partition.js'
29
+ import type { Task } from '../types.js'
30
+
31
+ // ── Helpers ───────────────────────────────────────────────────────────────────
32
+
33
+ function makeTask(id: string, files: string[], depends_on: string[] = []): Task {
34
+ return {
35
+ id,
36
+ prompt: `Prompt for ${id}`,
37
+ agent: 'developer',
38
+ timeout: '30s',
39
+ depends_on,
40
+ files,
41
+ description: '',
42
+ max_retries: 0,
43
+ }
44
+ }
45
+
46
+ function makeFakeStats(isSymlink: boolean, ino = 1): Stats {
47
+ return {
48
+ isSymbolicLink: () => isSymlink,
49
+ ino,
50
+ } as unknown as Stats
51
+ }
52
+
53
+ afterEach(() => {
54
+ vi.restoreAllMocks()
55
+ })
56
+
57
+ // ── normalizePath ─────────────────────────────────────────────────────────────
58
+
59
+ describe('normalizePath', () => {
60
+ it('strips leading ./', () => {
61
+ expect(normalizePath('./src/auth/')).toBe('src/auth/')
62
+ })
63
+
64
+ it('strips multiple leading ./', () => {
65
+ expect(normalizePath('././src/auth/service.ts')).toBe('src/auth/service.ts')
66
+ })
67
+
68
+ it('strips leading /', () => {
69
+ expect(normalizePath('/src/auth/service.ts')).toBe('src/auth/service.ts')
70
+ })
71
+
72
+ it('replaces backslashes with forward slashes', () => {
73
+ expect(normalizePath('src\\auth\\service.ts')).toBe('src/auth/service.ts')
74
+ })
75
+
76
+ it('throws for paths containing .. segments (resolves within root)', () => {
77
+ expect(() => normalizePath('src/auth/../lib/index.ts')).toThrow('Path traversal detected')
78
+ })
79
+
80
+ it('resolves . segments', () => {
81
+ expect(normalizePath('src/./auth/./service.ts')).toBe('src/auth/service.ts')
82
+ })
83
+
84
+ it('preserves trailing slash for directories', () => {
85
+ expect(normalizePath('src/auth/')).toBe('src/auth/')
86
+ })
87
+
88
+ it('preserves trailing slash after stripping leading ./', () => {
89
+ expect(normalizePath('./src/auth/')).toBe('src/auth/')
90
+ })
91
+
92
+ it('does not add trailing slash for files', () => {
93
+ expect(normalizePath('src/auth/service.ts')).toBe('src/auth/service.ts')
94
+ })
95
+
96
+ it('handles backslash-terminated paths as directories', () => {
97
+ expect(normalizePath('src\\auth\\')).toBe('src/auth/')
98
+ })
99
+
100
+ it('throws for paths containing .. segments via backslash', () => {
101
+ expect(() => normalizePath('src\\auth\\..\\lib\\')).toThrow('Path traversal detected')
102
+ })
103
+
104
+ it('throws for paths containing * glob', () => {
105
+ expect(() => normalizePath('src/**/*.ts')).toThrow(
106
+ 'Glob patterns are not allowed in file paths: "src/**/*.ts"',
107
+ )
108
+ })
109
+
110
+ it('throws for paths containing ? glob', () => {
111
+ expect(() => normalizePath('src/auth?.ts')).toThrow(
112
+ 'Glob patterns are not allowed in file paths: "src/auth?.ts"',
113
+ )
114
+ })
115
+
116
+ it('throws for path starting with ..', () => {
117
+ expect(() => normalizePath('../etc/passwd')).toThrow('Path traversal detected')
118
+ })
119
+
120
+ it('throws for path with escaping .. segments', () => {
121
+ expect(() => normalizePath('foo/../../bar')).toThrow('Path traversal detected')
122
+ })
123
+
124
+ it('throws for path with non-escaping .. segment', () => {
125
+ expect(() => normalizePath('foo/../bar')).toThrow('Path traversal detected')
126
+ })
127
+
128
+ it('does not throw for relative path starting with ./', () => {
129
+ expect(() => normalizePath('./foo/bar')).not.toThrow()
130
+ expect(normalizePath('./foo/bar')).toBe('foo/bar')
131
+ })
132
+
133
+ it('does not throw for plain relative path', () => {
134
+ expect(() => normalizePath('foo/bar')).not.toThrow()
135
+ expect(normalizePath('foo/bar')).toBe('foo/bar')
136
+ })
137
+ })
138
+
139
+ // ── pathsOverlap ──────────────────────────────────────────────────────────────
140
+
141
+ describe('pathsOverlap', () => {
142
+ it('exact match returns true', () => {
143
+ expect(pathsOverlap('src/auth/service.ts', 'src/auth/service.ts')).toBe(true)
144
+ })
145
+
146
+ it('directory prefix overlaps its child file (a is prefix of b)', () => {
147
+ expect(pathsOverlap('src/auth/', 'src/auth/service.ts')).toBe(true)
148
+ })
149
+
150
+ it('directory prefix overlaps its child file (b is prefix of a)', () => {
151
+ expect(pathsOverlap('src/auth/service.ts', 'src/auth/')).toBe(true)
152
+ })
153
+
154
+ it('non-trailing-slash directory prefix overlaps its child file', () => {
155
+ expect(pathsOverlap('src/auth', 'src/auth/service.ts')).toBe(true)
156
+ })
157
+
158
+ it('child file overlaps non-trailing-slash directory prefix', () => {
159
+ expect(pathsOverlap('src/auth/service.ts', 'src/auth')).toBe(true)
160
+ })
161
+
162
+ it('sibling directories do not overlap', () => {
163
+ expect(pathsOverlap('src/auth/', 'src/billing/')).toBe(false)
164
+ })
165
+
166
+ it('prefix without slash does not match different directory with same prefix', () => {
167
+ // 'src/auth' should NOT overlap 'src/auth-utils/' (different dir)
168
+ expect(pathsOverlap('src/auth', 'src/auth-utils/')).toBe(false)
169
+ })
170
+
171
+ it('completely different paths do not overlap', () => {
172
+ expect(pathsOverlap('src/auth/service.ts', 'src/billing/invoice.ts')).toBe(false)
173
+ })
174
+
175
+ it('nested directory overlaps parent', () => {
176
+ expect(pathsOverlap('src/', 'src/auth/service.ts')).toBe(true)
177
+ })
178
+
179
+ it('same file in different directories does not overlap', () => {
180
+ expect(pathsOverlap('src/auth/index.ts', 'src/billing/index.ts')).toBe(false)
181
+ })
182
+ })
183
+
184
+ // ── validateFilePartitions ────────────────────────────────────────────────────
185
+
186
+ describe('validateFilePartitions', () => {
187
+ // Stub determineFsCaseSensitivity to always return true (case-sensitive)
188
+ // so partition test results are deterministic regardless of the host OS.
189
+ function stubCaseSensitive(): void {
190
+ vi.mocked(fs.realpathSync).mockReturnValue('/tmp/probe' as unknown as string)
191
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined as never)
192
+ vi.mocked(fs.rmSync).mockReturnValue(undefined as never)
193
+ vi.mocked(fs.statSync)
194
+ .mockReturnValueOnce(makeFakeStats(false, 100))
195
+ .mockImplementationOnce(() => {
196
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
197
+ })
198
+ }
199
+
200
+ it('returns valid when no overlap between parallel tasks', () => {
201
+ stubCaseSensitive()
202
+ const taskA = makeTask('a', ['src/auth/'])
203
+ const taskB = makeTask('b', ['src/billing/'])
204
+ const phases = [[taskA, taskB]]
205
+
206
+ const result = validateFilePartitions([taskA, taskB], phases)
207
+
208
+ expect(result.valid).toBe(true)
209
+ expect(result.conflicts).toHaveLength(0)
210
+ })
211
+
212
+ it('returns conflict when parallel tasks have overlapping files', () => {
213
+ stubCaseSensitive()
214
+ const taskA = makeTask('a', ['src/auth/'])
215
+ const taskB = makeTask('b', ['src/auth/service.ts'])
216
+ const phases = [[taskA, taskB]]
217
+
218
+ const result = validateFilePartitions([taskA, taskB], phases)
219
+
220
+ expect(result.valid).toBe(false)
221
+ expect(result.conflicts).toHaveLength(1)
222
+ expect(result.conflicts[0]).toMatchObject({
223
+ phase: 0,
224
+ taskA: 'a',
225
+ taskB: 'b',
226
+ })
227
+ expect(result.conflicts[0].overlapping).toContain('src/auth/')
228
+ })
229
+
230
+ it('sequential tasks (different phases) with overlapping files are valid', () => {
231
+ stubCaseSensitive()
232
+ const taskA = makeTask('a', ['src/auth/'])
233
+ const taskB = makeTask('b', ['src/auth/service.ts'], ['a'])
234
+ // Phase 0: [taskA], Phase 1: [taskB]
235
+ const phases = [[taskA], [taskB]]
236
+
237
+ const result = validateFilePartitions([taskA, taskB], phases)
238
+
239
+ expect(result.valid).toBe(true)
240
+ expect(result.conflicts).toHaveLength(0)
241
+ })
242
+
243
+ it('returns valid when files arrays are empty', () => {
244
+ stubCaseSensitive()
245
+ const taskA = makeTask('a', [])
246
+ const taskB = makeTask('b', [])
247
+ const phases = [[taskA, taskB]]
248
+
249
+ const result = validateFilePartitions([taskA, taskB], phases)
250
+
251
+ expect(result.valid).toBe(true)
252
+ })
253
+
254
+ it('skips conflict check when one task has empty files array', () => {
255
+ stubCaseSensitive()
256
+ const taskA = makeTask('a', ['src/auth/'])
257
+ const taskB = makeTask('b', [])
258
+ const phases = [[taskA, taskB]]
259
+
260
+ const result = validateFilePartitions([taskA, taskB], phases)
261
+
262
+ expect(result.valid).toBe(true)
263
+ })
264
+
265
+ it('detects multiple conflicts across task pairs', () => {
266
+ stubCaseSensitive()
267
+ const taskA = makeTask('a', ['src/auth/', 'src/shared/'])
268
+ const taskB = makeTask('b', ['src/auth/login.ts'])
269
+ const taskC = makeTask('c', ['src/shared/utils.ts'])
270
+ const phases = [[taskA, taskB, taskC]]
271
+
272
+ const result = validateFilePartitions([taskA, taskB, taskC], phases)
273
+
274
+ expect(result.valid).toBe(false)
275
+ expect(result.conflicts).toHaveLength(2)
276
+ })
277
+ })
278
+
279
+ // ── determineFsCaseSensitivity ────────────────────────────────────────────────
280
+
281
+ describe('determineFsCaseSensitivity', () => {
282
+ it('returns true (case-sensitive) when stat throws for the lowercase path', () => {
283
+ vi.mocked(fs.realpathSync).mockReturnValue('/private/tmp' as unknown as string)
284
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined as never)
285
+ vi.mocked(fs.rmSync).mockReturnValue(undefined as never)
286
+ vi.mocked(fs.statSync)
287
+ .mockReturnValueOnce(makeFakeStats(false, 100)) // mixed-case file exists
288
+ .mockImplementationOnce(() => {
289
+ // lowercase path throws → case-sensitive filesystem
290
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
291
+ })
292
+
293
+ expect(determineFsCaseSensitivity()).toBe(true)
294
+ })
295
+
296
+ it('returns false (case-insensitive) when stat returns the same inode for both cases', () => {
297
+ vi.mocked(fs.realpathSync).mockReturnValue('/private/tmp' as unknown as string)
298
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined as never)
299
+ vi.mocked(fs.rmSync).mockReturnValue(undefined as never)
300
+ vi.mocked(fs.statSync)
301
+ .mockReturnValueOnce(makeFakeStats(false, 42)) // mixed-case
302
+ .mockReturnValueOnce(makeFakeStats(false, 42)) // lowercase — same inode
303
+
304
+ expect(determineFsCaseSensitivity()).toBe(false)
305
+ })
306
+
307
+ it('calls rmSync in finally (cleanup) and returns true (safe default) when statSync throws unexpectedly', () => {
308
+ vi.mocked(fs.realpathSync).mockReturnValue('/private/tmp' as unknown as string)
309
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined as never)
310
+ vi.mocked(fs.rmSync).mockReturnValue(undefined as never)
311
+ vi.mocked(fs.statSync).mockImplementation(() => {
312
+ throw new Error('unexpected disk error')
313
+ })
314
+
315
+ // The function handles errors gracefully: assumes case-sensitive (safe default)
316
+ expect(determineFsCaseSensitivity()).toBe(true)
317
+ // Cleanup always runs via finally
318
+ expect(fs.rmSync).toHaveBeenCalled()
319
+ })
320
+ })
321
+
322
+ // ── scanSymlinks ──────────────────────────────────────────────────────────────
323
+
324
+ describe('scanSymlinks', () => {
325
+ const BASE = '/project/worktree'
326
+
327
+ it('passes silently when no symlinks exist in files list', () => {
328
+ vi.mocked(fs.realpathSync).mockReturnValue(BASE as unknown as string)
329
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(false))
330
+
331
+ expect(() => scanSymlinks(['src/auth/service.ts'], BASE)).not.toThrow()
332
+ })
333
+
334
+ it('passes when symlink resolves within the partition (safe symlink)', () => {
335
+ vi.mocked(fs.realpathSync)
336
+ .mockReturnValueOnce(BASE as unknown as string) // resolve(basePath)
337
+ .mockReturnValueOnce(`${BASE}/src/auth/target.ts` as unknown as string) // symlink target
338
+
339
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
340
+
341
+ expect(() => scanSymlinks(['src/auth/link.ts'], BASE)).not.toThrow()
342
+ })
343
+
344
+ it('throws symlink_escape when symlink resolves outside the partition', () => {
345
+ vi.mocked(fs.realpathSync)
346
+ .mockReturnValueOnce(BASE as unknown as string)
347
+ .mockReturnValueOnce('/etc/passwd' as unknown as string) // escapes
348
+
349
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
350
+
351
+ expect(() => scanSymlinks(['src/evil-link.ts'], BASE)).toThrow('symlink_escape')
352
+ })
353
+
354
+ it('skips files that do not exist yet (lstatSync throws)', () => {
355
+ vi.mocked(fs.realpathSync).mockReturnValue(BASE as unknown as string)
356
+ vi.mocked(fs.lstatSync).mockImplementation(() => {
357
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
358
+ })
359
+
360
+ expect(() => scanSymlinks(['src/not-yet-created.ts'], BASE)).not.toThrow()
361
+ })
362
+
363
+ it('throws symlink_escape when realpathSync cannot resolve the symlink target', () => {
364
+ vi.mocked(fs.realpathSync)
365
+ .mockReturnValueOnce(BASE as unknown as string)
366
+ .mockImplementationOnce(() => {
367
+ throw new Error('broken symlink')
368
+ })
369
+
370
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
371
+
372
+ expect(() => scanSymlinks(['src/broken-link.ts'], BASE)).toThrow('symlink_escape')
373
+ })
374
+ })
375
+
376
+ // ── scanNewSymlinks ───────────────────────────────────────────────────────────
377
+
378
+ describe('scanNewSymlinks', () => {
379
+ const BASE = '/project/worktree'
380
+
381
+ it('passes silently when no symlinks were created', () => {
382
+ vi.mocked(fs.realpathSync).mockReturnValue(BASE as unknown as string)
383
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(false))
384
+
385
+ expect(() => scanNewSymlinks(BASE, ['src/auth/service.ts'])).not.toThrow()
386
+ })
387
+
388
+ it('passes when new symlink resolves within the worktree', () => {
389
+ vi.mocked(fs.realpathSync)
390
+ .mockReturnValueOnce(BASE as unknown as string)
391
+ .mockReturnValueOnce(`${BASE}/src/auth/target.ts` as unknown as string)
392
+
393
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
394
+
395
+ expect(() => scanNewSymlinks(BASE, ['src/auth/link.ts'])).not.toThrow()
396
+ })
397
+
398
+ it('throws symlink_escape_post_task when new symlink escapes worktree', () => {
399
+ vi.mocked(fs.realpathSync)
400
+ .mockReturnValueOnce(BASE as unknown as string)
401
+ .mockReturnValueOnce('/home/attacker/secret' as unknown as string)
402
+
403
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
404
+
405
+ expect(() => scanNewSymlinks(BASE, ['src/evil-link.ts'])).toThrow(
406
+ 'symlink_escape_post_task',
407
+ )
408
+ })
409
+
410
+ it('throws symlink_escape_post_task when new symlink cannot be resolved', () => {
411
+ vi.mocked(fs.realpathSync)
412
+ .mockReturnValueOnce(BASE as unknown as string)
413
+ .mockImplementationOnce(() => {
414
+ throw new Error('broken symlink')
415
+ })
416
+
417
+ vi.mocked(fs.lstatSync).mockReturnValue(makeFakeStats(true))
418
+
419
+ expect(() => scanNewSymlinks(BASE, ['src/new-broken-link.ts'])).toThrow(
420
+ 'symlink_escape_post_task',
421
+ )
422
+ })
423
+ })