From c6e10ede81459650850ff074d2a62ac25c970f23 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Sat, 28 Feb 2026 21:59:30 -0500 Subject: [PATCH 1/2] feat(issue-847): include complexity data in swarm issue output Add optional `complexity` field ({ level, reason }) to SwarmIssue objects returned by `il list --json`. Complexity data is loaded from recap files for each child loom, enabling the VS Code kanban board to display complexity indicators on swarm issue cards. Fixes #847 --- docs/iloom-commands.md | 1 + src/cli.ts | 15 ++- src/utils/loom-formatter.test.ts | 160 ++++++++++++++++++++++++++++++- src/utils/loom-formatter.ts | 55 ++++++++++- 4 files changed, 222 insertions(+), 9 deletions(-) diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index 912f620e..903d970f 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -529,6 +529,7 @@ Each entry in `swarmIssues` has: | `url` | `string` | Issue URL | | `state` | `string \| null` | Current lifecycle state (`pending`, `in_progress`, `code_review`, `done`, `failed`) or `null` | | `worktreePath` | `string \| null` | Path to the child's worktree, or `null` if not yet created | +| `complexity` | `object \| null` | Complexity assessment (`{ level, reason }`) from recap, or `null` if not available | **Examples:** diff --git a/src/cli.ts b/src/cli.ts index 0b6f57f2..7171109f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { hasMultipleRemotes } from './utils/remote.js' import { getIdeConfig, isIdeAvailable, getInstallHint } from './utils/ide.js' import { fileURLToPath } from 'url' import { realpathSync } from 'fs' -import { formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues } from './utils/loom-formatter.js' +import { formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues, loadComplexityMap } from './utils/loom-formatter.js' import { assembleChildrenData, type ChildrenData } from './utils/list-children.js' import { findMainWorktreePathWithSettings, GitCommandError, isValidGitRepo } from './utils/git.js' import chalk from 'chalk' @@ -1213,6 +1213,13 @@ program ? globalActiveLooms : Array.from(metadata.values()).filter((m): m is LoomMetadata => m != null) + // Build complexity map from recap files for all known worktree paths + const childWorktreePaths: string[] = [] + for (const meta of [...allActiveMetadata, ...finishedLooms]) { + if (meta.worktreePath) childWorktreePaths.push(meta.worktreePath) + } + const complexityMap = await loadComplexityMap(childWorktreePaths) + // Format active looms let activeJson: ReturnType extends (infer T)[] ? (T & { status: 'active'; finishedAt: null })[] : never = [] if (showActive) { @@ -1221,7 +1228,7 @@ program activeJson = globalActiveLooms.map(loom => { const isEpic = (loom.issueType ?? 'branch') === 'epic' const swarmIssues = isEpic && loom.childIssues && loom.childIssues.length > 0 - ? enrichSwarmIssues(loom.childIssues, globalActiveLooms, finishedLooms, loom.projectPath) + ? enrichSwarmIssues(loom.childIssues, globalActiveLooms, finishedLooms, loom.projectPath, complexityMap) : isEpic ? [] : undefined const depMap = isEpic ? (loom.dependencyMap && Object.keys(loom.dependencyMap).length > 0 @@ -1254,7 +1261,7 @@ program }) } else { // Format worktrees from current repo - activeJson = formatLoomsForJson(worktrees, mainWorktreePath, metadata, allActiveMetadata, finishedLooms).map(loom => ({ + activeJson = formatLoomsForJson(worktrees, mainWorktreePath, metadata, allActiveMetadata, finishedLooms, complexityMap).map(loom => ({ ...loom, status: 'active' as const, finishedAt: null, @@ -1272,7 +1279,7 @@ program // Format finished looms (only when --finished or --all is set) let finishedJson = showFinished - ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms)) + ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms, complexityMap)) : [] // Filter finished looms by project (include looms with null/undefined projectPath for legacy support) diff --git a/src/utils/loom-formatter.test.ts b/src/utils/loom-formatter.test.ts index ebf82f52..d51247a5 100644 --- a/src/utils/loom-formatter.test.ts +++ b/src/utils/loom-formatter.test.ts @@ -1,13 +1,21 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { formatLoomForJson, formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues, + loadComplexityMap, + type SwarmComplexity, } from './loom-formatter.js' +import { readRecapFile } from './mcp.js' import type { GitWorktree } from '../types/worktree.js' import type { LoomMetadata } from '../lib/MetadataManager.js' +vi.mock('./mcp.js', () => ({ + resolveRecapFilePath: (wp: string) => `/mock/recaps/${wp.replace(/\//g, '___')}.json`, + readRecapFile: vi.fn().mockResolvedValue({}), +})) + describe('formatLoomForJson', () => { /** * Factory to create realistic GitWorktree objects mimicking actual git worktree list output. @@ -1631,6 +1639,7 @@ describe('enrichSwarmIssues', () => { url: 'https://github.com/org/repo/issues/101', state: 'in_progress', worktreePath: '/Users/dev/projects/myapp-looms/issue-101__child', + complexity: null, }, { number: '#102', @@ -1638,6 +1647,7 @@ describe('enrichSwarmIssues', () => { url: 'https://github.com/org/repo/issues/102', state: 'done', worktreePath: '/Users/dev/projects/myapp-looms/issue-102__child', + complexity: null, }, ]) }) @@ -1657,6 +1667,7 @@ describe('enrichSwarmIssues', () => { url: 'https://github.com/org/repo/issues/101', state: null, worktreePath: null, + complexity: null, }, ]) }) @@ -1678,6 +1689,7 @@ describe('enrichSwarmIssues', () => { url: 'https://linear.app/org/issue/ENG-123', state: 'code_review', worktreePath: '/Users/dev/projects/myapp-looms/issue-ENG-123__task', + complexity: null, }, ]) }) @@ -1695,8 +1707,10 @@ describe('enrichSwarmIssues', () => { expect(result[0]?.state).toBe('pending') expect(result[0]?.worktreePath).toBe('/Users/dev/projects/myapp-looms/issue-101__child') + expect(result[0]?.complexity).toBeNull() expect(result[1]?.state).toBeNull() expect(result[1]?.worktreePath).toBeNull() + expect(result[1]?.complexity).toBeNull() }) it('should return empty array for empty childIssues', () => { @@ -1731,6 +1745,7 @@ describe('enrichSwarmIssues', () => { url: 'https://github.com/org/repo/issues/101', state: 'done', worktreePath: '/Users/dev/projects/myapp-looms/issue-101__child', + complexity: null, }, { number: '#102', @@ -1738,6 +1753,7 @@ describe('enrichSwarmIssues', () => { url: 'https://github.com/org/repo/issues/102', state: 'in_progress', worktreePath: '/Users/dev/projects/myapp-looms/issue-102__child', + complexity: null, }, ]) }) @@ -1885,6 +1901,144 @@ describe('enrichSwarmIssues', () => { expect(result[0]?.worktreePath).toBeNull() }) }) + + describe('complexity enrichment', () => { + it('should include complexity from complexityMap when worktreePath matches', () => { + const childIssues = [ + { number: '#101', title: 'First task', body: 'body1', url: 'https://github.com/org/repo/issues/101' }, + { number: '#102', title: 'Second task', body: 'body2', url: 'https://github.com/org/repo/issues/102' }, + ] + const allMetadata = [ + createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), + createChildLoomMetadata('102', 'done', '/Users/dev/projects/myapp-looms/issue-102__child'), + ] + const complexityMap = new Map([ + ['/Users/dev/projects/myapp-looms/issue-101__child', { level: 'simple', reason: 'Single file change' }], + ]) + + const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + + expect(result[0]?.complexity).toEqual({ level: 'simple', reason: 'Single file change' }) + expect(result[1]?.complexity).toBeNull() + }) + + it('should set complexity to null when no complexityMap is provided', () => { + const childIssues = [ + { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, + ] + const allMetadata = [ + createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), + ] + + const result = enrichSwarmIssues(childIssues, allMetadata) + + expect(result[0]?.complexity).toBeNull() + }) + + it('should set complexity to null when child has no worktreePath', () => { + const childIssues = [ + { number: '#101', title: 'Task without loom', body: 'body', url: 'https://github.com/org/repo/issues/101' }, + ] + const allMetadata: LoomMetadata[] = [] + const complexityMap = new Map([ + ['/some/path', { level: 'complex', reason: 'Many files' }], + ]) + + const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + + expect(result[0]?.complexity).toBeNull() + }) + + it('should include complexity with only level when reason is not provided', () => { + const childIssues = [ + { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, + ] + const allMetadata = [ + createChildLoomMetadata('101', 'pending', '/Users/dev/projects/myapp-looms/issue-101__child'), + ] + const complexityMap = new Map([ + ['/Users/dev/projects/myapp-looms/issue-101__child', { level: 'trivial' }], + ]) + + const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + + expect(result[0]?.complexity).toEqual({ level: 'trivial' }) + }) + }) +}) + +describe('loadComplexityMap', () => { + it('should return empty map when no worktree paths provided', async () => { + const result = await loadComplexityMap([]) + expect(result.size).toBe(0) + }) + + it('should return empty map when recap files have no complexity', async () => { + vi.mocked(readRecapFile).mockResolvedValue({}) + + const result = await loadComplexityMap(['/path/to/worktree']) + expect(result.size).toBe(0) + }) + + it('should extract complexity from recap files', async () => { + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'simple', reason: 'Single file', timestamp: '2024-01-01T00:00:00Z' }, + }) + + const result = await loadComplexityMap(['/path/to/worktree']) + + expect(result.size).toBe(1) + expect(result.get('/path/to/worktree')).toEqual({ level: 'simple', reason: 'Single file' }) + }) + + it('should exclude timestamp from complexity data', async () => { + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'complex', reason: 'Many changes', timestamp: '2024-01-01T00:00:00Z' }, + }) + + const result = await loadComplexityMap(['/path/to/worktree']) + const complexity = result.get('/path/to/worktree') + + expect(complexity).toEqual({ level: 'complex', reason: 'Many changes' }) + expect(complexity).not.toHaveProperty('timestamp') + }) + + it('should deduplicate worktree paths', async () => { + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'trivial', timestamp: '2024-01-01T00:00:00Z' }, + }) + + await loadComplexityMap(['/path/a', '/path/a', '/path/a']) + + expect(readRecapFile).toHaveBeenCalledTimes(1) + }) + + it('should filter out falsy paths', async () => { + vi.mocked(readRecapFile).mockResolvedValue({}) + + await loadComplexityMap(['', '/valid/path']) + + expect(readRecapFile).toHaveBeenCalledTimes(1) + }) + + it('should gracefully handle read errors', async () => { + vi.mocked(readRecapFile).mockRejectedValue(new Error('File read error')) + + const result = await loadComplexityMap(['/path/to/worktree']) + + expect(result.size).toBe(0) + }) + + it('should omit reason when not present in recap', async () => { + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'trivial', timestamp: '2024-01-01T00:00:00Z' }, + }) + + const result = await loadComplexityMap(['/path/to/worktree']) + + expect(result.get('/path/to/worktree')).toEqual({ level: 'trivial' }) + expect(result.get('/path/to/worktree')).not.toHaveProperty('reason') + }) }) describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () => { @@ -1979,6 +2133,7 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () url: 'https://github.com/org/repo/issues/101', state: 'in_progress', worktreePath: '/Users/dev/projects/myapp-looms/issue-101__child', + complexity: null, }, { number: '#102', @@ -1986,6 +2141,7 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () url: 'https://github.com/org/repo/issues/102', state: 'pending', worktreePath: '/Users/dev/projects/myapp-looms/issue-102__child', + complexity: null, }, ]) expect(result.dependencyMap).toEqual({ '#102': ['#101'] }) @@ -2118,6 +2274,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo url: 'https://github.com/org/repo/issues/201', state: 'done', worktreePath: '/Users/dev/projects/myapp-looms/issue-201__child', + complexity: null, }, ]) expect(result.dependencyMap).toEqual({}) @@ -2245,6 +2402,7 @@ describe('formatLoomsForJson - swarm issues propagation', () => { url: 'https://github.com/org/repo/issues/101', state: 'in_progress', worktreePath: '/Users/dev/projects/myapp-looms/issue-101__child', + complexity: null, }, ]) }) diff --git a/src/utils/loom-formatter.ts b/src/utils/loom-formatter.ts index 02cd7fd2..077ab4d5 100644 --- a/src/utils/loom-formatter.ts +++ b/src/utils/loom-formatter.ts @@ -1,5 +1,7 @@ import { realpathSync } from 'fs' import { extractIssueNumber } from './git.js' +import { logger } from './logger.js' +import { resolveRecapFilePath, readRecapFile } from './mcp.js' import type { GitWorktree } from '../types/worktree.js' import type { LoomMetadata, SwarmState } from '../lib/MetadataManager.js' import type { ProjectCapability } from '../types/loom.js' @@ -67,6 +69,15 @@ export interface ChildrenJson { summary: ChildrenSummary } +/** + * Complexity data for a swarm issue, sourced from recap files. + * Intentionally excludes timestamp from RecapComplexity. + */ +export interface SwarmComplexity { + level: string + reason?: string +} + /** * Swarm issue data for epic loom JSON output * Each child issue enriched with state and worktreePath from its loom metadata @@ -77,6 +88,38 @@ export interface SwarmIssue { url: string state: SwarmState | null worktreePath: string | null + complexity: SwarmComplexity | null +} + +/** + * Load complexity data from recap files for a set of worktree paths. + * Returns a Map for efficient lookup. + * Reads recap files in parallel with graceful degradation (missing/invalid files are skipped). + */ +export async function loadComplexityMap( + worktreePaths: string[], +): Promise> { + const map = new Map() + const uniquePaths = [...new Set(worktreePaths.filter(Boolean))] + + const results = await Promise.allSettled( + uniquePaths.map(async (wp) => { + const filePath = resolveRecapFilePath(wp) + const recap = await readRecapFile(filePath) + const comp = recap.complexity + if (comp && typeof comp === 'object' && !Array.isArray(comp) && typeof (comp as Record).level === 'string') { + const typed = comp as { level: string; reason?: string } + map.set(wp, { level: typed.level, ...(typed.reason && { reason: typed.reason }) }) + } + }), + ) + for (const result of results) { + if (result.status === 'rejected') { + logger.debug('Failed to load complexity from recap file', { error: result.reason }) + } + } + + return map } /** @@ -189,6 +232,7 @@ export function enrichSwarmIssues( allMetadata: LoomMetadata[], finishedMetadata?: LoomMetadata[], projectPath?: string | null, + complexityMap?: Map, ): SwarmIssue[] { // When projectPath is provided, filter metadata to only entries from the same project. // This prevents cross-project collisions where different projects share issue numbers. @@ -233,6 +277,7 @@ export function enrichSwarmIssues( url: child.url, state: childMeta?.state ?? null, worktreePath: childMeta?.worktreePath ?? null, + complexity: (childMeta?.worktreePath ? complexityMap?.get(childMeta.worktreePath) : undefined) ?? null, } }) } @@ -254,6 +299,7 @@ export function formatLoomForJson( metadata?: LoomMetadata | null, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], + complexityMap?: Map, ): LoomJsonOutput { // Use metadata values when available, otherwise derive from worktree const loomType = metadata?.issueType ?? determineLoomType(worktree) @@ -283,7 +329,7 @@ export function formatLoomForJson( // Build swarmIssues and dependencyMap for epic looms const isEpic = loomType === 'epic' const swarmIssues = isEpic && metadata?.childIssues && metadata.childIssues.length > 0 - ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata?.projectPath) + ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata?.projectPath, complexityMap) : isEpic ? [] : undefined const dependencyMap = isEpic ? (metadata?.dependencyMap && Object.keys(metadata.dependencyMap).length > 0 @@ -330,12 +376,13 @@ export function formatLoomsForJson( metadata?: Map, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], + complexityMap?: Map, ): LoomJsonOutput[] { // If allMetadata not provided, derive from metadata map values const resolvedAllMetadata = allMetadata ?? (metadata ? Array.from(metadata.values()).filter((m): m is LoomMetadata => m != null) : []) - return worktrees.map(wt => formatLoomForJson(wt, mainWorktreePath, metadata?.get(wt.path), resolvedAllMetadata, finishedMetadata)) + return worktrees.map(wt => formatLoomForJson(wt, mainWorktreePath, metadata?.get(wt.path), resolvedAllMetadata, finishedMetadata, complexityMap)) } /** @@ -347,14 +394,14 @@ export function formatLoomsForJson( * @param allMetadata - Optional array of all active loom metadata (for enriching epic swarm issues) * @param finishedMetadata - Optional finished/archived metadata for fallback swarm issue enrichment */ -export function formatFinishedLoomForJson(metadata: LoomMetadata, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[]): LoomJsonOutput { +export function formatFinishedLoomForJson(metadata: LoomMetadata, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], complexityMap?: Map): LoomJsonOutput { // Use metadata values for type, default to 'branch' if not set const loomType = metadata.issueType ?? 'branch' // Build swarmIssues and dependencyMap for epic looms const isEpic = loomType === 'epic' const swarmIssues = isEpic && metadata.childIssues && metadata.childIssues.length > 0 - ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata.projectPath) + ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata.projectPath, complexityMap) : isEpic ? [] : undefined const dependencyMap = isEpic ? (metadata.dependencyMap && Object.keys(metadata.dependencyMap).length > 0 From 13baf56d6ae223950d4d78806a935da0f6afe500 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Sat, 28 Feb 2026 22:24:48 -0500 Subject: [PATCH 2/2] refactor(issue-847): simplify complexity loading Remove loadComplexityMap helper and complexityMap parameter threading. Instead, read recap files inline in enrichSwarmIssues for each child with a worktreePath. Removes unnecessary pre-loading, deduplication, and parameter plumbing across 4 function signatures. --- src/cli.ts | 21 +- src/commands/list.regression.test.ts | 14 +- src/utils/loom-formatter.test.ts | 574 ++++++++++++--------------- src/utils/loom-formatter.ts | 79 ++-- 4 files changed, 308 insertions(+), 380 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7171109f..7b4858d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { hasMultipleRemotes } from './utils/remote.js' import { getIdeConfig, isIdeAvailable, getInstallHint } from './utils/ide.js' import { fileURLToPath } from 'url' import { realpathSync } from 'fs' -import { formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues, loadComplexityMap } from './utils/loom-formatter.js' +import { formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues } from './utils/loom-formatter.js' import { assembleChildrenData, type ChildrenData } from './utils/list-children.js' import { findMainWorktreePathWithSettings, GitCommandError, isValidGitRepo } from './utils/git.js' import chalk from 'chalk' @@ -1213,22 +1213,15 @@ program ? globalActiveLooms : Array.from(metadata.values()).filter((m): m is LoomMetadata => m != null) - // Build complexity map from recap files for all known worktree paths - const childWorktreePaths: string[] = [] - for (const meta of [...allActiveMetadata, ...finishedLooms]) { - if (meta.worktreePath) childWorktreePaths.push(meta.worktreePath) - } - const complexityMap = await loadComplexityMap(childWorktreePaths) - // Format active looms - let activeJson: ReturnType extends (infer T)[] ? (T & { status: 'active'; finishedAt: null })[] : never = [] + let activeJson: Awaited> extends (infer T)[] ? (T & { status: 'active'; finishedAt: null })[] : never = [] if (showActive) { if (options.global) { // Format global active looms from metadata (similar to finished looms format) - activeJson = globalActiveLooms.map(loom => { + activeJson = await Promise.all(globalActiveLooms.map(async loom => { const isEpic = (loom.issueType ?? 'branch') === 'epic' const swarmIssues = isEpic && loom.childIssues && loom.childIssues.length > 0 - ? enrichSwarmIssues(loom.childIssues, globalActiveLooms, finishedLooms, loom.projectPath, complexityMap) + ? await enrichSwarmIssues(loom.childIssues, globalActiveLooms, finishedLooms, loom.projectPath) : isEpic ? [] : undefined const depMap = isEpic ? (loom.dependencyMap && Object.keys(loom.dependencyMap).length > 0 @@ -1258,10 +1251,10 @@ program ...(swarmIssues !== undefined && { swarmIssues }), ...(depMap !== undefined && { dependencyMap: depMap }), } - }) + })) } else { // Format worktrees from current repo - activeJson = formatLoomsForJson(worktrees, mainWorktreePath, metadata, allActiveMetadata, finishedLooms, complexityMap).map(loom => ({ + activeJson = (await formatLoomsForJson(worktrees, mainWorktreePath, metadata, allActiveMetadata, finishedLooms)).map(loom => ({ ...loom, status: 'active' as const, finishedAt: null, @@ -1279,7 +1272,7 @@ program // Format finished looms (only when --finished or --all is set) let finishedJson = showFinished - ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms, complexityMap)) + ? await Promise.all(finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms))) : [] // Filter finished looms by project (include looms with null/undefined projectPath for legacy support) diff --git a/src/commands/list.regression.test.ts b/src/commands/list.regression.test.ts index 17060640..d9f089f6 100644 --- a/src/commands/list.regression.test.ts +++ b/src/commands/list.regression.test.ts @@ -70,13 +70,13 @@ describe('il list --json: finished looms gating regression', () => { expect(allLooms).toEqual([]) }) - it('should include finished looms when --finished flag is set', () => { + it('should include finished looms when --finished flag is set', async () => { // Simulate: il list --json --finished const options = { json: true, finished: true } const showFinished = Boolean(options.finished) || Boolean((options as { all?: boolean }).all) const finishedJson = showFinished - ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms)) + ? await Promise.all(finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms))) : [] const allLooms = [...activeJson, ...finishedJson] @@ -90,13 +90,13 @@ describe('il list --json: finished looms gating regression', () => { }) }) - it('should include finished looms when --all flag is set', () => { + it('should include finished looms when --all flag is set', async () => { // Simulate: il list --json --all const options = { json: true, all: true } const showFinished = Boolean((options as { finished?: boolean }).finished) || Boolean(options.all) const finishedJson = showFinished - ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms)) + ? await Promise.all(finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms))) : [] const allLooms = [...activeJson, ...finishedJson] @@ -110,7 +110,7 @@ describe('il list --json: finished looms gating regression', () => { }) }) - it('should demonstrate the bug: without gating, finished looms leak into output', () => { + it('should demonstrate the bug: without gating, finished looms leak into output', async () => { // This test shows what USED TO happen before the fix: // finishedJson was always populated from finishedLooms.map(...) // regardless of showFinished @@ -118,11 +118,11 @@ describe('il list --json: finished looms gating regression', () => { const showFinished = Boolean((options as { finished?: boolean }).finished) || Boolean((options as { all?: boolean }).all) // BUG BEHAVIOR (old code): always map finishedLooms - const buggyFinishedJson = finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms)) + const buggyFinishedJson = await Promise.all(finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms))) // FIX BEHAVIOR (new code): gate with showFinished const fixedFinishedJson = showFinished - ? finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms)) + ? await Promise.all(finishedLooms.map(loom => formatFinishedLoomForJson(loom, allActiveMetadata, finishedLooms))) : [] // The buggy version incorrectly includes finished looms diff --git a/src/utils/loom-formatter.test.ts b/src/utils/loom-formatter.test.ts index d51247a5..e2208726 100644 --- a/src/utils/loom-formatter.test.ts +++ b/src/utils/loom-formatter.test.ts @@ -4,8 +4,6 @@ import { formatLoomsForJson, formatFinishedLoomForJson, enrichSwarmIssues, - loadComplexityMap, - type SwarmComplexity, } from './loom-formatter.js' import { readRecapFile } from './mcp.js' import type { GitWorktree } from '../types/worktree.js' @@ -66,26 +64,26 @@ describe('formatLoomForJson', () => { } describe('isMainWorktree detection', () => { - it('should return true when worktree path matches mainWorktreePath', () => { + it('should return true when worktree path matches mainWorktreePath', async () => { const mainPath = '/Users/dev/projects/myapp' const worktree = createWorktree({ path: mainPath, branch: 'main' }) - const result = formatLoomForJson(worktree, mainPath) + const result = await formatLoomForJson(worktree, mainPath) expect(result.isMainWorktree).toBe(true) }) - it('should return false when worktree path does not match mainWorktreePath', () => { + it('should return false when worktree path does not match mainWorktreePath', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-456__add-feature' }) - const result = formatLoomForJson(worktree, '/Users/dev/projects/myapp') + const result = await formatLoomForJson(worktree, '/Users/dev/projects/myapp') expect(result.isMainWorktree).toBe(false) }) - it('should return false when mainWorktreePath is not provided', () => { + it('should return false when mainWorktreePath is not provided', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'main' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.isMainWorktree).toBe(false) }) - it('should correctly identify main worktree with realistic paths', () => { + it('should correctly identify main worktree with realistic paths', async () => { const mainPath = '/Users/adam/Documents/Projects/iloom-cli' const mainWorktree = createWorktree({ path: mainPath, @@ -98,8 +96,8 @@ describe('formatLoomForJson', () => { branchName: 'issue-269__json-formatter', }) - const mainResult = formatLoomForJson(mainWorktree, mainPath) - const featureResult = formatLoomForJson(featureWorktree, mainPath) + const mainResult = await formatLoomForJson(mainWorktree, mainPath) + const featureResult = await formatLoomForJson(featureWorktree, mainPath) expect(mainResult.isMainWorktree).toBe(true) expect(featureResult.isMainWorktree).toBe(false) @@ -107,33 +105,33 @@ describe('formatLoomForJson', () => { }) describe('type detection', () => { - it('should detect PR type from _pr_N path suffix', () => { + it('should detect PR type from _pr_N path suffix', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__feature', prNumber: 456, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('pr') }) - it('should detect issue type from issue-N branch pattern', () => { + it('should detect issue type from issue-N branch pattern', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__feature' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('issue') }) - it('should detect issue type from alphanumeric pattern (MARK-1)', () => { + it('should detect issue type from alphanumeric pattern (MARK-1)', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-MARK-1__feature' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('issue') }) - it('should default to branch type when no patterns match', () => { + it('should default to branch type when no patterns match', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'main', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('branch') }) @@ -173,12 +171,12 @@ describe('formatLoomForJson', () => { ['feature-dark-mode', 'branch'], ['add-json-formatter', 'branch'], ['wip-testing', 'branch'], - ])('should detect type for branch "%s" as "%s"', (branchName, expectedType) => { + ])('should detect type for branch "%s" as "%s"', async (branchName, expectedType) => { const worktree = createWorktree({ path: `/Users/dev/projects/myapp-looms/${branchName}`, branch: branchName, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe(expectedType) }) }) @@ -198,39 +196,39 @@ describe('formatLoomForJson', () => { // Path that looks like PR but branch has issue pattern ['/projects/myapp-looms/issue-42__test', 'issue'], - ])('should detect type for path "%s" as "%s"', (path, expectedType) => { + ])('should detect type for path "%s" as "%s"', async (path, expectedType) => { const worktree = createWorktree({ path, branch: 'issue-123__feature', // Branch always has issue pattern }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe(expectedType) }) }) }) describe('pr_numbers extraction', () => { - it('should extract PR number from path suffix for PR type', () => { + it('should extract PR number from path suffix for PR type', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__feature', prNumber: 456, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.pr_numbers).toEqual(['456']) }) - it('should return empty pr_numbers for issue type', () => { + it('should return empty pr_numbers for issue type', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__feature' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.pr_numbers).toEqual([]) }) - it('should return empty pr_numbers for branch type', () => { + it('should return empty pr_numbers for branch type', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'main', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.pr_numbers).toEqual([]) }) @@ -241,54 +239,54 @@ describe('formatLoomForJson', () => { [123, '123'], [9999, '9999'], [99999, '99999'], - ])('should extract PR number %d as string "%s"', (prNum, expectedStr) => { + ])('should extract PR number %d as string "%s"', async (prNum, expectedStr) => { const worktree = createRealisticWorktree({ branchName: 'issue-42__feature', prNumber: prNum, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.pr_numbers).toEqual([expectedStr]) }) }) describe('issue_numbers extraction', () => { - it('should extract numeric issue number from branch for issue type', () => { + it('should extract numeric issue number from branch for issue type', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-42__fix-bug' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual(['42']) expect(result.pr_numbers).toEqual([]) }) - it('should extract alphanumeric issue ID (Linear-style) for issue type', () => { + it('should extract alphanumeric issue ID (Linear-style) for issue type', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-PROJ-123__implement-feature' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual(['PROJ-123']) expect(result.pr_numbers).toEqual([]) }) - it('should return empty issue_numbers for branch type', () => { + it('should return empty issue_numbers for branch type', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'main', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual([]) }) - it('should handle old format issue branch (issue-N-slug)', () => { + it('should handle old format issue branch (issue-N-slug)', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-25-add-tests' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual(['25']) expect(result.pr_numbers).toEqual([]) }) - it('should return empty issue_numbers for PR type (pr_numbers populated instead)', () => { + it('should return empty issue_numbers for PR type (pr_numbers populated instead)', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__feature', prNumber: 456, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual(['456']) }) @@ -311,9 +309,9 @@ describe('formatLoomForJson', () => { ['issue-1-init', '1'], ['issue-42-fix', '42'], ['issue-123-feature', '123'], - ])('should extract issue number from "%s" as "%s"', (branchName, expectedIssue) => { + ])('should extract issue number from "%s" as "%s"', async (branchName, expectedIssue) => { const worktree = createRealisticWorktree({ branchName }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.issue_numbers).toEqual([expectedIssue]) expect(result.type).toBe('issue') }) @@ -321,59 +319,59 @@ describe('formatLoomForJson', () => { }) describe('field mapping', () => { - it('should use branch as name', () => { + it('should use branch as name', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-42__feature-test' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.name).toBe('issue-42__feature-test') }) - it('should use path as name when branch is empty', () => { + it('should use path as name when branch is empty', async () => { const worktree = createWorktree({ branch: '', path: '/Users/dev/projects/myapp-looms/orphan-worktree', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.name).toBe('/Users/dev/projects/myapp-looms/orphan-worktree') }) - it('should handle null worktreePath when bare is true', () => { + it('should handle null worktreePath when bare is true', async () => { const worktree = createWorktree({ bare: true, path: '/Users/dev/projects/myapp.git', branch: 'main', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBeNull() }) - it('should return path as worktreePath when not bare', () => { + it('should return path as worktreePath when not bare', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-42__feature' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBe('/Users/dev/projects/myapp-looms/issue-42__feature') }) - it('should return branch as branch field', () => { + it('should return branch as branch field', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-42__my-branch' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBe('issue-42__my-branch') }) - it('should return null for branch when empty', () => { + it('should return null for branch when empty', async () => { const worktree = createWorktree({ branch: '' }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBeNull() }) }) describe('detached HEAD states', () => { - it('should handle detached HEAD with branch set to HEAD', () => { + it('should handle detached HEAD with branch set to HEAD', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp-looms/detached-state', branch: 'HEAD', commit: 'abc123def456789012345678901234567890abcd', detached: true, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBe('HEAD') expect(result.name).toBe('HEAD') expect(result.type).toBe('branch') // No issue pattern in "HEAD" @@ -381,7 +379,7 @@ describe('formatLoomForJson', () => { expect(result.pr_numbers).toEqual([]) }) - it('should handle detached HEAD from bisect operation', () => { + it('should handle detached HEAD from bisect operation', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'HEAD', @@ -389,93 +387,93 @@ describe('formatLoomForJson', () => { detached: true, bare: false, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('branch') expect(result.worktreePath).toBe('/Users/dev/projects/myapp') }) - it('should correctly identify main worktree even when detached', () => { + it('should correctly identify main worktree even when detached', async () => { const mainPath = '/Users/dev/projects/myapp' const worktree = createWorktree({ path: mainPath, branch: 'HEAD', detached: true, }) - const result = formatLoomForJson(worktree, mainPath) + const result = await formatLoomForJson(worktree, mainPath) expect(result.isMainWorktree).toBe(true) }) }) describe('bare repositories', () => { - it('should set worktreePath to null for bare repository', () => { + it('should set worktreePath to null for bare repository', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp.git', branch: 'main', bare: true, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBeNull() expect(result.branch).toBe('main') expect(result.name).toBe('main') }) - it('should handle bare repo with custom default branch', () => { + it('should handle bare repo with custom default branch', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp.git', branch: 'develop', bare: true, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBeNull() expect(result.branch).toBe('develop') expect(result.type).toBe('branch') }) - it('should correctly identify bare repo as main worktree when path matches', () => { + it('should correctly identify bare repo as main worktree when path matches', async () => { const barePath = '/Users/dev/projects/myapp.git' const worktree = createWorktree({ path: barePath, branch: 'main', bare: true, }) - const result = formatLoomForJson(worktree, barePath) + const result = await formatLoomForJson(worktree, barePath) expect(result.isMainWorktree).toBe(true) expect(result.worktreePath).toBeNull() }) }) describe('locked worktrees', () => { - it('should handle locked worktree without reason', () => { + it('should handle locked worktree without reason', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-42__in-progress', locked: true, }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('issue') expect(result.issue_numbers).toEqual(['42']) // Note: locked status is not exposed in LoomJsonOutput, but should not break formatting }) - it('should handle locked worktree with lock reason', () => { + it('should handle locked worktree with lock reason', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-123__critical-fix', locked: true, lockReason: 'Locked for deployment review', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('issue') expect(result.issue_numbers).toEqual(['123']) expect(result.worktreePath).toBe('/Users/dev/projects/myapp-looms/issue-123__critical-fix') }) - it('should handle locked PR worktree', () => { + it('should handle locked PR worktree', async () => { const worktree = createRealisticWorktree({ branchName: 'issue-789__feature', prNumber: 100, locked: true, lockReason: 'PR under review', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.type).toBe('pr') expect(result.pr_numbers).toEqual(['100']) expect(result.issue_numbers).toEqual([]) @@ -483,59 +481,59 @@ describe('formatLoomForJson', () => { }) describe('edge cases and realistic git output scenarios', () => { - it('should handle worktree with empty commit hash', () => { + it('should handle worktree with empty commit hash', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp', branch: 'main', commit: '', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBe('main') expect(result.type).toBe('branch') }) - it('should handle worktree paths with spaces', () => { + it('should handle worktree paths with spaces', async () => { const worktree = createWorktree({ path: '/Users/dev/My Projects/myapp-looms/issue-42__feature', branch: 'issue-42__feature', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBe('/Users/dev/My Projects/myapp-looms/issue-42__feature') expect(result.type).toBe('issue') }) - it('should handle worktree paths with special characters', () => { + it('should handle worktree paths with special characters', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/my-app@2.0-looms/issue-42__feature', branch: 'issue-42__feature', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBe('/Users/dev/projects/my-app@2.0-looms/issue-42__feature') }) - it('should handle branch names with forward slashes (converted by git worktree)', () => { + it('should handle branch names with forward slashes (converted by git worktree)', async () => { // When a branch like "feat/add-feature" is used with worktrees, // the path typically has the slash converted const worktree = createWorktree({ path: '/Users/dev/projects/myapp-looms/feat-add-feature', branch: 'feat/add-feature', // Original branch name preserved }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBe('feat/add-feature') expect(result.name).toBe('feat/add-feature') expect(result.type).toBe('branch') }) - it('should handle very long branch names', () => { + it('should handle very long branch names', async () => { const longSlug = 'a'.repeat(100) const branchName = `issue-42__${longSlug}` const worktree = createRealisticWorktree({ branchName }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.branch).toBe(branchName) expect(result.issue_numbers).toEqual(['42']) }) - it('should handle worktree with all flags set', () => { + it('should handle worktree with all flags set', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp.git', branch: 'main', @@ -545,34 +543,34 @@ describe('formatLoomForJson', () => { locked: true, lockReason: 'Test lock', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBeNull() // bare=true overrides expect(result.branch).toBe('main') }) - it('should handle Windows-style paths', () => { + it('should handle Windows-style paths', async () => { const worktree = createWorktree({ path: 'C:\\Users\\dev\\projects\\myapp-looms\\issue-42__feature', branch: 'issue-42__feature', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBe('C:\\Users\\dev\\projects\\myapp-looms\\issue-42__feature') expect(result.type).toBe('issue') }) - it('should handle network/UNC paths', () => { + it('should handle network/UNC paths', async () => { const worktree = createWorktree({ path: '//server/share/projects/myapp-looms/issue-42__feature', branch: 'issue-42__feature', }) - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.worktreePath).toBe('//server/share/projects/myapp-looms/issue-42__feature') }) }) }) describe('formatLoomsForJson', () => { - it('should transform array of worktrees to JSON schema with correct issue/pr numbers', () => { + it('should transform array of worktrees to JSON schema with correct issue/pr numbers', async () => { const mainPath = '/Users/dev/projects/myapp' const worktrees: GitWorktree[] = [ { @@ -601,7 +599,7 @@ describe('formatLoomsForJson', () => { }, ] - const result = formatLoomsForJson(worktrees, mainPath) + const result = await formatLoomsForJson(worktrees, mainPath) expect(result).toHaveLength(3) // Issue type - issue_numbers populated, pr_numbers empty, not main @@ -624,13 +622,13 @@ describe('formatLoomsForJson', () => { expect(result[2].isMainWorktree).toBe(false) }) - it('should return empty array for empty input', () => { - const result = formatLoomsForJson([]) + it('should return empty array for empty input', async () => { + const result = await formatLoomsForJson([]) expect(result).toEqual([]) }) describe('realistic multi-worktree scenarios', () => { - it('should handle typical iloom workspace with multiple active issues', () => { + it('should handle typical iloom workspace with multiple active issues', async () => { const mainPath = '/Users/adam/Documents/Projects/iloom-cli' const worktrees: GitWorktree[] = [ // Main worktree @@ -671,7 +669,7 @@ describe('formatLoomsForJson', () => { }, ] - const result = formatLoomsForJson(worktrees, mainPath) + const result = await formatLoomsForJson(worktrees, mainPath) expect(result).toHaveLength(4) @@ -764,7 +762,7 @@ describe('formatLoomsForJson', () => { }) }) - it('should handle mixed worktree states (detached, locked, bare)', () => { + it('should handle mixed worktree states (detached, locked, bare)', async () => { const worktrees: GitWorktree[] = [ // Main bare repo { @@ -796,7 +794,7 @@ describe('formatLoomsForJson', () => { }, ] - const result = formatLoomsForJson(worktrees) + const result = await formatLoomsForJson(worktrees) expect(result).toHaveLength(3) @@ -814,7 +812,7 @@ describe('formatLoomsForJson', () => { expect(result[2].issue_numbers).toEqual(['100']) }) - it('should handle worktrees without mainWorktreePath provided', () => { + it('should handle worktrees without mainWorktreePath provided', async () => { const worktrees: GitWorktree[] = [ { path: '/Users/dev/projects/myapp', @@ -835,7 +833,7 @@ describe('formatLoomsForJson', () => { ] // No mainWorktreePath provided - const result = formatLoomsForJson(worktrees) + const result = await formatLoomsForJson(worktrees) expect(result).toHaveLength(2) // All should have isMainWorktree: false when not provided @@ -872,9 +870,9 @@ describe('formatFinishedLoomForJson', () => { }) describe('field mapping', () => { - it('should correctly format finished loom with all fields populated', () => { + it('should correctly format finished loom with all fields populated', async () => { const metadata = createFinishedMetadata() - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result).toEqual({ name: 'issue-269__json-formatter', @@ -900,61 +898,61 @@ describe('formatFinishedLoomForJson', () => { }) }) - it('should use branchName for name field', () => { + it('should use branchName for name field', async () => { const metadata = createFinishedMetadata({ branchName: 'issue-PROJ-123__feature-work', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.name).toBe('issue-PROJ-123__feature-work') }) - it('should fallback to worktreePath for name when branchName is null', () => { + it('should fallback to worktreePath for name when branchName is null', async () => { const metadata = createFinishedMetadata({ branchName: null, worktreePath: '/Users/dev/projects/myapp-looms/orphan-branch', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.name).toBe('/Users/dev/projects/myapp-looms/orphan-branch') }) - it('should fallback to "unknown" for name when both branchName and worktreePath are null', () => { + it('should fallback to "unknown" for name when both branchName and worktreePath are null', async () => { const metadata = createFinishedMetadata({ branchName: null, worktreePath: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.name).toBe('unknown') }) - it('should always set worktreePath to null for finished looms', () => { + it('should always set worktreePath to null for finished looms', async () => { const metadata = createFinishedMetadata({ worktreePath: '/some/path/that/should/be/ignored', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.worktreePath).toBeNull() }) - it('should always set isMainWorktree to false for finished looms', () => { + it('should always set isMainWorktree to false for finished looms', async () => { const metadata = createFinishedMetadata() - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.isMainWorktree).toBe(false) }) }) describe('type detection and issue/pr numbers', () => { - it('should format finished issue loom correctly', () => { + it('should format finished issue loom correctly', async () => { const metadata = createFinishedMetadata({ issueType: 'issue', issue_numbers: ['42'], pr_numbers: [], }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('issue') expect(result.issue_numbers).toEqual(['42']) expect(result.pr_numbers).toEqual([]) }) - it('should format finished PR loom correctly', () => { + it('should format finished PR loom correctly', async () => { const metadata = createFinishedMetadata({ branchName: 'issue-254__dotenv-flow', issueType: 'pr', @@ -962,47 +960,47 @@ describe('formatFinishedLoomForJson', () => { pr_numbers: ['255'], prUrls: { '255': 'https://github.com/owner/repo/pull/255' }, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('pr') expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual(['255']) expect(result.prUrls).toEqual({ '255': 'https://github.com/owner/repo/pull/255' }) }) - it('should format finished branch loom correctly', () => { + it('should format finished branch loom correctly', async () => { const metadata = createFinishedMetadata({ branchName: 'feat/new-feature', issueType: 'branch', issue_numbers: [], pr_numbers: [], }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('branch') expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual([]) }) - it('should default to branch type when issueType is null', () => { + it('should default to branch type when issueType is null', async () => { const metadata = createFinishedMetadata({ issueType: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('branch') }) - it('should handle Linear-style alphanumeric issue numbers', () => { + it('should handle Linear-style alphanumeric issue numbers', async () => { const metadata = createFinishedMetadata({ branchName: 'issue-PROJ-123__implement-feature', issueType: 'issue', issue_numbers: ['PROJ-123'], issueUrls: { 'PROJ-123': 'https://linear.app/org/issue/PROJ-123' }, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issue_numbers).toEqual(['PROJ-123']) expect(result.issueUrls).toEqual({ 'PROJ-123': 'https://linear.app/org/issue/PROJ-123' }) }) - it('should handle multiple issue numbers', () => { + it('should handle multiple issue numbers', async () => { const metadata = createFinishedMetadata({ issueType: 'issue', issue_numbers: ['42', 'PROJ-123', '999'], @@ -1012,7 +1010,7 @@ describe('formatFinishedLoomForJson', () => { '999': 'https://github.com/owner/repo/issues/999', }, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issue_numbers).toEqual(['42', 'PROJ-123', '999']) expect(result.issueUrls).toEqual({ '42': 'https://github.com/owner/repo/issues/42', @@ -1021,7 +1019,7 @@ describe('formatFinishedLoomForJson', () => { }) }) - it('should handle multiple PR numbers', () => { + it('should handle multiple PR numbers', async () => { const metadata = createFinishedMetadata({ issueType: 'pr', issue_numbers: [], @@ -1031,7 +1029,7 @@ describe('formatFinishedLoomForJson', () => { '101': 'https://github.com/owner/repo/pull/101', }, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.pr_numbers).toEqual(['100', '101']) expect(result.prUrls).toEqual({ '100': 'https://github.com/owner/repo/pull/100', @@ -1041,83 +1039,83 @@ describe('formatFinishedLoomForJson', () => { }) describe('optional field handling', () => { - it('should handle empty description field', () => { + it('should handle empty description field', async () => { const metadata = createFinishedMetadata({ description: '', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.description).toBe('') }) - it('should handle null created_at', () => { + it('should handle null created_at', async () => { const metadata = createFinishedMetadata({ created_at: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.created_at).toBeNull() }) - it('should handle null issueTracker', () => { + it('should handle null issueTracker', async () => { const metadata = createFinishedMetadata({ issueTracker: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issueTracker).toBeNull() }) - it('should handle null colorHex', () => { + it('should handle null colorHex', async () => { const metadata = createFinishedMetadata({ colorHex: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.colorHex).toBeNull() }) - it('should handle null projectPath', () => { + it('should handle null projectPath', async () => { const metadata = createFinishedMetadata({ projectPath: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.projectPath).toBeNull() }) - it('should handle empty issueUrls and prUrls', () => { + it('should handle empty issueUrls and prUrls', async () => { const metadata = createFinishedMetadata({ issueUrls: {}, prUrls: {}, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issueUrls).toEqual({}) expect(result.prUrls).toEqual({}) }) - it('should handle undefined status field with default "finished"', () => { + it('should handle undefined status field with default "finished"', async () => { const metadata = createFinishedMetadata({ status: undefined, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.status).toBe('finished') }) - it('should handle null finishedAt', () => { + it('should handle null finishedAt', async () => { const metadata = createFinishedMetadata({ finishedAt: null, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.finishedAt).toBeNull() }) - it('should handle undefined finishedAt', () => { + it('should handle undefined finishedAt', async () => { const metadata = createFinishedMetadata({ finishedAt: undefined, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.finishedAt).toBeNull() }) }) describe('edge cases and legacy metadata', () => { - it('should handle minimal legacy metadata with only required fields', () => { + it('should handle minimal legacy metadata with only required fields', async () => { const metadata: LoomMetadata = { description: 'Legacy loom', created_at: null, @@ -1138,7 +1136,7 @@ describe('formatFinishedLoomForJson', () => { status: 'finished', finishedAt: null, } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result).toEqual({ name: 'old-branch', worktreePath: null, @@ -1163,55 +1161,55 @@ describe('formatFinishedLoomForJson', () => { }) }) - it('should handle empty issue_numbers and pr_numbers arrays', () => { + it('should handle empty issue_numbers and pr_numbers arrays', async () => { const metadata = createFinishedMetadata({ issue_numbers: [], pr_numbers: [], }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual([]) }) - it('should handle branch names with special characters', () => { + it('should handle branch names with special characters', async () => { const metadata = createFinishedMetadata({ branchName: 'feat/add-feature@v2.0-beta', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.branch).toBe('feat/add-feature@v2.0-beta') expect(result.name).toBe('feat/add-feature@v2.0-beta') }) - it('should handle very long branch names', () => { + it('should handle very long branch names', async () => { const longSlug = 'a'.repeat(200) const branchName = `issue-42__${longSlug}` const metadata = createFinishedMetadata({ branchName, }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.branch).toBe(branchName) expect(result.name).toBe(branchName) }) - it('should handle finished loom with active status', () => { + it('should handle finished loom with active status', async () => { const metadata = createFinishedMetadata({ status: 'active', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.status).toBe('active') }) - it('should preserve exact status value from metadata', () => { + it('should preserve exact status value from metadata', async () => { const metadata = createFinishedMetadata({ status: 'finished', }) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.status).toBe('finished') }) }) describe('realistic finished loom scenarios', () => { - it('should format finished issue loom from iloom-cli project', () => { + it('should format finished issue loom from iloom-cli project', async () => { const metadata: LoomMetadata = { description: 'Add JSON formatter support to il list command', created_at: '2024-01-15T10:30:00.000Z', @@ -1232,7 +1230,7 @@ describe('formatFinishedLoomForJson', () => { status: 'finished', finishedAt: '2024-01-20T15:45:00.000Z', } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result).toEqual({ name: 'issue-269__json-formatter', worktreePath: null, @@ -1257,7 +1255,7 @@ describe('formatFinishedLoomForJson', () => { }) }) - it('should format finished PR loom', () => { + it('should format finished PR loom', async () => { const metadata: LoomMetadata = { description: 'Implement dotenv-flow integration', created_at: '2024-01-10T08:00:00.000Z', @@ -1278,14 +1276,14 @@ describe('formatFinishedLoomForJson', () => { status: 'finished', finishedAt: '2024-01-18T12:30:00.000Z', } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('pr') expect(result.pr_numbers).toEqual(['255']) expect(result.prUrls).toEqual({ '255': 'https://github.com/acreeger/iloom-cli/pull/255' }) expect(result.status).toBe('finished') }) - it('should format finished Linear-style issue loom', () => { + it('should format finished Linear-style issue loom', async () => { const metadata: LoomMetadata = { description: 'Implement new reporting feature', created_at: '2024-01-12T14:20:00.000Z', @@ -1306,13 +1304,13 @@ describe('formatFinishedLoomForJson', () => { status: 'finished', finishedAt: '2024-01-22T09:15:00.000Z', } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.issue_numbers).toEqual(['ILOOM-42']) expect(result.issueUrls).toEqual({ 'ILOOM-42': 'https://linear.app/company/issue/ILOOM-42' }) expect(result.issueTracker).toBe('linear') }) - it('should format finished branch loom without issue tracking', () => { + it('should format finished branch loom without issue tracking', async () => { const metadata: LoomMetadata = { description: 'Experimental feature branch', created_at: '2024-01-08T16:45:00.000Z', @@ -1333,7 +1331,7 @@ describe('formatFinishedLoomForJson', () => { status: 'finished', finishedAt: '2024-01-25T11:00:00.000Z', } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.type).toBe('branch') expect(result.issue_numbers).toEqual([]) expect(result.pr_numbers).toEqual([]) @@ -1378,24 +1376,24 @@ describe('formatLoomForJson - child loom fields', () => { }, }) - it('should set isChildLoom: false when parentLoom is null', () => { + it('should set isChildLoom: false when parentLoom is null', async () => { const worktree = createWorktree() - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.isChildLoom).toBe(false) expect(result.parentLoom).toBeNull() }) - it('should set isChildLoom: true when parentLoom exists', () => { + it('should set isChildLoom: true when parentLoom exists', async () => { const worktree = createWorktree() const metadata = createMetadataWithParent() - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.isChildLoom).toBe(true) }) - it('should include parentLoom reference in output when present', () => { + it('should include parentLoom reference in output when present', async () => { const worktree = createWorktree() const metadata = createMetadataWithParent() - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.parentLoom).toEqual({ type: 'issue', identifier: '100', @@ -1405,7 +1403,7 @@ describe('formatLoomForJson - child loom fields', () => { }) }) - it('should handle parentLoom without optional databaseBranch', () => { + it('should handle parentLoom without optional databaseBranch', async () => { const worktree = createWorktree() const metadata: LoomMetadata = { ...createMetadataWithParent(), @@ -1416,7 +1414,7 @@ describe('formatLoomForJson - child loom fields', () => { worktreePath: '/Users/dev/projects/myapp-looms/issue-100__parent-feature', }, } - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.isChildLoom).toBe(true) expect(result.parentLoom?.databaseBranch).toBeUndefined() }) @@ -1449,25 +1447,25 @@ describe('formatFinishedLoomForJson - child loom fields', () => { finishedAt: '2024-01-20T15:45:00.000Z', }) - it('should set isChildLoom: false when parentLoom is null', () => { + it('should set isChildLoom: false when parentLoom is null', async () => { const metadata: LoomMetadata = { ...createFinishedMetadataWithParent(), parentLoom: null, } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.isChildLoom).toBe(false) expect(result.parentLoom).toBeNull() }) - it('should set isChildLoom: true when parentLoom exists', () => { + it('should set isChildLoom: true when parentLoom exists', async () => { const metadata = createFinishedMetadataWithParent() - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.isChildLoom).toBe(true) }) - it('should include parentLoom reference in output when present', () => { + it('should include parentLoom reference in output when present', async () => { const metadata = createFinishedMetadataWithParent() - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.parentLoom).toEqual({ type: 'issue', identifier: '100', @@ -1510,16 +1508,16 @@ describe('formatLoomForJson - swarm state field', () => { parentLoom: null, }) - it('should return state: null when no metadata is provided', () => { + it('should return state: null when no metadata is provided', async () => { const worktree = createWorktree() - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.state).toBeNull() }) - it('should return state: null when metadata has no state', () => { + it('should return state: null when metadata has no state', async () => { const worktree = createWorktree() const metadata = createMetadataWithState(null) - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.state).toBeNull() }) @@ -1529,10 +1527,10 @@ describe('formatLoomForJson - swarm state field', () => { 'code_review' as const, 'done' as const, 'failed' as const, - ])('should include state "%s" in output when set', (state) => { + ])('should include state "%s" in output when set', async (state) => { const worktree = createWorktree() const metadata = createMetadataWithState(state) - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.state).toBe(state) }) }) @@ -1562,9 +1560,9 @@ describe('formatFinishedLoomForJson - swarm state field', () => { finishedAt: '2024-01-20T15:45:00.000Z', }) - it('should return state: null when metadata has no state', () => { + it('should return state: null when metadata has no state', async () => { const metadata = createFinishedMetadataWithState(null) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.state).toBeNull() }) @@ -1574,9 +1572,9 @@ describe('formatFinishedLoomForJson - swarm state field', () => { 'code_review' as const, 'done' as const, 'failed' as const, - ])('should include state "%s" in output when set', (state) => { + ])('should include state "%s" in output when set', async (state) => { const metadata = createFinishedMetadataWithState(state) - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.state).toBe(state) }) }) @@ -1620,7 +1618,7 @@ describe('enrichSwarmIssues', () => { dependencyMap: {}, }) - it('should enrich child issues with state and worktreePath from child loom metadata', () => { + it('should enrich child issues with state and worktreePath from child loom metadata', async () => { const childIssues = [ { number: '#101', title: 'First task', body: 'body1', url: 'https://github.com/org/repo/issues/101' }, { number: '#102', title: 'Second task', body: 'body2', url: 'https://github.com/org/repo/issues/102' }, @@ -1630,7 +1628,7 @@ describe('enrichSwarmIssues', () => { createChildLoomMetadata('102', 'done', '/Users/dev/projects/myapp-looms/issue-102__child'), ] - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result).toEqual([ { @@ -1652,13 +1650,13 @@ describe('enrichSwarmIssues', () => { ]) }) - it('should set state and worktreePath to null when no child loom exists', () => { + it('should set state and worktreePath to null when no child loom exists', async () => { const childIssues = [ { number: '#101', title: 'Task without loom', body: 'body', url: 'https://github.com/org/repo/issues/101' }, ] const allMetadata: LoomMetadata[] = [] - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result).toEqual([ { @@ -1672,7 +1670,7 @@ describe('enrichSwarmIssues', () => { ]) }) - it('should handle Linear-style issue numbers (no # prefix)', () => { + it('should handle Linear-style issue numbers (no # prefix)', async () => { const childIssues = [ { number: 'ENG-123', title: 'Linear task', body: 'body', url: 'https://linear.app/org/issue/ENG-123' }, ] @@ -1680,7 +1678,7 @@ describe('enrichSwarmIssues', () => { createChildLoomMetadata('ENG-123', 'code_review', '/Users/dev/projects/myapp-looms/issue-ENG-123__task'), ] - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result).toEqual([ { @@ -1694,7 +1692,7 @@ describe('enrichSwarmIssues', () => { ]) }) - it('should handle mixed matched and unmatched child issues', () => { + it('should handle mixed matched and unmatched child issues', async () => { const childIssues = [ { number: '#101', title: 'Matched task', body: 'body1', url: 'https://github.com/org/repo/issues/101' }, { number: '#102', title: 'Unmatched task', body: 'body2', url: 'https://github.com/org/repo/issues/102' }, @@ -1703,7 +1701,7 @@ describe('enrichSwarmIssues', () => { createChildLoomMetadata('101', 'pending', '/Users/dev/projects/myapp-looms/issue-101__child'), ] - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.state).toBe('pending') expect(result[0]?.worktreePath).toBe('/Users/dev/projects/myapp-looms/issue-101__child') @@ -1713,12 +1711,12 @@ describe('enrichSwarmIssues', () => { expect(result[1]?.complexity).toBeNull() }) - it('should return empty array for empty childIssues', () => { - const result = enrichSwarmIssues([], []) + it('should return empty array for empty childIssues', async () => { + const result = await enrichSwarmIssues([], []) expect(result).toEqual([]) }) - it('should fall back to finished metadata when child loom is not in active metadata', () => { + it('should fall back to finished metadata when child loom is not in active metadata', async () => { const childIssues = [ { number: '#101', title: 'Cleaned up task', body: 'body1', url: 'https://github.com/org/repo/issues/101' }, { number: '#102', title: 'Still active task', body: 'body2', url: 'https://github.com/org/repo/issues/102' }, @@ -1736,7 +1734,7 @@ describe('enrichSwarmIssues', () => { }, ] - const result = enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata) + const result = await enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata) expect(result).toEqual([ { @@ -1758,7 +1756,7 @@ describe('enrichSwarmIssues', () => { ]) }) - it('should prefer active metadata over finished metadata for the same issue', () => { + it('should prefer active metadata over finished metadata for the same issue', async () => { const childIssues = [ { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, ] @@ -1773,7 +1771,7 @@ describe('enrichSwarmIssues', () => { }, ] - const result = enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata) + const result = await enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata) // Active metadata should take precedence expect(result[0]?.state).toBe('in_progress') @@ -1791,7 +1789,7 @@ describe('enrichSwarmIssues', () => { projectPath, }) - it('should only match metadata from the same project, preventing cross-project collisions', () => { + it('should only match metadata from the same project, preventing cross-project collisions', async () => { const childIssues = [ { number: '#2', title: 'Resume builder task', body: 'body', url: 'https://github.com/org/resume-builder/issues/2' }, ] @@ -1801,13 +1799,13 @@ describe('enrichSwarmIssues', () => { createMetaForProject('2', 'done', '/projects/real-estate-looms/issue-2__task', '/projects/real-estate'), ] - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/resume-builder') + const result = await enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/resume-builder') expect(result[0]?.state).toBe('in_progress') expect(result[0]?.worktreePath).toBe('/projects/resume-builder-looms/issue-2__task') }) - it('should scope finished metadata by project too', () => { + it('should scope finished metadata by project too', async () => { const childIssues = [ { number: '#3', title: 'Task three', body: 'body', url: 'https://github.com/org/project-a/issues/3' }, ] @@ -1829,14 +1827,14 @@ describe('enrichSwarmIssues', () => { }, ] - const result = enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata, '/projects/project-a') + const result = await enrichSwarmIssues(childIssues, activeMetadata, finishedMetadata, '/projects/project-a') // Should fall back to project-a's finished metadata, not project-b's active or finished expect(result[0]?.state).toBe('done') expect(result[0]?.worktreePath).toBe('/projects/project-a-looms/issue-3__done') }) - it('should fall back to unscoped behavior when projectPath is null', () => { + it('should fall back to unscoped behavior when projectPath is null', async () => { const childIssues = [ { number: '#5', title: 'Legacy task', body: 'body', url: 'https://github.com/org/repo/issues/5' }, ] @@ -1845,13 +1843,13 @@ describe('enrichSwarmIssues', () => { ] // null projectPath => no filtering, matches any project - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, null) + const result = await enrichSwarmIssues(childIssues, allMetadata, undefined, null) expect(result[0]?.state).toBe('pending') expect(result[0]?.worktreePath).toBe('/projects/some-project-looms/issue-5__work') }) - it('should fall back to unscoped behavior when projectPath is undefined', () => { + it('should fall back to unscoped behavior when projectPath is undefined', async () => { const childIssues = [ { number: '#5', title: 'Legacy task', body: 'body', url: 'https://github.com/org/repo/issues/5' }, ] @@ -1860,12 +1858,12 @@ describe('enrichSwarmIssues', () => { ] // undefined projectPath => no filtering - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.state).toBe('pending') }) - it('should handle realpathSync errors gracefully (falls back to original path)', () => { + it('should handle realpathSync errors gracefully (falls back to original path)', async () => { // When realpathSync throws (e.g., path doesn't exist), resolvePathSafe falls back to original. // This means paths that differ only in symlinks but don't resolve will still compare by string. const childIssues = [ @@ -1876,13 +1874,13 @@ describe('enrichSwarmIssues', () => { ] // Same string path => matches even if realpathSync can't resolve - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/project-a') + const result = await enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/project-a') expect(result[0]?.state).toBe('in_progress') expect(result[0]?.worktreePath).toBe('/projects/project-a-looms/issue-7__work') }) - it('should exclude metadata entries with null projectPath when scoping is active', () => { + it('should exclude metadata entries with null projectPath when scoping is active', async () => { const childIssues = [ { number: '#10', title: 'Test', body: 'body', url: 'https://github.com/org/repo/issues/10' }, ] @@ -1894,7 +1892,7 @@ describe('enrichSwarmIssues', () => { }, ] - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/my-project') + const result = await enrichSwarmIssues(childIssues, allMetadata, undefined, '/projects/my-project') // Legacy entry (null projectPath) should NOT match when we're scoping expect(result[0]?.state).toBeNull() @@ -1903,7 +1901,7 @@ describe('enrichSwarmIssues', () => { }) describe('complexity enrichment', () => { - it('should include complexity from complexityMap when worktreePath matches', () => { + it('should read complexity from recap file for children with worktreePaths', async () => { const childIssues = [ { number: '#101', title: 'First task', body: 'body1', url: 'https://github.com/org/repo/issues/101' }, { number: '#102', title: 'Second task', body: 'body2', url: 'https://github.com/org/repo/issues/102' }, @@ -1912,132 +1910,88 @@ describe('enrichSwarmIssues', () => { createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), createChildLoomMetadata('102', 'done', '/Users/dev/projects/myapp-looms/issue-102__child'), ] - const complexityMap = new Map([ - ['/Users/dev/projects/myapp-looms/issue-101__child', { level: 'simple', reason: 'Single file change' }], - ]) + vi.mocked(readRecapFile) + .mockResolvedValueOnce({ complexity: { level: 'simple', reason: 'Single file change' } }) + .mockResolvedValueOnce({}) - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.complexity).toEqual({ level: 'simple', reason: 'Single file change' }) expect(result[1]?.complexity).toBeNull() }) - it('should set complexity to null when no complexityMap is provided', () => { + it('should set complexity to null when recap file has no complexity', async () => { const childIssues = [ { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, ] const allMetadata = [ createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), ] + vi.mocked(readRecapFile).mockResolvedValue({}) - const result = enrichSwarmIssues(childIssues, allMetadata) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.complexity).toBeNull() }) - it('should set complexity to null when child has no worktreePath', () => { + it('should set complexity to null when child has no worktreePath', async () => { const childIssues = [ { number: '#101', title: 'Task without loom', body: 'body', url: 'https://github.com/org/repo/issues/101' }, ] const allMetadata: LoomMetadata[] = [] - const complexityMap = new Map([ - ['/some/path', { level: 'complex', reason: 'Many files' }], - ]) - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.complexity).toBeNull() + expect(readRecapFile).not.toHaveBeenCalled() }) - it('should include complexity with only level when reason is not provided', () => { + it('should include complexity with only level when reason is not provided', async () => { const childIssues = [ { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, ] const allMetadata = [ createChildLoomMetadata('101', 'pending', '/Users/dev/projects/myapp-looms/issue-101__child'), ] - const complexityMap = new Map([ - ['/Users/dev/projects/myapp-looms/issue-101__child', { level: 'trivial' }], - ]) + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'trivial' }, + }) - const result = enrichSwarmIssues(childIssues, allMetadata, undefined, undefined, complexityMap) + const result = await enrichSwarmIssues(childIssues, allMetadata) expect(result[0]?.complexity).toEqual({ level: 'trivial' }) }) - }) -}) - -describe('loadComplexityMap', () => { - it('should return empty map when no worktree paths provided', async () => { - const result = await loadComplexityMap([]) - expect(result.size).toBe(0) - }) - - it('should return empty map when recap files have no complexity', async () => { - vi.mocked(readRecapFile).mockResolvedValue({}) - - const result = await loadComplexityMap(['/path/to/worktree']) - expect(result.size).toBe(0) - }) - - it('should extract complexity from recap files', async () => { - vi.mocked(readRecapFile).mockResolvedValue({ - complexity: { level: 'simple', reason: 'Single file', timestamp: '2024-01-01T00:00:00Z' }, - }) - - const result = await loadComplexityMap(['/path/to/worktree']) - expect(result.size).toBe(1) - expect(result.get('/path/to/worktree')).toEqual({ level: 'simple', reason: 'Single file' }) - }) - - it('should exclude timestamp from complexity data', async () => { - vi.mocked(readRecapFile).mockResolvedValue({ - complexity: { level: 'complex', reason: 'Many changes', timestamp: '2024-01-01T00:00:00Z' }, - }) - - const result = await loadComplexityMap(['/path/to/worktree']) - const complexity = result.get('/path/to/worktree') + it('should exclude timestamp from complexity data', async () => { + const childIssues = [ + { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, + ] + const allMetadata = [ + createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), + ] + vi.mocked(readRecapFile).mockResolvedValue({ + complexity: { level: 'complex', reason: 'Many changes', timestamp: '2024-01-01T00:00:00Z' }, + }) - expect(complexity).toEqual({ level: 'complex', reason: 'Many changes' }) - expect(complexity).not.toHaveProperty('timestamp') - }) + const result = await enrichSwarmIssues(childIssues, allMetadata) - it('should deduplicate worktree paths', async () => { - vi.mocked(readRecapFile).mockResolvedValue({ - complexity: { level: 'trivial', timestamp: '2024-01-01T00:00:00Z' }, + expect(result[0]?.complexity).toEqual({ level: 'complex', reason: 'Many changes' }) + expect(result[0]?.complexity).not.toHaveProperty('timestamp') }) - await loadComplexityMap(['/path/a', '/path/a', '/path/a']) - - expect(readRecapFile).toHaveBeenCalledTimes(1) - }) - - it('should filter out falsy paths', async () => { - vi.mocked(readRecapFile).mockResolvedValue({}) - - await loadComplexityMap(['', '/valid/path']) - - expect(readRecapFile).toHaveBeenCalledTimes(1) - }) - - it('should gracefully handle read errors', async () => { - vi.mocked(readRecapFile).mockRejectedValue(new Error('File read error')) - - const result = await loadComplexityMap(['/path/to/worktree']) + it('should gracefully handle recap file read errors', async () => { + const childIssues = [ + { number: '#101', title: 'Task', body: 'body', url: 'https://github.com/org/repo/issues/101' }, + ] + const allMetadata = [ + createChildLoomMetadata('101', 'in_progress', '/Users/dev/projects/myapp-looms/issue-101__child'), + ] + vi.mocked(readRecapFile).mockRejectedValue(new Error('File read error')) - expect(result.size).toBe(0) - }) + const result = await enrichSwarmIssues(childIssues, allMetadata) - it('should omit reason when not present in recap', async () => { - vi.mocked(readRecapFile).mockResolvedValue({ - complexity: { level: 'trivial', timestamp: '2024-01-01T00:00:00Z' }, + expect(result[0]?.complexity).toBeNull() }) - - const result = await loadComplexityMap(['/path/to/worktree']) - - expect(result.get('/path/to/worktree')).toEqual({ level: 'trivial' }) - expect(result.get('/path/to/worktree')).not.toHaveProperty('reason') }) }) @@ -2115,7 +2069,7 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () dependencyMap: {}, }) - it('should include swarmIssues and dependencyMap for epic loom with child issues', () => { + it('should include swarmIssues and dependencyMap for epic loom with child issues', async () => { const worktree = createWorktree() const metadata = createEpicMetadata() const allMetadata = [ @@ -2123,7 +2077,7 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () createChildMetadata('102', 'pending', '/Users/dev/projects/myapp-looms/issue-102__child'), ] - const result = formatLoomForJson(worktree, undefined, metadata, allMetadata) + const result = await formatLoomForJson(worktree, undefined, metadata, allMetadata) expect(result.type).toBe('epic') expect(result.swarmIssues).toEqual([ @@ -2147,18 +2101,18 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () expect(result.dependencyMap).toEqual({ '#102': ['#101'] }) }) - it('should return empty swarmIssues for epic loom with no childIssues', () => { + it('should return empty swarmIssues for epic loom with no childIssues', async () => { const worktree = createWorktree() const metadata = createEpicMetadata({ childIssues: [], dependencyMap: {} }) - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.type).toBe('epic') expect(result.swarmIssues).toEqual([]) expect(result.dependencyMap).toEqual({}) }) - it('should not include swarmIssues or dependencyMap for non-epic looms', () => { + it('should not include swarmIssues or dependencyMap for non-epic looms', async () => { const worktree = createWorktree({ path: '/Users/dev/projects/myapp-looms/issue-42__feature', branch: 'issue-42__feature', @@ -2188,16 +2142,16 @@ describe('formatLoomForJson - swarmIssues and dependencyMap for epic looms', () dependencyMap: {}, } - const result = formatLoomForJson(worktree, undefined, metadata) + const result = await formatLoomForJson(worktree, undefined, metadata) expect(result.type).toBe('issue') expect(result.swarmIssues).toBeUndefined() expect(result.dependencyMap).toBeUndefined() }) - it('should not include swarmIssues or dependencyMap when no metadata', () => { + it('should not include swarmIssues or dependencyMap when no metadata', async () => { const worktree = createWorktree() - const result = formatLoomForJson(worktree) + const result = await formatLoomForJson(worktree) expect(result.swarmIssues).toBeUndefined() expect(result.dependencyMap).toBeUndefined() @@ -2235,7 +2189,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo ...overrides, }) - it('should include swarmIssues and dependencyMap for finished epic loom', () => { + it('should include swarmIssues and dependencyMap for finished epic loom', async () => { const metadata = createFinishedEpicMetadata() const allMetadata: LoomMetadata[] = [ { @@ -2264,7 +2218,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo }, ] - const result = formatFinishedLoomForJson(metadata, allMetadata) + const result = await formatFinishedLoomForJson(metadata, allMetadata) expect(result.type).toBe('epic') expect(result.swarmIssues).toEqual([ @@ -2280,7 +2234,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo expect(result.dependencyMap).toEqual({}) }) - it('should not include swarmIssues or dependencyMap for finished non-epic loom', () => { + it('should not include swarmIssues or dependencyMap for finished non-epic loom', async () => { const metadata: LoomMetadata = { description: 'Finished issue', created_at: '2024-01-15T10:30:00.000Z', @@ -2308,7 +2262,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo finishedAt: '2024-01-20T15:45:00.000Z', } - const result = formatFinishedLoomForJson(metadata) + const result = await formatFinishedLoomForJson(metadata) expect(result.swarmIssues).toBeUndefined() expect(result.dependencyMap).toBeUndefined() @@ -2316,7 +2270,7 @@ describe('formatFinishedLoomForJson - swarmIssues and dependencyMap for epic loo }) describe('formatLoomsForJson - swarm issues propagation', () => { - it('should propagate allMetadata to individual loom formatting for epic looms', () => { + it('should propagate allMetadata to individual loom formatting for epic looms', async () => { const mainPath = '/Users/dev/projects/myapp' const epicWorktree: GitWorktree = { path: '/Users/dev/projects/myapp-looms/issue-100__epic', @@ -2387,7 +2341,7 @@ describe('formatLoomsForJson - swarm issues propagation', () => { const metadataMap = new Map() metadataMap.set(epicWorktree.path, epicMetadata) - const result = formatLoomsForJson( + const result = await formatLoomsForJson( [epicWorktree], mainPath, metadataMap, diff --git a/src/utils/loom-formatter.ts b/src/utils/loom-formatter.ts index 077ab4d5..fae80a73 100644 --- a/src/utils/loom-formatter.ts +++ b/src/utils/loom-formatter.ts @@ -1,6 +1,5 @@ import { realpathSync } from 'fs' import { extractIssueNumber } from './git.js' -import { logger } from './logger.js' import { resolveRecapFilePath, readRecapFile } from './mcp.js' import type { GitWorktree } from '../types/worktree.js' import type { LoomMetadata, SwarmState } from '../lib/MetadataManager.js' @@ -91,37 +90,6 @@ export interface SwarmIssue { complexity: SwarmComplexity | null } -/** - * Load complexity data from recap files for a set of worktree paths. - * Returns a Map for efficient lookup. - * Reads recap files in parallel with graceful degradation (missing/invalid files are skipped). - */ -export async function loadComplexityMap( - worktreePaths: string[], -): Promise> { - const map = new Map() - const uniquePaths = [...new Set(worktreePaths.filter(Boolean))] - - const results = await Promise.allSettled( - uniquePaths.map(async (wp) => { - const filePath = resolveRecapFilePath(wp) - const recap = await readRecapFile(filePath) - const comp = recap.complexity - if (comp && typeof comp === 'object' && !Array.isArray(comp) && typeof (comp as Record).level === 'string') { - const typed = comp as { level: string; reason?: string } - map.set(wp, { level: typed.level, ...(typed.reason && { reason: typed.reason }) }) - } - }), - ) - for (const result of results) { - if (result.status === 'rejected') { - logger.debug('Failed to load complexity from recap file', { error: result.reason }) - } - } - - return map -} - /** * JSON output schema for il list --json */ @@ -221,19 +189,20 @@ function extractIssueNumbers(branch: string): string[] { * When a child loom is not found in active metadata, falls back to checking * finished/archived metadata to preserve state for cleaned-up child looms. * + * For children with a worktreePath, reads the recap file to extract complexity data. + * * @param childIssues - Child issues from epic metadata * @param allMetadata - All active loom metadata to search for child looms * @param finishedMetadata - Optional finished/archived loom metadata for fallback lookup * @param projectPath - Optional project path to scope metadata filtering (prevents cross-project collisions) - * @returns Array of SwarmIssue with enriched state and worktreePath + * @returns Array of SwarmIssue with enriched state, worktreePath, and complexity */ -export function enrichSwarmIssues( +export async function enrichSwarmIssues( childIssues: LoomMetadata['childIssues'], allMetadata: LoomMetadata[], finishedMetadata?: LoomMetadata[], projectPath?: string | null, - complexityMap?: Map, -): SwarmIssue[] { +): Promise { // When projectPath is provided, filter metadata to only entries from the same project. // This prevents cross-project collisions where different projects share issue numbers. const resolvedProjectPath = projectPath ? resolvePathSafe(projectPath) : null @@ -262,7 +231,7 @@ export function enrichSwarmIssues( } } - return childIssues.map((child) => { + return Promise.all(childIssues.map(async (child) => { // Strip the '#' prefix from GitHub issue numbers for lookup // e.g., "#123" -> "123", "ENG-123" stays as-is const lookupNumber = child.number.startsWith('#') @@ -271,15 +240,29 @@ export function enrichSwarmIssues( const childMeta = issueNumberToMetadata.get(lookupNumber) ?? finishedIssueNumberToMetadata.get(lookupNumber) + let complexity: SwarmComplexity | null = null + if (childMeta?.worktreePath) { + try { + const recapPath = resolveRecapFilePath(childMeta.worktreePath) + const recap = await readRecapFile(recapPath) + const comp = recap.complexity as { level?: string; reason?: string } | undefined + if (comp && typeof comp.level === 'string') { + complexity = { level: comp.level, ...(comp.reason ? { reason: comp.reason } : {}) } + } + } catch { + // Recap file missing or invalid - complexity stays null + } + } + return { number: child.number, title: child.title, url: child.url, state: childMeta?.state ?? null, worktreePath: childMeta?.worktreePath ?? null, - complexity: (childMeta?.worktreePath ? complexityMap?.get(childMeta.worktreePath) : undefined) ?? null, + complexity, } - }) + })) } /** @@ -293,14 +276,13 @@ export function enrichSwarmIssues( * @param allMetadata - Optional array of all active loom metadata (for enriching epic swarm issues) * @param finishedMetadata - Optional finished/archived metadata for fallback swarm issue enrichment */ -export function formatLoomForJson( +export async function formatLoomForJson( worktree: GitWorktree, mainWorktreePath?: string, metadata?: LoomMetadata | null, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], - complexityMap?: Map, -): LoomJsonOutput { +): Promise { // Use metadata values when available, otherwise derive from worktree const loomType = metadata?.issueType ?? determineLoomType(worktree) @@ -329,7 +311,7 @@ export function formatLoomForJson( // Build swarmIssues and dependencyMap for epic looms const isEpic = loomType === 'epic' const swarmIssues = isEpic && metadata?.childIssues && metadata.childIssues.length > 0 - ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata?.projectPath, complexityMap) + ? await enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata?.projectPath) : isEpic ? [] : undefined const dependencyMap = isEpic ? (metadata?.dependencyMap && Object.keys(metadata.dependencyMap).length > 0 @@ -370,19 +352,18 @@ export function formatLoomForJson( * @param allMetadata - Optional array of all active loom metadata (for enriching epic swarm issues) * @param finishedMetadata - Optional finished/archived metadata for fallback swarm issue enrichment */ -export function formatLoomsForJson( +export async function formatLoomsForJson( worktrees: GitWorktree[], mainWorktreePath?: string, metadata?: Map, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], - complexityMap?: Map, -): LoomJsonOutput[] { +): Promise { // If allMetadata not provided, derive from metadata map values const resolvedAllMetadata = allMetadata ?? (metadata ? Array.from(metadata.values()).filter((m): m is LoomMetadata => m != null) : []) - return worktrees.map(wt => formatLoomForJson(wt, mainWorktreePath, metadata?.get(wt.path), resolvedAllMetadata, finishedMetadata, complexityMap)) + return Promise.all(worktrees.map(wt => formatLoomForJson(wt, mainWorktreePath, metadata?.get(wt.path), resolvedAllMetadata, finishedMetadata))) } /** @@ -394,14 +375,14 @@ export function formatLoomsForJson( * @param allMetadata - Optional array of all active loom metadata (for enriching epic swarm issues) * @param finishedMetadata - Optional finished/archived metadata for fallback swarm issue enrichment */ -export function formatFinishedLoomForJson(metadata: LoomMetadata, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[], complexityMap?: Map): LoomJsonOutput { +export async function formatFinishedLoomForJson(metadata: LoomMetadata, allMetadata?: LoomMetadata[], finishedMetadata?: LoomMetadata[]): Promise { // Use metadata values for type, default to 'branch' if not set const loomType = metadata.issueType ?? 'branch' // Build swarmIssues and dependencyMap for epic looms const isEpic = loomType === 'epic' const swarmIssues = isEpic && metadata.childIssues && metadata.childIssues.length > 0 - ? enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata.projectPath, complexityMap) + ? await enrichSwarmIssues(metadata.childIssues, allMetadata ?? [], finishedMetadata, metadata.projectPath) : isEpic ? [] : undefined const dependencyMap = isEpic ? (metadata.dependencyMap && Object.keys(metadata.dependencyMap).length > 0