sequant 1.15.2 → 1.15.4

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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/bin/cli.js +4 -0
  3. package/dist/src/commands/logs.js +15 -0
  4. package/dist/src/commands/run.d.ts +150 -1
  5. package/dist/src/commands/run.js +642 -31
  6. package/dist/src/commands/stats.js +48 -0
  7. package/dist/src/lib/scope/index.d.ts +1 -0
  8. package/dist/src/lib/scope/index.js +2 -0
  9. package/dist/src/lib/scope/settings-converter.d.ts +28 -0
  10. package/dist/src/lib/scope/settings-converter.js +53 -0
  11. package/dist/src/lib/settings.d.ts +45 -0
  12. package/dist/src/lib/settings.js +30 -0
  13. package/dist/src/lib/test-tautology-detector.d.ts +122 -0
  14. package/dist/src/lib/test-tautology-detector.js +488 -0
  15. package/dist/src/lib/workflow/git-diff-utils.d.ts +39 -0
  16. package/dist/src/lib/workflow/git-diff-utils.js +142 -0
  17. package/dist/src/lib/workflow/log-writer.d.ts +13 -2
  18. package/dist/src/lib/workflow/log-writer.js +25 -3
  19. package/dist/src/lib/workflow/metrics-schema.d.ts +9 -0
  20. package/dist/src/lib/workflow/metrics-schema.js +10 -1
  21. package/dist/src/lib/workflow/phase-detection.d.ts +3 -0
  22. package/dist/src/lib/workflow/phase-detection.js +27 -1
  23. package/dist/src/lib/workflow/qa-cache.d.ts +3 -1
  24. package/dist/src/lib/workflow/qa-cache.js +2 -0
  25. package/dist/src/lib/workflow/run-log-schema.d.ts +90 -3
  26. package/dist/src/lib/workflow/run-log-schema.js +44 -2
  27. package/dist/src/lib/workflow/state-utils.d.ts +46 -0
  28. package/dist/src/lib/workflow/state-utils.js +167 -0
  29. package/dist/src/lib/workflow/token-utils.d.ts +92 -0
  30. package/dist/src/lib/workflow/token-utils.js +170 -0
  31. package/dist/src/lib/workflow/types.d.ts +10 -0
  32. package/dist/src/lib/workflow/types.js +1 -0
  33. package/package.json +1 -1
  34. package/templates/hooks/pre-tool.sh +4 -0
  35. package/templates/skills/assess/SKILL.md +1 -1
  36. package/templates/skills/exec/SKILL.md +6 -4
  37. package/templates/skills/improve/SKILL.md +37 -24
  38. package/templates/skills/loop/SKILL.md +3 -3
  39. package/templates/skills/qa/references/code-review-checklist.md +10 -11
  40. package/templates/skills/qa/scripts/quality-checks.sh +16 -0
  41. package/templates/skills/security-review/references/security-checklists.md +89 -36
  42. package/templates/skills/solve/SKILL.md +3 -1
  43. package/templates/skills/spec/SKILL.md +8 -4
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Test Tautology Detector
3
+ *
4
+ * Detects tautological tests — tests that pass but don't call any production code.
5
+ * These tests provide zero regression protection as they only assert on local values.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { detectTautologicalTests, formatTautologyResults } from './test-tautology-detector';
10
+ *
11
+ * const results = detectTautologicalTests([
12
+ * { path: 'src/lib/foo.test.ts', content: fileContent },
13
+ * ]);
14
+ * console.log(formatTautologyResults(results));
15
+ * ```
16
+ */
17
+ /**
18
+ * Test library imports to exclude from production function detection
19
+ */
20
+ const TEST_LIBRARY_PATTERNS = [
21
+ /^vitest$/,
22
+ /^@vitest\//,
23
+ /^jest$/,
24
+ /^@jest\//,
25
+ /^@testing-library\//,
26
+ /^react-test-renderer/,
27
+ /^enzyme/,
28
+ /^sinon/,
29
+ /^chai/,
30
+ /^mocha/,
31
+ /^node:test/,
32
+ /^assert$/,
33
+ ];
34
+ /**
35
+ * Mock/fixture path patterns to exclude
36
+ */
37
+ const MOCK_FIXTURE_PATTERNS = [
38
+ /mock/i,
39
+ /fixture/i,
40
+ /stub/i,
41
+ /fake/i,
42
+ /__mocks__/,
43
+ /__fixtures__/,
44
+ /test-utils?/i,
45
+ /test-helper/i,
46
+ ];
47
+ /**
48
+ * Check if an import path is from a source module (not a test library or mock)
49
+ */
50
+ export function isSourceModule(modulePath) {
51
+ // Check if it's a test library
52
+ for (const pattern of TEST_LIBRARY_PATTERNS) {
53
+ if (pattern.test(modulePath)) {
54
+ return false;
55
+ }
56
+ }
57
+ // Check if it's a mock/fixture
58
+ for (const pattern of MOCK_FIXTURE_PATTERNS) {
59
+ if (pattern.test(modulePath)) {
60
+ return false;
61
+ }
62
+ }
63
+ // Check if it's a Node.js built-in
64
+ if (modulePath.startsWith("node:")) {
65
+ return false;
66
+ }
67
+ // Source modules typically start with ./ or ../ or are absolute imports
68
+ // For this detector, we consider relative imports as production code
69
+ if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
70
+ return true;
71
+ }
72
+ // Absolute imports from the project (non-node_modules) are also production code
73
+ // We can't reliably detect this without filesystem access, so we're conservative
74
+ // and only count relative imports as production code
75
+ return false;
76
+ }
77
+ /**
78
+ * Extract imports from a test file
79
+ *
80
+ * Handles:
81
+ * - Named imports: `import { foo, bar } from './module'`
82
+ * - Default imports: `import foo from './module'`
83
+ * - Namespace imports: `import * as foo from './module'` (extracts the namespace name)
84
+ */
85
+ export function extractImports(content) {
86
+ const imports = [];
87
+ // Named imports: import { foo, bar, baz as qux } from './module'
88
+ const namedImportPattern = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
89
+ let match;
90
+ while ((match = namedImportPattern.exec(content)) !== null) {
91
+ const names = match[1];
92
+ const modulePath = match[2];
93
+ if (!isSourceModule(modulePath)) {
94
+ continue;
95
+ }
96
+ // Parse individual imports, handling aliases (foo as bar)
97
+ const importedNames = names.split(",").map((n) => n.trim());
98
+ for (const name of importedNames) {
99
+ if (!name)
100
+ continue;
101
+ // Handle aliased imports: "originalName as aliasName"
102
+ const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
103
+ if (aliasMatch) {
104
+ // Use the alias (the name actually used in code)
105
+ imports.push({ name: aliasMatch[2], modulePath });
106
+ }
107
+ else {
108
+ // No alias, use the name directly
109
+ const cleanName = name.replace(/\s+/g, "");
110
+ if (cleanName) {
111
+ imports.push({ name: cleanName, modulePath });
112
+ }
113
+ }
114
+ }
115
+ }
116
+ // Default imports: import foo from './module'
117
+ const defaultImportPattern = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
118
+ while ((match = defaultImportPattern.exec(content)) !== null) {
119
+ const name = match[1];
120
+ const modulePath = match[2];
121
+ if (isSourceModule(modulePath)) {
122
+ imports.push({ name, modulePath });
123
+ }
124
+ }
125
+ // Namespace imports: import * as foo from './module'
126
+ const namespaceImportPattern = /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
127
+ while ((match = namespaceImportPattern.exec(content)) !== null) {
128
+ const name = match[1];
129
+ const modulePath = match[2];
130
+ if (isSourceModule(modulePath)) {
131
+ imports.push({ name, modulePath });
132
+ }
133
+ }
134
+ return imports;
135
+ }
136
+ /**
137
+ * Extract test blocks (it() and test()) from content
138
+ *
139
+ * Returns the description, line number, body content, and style of each test block.
140
+ */
141
+ export function extractTestBlocks(content) {
142
+ const blocks = [];
143
+ // Find test block starts with their line numbers
144
+ // Pattern matches: it("...", ...) or test("...", ...)
145
+ // Including variations like it.skip, it.only, test.skip, test.only
146
+ const testBlockStartPattern = /\b(it|test)(?:\.skip|\.only)?\s*\(\s*(['"`])(.+?)\2/g;
147
+ let match;
148
+ while ((match = testBlockStartPattern.exec(content)) !== null) {
149
+ const style = match[1];
150
+ const description = match[3];
151
+ const startIndex = match.index;
152
+ // Skip matches inside string literals (e.g., test code embedded in template literals)
153
+ if (isInsideString(content, startIndex)) {
154
+ continue;
155
+ }
156
+ // Calculate line number
157
+ const contentBeforeMatch = content.substring(0, startIndex);
158
+ const lineNumber = contentBeforeMatch.split("\n").length;
159
+ // Find the matching closing brace for the test block
160
+ // This is a simplified approach that works for most cases
161
+ const afterMatch = content.substring(startIndex);
162
+ const body = extractBlockBody(afterMatch);
163
+ blocks.push({
164
+ description,
165
+ lineNumber,
166
+ body,
167
+ style,
168
+ });
169
+ }
170
+ return blocks;
171
+ }
172
+ /**
173
+ * Check if a position in the content is inside a non-code context:
174
+ * string literal (single, double, or template), comment (line or block),
175
+ * or a template expression's string context.
176
+ *
177
+ * Handles nested template literals: `` `outer ${`inner`} still outer` ``
178
+ * by tracking template expression depth via a stack.
179
+ */
180
+ function isInsideString(content, position) {
181
+ let inString = false;
182
+ let stringChar = "";
183
+ let escaped = false;
184
+ // Stack tracks brace depth inside template expressions.
185
+ // When we encounter `${`, we push 0. Nested `{` increments top.
186
+ // `}` at depth 0 pops the stack and re-enters the template literal.
187
+ const templateExprStack = [];
188
+ for (let i = 0; i < position && i < content.length; i++) {
189
+ const char = content[i];
190
+ if (escaped) {
191
+ escaped = false;
192
+ continue;
193
+ }
194
+ if (char === "\\") {
195
+ escaped = true;
196
+ continue;
197
+ }
198
+ // Inside a template literal — handle ${...} expressions
199
+ if (inString && stringChar === "`") {
200
+ if (char === "$" && i + 1 < content.length && content[i + 1] === "{") {
201
+ // Enter template expression — temporarily leave string context
202
+ templateExprStack.push(0);
203
+ inString = false;
204
+ i++; // skip the `{`
205
+ continue;
206
+ }
207
+ if (char === "`") {
208
+ inString = false;
209
+ continue;
210
+ }
211
+ continue;
212
+ }
213
+ // Inside a non-template string
214
+ if (inString) {
215
+ if (char === stringChar) {
216
+ inString = false;
217
+ }
218
+ continue;
219
+ }
220
+ // Not in any string — check if we're inside a template expression
221
+ if (templateExprStack.length > 0) {
222
+ if (char === "{") {
223
+ templateExprStack[templateExprStack.length - 1]++;
224
+ }
225
+ else if (char === "}") {
226
+ if (templateExprStack[templateExprStack.length - 1] === 0) {
227
+ // Closing the template expression — re-enter the template literal
228
+ templateExprStack.pop();
229
+ inString = true;
230
+ stringChar = "`";
231
+ }
232
+ else {
233
+ templateExprStack[templateExprStack.length - 1]--;
234
+ }
235
+ }
236
+ else if (char === "`" || char === '"' || char === "'") {
237
+ inString = true;
238
+ stringChar = char;
239
+ }
240
+ else if (char === "/" &&
241
+ i + 1 < content.length &&
242
+ content[i + 1] === "/") {
243
+ // Line comment — if position falls within it, return true
244
+ const eol = content.indexOf("\n", i);
245
+ const commentEnd = eol === -1 ? content.length : eol;
246
+ if (position <= commentEnd)
247
+ return true;
248
+ i = commentEnd;
249
+ }
250
+ else if (char === "/" &&
251
+ i + 1 < content.length &&
252
+ content[i + 1] === "*") {
253
+ // Block comment — if position falls within it, return true
254
+ const end = content.indexOf("*/", i + 2);
255
+ const commentEnd = end === -1 ? content.length : end + 1;
256
+ if (position <= commentEnd)
257
+ return true;
258
+ i = commentEnd;
259
+ }
260
+ continue;
261
+ }
262
+ // Top-level code
263
+ if (char === "`" || char === '"' || char === "'") {
264
+ inString = true;
265
+ stringChar = char;
266
+ }
267
+ else if (char === "/" &&
268
+ i + 1 < content.length &&
269
+ content[i + 1] === "/") {
270
+ // Line comment — if position falls within it, return true
271
+ const eol = content.indexOf("\n", i);
272
+ const commentEnd = eol === -1 ? content.length : eol;
273
+ if (position <= commentEnd)
274
+ return true;
275
+ i = commentEnd;
276
+ }
277
+ else if (char === "/" &&
278
+ i + 1 < content.length &&
279
+ content[i + 1] === "*") {
280
+ // Block comment — if position falls within it, return true
281
+ const end = content.indexOf("*/", i + 2);
282
+ const commentEnd = end === -1 ? content.length : end + 1;
283
+ if (position <= commentEnd)
284
+ return true;
285
+ i = commentEnd;
286
+ }
287
+ }
288
+ return inString || templateExprStack.length > 0;
289
+ }
290
+ /**
291
+ * Extract the body of a function block (content between { and matching })
292
+ */
293
+ function extractBlockBody(content) {
294
+ // Find the first opening brace
295
+ const firstBrace = content.indexOf("{");
296
+ if (firstBrace === -1) {
297
+ return "";
298
+ }
299
+ let depth = 0;
300
+ let inString = false;
301
+ let stringChar = "";
302
+ let escaped = false;
303
+ for (let i = firstBrace; i < content.length; i++) {
304
+ const char = content[i];
305
+ if (escaped) {
306
+ escaped = false;
307
+ continue;
308
+ }
309
+ if (char === "\\") {
310
+ escaped = true;
311
+ continue;
312
+ }
313
+ if (!inString && (char === '"' || char === "'" || char === "`")) {
314
+ inString = true;
315
+ stringChar = char;
316
+ continue;
317
+ }
318
+ if (inString && char === stringChar) {
319
+ inString = false;
320
+ continue;
321
+ }
322
+ if (!inString) {
323
+ if (char === "{") {
324
+ depth++;
325
+ }
326
+ else if (char === "}") {
327
+ depth--;
328
+ if (depth === 0) {
329
+ return content.substring(firstBrace, i + 1);
330
+ }
331
+ }
332
+ }
333
+ }
334
+ // If we didn't find a matching brace, return everything after the first brace
335
+ return content.substring(firstBrace);
336
+ }
337
+ /**
338
+ * Escape special regex characters in a string for safe use in `new RegExp()`.
339
+ */
340
+ function escapeRegex(str) {
341
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
342
+ }
343
+ /**
344
+ * Check if a test block contains calls to any of the imported production functions
345
+ */
346
+ export function testBlockCallsProductionCode(body, importedFunctions) {
347
+ if (importedFunctions.length === 0) {
348
+ return false;
349
+ }
350
+ for (const fn of importedFunctions) {
351
+ // Check for any reference to the imported name bounded by non-identifier chars.
352
+ // Uses [\w$] to match JS identifier characters (letters, digits, _, $).
353
+ // This catches direct calls (fn()), method calls (ns.method()),
354
+ // callback references (arr.map(fn)), and assignments (const x = fn).
355
+ const escaped = escapeRegex(fn.name);
356
+ const referencePattern = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
357
+ if (referencePattern.test(body)) {
358
+ return true;
359
+ }
360
+ }
361
+ return false;
362
+ }
363
+ /**
364
+ * Analyze a single test file for tautological tests
365
+ */
366
+ export function analyzeTestFile(content, filePath) {
367
+ try {
368
+ const importedFunctions = extractImports(content);
369
+ const testBlocks = extractTestBlocks(content);
370
+ const analyzedBlocks = testBlocks.map((block) => ({
371
+ description: block.description,
372
+ lineNumber: block.lineNumber,
373
+ style: block.style,
374
+ isTautological: !testBlockCallsProductionCode(block.body, importedFunctions),
375
+ }));
376
+ const tautologicalCount = analyzedBlocks.filter((b) => b.isTautological).length;
377
+ const totalTests = analyzedBlocks.length;
378
+ const tautologicalPercentage = totalTests > 0 ? (tautologicalCount / totalTests) * 100 : 0;
379
+ return {
380
+ filePath,
381
+ totalTests,
382
+ tautologicalCount,
383
+ tautologicalPercentage,
384
+ testBlocks: analyzedBlocks,
385
+ importedFunctions,
386
+ parseSuccess: true,
387
+ };
388
+ }
389
+ catch (error) {
390
+ return {
391
+ filePath,
392
+ totalTests: 0,
393
+ tautologicalCount: 0,
394
+ tautologicalPercentage: 0,
395
+ testBlocks: [],
396
+ importedFunctions: [],
397
+ parseSuccess: false,
398
+ parseError: error instanceof Error ? error.message : "Unknown parse error",
399
+ };
400
+ }
401
+ }
402
+ /**
403
+ * Detect tautological tests across multiple files
404
+ */
405
+ export function detectTautologicalTests(files) {
406
+ const fileResults = files.map((file) => analyzeTestFile(file.content, file.path));
407
+ const totalFiles = fileResults.length;
408
+ const totalTests = fileResults.reduce((sum, r) => sum + r.totalTests, 0);
409
+ const totalTautological = fileResults.reduce((sum, r) => sum + r.tautologicalCount, 0);
410
+ const overallPercentage = totalTests > 0 ? (totalTautological / totalTests) * 100 : 0;
411
+ return {
412
+ fileResults,
413
+ summary: {
414
+ totalFiles,
415
+ totalTests,
416
+ totalTautological,
417
+ overallPercentage,
418
+ exceedsBlockingThreshold: overallPercentage > 50,
419
+ },
420
+ };
421
+ }
422
+ /**
423
+ * Format tautology results as markdown for QA output
424
+ */
425
+ export function formatTautologyResults(results) {
426
+ const lines = [];
427
+ lines.push("### Test Quality Review");
428
+ lines.push("");
429
+ // Summary table
430
+ lines.push("| Category | Status | Notes |");
431
+ lines.push("|----------|--------|-------|");
432
+ if (results.summary.totalTests === 0) {
433
+ lines.push("| Tautology Check | ⏭️ SKIP | No test blocks found |");
434
+ return lines.join("\n");
435
+ }
436
+ const status = results.summary.exceedsBlockingThreshold
437
+ ? "❌ FAIL"
438
+ : results.summary.totalTautological > 0
439
+ ? "⚠️ WARN"
440
+ : "✅ OK";
441
+ const notes = results.summary.totalTautological > 0
442
+ ? `${results.summary.totalTautological} tautological test blocks found (${results.summary.overallPercentage.toFixed(1)}%)`
443
+ : "All tests call production code";
444
+ lines.push(`| Tautology Check | ${status} | ${notes} |`);
445
+ lines.push("");
446
+ // List tautological tests if any found
447
+ if (results.summary.totalTautological > 0) {
448
+ lines.push("**Tautological Tests Found:**");
449
+ lines.push("");
450
+ for (const fileResult of results.fileResults) {
451
+ const tautologicalBlocks = fileResult.testBlocks.filter((b) => b.isTautological);
452
+ for (const block of tautologicalBlocks) {
453
+ lines.push(`- \`${fileResult.filePath}:${block.lineNumber}\` - \`${block.style}("${block.description}")\` - No production function calls`);
454
+ }
455
+ }
456
+ lines.push("");
457
+ }
458
+ // Verdict impact
459
+ if (results.summary.exceedsBlockingThreshold) {
460
+ lines.push("**Verdict Impact:** >50% tautological tests — blocks `READY_FOR_MERGE`");
461
+ lines.push("");
462
+ }
463
+ // Parse errors if any
464
+ const parseErrors = results.fileResults.filter((r) => !r.parseSuccess);
465
+ if (parseErrors.length > 0) {
466
+ lines.push("**Parse Warnings:**");
467
+ for (const error of parseErrors) {
468
+ lines.push(`- \`${error.filePath}\`: ${error.parseError}`);
469
+ }
470
+ lines.push("");
471
+ }
472
+ return lines.join("\n");
473
+ }
474
+ /**
475
+ * Determine verdict impact based on tautology results
476
+ */
477
+ export function getTautologyVerdictImpact(results) {
478
+ if (results.summary.totalTests === 0) {
479
+ return "none";
480
+ }
481
+ if (results.summary.exceedsBlockingThreshold) {
482
+ return "blocking";
483
+ }
484
+ if (results.summary.totalTautological > 0) {
485
+ return "warning";
486
+ }
487
+ return "none";
488
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Git diff utilities for pipeline observability (AC-1, AC-3, AC-4)
3
+ *
4
+ * Provides efficient git diff statistics for phase logging.
5
+ * Uses single git commands where possible to avoid redundant operations.
6
+ */
7
+ import type { FileDiffStat } from "./run-log-schema.js";
8
+ /**
9
+ * Result from getGitDiffStats (AC-4)
10
+ */
11
+ export interface GitDiffStatsResult {
12
+ /** List of modified file paths (AC-1) */
13
+ filesModified: string[];
14
+ /** Per-file diff statistics (AC-3) */
15
+ fileDiffStats: FileDiffStat[];
16
+ /** Total lines added across all files */
17
+ totalAdditions: number;
18
+ /** Total lines deleted across all files */
19
+ totalDeletions: number;
20
+ }
21
+ /**
22
+ * Get git commit SHA for a worktree (AC-2)
23
+ *
24
+ * @param worktreePath - Path to the git worktree
25
+ * @returns The current HEAD commit SHA, or undefined on error
26
+ */
27
+ export declare function getCommitHash(worktreePath: string): string | undefined;
28
+ /**
29
+ * Get git diff statistics for a worktree (AC-1, AC-3, AC-4)
30
+ *
31
+ * Efficiently captures both filesModified and fileDiffStats using
32
+ * minimal git commands. Uses main...HEAD comparison by default.
33
+ *
34
+ * @param worktreePath - Path to the git worktree
35
+ * @param baseBranch - Branch to compare against (default: "main")
36
+ * @returns GitDiffStatsResult with files, stats, and totals
37
+ */
38
+ export declare function getGitDiffStats(worktreePath: string, baseBranch?: string): GitDiffStatsResult;
39
+ export type { FileDiffStat };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Git diff utilities for pipeline observability (AC-1, AC-3, AC-4)
3
+ *
4
+ * Provides efficient git diff statistics for phase logging.
5
+ * Uses single git commands where possible to avoid redundant operations.
6
+ */
7
+ import { spawnSync } from "child_process";
8
+ /**
9
+ * Parse git diff --numstat output into additions/deletions per file
10
+ *
11
+ * Format: <additions>\t<deletions>\t<filepath>
12
+ * Binary files show: -\t-\t<filepath>
13
+ */
14
+ function parseNumstat(output) {
15
+ const result = new Map();
16
+ if (!output.trim()) {
17
+ return result;
18
+ }
19
+ const lines = output.trim().split("\n");
20
+ for (const line of lines) {
21
+ if (!line.trim())
22
+ continue;
23
+ const parts = line.split("\t");
24
+ if (parts.length < 3)
25
+ continue;
26
+ const [addStr, delStr, ...pathParts] = parts;
27
+ const filePath = pathParts.join("\t"); // Handle filenames with tabs
28
+ // Binary files show "-" for additions/deletions
29
+ const additions = addStr === "-" ? 0 : parseInt(addStr, 10);
30
+ const deletions = delStr === "-" ? 0 : parseInt(delStr, 10);
31
+ if (!isNaN(additions) && !isNaN(deletions)) {
32
+ result.set(filePath, { additions, deletions });
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+ /**
38
+ * Parse git diff --name-status output into file statuses
39
+ *
40
+ * Format: <status>\t<filepath> (or <status>\t<oldpath>\t<newpath> for renames)
41
+ * Status codes: A=added, M=modified, D=deleted, R=renamed, C=copied, T=type-changed
42
+ */
43
+ function parseNameStatus(output) {
44
+ const result = new Map();
45
+ if (!output.trim()) {
46
+ return result;
47
+ }
48
+ const lines = output.trim().split("\n");
49
+ for (const line of lines) {
50
+ if (!line.trim())
51
+ continue;
52
+ const parts = line.split("\t");
53
+ if (parts.length < 2)
54
+ continue;
55
+ const statusCode = parts[0];
56
+ // For renames (R100), use the new filename (last part)
57
+ const filePath = parts[parts.length - 1];
58
+ let status;
59
+ if (statusCode.startsWith("A")) {
60
+ status = "added";
61
+ }
62
+ else if (statusCode.startsWith("D")) {
63
+ status = "deleted";
64
+ }
65
+ else if (statusCode.startsWith("R")) {
66
+ status = "renamed";
67
+ }
68
+ else {
69
+ // M, C, T, or anything else -> modified
70
+ status = "modified";
71
+ }
72
+ result.set(filePath, status);
73
+ }
74
+ return result;
75
+ }
76
+ /**
77
+ * Get git commit SHA for a worktree (AC-2)
78
+ *
79
+ * @param worktreePath - Path to the git worktree
80
+ * @returns The current HEAD commit SHA, or undefined on error
81
+ */
82
+ export function getCommitHash(worktreePath) {
83
+ const result = spawnSync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
84
+ stdio: "pipe",
85
+ encoding: "utf-8",
86
+ });
87
+ if (result.status !== 0) {
88
+ return undefined;
89
+ }
90
+ return result.stdout.trim();
91
+ }
92
+ /**
93
+ * Get git diff statistics for a worktree (AC-1, AC-3, AC-4)
94
+ *
95
+ * Efficiently captures both filesModified and fileDiffStats using
96
+ * minimal git commands. Uses main...HEAD comparison by default.
97
+ *
98
+ * @param worktreePath - Path to the git worktree
99
+ * @param baseBranch - Branch to compare against (default: "main")
100
+ * @returns GitDiffStatsResult with files, stats, and totals
101
+ */
102
+ export function getGitDiffStats(worktreePath, baseBranch = "main") {
103
+ const diffRef = `${baseBranch}...HEAD`;
104
+ // Get numstat for additions/deletions
105
+ const numstatResult = spawnSync("git", ["-C", worktreePath, "diff", "--numstat", diffRef], { stdio: "pipe", encoding: "utf-8" });
106
+ // Get name-status for file status (added/modified/deleted/renamed)
107
+ const nameStatusResult = spawnSync("git", ["-C", worktreePath, "diff", "--name-status", diffRef], { stdio: "pipe", encoding: "utf-8" });
108
+ // Handle git command failures gracefully
109
+ if (numstatResult.status !== 0 || nameStatusResult.status !== 0) {
110
+ return {
111
+ filesModified: [],
112
+ fileDiffStats: [],
113
+ totalAdditions: 0,
114
+ totalDeletions: 0,
115
+ };
116
+ }
117
+ const numstatMap = parseNumstat(numstatResult.stdout);
118
+ const statusMap = parseNameStatus(nameStatusResult.stdout);
119
+ // Combine into fileDiffStats array
120
+ const fileDiffStats = [];
121
+ let totalAdditions = 0;
122
+ let totalDeletions = 0;
123
+ for (const [path, stats] of numstatMap) {
124
+ const status = statusMap.get(path) ?? "modified";
125
+ fileDiffStats.push({
126
+ path,
127
+ additions: stats.additions,
128
+ deletions: stats.deletions,
129
+ status,
130
+ });
131
+ totalAdditions += stats.additions;
132
+ totalDeletions += stats.deletions;
133
+ }
134
+ // filesModified is just the paths
135
+ const filesModified = fileDiffStats.map((f) => f.path);
136
+ return {
137
+ filesModified,
138
+ fileDiffStats,
139
+ totalAdditions,
140
+ totalDeletions,
141
+ };
142
+ }