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