dsh-tower 1.2.0 → 2.0.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 (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/lib/tests.js ADDED
@@ -0,0 +1,628 @@
1
+ // Tower test discovery (2.1): fail-closed admission evidence from the
2
+ // repository's own `package.json` `tower.testDiscovery` block.
3
+ //
4
+ // Trust boundary: every configured command executes repository-controlled code
5
+ // inside the mission worktree. Only enable discovery for repositories you
6
+ // trust (`tests.allowPackageDiscovery`, documented in the security guide).
7
+ // This module never touches the network and never mutates the state store.
8
+ //
9
+ // Contract (implemented exactly by the 2.1 plan):
10
+ // readDiscoveryConfig(pkgJsonText) -> { configured, config, error }
11
+ // normalizeDiscovery(config) -> { version, ok, commands, error }
12
+ // parseTap(stdout) -> { tests, ok, count, failed, topLevel, plan, error }
13
+ // parseJsonArray(stdout) -> { tests, ok, count, failed, error }
14
+ // runDiscovery(options) -> receipt (see below)
15
+ //
16
+ // Exit paths are intentionally split:
17
+ // - a test failure (nonzero exit, malformed output, timeout, oversize,
18
+ // minCount/required shortfall) returns `ok: false` in the receipt;
19
+ // - an invalid discovery configuration (or invalid call arguments) throws a
20
+ // plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
21
+ import { spawn } from 'node:child_process'
22
+ import { createWriteStream } from 'node:fs'
23
+ import crypto from 'node:crypto'
24
+ import fsp from 'node:fs/promises'
25
+ import path from 'node:path'
26
+
27
+ import { tryGit } from './git.js'
28
+
29
+ export const DISCOVERY_FORMATS = ['node-tap', 'json-array']
30
+ export const DEFAULT_TIMEOUT_MS = 60000
31
+ export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
32
+
33
+ const MAX_COMMANDS = 20
34
+ const MAX_COMMAND_LENGTH = 4000
35
+ const COMMAND_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
36
+ const PASS_STATUSES = new Set(['passed', 'pass', 'ok', 'success', 'succeeded', 'done', 'green'])
37
+ const FAIL_STATUSES = new Set(['failed', 'fail', 'error', 'errored', 'timeout', 'timed-out', 'blocked'])
38
+
39
+ function isPlainObject(value) {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value.trim().length > 0
45
+ }
46
+
47
+ function normalizePositiveInteger(value, name) {
48
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be an integer > 0`)
49
+ return value
50
+ }
51
+
52
+ function safeSegment(value) {
53
+ return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60) || 'command'
54
+ }
55
+
56
+ function stamp(date) {
57
+ return date.toISOString().replace(/[-:.]/g, '')
58
+ }
59
+
60
+ /**
61
+ * Read `tower.testDiscovery` from package.json text.
62
+ * Returns `{ configured, config, error }`; `configured:false` only means the
63
+ * repository did not opt in (compat), never that discovery succeeded.
64
+ */
65
+ export function readDiscoveryConfig(pkgJsonText) {
66
+ if (typeof pkgJsonText !== 'string' || pkgJsonText.trim().length === 0) {
67
+ return { configured: false, config: null, error: 'package.json is empty' }
68
+ }
69
+ let pkg
70
+ try {
71
+ pkg = JSON.parse(pkgJsonText)
72
+ } catch (error) {
73
+ return { configured: false, config: null, error: `package.json is not valid JSON: ${error.message}` }
74
+ }
75
+ if (!isPlainObject(pkg)) {
76
+ return { configured: false, config: null, error: 'package.json must contain a JSON object' }
77
+ }
78
+ const tower = isPlainObject(pkg.tower) ? pkg.tower : null
79
+ const discovery = tower ? tower.testDiscovery : undefined
80
+ if (discovery === undefined || discovery === null) {
81
+ return { configured: false, config: null, error: null }
82
+ }
83
+ return { configured: true, config: discovery, error: null }
84
+ }
85
+
86
+ /**
87
+ * Validate and normalize a `tower.testDiscovery` value (Tower Codex version 1):
88
+ * `{ version: 1, commands: [{ id, command, format, minCount?, required? }] }`.
89
+ * Returns `{ version, ok, commands, error }`; never throws.
90
+ * Defaults: `minCount: 1` (zero discovery is rejected unless explicitly relaxed),
91
+ * `required: []`. The normalized value is accepted again by this function.
92
+ */
93
+ export function normalizeDiscovery(config) {
94
+ const fail = (message) => ({ version: 1, ok: false, commands: [], error: message })
95
+ if (!isPlainObject(config)) return fail('tower.testDiscovery must be an object')
96
+ if (config.version !== 1) return fail('tower.testDiscovery.version must be one of: 1')
97
+ if (!Array.isArray(config.commands)) return fail('tower.testDiscovery.commands must be a non-empty array')
98
+ if (config.commands.length === 0) return fail('tower.testDiscovery.commands must be a non-empty array')
99
+ if (config.commands.length > MAX_COMMANDS) {
100
+ return fail(`tower.testDiscovery.commands must have at most ${MAX_COMMANDS} entries`)
101
+ }
102
+
103
+ const commands = []
104
+ const ids = new Set()
105
+ for (const [index, raw] of config.commands.entries()) {
106
+ const where = `tower.testDiscovery.commands[${index}]`
107
+ if (!isPlainObject(raw)) return fail(`${where} must be an object`)
108
+ if (!nonEmptyString(raw.id)) return fail(`${where}.id must be a non-empty string`)
109
+ const id = raw.id.trim()
110
+ if (!COMMAND_ID_PATTERN.test(id)) {
111
+ return fail(`${where}.id must match ${COMMAND_ID_PATTERN} (letters, digits, dot, dash, underscore)`)
112
+ }
113
+ if (ids.has(id)) return fail(`${where}.id must not duplicate command id "${id}"`)
114
+ ids.add(id)
115
+ if (!nonEmptyString(raw.command)) return fail(`${where}.command must be a non-empty string`)
116
+ const command = raw.command.trim()
117
+ if (command.length > MAX_COMMAND_LENGTH) {
118
+ return fail(`${where}.command must be at most ${MAX_COMMAND_LENGTH} characters`)
119
+ }
120
+ if (!DISCOVERY_FORMATS.includes(raw.format)) {
121
+ return fail(`${where}.format must be one of: ${DISCOVERY_FORMATS.join(', ')}`)
122
+ }
123
+ let minCount = 1
124
+ if (raw.minCount !== undefined) {
125
+ if (!Number.isInteger(raw.minCount) || raw.minCount < 0) {
126
+ return fail(`${where}.minCount must be an integer >= 0`)
127
+ }
128
+ minCount = raw.minCount
129
+ }
130
+ const required = []
131
+ if (raw.required !== undefined) {
132
+ if (!Array.isArray(raw.required)) return fail(`${where}.required must be an array of test names`)
133
+ for (const [requiredIndex, name] of raw.required.entries()) {
134
+ if (!nonEmptyString(name)) {
135
+ return fail(`${where}.required[${requiredIndex}] must be a non-empty string`)
136
+ }
137
+ const trimmed = name.trim()
138
+ if (required.includes(trimmed)) {
139
+ return fail(`${where}.required must not duplicate test name "${trimmed}"`)
140
+ }
141
+ required.push(trimmed)
142
+ }
143
+ }
144
+ commands.push({ id, command, format: raw.format, minCount, required })
145
+ }
146
+ return { version: 1, ok: true, commands, error: null }
147
+ }
148
+
149
+ /**
150
+ * Parse node-tap output. Counts every test point (nested `node --test` subtests
151
+ * are included) while validating the top-level TAP plan against top-level
152
+ * points only. Failures return `ok:false` plus an `error`; skip/TODO points are
153
+ * tolerated the way TAP defines them.
154
+ */
155
+ export function parseTap(stdout) {
156
+ const text = typeof stdout === 'string' ? stdout : ''
157
+ const points = []
158
+ let plan = null
159
+ let sawHeader = false
160
+ let bailout = false
161
+
162
+ for (const rawLine of text.split(/\r?\n/)) {
163
+ const line = rawLine.replace(/\r$/, '')
164
+ if (/^TAP version \d+\s*$/i.test(line)) {
165
+ sawHeader = true
166
+ continue
167
+ }
168
+ if (/^Bail out!/.test(line)) {
169
+ bailout = true
170
+ continue
171
+ }
172
+ // The `(?=\s|$)` lookahead keeps YAML diagnostics lines such as
173
+ // ` ok: true` from being mistaken for test points.
174
+ const point = /^(\s*)(not ok|ok)\b(?=\s|$)(?:\s+(\d+))?(?:\s*-\s*)?(.*)$/.exec(line)
175
+ if (point) {
176
+ const [, indent, marker, , rest] = point
177
+ let name = rest.trim()
178
+ let directive = null
179
+ const directiveMatch = /\s+#\s*(SKIP|TODO)\b.*$/i.exec(name)
180
+ if (directiveMatch) {
181
+ directive = directiveMatch[1].toUpperCase()
182
+ name = name.slice(0, directiveMatch.index).trim()
183
+ }
184
+ const rawOk = marker === 'ok'
185
+ points.push({ name, ok: rawOk || directive !== null, rawOk, topLevel: indent.length === 0, directive })
186
+ continue
187
+ }
188
+ const planMatch = /^(\s*)1\.\.(\d+)\s*(?:#.*)?$/.exec(line)
189
+ if (planMatch && planMatch[1].length === 0) plan = Number(planMatch[2])
190
+ }
191
+
192
+ const tests = points.map((point) => {
193
+ const entry = { name: point.name, ok: point.ok }
194
+ if (point.directive) entry.directive = point.directive
195
+ return entry
196
+ })
197
+ const topLevel = points.filter((point) => point.topLevel).length
198
+ const failed = tests.filter((test) => !test.ok).length
199
+ let error = null
200
+ if (bailout) error = 'TAP bail out'
201
+ else if (plan === null && points.length === 0) error = 'no TAP test points found'
202
+ else if (plan !== null && plan !== topLevel) {
203
+ error = `TAP plan says ${plan} but ${topLevel} top-level test point(s) were found`
204
+ }
205
+ return { tests, ok: error === null && failed === 0, count: tests.length, failed, topLevel, plan, error }
206
+ }
207
+
208
+ /**
209
+ * Parse the `json-array` format: a JSON array of test entries. Entries may be
210
+ * plain name strings or objects carrying `{ name|title|test, ok|pass|success|
211
+ * status|exitCode }`.
212
+ */
213
+ export function parseJsonArray(stdout) {
214
+ const text = typeof stdout === 'string' ? stdout.trim() : ''
215
+ const fail = (error) => ({ tests: [], ok: false, count: 0, failed: 0, error })
216
+ if (text.length === 0) return fail('empty output')
217
+ let parsed
218
+ try {
219
+ parsed = JSON.parse(text)
220
+ } catch (error) {
221
+ return fail(`output is not valid JSON: ${error.message}`)
222
+ }
223
+ if (!Array.isArray(parsed)) return fail('output must be a JSON array of tests')
224
+
225
+ const tests = []
226
+ for (const [index, entry] of parsed.entries()) {
227
+ if (typeof entry === 'string') {
228
+ const name = entry.trim()
229
+ if (name.length === 0) return fail(`entries[${index}] must be a non-empty test name`)
230
+ tests.push({ name, ok: true })
231
+ continue
232
+ }
233
+ if (!isPlainObject(entry)) return fail(`entries[${index}] must be an object or a test name string`)
234
+ const name = [entry.name, entry.title, entry.test].find(nonEmptyString)
235
+ if (!name) return fail(`entries[${index}] has no non-empty name`)
236
+ let ok
237
+ if (typeof entry.ok === 'boolean') ok = entry.ok
238
+ else if (typeof entry.pass === 'boolean') ok = entry.pass
239
+ else if (typeof entry.success === 'boolean') ok = entry.success
240
+ else if (typeof entry.status === 'string') {
241
+ const status = entry.status.trim().toLowerCase()
242
+ if (PASS_STATUSES.has(status)) ok = true
243
+ else if (FAIL_STATUSES.has(status)) ok = false
244
+ } else if (entry.exitCode !== undefined && entry.exitCode !== null) {
245
+ ok = Number(entry.exitCode) === 0
246
+ }
247
+ if (ok === undefined) {
248
+ return fail(`entries[${index}] must have a boolean ok/pass field or a pass/fail status`)
249
+ }
250
+ tests.push({ name: name.trim(), ok })
251
+ }
252
+ const failed = tests.filter((test) => !test.ok).length
253
+ return { tests, ok: failed === 0, count: tests.length, failed, error: null }
254
+ }
255
+
256
+ /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
257
+ function shellSpec(platform = process.platform) {
258
+ if (platform === 'win32') {
259
+ const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
260
+ return {
261
+ file: 'pwsh',
262
+ args,
263
+ fallback: { file: 'powershell', args },
264
+ }
265
+ }
266
+ return { file: 'bash', args: (command) => ['-lc', command], fallback: null }
267
+ }
268
+
269
+ function killTree(child) {
270
+ if (!child || !child.pid) return
271
+ if (process.platform === 'win32') {
272
+ try {
273
+ child.kill('SIGKILL')
274
+ } catch {
275
+ // already gone
276
+ }
277
+ try {
278
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
279
+ stdio: 'ignore',
280
+ windowsHide: true,
281
+ })
282
+ killer.on('error', () => {})
283
+ killer.unref()
284
+ } catch {
285
+ // best effort: the direct kill above already ended the shell
286
+ }
287
+ return
288
+ }
289
+ try {
290
+ process.kill(-child.pid, 'SIGKILL')
291
+ } catch {
292
+ try {
293
+ child.kill('SIGKILL')
294
+ } catch {
295
+ // already gone
296
+ }
297
+ }
298
+ }
299
+
300
+ function closeStreams(streams) {
301
+ return Promise.all(
302
+ streams.map(
303
+ (stream) =>
304
+ new Promise((resolve) => {
305
+ if (stream.closed || stream.destroyed) {
306
+ resolve()
307
+ return
308
+ }
309
+ stream.once('close', resolve)
310
+ stream.once('error', resolve)
311
+ stream.end()
312
+ }),
313
+ ),
314
+ )
315
+ }
316
+
317
+ /**
318
+ * Default runner: execute one command with the platform shell, streaming both
319
+ * outputs to the given files under a combined `maxBytes` cap. Resolves with
320
+ * `{ exitCode, signal, timedOut, overflow, spawnError, durationMs }`.
321
+ * Tests may inject their own `runner` with the same input/result shape.
322
+ */
323
+ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPath, shell }) {
324
+ const started = Date.now()
325
+ const stdout = createWriteStream(stdoutPath, { flags: 'w' })
326
+ const stderr = createWriteStream(stderrPath, { flags: 'w' })
327
+ for (const stream of [stdout, stderr]) stream.on('error', () => {})
328
+
329
+ return new Promise((resolve) => {
330
+ let settled = false
331
+ let child = null
332
+ let timer = null
333
+ let stdoutBytes = 0
334
+ let stderrBytes = 0
335
+ let overflow = false
336
+ let timedOut = false
337
+ let spawnError = null
338
+ let lastCode = null
339
+ let lastSignal = null
340
+
341
+ const result = () => ({ exitCode: lastCode, signal: lastSignal, timedOut, overflow, spawnError, durationMs: Date.now() - started })
342
+ const settle = () => {
343
+ if (settled) return
344
+ settled = true
345
+ if (timer) clearTimeout(timer)
346
+ closeStreams([stdout, stderr]).then(() => resolve(result()))
347
+ }
348
+ const stop = (reason) => {
349
+ if (reason === 'timeout') timedOut = true
350
+ else overflow = true
351
+ killTree(child)
352
+ }
353
+ // Each stream is capped independently at maxBytes (the configured
354
+ // "captured stdout/stderr bytes per command" budget).
355
+ const writeChunk = (stream, chunk, kind) => {
356
+ if (overflow || settled) return
357
+ const used = kind === 'stdout' ? stdoutBytes : stderrBytes
358
+ const remaining = maxBytes - used
359
+ if (remaining <= 0) {
360
+ stop('overflow')
361
+ return
362
+ }
363
+ if (chunk.length > remaining) {
364
+ stream.write(chunk.subarray(0, remaining))
365
+ if (kind === 'stdout') stdoutBytes = maxBytes
366
+ else stderrBytes = maxBytes
367
+ stop('overflow')
368
+ return
369
+ }
370
+ stream.write(chunk)
371
+ if (kind === 'stdout') stdoutBytes += chunk.length
372
+ else stderrBytes += chunk.length
373
+ }
374
+
375
+ try {
376
+ child = spawn(shell.file, shell.args(command), {
377
+ cwd,
378
+ stdio: ['ignore', 'pipe', 'pipe'],
379
+ windowsHide: true,
380
+ // POSIX: a process group lets a timeout kill grandchildren too.
381
+ detached: process.platform !== 'win32',
382
+ env: process.env,
383
+ })
384
+ } catch (error) {
385
+ spawnError = error
386
+ settle()
387
+ return
388
+ }
389
+
390
+ child.stdout?.on('data', (chunk) => writeChunk(stdout, chunk, 'stdout'))
391
+ child.stderr?.on('data', (chunk) => writeChunk(stderr, chunk, 'stderr'))
392
+ child.once('error', (error) => {
393
+ spawnError = error
394
+ settle()
395
+ })
396
+ child.once('close', (code, signal) => {
397
+ lastCode = typeof code === 'number' ? code : null
398
+ lastSignal = signal ?? null
399
+ settle()
400
+ })
401
+ timer = setTimeout(() => stop('timeout'), timeoutMs)
402
+ })
403
+ }
404
+
405
+ async function readFileOrEmpty(file) {
406
+ try {
407
+ return await fsp.readFile(file)
408
+ } catch {
409
+ return Buffer.alloc(0)
410
+ }
411
+ }
412
+
413
+ function sha256(buffer) {
414
+ return crypto.createHash('sha256').update(buffer).digest('hex')
415
+ }
416
+
417
+ async function readTreeTip(worktreePath) {
418
+ const tip = await tryGit(worktreePath, ['rev-parse', 'HEAD'])
419
+ return tip && tip.trim() ? tip.trim() : null
420
+ }
421
+
422
+ async function resolveCommonDir(worktreePath) {
423
+ const raw = await tryGit(worktreePath, ['rev-parse', '--git-common-dir'])
424
+ if (!raw || !raw.trim()) return null
425
+ return path.isAbsolute(raw.trim()) ? path.resolve(raw.trim()) : path.resolve(worktreePath, raw.trim())
426
+ }
427
+
428
+ async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }) {
429
+ if (diagnosticsDir) {
430
+ await fsp.mkdir(diagnosticsDir, { recursive: true })
431
+ return diagnosticsDir
432
+ }
433
+ const now = new Date()
434
+ const suffix = stamp(now)
435
+ const mission = safeSegment(missionId ?? path.basename(worktreePath))
436
+ const commonDir = await resolveCommonDir(worktreePath)
437
+ if (commonDir) {
438
+ const dir = path.join(commonDir, 'dsh-tower', 'test-runs', `${mission}-${suffix}`)
439
+ await fsp.mkdir(dir, { recursive: true })
440
+ return dir
441
+ }
442
+ const dir = path.join(worktreePath, '.tower-discovery-diagnostics', `${mission}-${suffix}`)
443
+ await fsp.mkdir(dir, { recursive: true })
444
+ return dir
445
+ }
446
+
447
+ function normalizeDiscoveryInput(config) {
448
+ if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
449
+ if (config.error) throw new Error(`test discovery configuration is invalid: ${config.error}`)
450
+ if (config.configured === false) return { configured: false, config: null, error: null }
451
+ return { configured: true, config: config.config, error: null }
452
+ }
453
+ if (config === undefined || config === null) return { configured: false, config: null, error: null }
454
+ return { configured: true, config, error: null }
455
+ }
456
+
457
+ /**
458
+ * Run the declared discovery commands inside `worktreePath` and return a
459
+ * fail-closed receipt:
460
+ *
461
+ * {
462
+ * configured, ok, commands: [{
463
+ * id, format, command, exitCode,
464
+ * stdoutPath, stderrPath, stdoutSha256, stderrSha256,
465
+ * count, requiredMissing, error, ok, durationMs
466
+ * }],
467
+ * tests: [{ name, ok }], treeTip, treeTipAfter,
468
+ * startedAt, finishedAt, diagnosticsPath, receiptPath, error
469
+ * }
470
+ *
471
+ * Options:
472
+ * - `diagnosticsDir` overrides where the receipt/stdout/stderr are written.
473
+ * When omitted, diagnostics go under
474
+ * `<git-common-dir>/dsh-tower/test-runs/<worktree>-<stamp>/` (2.1 spec) and
475
+ * fall back to `<worktree>/.tower-discovery-diagnostics/<stamp>/` when the
476
+ * worktree is not a Git repository.
477
+ * - `runner` replaces the default shell runner (same input/result shape);
478
+ * used by tests and by callers that already own process execution.
479
+ * - `missionId` only labels the fallback diagnostics path.
480
+ *
481
+ * Invalid call arguments and invalid discovery configurations throw; test
482
+ * failures never throw and are reported in the receipt.
483
+ */
484
+ export async function runDiscovery(options = {}) {
485
+ const {
486
+ worktreePath,
487
+ config,
488
+ timeoutMs = DEFAULT_TIMEOUT_MS,
489
+ maxBytes = DEFAULT_MAX_BYTES,
490
+ runner,
491
+ diagnosticsDir,
492
+ missionId,
493
+ } = options
494
+ const startedAt = new Date().toISOString()
495
+
496
+ if (!nonEmptyString(worktreePath)) throw new Error('worktreePath is required')
497
+ if (!path.isAbsolute(worktreePath)) throw new Error(`worktreePath must be an absolute path: ${worktreePath}`)
498
+ const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
499
+ const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
500
+ if (runner !== undefined && runner !== null && typeof runner !== 'function') {
501
+ throw new Error('runner must be a function')
502
+ }
503
+ if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
504
+ if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
505
+ throw new Error(`diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
506
+ }
507
+ }
508
+
509
+ const discovery = normalizeDiscoveryInput(config)
510
+ const treeTip = await readTreeTip(worktreePath)
511
+ const finishUnconfigured = (error) => ({
512
+ configured: false,
513
+ ok: false,
514
+ commands: [],
515
+ tests: [],
516
+ treeTip,
517
+ treeTipAfter: treeTip,
518
+ startedAt,
519
+ finishedAt: new Date().toISOString(),
520
+ diagnosticsPath: null,
521
+ receiptPath: null,
522
+ error: error ?? 'test discovery is not configured',
523
+ })
524
+
525
+ if (!discovery.configured) return finishUnconfigured(discovery.error)
526
+ const normalized = normalizeDiscovery(discovery.config)
527
+ if (!normalized.ok) throw new Error(`invalid tower.testDiscovery config: ${normalized.error}`)
528
+
529
+ const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
530
+ const commands = []
531
+ const tests = []
532
+ const failures = []
533
+ const shell = shellSpec()
534
+
535
+ for (const [index, command] of normalized.commands.entries()) {
536
+ const base = `${String(index + 1).padStart(2, '0')}-${safeSegment(command.id)}`
537
+ const stdoutPath = path.join(dir, `${base}.stdout.log`)
538
+ const stderrPath = path.join(dir, `${base}.stderr.log`)
539
+ const runStarted = Date.now()
540
+
541
+ let result
542
+ try {
543
+ const payload = {
544
+ command: command.command,
545
+ cwd: worktreePath,
546
+ timeoutMs: effectiveTimeout,
547
+ maxBytes: effectiveMaxBytes,
548
+ stdoutPath,
549
+ stderrPath,
550
+ shell,
551
+ }
552
+ if (runner) {
553
+ result = await runner(payload)
554
+ } else {
555
+ result = await spawnToFiles(payload)
556
+ if (result.spawnError?.code === 'ENOENT' && shell.fallback) {
557
+ result = await spawnToFiles({ ...payload, shell: shell.fallback })
558
+ }
559
+ }
560
+ } catch (error) {
561
+ result = { exitCode: null, signal: null, timedOut: false, overflow: false, spawnError: error, durationMs: Date.now() - runStarted }
562
+ }
563
+
564
+ const stdout = await readFileOrEmpty(stdoutPath)
565
+ const stderr = await readFileOrEmpty(stderrPath)
566
+ let parsed = null
567
+ let error = null
568
+ let requiredMissing = []
569
+
570
+ if (result.spawnError) {
571
+ error = `spawn failed: ${result.spawnError.message}`
572
+ } else if (result.timedOut) {
573
+ error = `timed out after ${effectiveTimeout}ms`
574
+ } else if (result.overflow) {
575
+ error = `output exceeded ${effectiveMaxBytes} bytes`
576
+ } else if (result.exitCode !== 0) {
577
+ error = `exit code ${result.exitCode ?? 'unknown'}`
578
+ } else {
579
+ parsed = command.format === 'node-tap' ? parseTap(stdout.toString('utf8')) : parseJsonArray(stdout.toString('utf8'))
580
+ requiredMissing = command.required.filter((name) => !parsed.tests.some((test) => test.name === name))
581
+ if (parsed.error) error = `malformed-output: ${parsed.error}`
582
+ else if (parsed.failed > 0) error = `${parsed.failed} test(s) failed`
583
+ else if (parsed.count < command.minCount) error = `found ${parsed.count} test(s), below minCount ${command.minCount}`
584
+ else if (requiredMissing.length > 0) error = `missing required test(s): ${requiredMissing.join(', ')}`
585
+ }
586
+
587
+ if (parsed) tests.push(...parsed.tests)
588
+ commands.push({
589
+ id: command.id,
590
+ format: command.format,
591
+ command: command.command,
592
+ exitCode: result.exitCode ?? null,
593
+ stdoutPath,
594
+ stderrPath,
595
+ stdoutSha256: sha256(stdout),
596
+ stderrSha256: sha256(stderr),
597
+ count: parsed ? parsed.count : 0,
598
+ requiredMissing,
599
+ error,
600
+ ok: error === null,
601
+ durationMs: Date.now() - runStarted,
602
+ })
603
+ if (error) failures.push(`${command.id}: ${error}`)
604
+ }
605
+
606
+ const treeTipAfter = await readTreeTip(worktreePath)
607
+ if (treeTip && treeTipAfter && treeTip !== treeTipAfter) {
608
+ failures.push(`worktree HEAD moved during test discovery (${treeTip} -> ${treeTipAfter})`)
609
+ }
610
+ if (tests.length === 0) failures.push('test discovery produced zero tests')
611
+
612
+ const receiptPath = path.join(dir, 'receipt.json')
613
+ const receipt = {
614
+ configured: true,
615
+ ok: failures.length === 0,
616
+ commands,
617
+ tests,
618
+ treeTip,
619
+ treeTipAfter,
620
+ startedAt,
621
+ finishedAt: new Date().toISOString(),
622
+ diagnosticsPath: dir,
623
+ receiptPath,
624
+ error: failures.length > 0 ? failures.join('; ') : null,
625
+ }
626
+ await fsp.writeFile(receiptPath, JSON.stringify(receipt, null, 2), 'utf8')
627
+ return receipt
628
+ }