taskplane 0.28.4 → 0.28.6

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,542 +1,542 @@
1
- /**
2
- * Verification baseline fingerprinting system.
3
- *
4
- * Captures test output before and after merge, parses it into normalized
5
- * fingerprints, and diffs to identify genuinely new failures vs pre-existing ones.
6
- *
7
- * Design notes:
8
- *
9
- * **Runner result schema:** Each command produces a CommandResult with:
10
- * - commandId: string key from testing.commands config
11
- * - exitCode: number (process exit code, -1 for spawn errors)
12
- * - stdout: string (captured raw stdout)
13
- * - stderr: string (captured raw stderr)
14
- * - durationMs: number
15
- * - error: string | null (spawn/timeout error message)
16
- *
17
- * **Fingerprint equality key:** Composite of all five fields joined by \0:
18
- * `${commandId}\0${file}\0${case}\0${kind}\0${messageNorm}`
19
- * Duplicates within a single run are collapsed before diffing.
20
- *
21
- * **messageNorm normalization rules:**
22
- * 1. Strip ANSI escape sequences
23
- * 2. Normalize path separators (backslash → forward slash)
24
- * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
25
- * 4. Remove ISO-8601 timestamps
26
- * 5. Collapse whitespace (runs of space/tab/newline → single space, then trim)
27
- * 6. Truncate to 512 chars (bound fingerprint size)
28
- *
29
- * **Fallback for non-JSON output:**
30
- * If legacy Vitest JSON parsing fails (truncated, missing, non-JSON), produce
31
- * a single fingerprint with kind: "command_error" and the first 512 chars
32
- * of stderr (or stdout) as messageNorm.
33
- *
34
- * **Compatibility note:**
35
- * Taskplane's default tests use Node.js native `node:test`. The Vitest parser
36
- * in this module is retained only for backward compatibility when projects
37
- * provide custom `testing.commands` that still emit Vitest JSON.
38
- *
39
- * @module orch/verification
40
- */
41
- import { spawnSync } from "child_process";
42
-
43
- // ── Types ────────────────────────────────────────────────────────────
44
-
45
- /**
46
- * A configured verification command from testing.commands config.
47
- */
48
- export interface VerificationCommand {
49
- /** Stable key from config (e.g., "test", "build") — used as commandId */
50
- id: string;
51
- /** Shell command string to execute */
52
- command: string;
53
- }
54
-
55
- /**
56
- * Result of running a single verification command.
57
- */
58
- export interface CommandResult {
59
- /** Key from testing.commands config (e.g., "test", "build") */
60
- commandId: string;
61
- /** Process exit code. -1 for spawn/timeout errors. */
62
- exitCode: number;
63
- /** Captured stdout */
64
- stdout: string;
65
- /** Captured stderr */
66
- stderr: string;
67
- /** Wall-clock duration in milliseconds */
68
- durationMs: number;
69
- /** Error message if command failed to spawn or timed out; null otherwise */
70
- error: string | null;
71
- }
72
-
73
- /**
74
- * Normalized test fingerprint identifying a single test outcome.
75
- *
76
- * Equality is determined by ALL five fields — the composite key.
77
- */
78
- export interface TestFingerprint {
79
- /** Command that produced this result (key from testing.commands) */
80
- commandId: string;
81
- /** Source file path (normalized to forward slashes) */
82
- file: string;
83
- /** Test case full name (describe > it chain) */
84
- case: string;
85
- /** Failure classification */
86
- kind: "assertion_error" | "runtime_error" | "timeout" | "command_error" | "unknown";
87
- /** Normalized failure message (see normalization rules in module doc) */
88
- messageNorm: string;
89
- }
90
-
91
- /**
92
- * A captured verification baseline or post-merge snapshot.
93
- */
94
- export interface VerificationBaseline {
95
- /** When this baseline was captured (ISO 8601) */
96
- capturedAt: string;
97
- /** Command results (one per configured command) */
98
- commandResults: CommandResult[];
99
- /** Deduplicated fingerprints extracted from all command results */
100
- fingerprints: TestFingerprint[];
101
- }
102
-
103
- /**
104
- * Result of diffing two fingerprint sets.
105
- */
106
- export interface FingerprintDiff {
107
- /** Failures present in postMerge but not in baseline */
108
- newFailures: TestFingerprint[];
109
- /** Failures present in both baseline and postMerge (pre-existing) */
110
- preExisting: TestFingerprint[];
111
- /** Failures in baseline that disappeared in postMerge (fixed) */
112
- fixed: TestFingerprint[];
113
- }
114
-
115
-
116
- // ── Normalization Helpers ────────────────────────────────────────────
117
-
118
- /** Max length for normalized message strings */
119
- const MESSAGE_NORM_MAX_LENGTH = 512;
120
-
121
- // eslint-disable-next-line no-control-regex
122
- const ANSI_REGEX = /[\u001b\u009b]\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]/g;
123
-
124
- /** Match duration strings like (42ms), (1.2s), (3m 12s), 42 ms, 1200ms */
125
- const DURATION_REGEX = /\(?\d+(?:\.\d+)?\s*(?:ms|s|m)\s*(?:\d+(?:\.\d+)?\s*(?:ms|s))?\)?/g;
126
-
127
- /** Match ISO-8601 timestamps like 2026-03-20T12:34:56.789Z */
128
- const TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g;
129
-
130
- /**
131
- * Normalize a failure message for stable fingerprinting.
132
- *
133
- * 1. Strip ANSI escape sequences
134
- * 2. Normalize path separators (\ → /)
135
- * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
136
- * 4. Remove ISO-8601 timestamps
137
- * 5. Collapse whitespace
138
- * 6. Truncate to MESSAGE_NORM_MAX_LENGTH
139
- */
140
- export function normalizeMessage(raw: string): string {
141
- let msg = raw;
142
- // 1. Strip ANSI
143
- msg = msg.replace(ANSI_REGEX, "");
144
- // 2. Normalize path separators
145
- msg = msg.replace(/\\/g, "/");
146
- // 3. Remove duration strings
147
- msg = msg.replace(DURATION_REGEX, "");
148
- // 4. Remove ISO-8601 timestamps
149
- msg = msg.replace(TIMESTAMP_REGEX, "");
150
- // 5. Collapse whitespace
151
- msg = msg.replace(/\s+/g, " ").trim();
152
- // 6. Truncate
153
- if (msg.length > MESSAGE_NORM_MAX_LENGTH) {
154
- msg = msg.slice(0, MESSAGE_NORM_MAX_LENGTH);
155
- }
156
- return msg;
157
- }
158
-
159
- /**
160
- * Normalize a file path for stable fingerprinting.
161
- * Converts backslashes to forward slashes.
162
- */
163
- export function normalizeFilePath(raw: string): string {
164
- return raw.replace(/\\/g, "/");
165
- }
166
-
167
- /**
168
- * Compute a stable string key for a fingerprint used in set operations.
169
- * Fields joined by null byte (unlikely in test output).
170
- */
171
- export function fingerprintKey(fp: TestFingerprint): string {
172
- return `${fp.commandId}\0${fp.file}\0${fp.case}\0${fp.kind}\0${fp.messageNorm}`;
173
- }
174
-
175
-
176
- // ── Command Runner ───────────────────────────────────────────────────
177
-
178
- /** Default timeout for verification commands: 5 minutes */
179
- const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
180
-
181
- /**
182
- * Run configured verification commands and return per-command results.
183
- *
184
- * Commands are iterated in deterministic insertion order of the
185
- * `testing.commands` config map. Each command runs synchronously in
186
- * the specified working directory (typically the merge worktree).
187
- *
188
- * @param commands - Map of commandId → shell command string (from testing.commands config)
189
- * @param cwd - Working directory to run commands in
190
- * @param timeoutMs - Per-command timeout in milliseconds (default: 5 min)
191
- * @returns Array of CommandResult in config iteration order
192
- */
193
- export function runVerificationCommands(
194
- commands: Record<string, string>,
195
- cwd: string,
196
- timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
197
- ): CommandResult[] {
198
- const results: CommandResult[] = [];
199
-
200
- for (const [commandId, command] of Object.entries(commands)) {
201
- const start = Date.now();
202
- try {
203
- const isWindows = process.platform === "win32";
204
- const shell = isWindows ? "cmd" : "/bin/sh";
205
- const shellArgs = isWindows ? ["/c", command] : ["-c", command];
206
-
207
- const proc = spawnSync(shell, shellArgs, {
208
- cwd,
209
- encoding: "utf-8",
210
- timeout: timeoutMs,
211
- stdio: ["pipe", "pipe", "pipe"],
212
- // Ensure child processes don't inherit stdin
213
- env: { ...process.env },
214
- });
215
-
216
- const durationMs = Date.now() - start;
217
-
218
- if (proc.error) {
219
- // Spawn error or timeout
220
- const isTimeout = (proc.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
221
- results.push({
222
- commandId,
223
- exitCode: -1,
224
- stdout: proc.stdout || "",
225
- stderr: proc.stderr || "",
226
- durationMs,
227
- error: isTimeout
228
- ? `Command timed out after ${timeoutMs}ms`
229
- : `Spawn error: ${proc.error.message}`,
230
- });
231
- } else {
232
- results.push({
233
- commandId,
234
- exitCode: proc.status ?? -1,
235
- stdout: proc.stdout || "",
236
- stderr: proc.stderr || "",
237
- durationMs,
238
- error: null,
239
- });
240
- }
241
- } catch (err: unknown) {
242
- const durationMs = Date.now() - start;
243
- const message = err instanceof Error ? err.message : String(err);
244
- results.push({
245
- commandId,
246
- exitCode: -1,
247
- stdout: "",
248
- stderr: "",
249
- durationMs,
250
- error: `Unexpected error: ${message}`,
251
- });
252
- }
253
- }
254
-
255
- return results;
256
- }
257
-
258
-
259
- // ── Test Output Parsers ──────────────────────────────────────────────
260
-
261
- /**
262
- * Vitest JSON reporter output shape (subset of fields we care about).
263
- */
264
- interface VitestJsonResult {
265
- testResults?: Array<{
266
- name?: string;
267
- status?: string;
268
- message?: string;
269
- assertionResults?: Array<{
270
- fullName?: string;
271
- status?: string;
272
- failureMessages?: string[];
273
- }>;
274
- }>;
275
- }
276
-
277
- /**
278
- * Classify a failure message into a kind.
279
- */
280
- function classifyFailureKind(message: string): TestFingerprint["kind"] {
281
- const lower = message.toLowerCase();
282
- if (lower.includes("timeout") || lower.includes("timed out")) {
283
- return "timeout";
284
- }
285
- if (
286
- lower.includes("assert") ||
287
- lower.includes("expect") ||
288
- lower.includes("tobe") ||
289
- lower.includes("toequal") ||
290
- lower.includes("tohave")
291
- ) {
292
- return "assertion_error";
293
- }
294
- if (
295
- lower.includes("referenceerror") ||
296
- lower.includes("typeerror") ||
297
- lower.includes("syntaxerror") ||
298
- lower.includes("cannot find module") ||
299
- lower.includes("is not defined") ||
300
- lower.includes("is not a function")
301
- ) {
302
- return "runtime_error";
303
- }
304
- return "unknown";
305
- }
306
-
307
- /**
308
- * Parse legacy Vitest JSON reporter output into test fingerprints.
309
- *
310
- * Expects stdout to contain a JSON object matching Vitest's JSON reporter format.
311
- * Only failed tests produce fingerprints (passed tests are irrelevant for baseline diffing).
312
- *
313
- * If JSON parsing fails or the structure is unexpected, returns null to signal
314
- * that the caller should use fallback fingerprinting.
315
- *
316
- * @param commandId - The command that produced this output
317
- * @param stdout - Raw stdout from the Vitest command (legacy compatibility path)
318
- * @returns Array of fingerprints for failed tests, or null if parsing fails
319
- */
320
- export function parseVitestOutput(commandId: string, stdout: string): TestFingerprint[] | null {
321
- // Try to extract JSON from stdout (Vitest may prepend/append non-JSON lines)
322
- let json: VitestJsonResult;
323
- try {
324
- // First attempt: parse the whole stdout as JSON
325
- json = JSON.parse(stdout);
326
- } catch {
327
- // Second attempt: find the first { and last } to extract JSON block
328
- const firstBrace = stdout.indexOf("{");
329
- const lastBrace = stdout.lastIndexOf("}");
330
- if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
331
- return null;
332
- }
333
- try {
334
- json = JSON.parse(stdout.slice(firstBrace, lastBrace + 1));
335
- } catch {
336
- return null;
337
- }
338
- }
339
-
340
- if (!json || !Array.isArray(json.testResults)) {
341
- return null;
342
- }
343
-
344
- const fingerprints: TestFingerprint[] = [];
345
-
346
- for (const testFile of json.testResults) {
347
- const file = normalizeFilePath(testFile.name || "unknown");
348
- const assertions = testFile.assertionResults;
349
- const hasAssertions = Array.isArray(assertions) && assertions.length > 0;
350
-
351
- if (hasAssertions) {
352
- for (const assertion of assertions!) {
353
- // Only fingerprint failures
354
- if (assertion.status !== "failed") continue;
355
-
356
- const caseName = assertion.fullName || "unknown";
357
- const messages = assertion.failureMessages || [];
358
- const rawMessage = messages.join("\n") || "no failure message";
359
-
360
- fingerprints.push({
361
- commandId,
362
- file,
363
- case: caseName,
364
- kind: classifyFailureKind(rawMessage),
365
- messageNorm: normalizeMessage(rawMessage),
366
- });
367
- }
368
- }
369
-
370
- // Suite-level failures: testResults[].status === "failed" with no assertion-level details.
371
- // This covers setup/import/runtime-at-file-load errors where Vitest marks the file as
372
- // failed but produces no assertionResults (or only non-failed ones).
373
- if (testFile.status === "failed") {
374
- const hasFailedAssertions = hasAssertions && assertions!.some(a => a.status === "failed");
375
- if (!hasFailedAssertions) {
376
- // No assertion-level failures captured — emit suite-level runtime_error fingerprint
377
- const suiteMessage = testFile.message || "Suite failed with no message";
378
- fingerprints.push({
379
- commandId,
380
- file,
381
- case: "<suite>",
382
- kind: "runtime_error",
383
- messageNorm: normalizeMessage(suiteMessage),
384
- });
385
- }
386
- }
387
- }
388
-
389
- return fingerprints;
390
- }
391
-
392
- /**
393
- * Parse test output into normalized fingerprints.
394
- *
395
- * Strategy:
396
- * 1. Try legacy Vitest JSON adapter
397
- * 2. If parsing fails: produce a fallback command_error fingerprint
398
- *
399
- * The adapter pattern is extensible — future parsers for jest, pytest, etc.
400
- * can be added here as additional try paths before the fallback.
401
- *
402
- * @param commandResult - Result from runVerificationCommands
403
- * @returns Array of fingerprints (always non-empty for failed commands)
404
- */
405
- export function parseTestOutput(commandResult: CommandResult): TestFingerprint[] {
406
- const { commandId, exitCode, stdout, stderr, error } = commandResult;
407
-
408
- // If command had a spawn/timeout error, produce a command_error fingerprint
409
- if (error) {
410
- return [{
411
- commandId,
412
- file: "",
413
- case: "",
414
- kind: "command_error",
415
- messageNorm: normalizeMessage(error),
416
- }];
417
- }
418
-
419
- // If exit code is 0, no failures to fingerprint
420
- if (exitCode === 0) {
421
- return [];
422
- }
423
-
424
- // Try legacy Vitest JSON adapter
425
- const vitestFingerprints = parseVitestOutput(commandId, stdout);
426
- if (vitestFingerprints !== null && vitestFingerprints.length > 0) {
427
- return vitestFingerprints;
428
- }
429
-
430
- // Vitest JSON parsed successfully but produced zero fingerprints with non-zero exit.
431
- // This can happen if the JSON structure is valid but contains no failure details
432
- // we could extract. Fall through to the generic fallback below.
433
-
434
- // Fallback: command_error fingerprint with stderr (or stdout if stderr is empty)
435
- const fallbackMessage = stderr.trim() || stdout.trim() || "Command failed with no output";
436
- return [{
437
- commandId,
438
- file: "",
439
- case: "",
440
- kind: "command_error",
441
- messageNorm: normalizeMessage(fallbackMessage),
442
- }];
443
- }
444
-
445
-
446
- // ── Fingerprint Diffing ──────────────────────────────────────────────
447
-
448
- /**
449
- * Deduplicate fingerprints by their composite key.
450
- * Preserves the first occurrence of each unique fingerprint.
451
- */
452
- export function deduplicateFingerprints(fingerprints: TestFingerprint[]): TestFingerprint[] {
453
- const seen = new Set<string>();
454
- const result: TestFingerprint[] = [];
455
-
456
- for (const fp of fingerprints) {
457
- const key = fingerprintKey(fp);
458
- if (!seen.has(key)) {
459
- seen.add(key);
460
- result.push(fp);
461
- }
462
- }
463
-
464
- return result;
465
- }
466
-
467
- /**
468
- * Diff two fingerprint sets to identify new failures, pre-existing failures, and fixes.
469
- *
470
- * Uses set-based comparison on the composite fingerprint key.
471
- * Both sets are deduplicated before comparison.
472
- *
473
- * @param baseline - Fingerprints from pre-merge verification run
474
- * @param postMerge - Fingerprints from post-merge verification run
475
- * @returns FingerprintDiff with new failures, pre-existing, and fixed sets
476
- */
477
- export function diffFingerprints(
478
- baseline: TestFingerprint[],
479
- postMerge: TestFingerprint[],
480
- ): FingerprintDiff {
481
- const dedupBaseline = deduplicateFingerprints(baseline);
482
- const dedupPostMerge = deduplicateFingerprints(postMerge);
483
-
484
- const baselineKeys = new Set(dedupBaseline.map(fingerprintKey));
485
- const postMergeKeys = new Set(dedupPostMerge.map(fingerprintKey));
486
-
487
- const newFailures: TestFingerprint[] = [];
488
- const preExisting: TestFingerprint[] = [];
489
- const fixed: TestFingerprint[] = [];
490
-
491
- // Classify post-merge fingerprints
492
- for (const fp of dedupPostMerge) {
493
- const key = fingerprintKey(fp);
494
- if (baselineKeys.has(key)) {
495
- preExisting.push(fp);
496
- } else {
497
- newFailures.push(fp);
498
- }
499
- }
500
-
501
- // Find fixed: in baseline but not in post-merge
502
- for (const fp of dedupBaseline) {
503
- const key = fingerprintKey(fp);
504
- if (!postMergeKeys.has(key)) {
505
- fixed.push(fp);
506
- }
507
- }
508
-
509
- return { newFailures, preExisting, fixed };
510
- }
511
-
512
-
513
- // ── Baseline Capture ─────────────────────────────────────────────────
514
-
515
- /**
516
- * Run verification commands and capture a complete baseline snapshot.
517
- *
518
- * @param commands - Map of commandId → shell command string
519
- * @param cwd - Working directory (merge worktree)
520
- * @param timeoutMs - Per-command timeout
521
- * @returns VerificationBaseline with command results and extracted fingerprints
522
- */
523
- export function captureBaseline(
524
- commands: Record<string, string>,
525
- cwd: string,
526
- timeoutMs?: number,
527
- ): VerificationBaseline {
528
- const commandResults = runVerificationCommands(commands, cwd, timeoutMs);
529
-
530
- // Extract fingerprints from all command results
531
- const allFingerprints: TestFingerprint[] = [];
532
- for (const result of commandResults) {
533
- const fps = parseTestOutput(result);
534
- allFingerprints.push(...fps);
535
- }
536
-
537
- return {
538
- capturedAt: new Date().toISOString(),
539
- commandResults,
540
- fingerprints: deduplicateFingerprints(allFingerprints),
541
- };
542
- }
1
+ /**
2
+ * Verification baseline fingerprinting system.
3
+ *
4
+ * Captures test output before and after merge, parses it into normalized
5
+ * fingerprints, and diffs to identify genuinely new failures vs pre-existing ones.
6
+ *
7
+ * Design notes:
8
+ *
9
+ * **Runner result schema:** Each command produces a CommandResult with:
10
+ * - commandId: string key from testing.commands config
11
+ * - exitCode: number (process exit code, -1 for spawn errors)
12
+ * - stdout: string (captured raw stdout)
13
+ * - stderr: string (captured raw stderr)
14
+ * - durationMs: number
15
+ * - error: string | null (spawn/timeout error message)
16
+ *
17
+ * **Fingerprint equality key:** Composite of all five fields joined by \0:
18
+ * `${commandId}\0${file}\0${case}\0${kind}\0${messageNorm}`
19
+ * Duplicates within a single run are collapsed before diffing.
20
+ *
21
+ * **messageNorm normalization rules:**
22
+ * 1. Strip ANSI escape sequences
23
+ * 2. Normalize path separators (backslash → forward slash)
24
+ * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
25
+ * 4. Remove ISO-8601 timestamps
26
+ * 5. Collapse whitespace (runs of space/tab/newline → single space, then trim)
27
+ * 6. Truncate to 512 chars (bound fingerprint size)
28
+ *
29
+ * **Fallback for non-JSON output:**
30
+ * If legacy Vitest JSON parsing fails (truncated, missing, non-JSON), produce
31
+ * a single fingerprint with kind: "command_error" and the first 512 chars
32
+ * of stderr (or stdout) as messageNorm.
33
+ *
34
+ * **Compatibility note:**
35
+ * Taskplane's default tests use Node.js native `node:test`. The Vitest parser
36
+ * in this module is retained only for backward compatibility when projects
37
+ * provide custom `testing.commands` that still emit Vitest JSON.
38
+ *
39
+ * @module orch/verification
40
+ */
41
+ import { spawnSync } from "child_process";
42
+
43
+ // ── Types ────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A configured verification command from testing.commands config.
47
+ */
48
+ export interface VerificationCommand {
49
+ /** Stable key from config (e.g., "test", "build") — used as commandId */
50
+ id: string;
51
+ /** Shell command string to execute */
52
+ command: string;
53
+ }
54
+
55
+ /**
56
+ * Result of running a single verification command.
57
+ */
58
+ export interface CommandResult {
59
+ /** Key from testing.commands config (e.g., "test", "build") */
60
+ commandId: string;
61
+ /** Process exit code. -1 for spawn/timeout errors. */
62
+ exitCode: number;
63
+ /** Captured stdout */
64
+ stdout: string;
65
+ /** Captured stderr */
66
+ stderr: string;
67
+ /** Wall-clock duration in milliseconds */
68
+ durationMs: number;
69
+ /** Error message if command failed to spawn or timed out; null otherwise */
70
+ error: string | null;
71
+ }
72
+
73
+ /**
74
+ * Normalized test fingerprint identifying a single test outcome.
75
+ *
76
+ * Equality is determined by ALL five fields — the composite key.
77
+ */
78
+ export interface TestFingerprint {
79
+ /** Command that produced this result (key from testing.commands) */
80
+ commandId: string;
81
+ /** Source file path (normalized to forward slashes) */
82
+ file: string;
83
+ /** Test case full name (describe > it chain) */
84
+ case: string;
85
+ /** Failure classification */
86
+ kind: "assertion_error" | "runtime_error" | "timeout" | "command_error" | "unknown";
87
+ /** Normalized failure message (see normalization rules in module doc) */
88
+ messageNorm: string;
89
+ }
90
+
91
+ /**
92
+ * A captured verification baseline or post-merge snapshot.
93
+ */
94
+ export interface VerificationBaseline {
95
+ /** When this baseline was captured (ISO 8601) */
96
+ capturedAt: string;
97
+ /** Command results (one per configured command) */
98
+ commandResults: CommandResult[];
99
+ /** Deduplicated fingerprints extracted from all command results */
100
+ fingerprints: TestFingerprint[];
101
+ }
102
+
103
+ /**
104
+ * Result of diffing two fingerprint sets.
105
+ */
106
+ export interface FingerprintDiff {
107
+ /** Failures present in postMerge but not in baseline */
108
+ newFailures: TestFingerprint[];
109
+ /** Failures present in both baseline and postMerge (pre-existing) */
110
+ preExisting: TestFingerprint[];
111
+ /** Failures in baseline that disappeared in postMerge (fixed) */
112
+ fixed: TestFingerprint[];
113
+ }
114
+
115
+
116
+ // ── Normalization Helpers ────────────────────────────────────────────
117
+
118
+ /** Max length for normalized message strings */
119
+ const MESSAGE_NORM_MAX_LENGTH = 512;
120
+
121
+ // eslint-disable-next-line no-control-regex
122
+ const ANSI_REGEX = /[\u001b\u009b]\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]/g;
123
+
124
+ /** Match duration strings like (42ms), (1.2s), (3m 12s), 42 ms, 1200ms */
125
+ const DURATION_REGEX = /\(?\d+(?:\.\d+)?\s*(?:ms|s|m)\s*(?:\d+(?:\.\d+)?\s*(?:ms|s))?\)?/g;
126
+
127
+ /** Match ISO-8601 timestamps like 2026-03-20T12:34:56.789Z */
128
+ const TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g;
129
+
130
+ /**
131
+ * Normalize a failure message for stable fingerprinting.
132
+ *
133
+ * 1. Strip ANSI escape sequences
134
+ * 2. Normalize path separators (\ → /)
135
+ * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
136
+ * 4. Remove ISO-8601 timestamps
137
+ * 5. Collapse whitespace
138
+ * 6. Truncate to MESSAGE_NORM_MAX_LENGTH
139
+ */
140
+ export function normalizeMessage(raw: string): string {
141
+ let msg = raw;
142
+ // 1. Strip ANSI
143
+ msg = msg.replace(ANSI_REGEX, "");
144
+ // 2. Normalize path separators
145
+ msg = msg.replace(/\\/g, "/");
146
+ // 3. Remove duration strings
147
+ msg = msg.replace(DURATION_REGEX, "");
148
+ // 4. Remove ISO-8601 timestamps
149
+ msg = msg.replace(TIMESTAMP_REGEX, "");
150
+ // 5. Collapse whitespace
151
+ msg = msg.replace(/\s+/g, " ").trim();
152
+ // 6. Truncate
153
+ if (msg.length > MESSAGE_NORM_MAX_LENGTH) {
154
+ msg = msg.slice(0, MESSAGE_NORM_MAX_LENGTH);
155
+ }
156
+ return msg;
157
+ }
158
+
159
+ /**
160
+ * Normalize a file path for stable fingerprinting.
161
+ * Converts backslashes to forward slashes.
162
+ */
163
+ export function normalizeFilePath(raw: string): string {
164
+ return raw.replace(/\\/g, "/");
165
+ }
166
+
167
+ /**
168
+ * Compute a stable string key for a fingerprint used in set operations.
169
+ * Fields joined by null byte (unlikely in test output).
170
+ */
171
+ export function fingerprintKey(fp: TestFingerprint): string {
172
+ return `${fp.commandId}\0${fp.file}\0${fp.case}\0${fp.kind}\0${fp.messageNorm}`;
173
+ }
174
+
175
+
176
+ // ── Command Runner ───────────────────────────────────────────────────
177
+
178
+ /** Default timeout for verification commands: 5 minutes */
179
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
180
+
181
+ /**
182
+ * Run configured verification commands and return per-command results.
183
+ *
184
+ * Commands are iterated in deterministic insertion order of the
185
+ * `testing.commands` config map. Each command runs synchronously in
186
+ * the specified working directory (typically the merge worktree).
187
+ *
188
+ * @param commands - Map of commandId → shell command string (from testing.commands config)
189
+ * @param cwd - Working directory to run commands in
190
+ * @param timeoutMs - Per-command timeout in milliseconds (default: 5 min)
191
+ * @returns Array of CommandResult in config iteration order
192
+ */
193
+ export function runVerificationCommands(
194
+ commands: Record<string, string>,
195
+ cwd: string,
196
+ timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
197
+ ): CommandResult[] {
198
+ const results: CommandResult[] = [];
199
+
200
+ for (const [commandId, command] of Object.entries(commands)) {
201
+ const start = Date.now();
202
+ try {
203
+ const isWindows = process.platform === "win32";
204
+ const shell = isWindows ? "cmd" : "/bin/sh";
205
+ const shellArgs = isWindows ? ["/c", command] : ["-c", command];
206
+
207
+ const proc = spawnSync(shell, shellArgs, {
208
+ cwd,
209
+ encoding: "utf-8",
210
+ timeout: timeoutMs,
211
+ stdio: ["pipe", "pipe", "pipe"],
212
+ // Ensure child processes don't inherit stdin
213
+ env: { ...process.env },
214
+ });
215
+
216
+ const durationMs = Date.now() - start;
217
+
218
+ if (proc.error) {
219
+ // Spawn error or timeout
220
+ const isTimeout = (proc.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
221
+ results.push({
222
+ commandId,
223
+ exitCode: -1,
224
+ stdout: proc.stdout || "",
225
+ stderr: proc.stderr || "",
226
+ durationMs,
227
+ error: isTimeout
228
+ ? `Command timed out after ${timeoutMs}ms`
229
+ : `Spawn error: ${proc.error.message}`,
230
+ });
231
+ } else {
232
+ results.push({
233
+ commandId,
234
+ exitCode: proc.status ?? -1,
235
+ stdout: proc.stdout || "",
236
+ stderr: proc.stderr || "",
237
+ durationMs,
238
+ error: null,
239
+ });
240
+ }
241
+ } catch (err: unknown) {
242
+ const durationMs = Date.now() - start;
243
+ const message = err instanceof Error ? err.message : String(err);
244
+ results.push({
245
+ commandId,
246
+ exitCode: -1,
247
+ stdout: "",
248
+ stderr: "",
249
+ durationMs,
250
+ error: `Unexpected error: ${message}`,
251
+ });
252
+ }
253
+ }
254
+
255
+ return results;
256
+ }
257
+
258
+
259
+ // ── Test Output Parsers ──────────────────────────────────────────────
260
+
261
+ /**
262
+ * Vitest JSON reporter output shape (subset of fields we care about).
263
+ */
264
+ interface VitestJsonResult {
265
+ testResults?: Array<{
266
+ name?: string;
267
+ status?: string;
268
+ message?: string;
269
+ assertionResults?: Array<{
270
+ fullName?: string;
271
+ status?: string;
272
+ failureMessages?: string[];
273
+ }>;
274
+ }>;
275
+ }
276
+
277
+ /**
278
+ * Classify a failure message into a kind.
279
+ */
280
+ function classifyFailureKind(message: string): TestFingerprint["kind"] {
281
+ const lower = message.toLowerCase();
282
+ if (lower.includes("timeout") || lower.includes("timed out")) {
283
+ return "timeout";
284
+ }
285
+ if (
286
+ lower.includes("assert") ||
287
+ lower.includes("expect") ||
288
+ lower.includes("tobe") ||
289
+ lower.includes("toequal") ||
290
+ lower.includes("tohave")
291
+ ) {
292
+ return "assertion_error";
293
+ }
294
+ if (
295
+ lower.includes("referenceerror") ||
296
+ lower.includes("typeerror") ||
297
+ lower.includes("syntaxerror") ||
298
+ lower.includes("cannot find module") ||
299
+ lower.includes("is not defined") ||
300
+ lower.includes("is not a function")
301
+ ) {
302
+ return "runtime_error";
303
+ }
304
+ return "unknown";
305
+ }
306
+
307
+ /**
308
+ * Parse legacy Vitest JSON reporter output into test fingerprints.
309
+ *
310
+ * Expects stdout to contain a JSON object matching Vitest's JSON reporter format.
311
+ * Only failed tests produce fingerprints (passed tests are irrelevant for baseline diffing).
312
+ *
313
+ * If JSON parsing fails or the structure is unexpected, returns null to signal
314
+ * that the caller should use fallback fingerprinting.
315
+ *
316
+ * @param commandId - The command that produced this output
317
+ * @param stdout - Raw stdout from the Vitest command (legacy compatibility path)
318
+ * @returns Array of fingerprints for failed tests, or null if parsing fails
319
+ */
320
+ export function parseVitestOutput(commandId: string, stdout: string): TestFingerprint[] | null {
321
+ // Try to extract JSON from stdout (Vitest may prepend/append non-JSON lines)
322
+ let json: VitestJsonResult;
323
+ try {
324
+ // First attempt: parse the whole stdout as JSON
325
+ json = JSON.parse(stdout);
326
+ } catch {
327
+ // Second attempt: find the first { and last } to extract JSON block
328
+ const firstBrace = stdout.indexOf("{");
329
+ const lastBrace = stdout.lastIndexOf("}");
330
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
331
+ return null;
332
+ }
333
+ try {
334
+ json = JSON.parse(stdout.slice(firstBrace, lastBrace + 1));
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ if (!json || !Array.isArray(json.testResults)) {
341
+ return null;
342
+ }
343
+
344
+ const fingerprints: TestFingerprint[] = [];
345
+
346
+ for (const testFile of json.testResults) {
347
+ const file = normalizeFilePath(testFile.name || "unknown");
348
+ const assertions = testFile.assertionResults;
349
+ const hasAssertions = Array.isArray(assertions) && assertions.length > 0;
350
+
351
+ if (hasAssertions) {
352
+ for (const assertion of assertions!) {
353
+ // Only fingerprint failures
354
+ if (assertion.status !== "failed") continue;
355
+
356
+ const caseName = assertion.fullName || "unknown";
357
+ const messages = assertion.failureMessages || [];
358
+ const rawMessage = messages.join("\n") || "no failure message";
359
+
360
+ fingerprints.push({
361
+ commandId,
362
+ file,
363
+ case: caseName,
364
+ kind: classifyFailureKind(rawMessage),
365
+ messageNorm: normalizeMessage(rawMessage),
366
+ });
367
+ }
368
+ }
369
+
370
+ // Suite-level failures: testResults[].status === "failed" with no assertion-level details.
371
+ // This covers setup/import/runtime-at-file-load errors where Vitest marks the file as
372
+ // failed but produces no assertionResults (or only non-failed ones).
373
+ if (testFile.status === "failed") {
374
+ const hasFailedAssertions = hasAssertions && assertions!.some(a => a.status === "failed");
375
+ if (!hasFailedAssertions) {
376
+ // No assertion-level failures captured — emit suite-level runtime_error fingerprint
377
+ const suiteMessage = testFile.message || "Suite failed with no message";
378
+ fingerprints.push({
379
+ commandId,
380
+ file,
381
+ case: "<suite>",
382
+ kind: "runtime_error",
383
+ messageNorm: normalizeMessage(suiteMessage),
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ return fingerprints;
390
+ }
391
+
392
+ /**
393
+ * Parse test output into normalized fingerprints.
394
+ *
395
+ * Strategy:
396
+ * 1. Try legacy Vitest JSON adapter
397
+ * 2. If parsing fails: produce a fallback command_error fingerprint
398
+ *
399
+ * The adapter pattern is extensible — future parsers for jest, pytest, etc.
400
+ * can be added here as additional try paths before the fallback.
401
+ *
402
+ * @param commandResult - Result from runVerificationCommands
403
+ * @returns Array of fingerprints (always non-empty for failed commands)
404
+ */
405
+ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[] {
406
+ const { commandId, exitCode, stdout, stderr, error } = commandResult;
407
+
408
+ // If command had a spawn/timeout error, produce a command_error fingerprint
409
+ if (error) {
410
+ return [{
411
+ commandId,
412
+ file: "",
413
+ case: "",
414
+ kind: "command_error",
415
+ messageNorm: normalizeMessage(error),
416
+ }];
417
+ }
418
+
419
+ // If exit code is 0, no failures to fingerprint
420
+ if (exitCode === 0) {
421
+ return [];
422
+ }
423
+
424
+ // Try legacy Vitest JSON adapter
425
+ const vitestFingerprints = parseVitestOutput(commandId, stdout);
426
+ if (vitestFingerprints !== null && vitestFingerprints.length > 0) {
427
+ return vitestFingerprints;
428
+ }
429
+
430
+ // Vitest JSON parsed successfully but produced zero fingerprints with non-zero exit.
431
+ // This can happen if the JSON structure is valid but contains no failure details
432
+ // we could extract. Fall through to the generic fallback below.
433
+
434
+ // Fallback: command_error fingerprint with stderr (or stdout if stderr is empty)
435
+ const fallbackMessage = stderr.trim() || stdout.trim() || "Command failed with no output";
436
+ return [{
437
+ commandId,
438
+ file: "",
439
+ case: "",
440
+ kind: "command_error",
441
+ messageNorm: normalizeMessage(fallbackMessage),
442
+ }];
443
+ }
444
+
445
+
446
+ // ── Fingerprint Diffing ──────────────────────────────────────────────
447
+
448
+ /**
449
+ * Deduplicate fingerprints by their composite key.
450
+ * Preserves the first occurrence of each unique fingerprint.
451
+ */
452
+ export function deduplicateFingerprints(fingerprints: TestFingerprint[]): TestFingerprint[] {
453
+ const seen = new Set<string>();
454
+ const result: TestFingerprint[] = [];
455
+
456
+ for (const fp of fingerprints) {
457
+ const key = fingerprintKey(fp);
458
+ if (!seen.has(key)) {
459
+ seen.add(key);
460
+ result.push(fp);
461
+ }
462
+ }
463
+
464
+ return result;
465
+ }
466
+
467
+ /**
468
+ * Diff two fingerprint sets to identify new failures, pre-existing failures, and fixes.
469
+ *
470
+ * Uses set-based comparison on the composite fingerprint key.
471
+ * Both sets are deduplicated before comparison.
472
+ *
473
+ * @param baseline - Fingerprints from pre-merge verification run
474
+ * @param postMerge - Fingerprints from post-merge verification run
475
+ * @returns FingerprintDiff with new failures, pre-existing, and fixed sets
476
+ */
477
+ export function diffFingerprints(
478
+ baseline: TestFingerprint[],
479
+ postMerge: TestFingerprint[],
480
+ ): FingerprintDiff {
481
+ const dedupBaseline = deduplicateFingerprints(baseline);
482
+ const dedupPostMerge = deduplicateFingerprints(postMerge);
483
+
484
+ const baselineKeys = new Set(dedupBaseline.map(fingerprintKey));
485
+ const postMergeKeys = new Set(dedupPostMerge.map(fingerprintKey));
486
+
487
+ const newFailures: TestFingerprint[] = [];
488
+ const preExisting: TestFingerprint[] = [];
489
+ const fixed: TestFingerprint[] = [];
490
+
491
+ // Classify post-merge fingerprints
492
+ for (const fp of dedupPostMerge) {
493
+ const key = fingerprintKey(fp);
494
+ if (baselineKeys.has(key)) {
495
+ preExisting.push(fp);
496
+ } else {
497
+ newFailures.push(fp);
498
+ }
499
+ }
500
+
501
+ // Find fixed: in baseline but not in post-merge
502
+ for (const fp of dedupBaseline) {
503
+ const key = fingerprintKey(fp);
504
+ if (!postMergeKeys.has(key)) {
505
+ fixed.push(fp);
506
+ }
507
+ }
508
+
509
+ return { newFailures, preExisting, fixed };
510
+ }
511
+
512
+
513
+ // ── Baseline Capture ─────────────────────────────────────────────────
514
+
515
+ /**
516
+ * Run verification commands and capture a complete baseline snapshot.
517
+ *
518
+ * @param commands - Map of commandId → shell command string
519
+ * @param cwd - Working directory (merge worktree)
520
+ * @param timeoutMs - Per-command timeout
521
+ * @returns VerificationBaseline with command results and extracted fingerprints
522
+ */
523
+ export function captureBaseline(
524
+ commands: Record<string, string>,
525
+ cwd: string,
526
+ timeoutMs?: number,
527
+ ): VerificationBaseline {
528
+ const commandResults = runVerificationCommands(commands, cwd, timeoutMs);
529
+
530
+ // Extract fingerprints from all command results
531
+ const allFingerprints: TestFingerprint[] = [];
532
+ for (const result of commandResults) {
533
+ const fps = parseTestOutput(result);
534
+ allFingerprints.push(...fps);
535
+ }
536
+
537
+ return {
538
+ capturedAt: new Date().toISOString(),
539
+ commandResults,
540
+ fingerprints: deduplicateFingerprints(allFingerprints),
541
+ };
542
+ }