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.
- package/README.md +7 -1
- package/bin/cli.mjs +10 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +68 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2102 -26
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1572 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/events.d.ts +4 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +74 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +154 -27
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +47 -5
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +525 -19
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1345 -12
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +156 -2
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/destroy.d.ts +3 -0
- package/dist/cli/destroy.d.ts.map +1 -0
- package/dist/cli/destroy.js +69 -0
- package/dist/cli/destroy.js.map +1 -0
- package/dist/cli/destroy.test.d.ts +2 -0
- package/dist/cli/destroy.test.d.ts.map +1 -0
- package/dist/cli/destroy.test.js +116 -0
- package/dist/cli/destroy.test.js.map +1 -0
- package/dist/cli/gitignore.d.ts +9 -0
- package/dist/cli/gitignore.d.ts.map +1 -1
- package/dist/cli/gitignore.js +29 -0
- package/dist/cli/gitignore.js.map +1 -1
- package/dist/cli/plan.d.ts +3 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +288 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/engine.test.ts +1839 -70
- package/src/cli/convoy/engine.ts +2417 -38
- package/src/cli/convoy/events.test.ts +179 -38
- package/src/cli/convoy/events.ts +88 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +1512 -14
- package/src/cli/convoy/store.ts +676 -30
- package/src/cli/convoy/types.ts +170 -1
- package/src/cli/destroy.test.ts +141 -0
- package/src/cli/destroy.ts +88 -0
- package/src/cli/gitignore.ts +36 -0
- package/src/cli/plan.ts +316 -0
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- 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('
|
|
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
|
-
|
|
117
|
-
|
|
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).
|
package/src/cli/convoy/merge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|