diff --git a/.gitignore b/.gitignore index 898c50a..eef20aa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ docker/neo4j/plugins/ # Promotion materials (internal) promotion/ .tmp/ + +# MCP (Model Context Protocol) server configuration +/.mcp.json diff --git a/src/lib/application/services/RepoProfileService.ts b/src/lib/application/services/RepoProfileService.ts new file mode 100644 index 0000000..e3a74e2 --- /dev/null +++ b/src/lib/application/services/RepoProfileService.ts @@ -0,0 +1,460 @@ +/** + * RepoProfileService + * + * Generates and maintains a living markdown profile at `.lisa/data/repo-profile.md` + * that gives every new session instant understanding of the project. + * + * Consumes pipeline data from Phase 1 (triage) and Phase 3 (extraction) + * to render structured markdown with sections for overview, architecture, + * hotspots, decisions, gotchas, and recent activity. + * + * Part of LISA-11: Repo Profile Generation. + */ + +import path from 'node:path'; +import type { IFileSystem } from '../../domain/interfaces/IFileSystem'; +import type { ILogger } from '../../domain/interfaces/ILogger'; +import type { + IRepoProfileService, + IProfileInput, + IProfileGenerateResult, + IProfileReadResult, + IProfileFreshnessResult, + IProfileGenerateOptions, +} from '../../domain/interfaces/IRepoProfileService'; +import type { IFileHotspot, ITagInfo, IGitCommitData } from '../../domain/interfaces/IGitTriageService'; +import type { IHeuristicFact } from '../../domain/interfaces/IGitExtractor'; + +/** Relative path from project root to the profile file. */ +const PROFILE_RELATIVE_PATH = path.join('.lisa', 'data', 'repo-profile.md'); + +/** Total number of content sections in the profile template. */ +const TOTAL_SECTIONS = 8; + +/** Default generation options. */ +const DEFAULT_OPTIONS: Required = { + maxHotspots: 10, + maxFactsPerSection: 10, + maxRecentCommits: 10, +}; + +/** Freshness thresholds in hours. */ +const FRESH_THRESHOLD_HOURS = 24; +const STALE_THRESHOLD_HOURS = 168; // 7 days + +/** + * Resolve the absolute path to the profile file. + */ +function getProfilePath(projectRoot: string): string { + return path.join(projectRoot, PROFILE_RELATIVE_PATH); +} + +/** + * Calculate freshness state from file modification timestamp. + * Pure function for testability. + */ +export function calculateFreshness( + mtimeMs: number | null, + now: number = Date.now() +): IProfileFreshnessResult { + if (mtimeMs === null) { + return { + freshness: 'missing', + lastUpdated: null, + ageHours: null, + note: 'No profile found. Run `lisa onboard` to generate.', + }; + } + + const ageHours = (now - mtimeMs) / (1000 * 60 * 60); + const lastUpdated = new Date(mtimeMs); + + if (ageHours <= FRESH_THRESHOLD_HOURS) { + return { + freshness: 'fresh', + lastUpdated, + ageHours: Math.round(ageHours * 10) / 10, + note: 'Profile is up to date.', + }; + } + + if (ageHours <= STALE_THRESHOLD_HOURS) { + const days = Math.round(ageHours / 24); + return { + freshness: 'stale', + lastUpdated, + ageHours: Math.round(ageHours * 10) / 10, + note: `Profile may be stale (${days} day${days !== 1 ? 's' : ''} old).`, + }; + } + + const days = Math.round(ageHours / 24); + return { + freshness: 'expired', + lastUpdated, + ageHours: Math.round(ageHours * 10) / 10, + note: `Profile is outdated (${days} days old). Run \`lisa onboard --refresh\`.`, + }; +} + +/** + * Render the full profile markdown from pipeline data. + * Pure function for testability. + */ +export function renderMarkdown( + input: IProfileInput, + options: Required +): { markdown: string; sectionsPopulated: number } { + const { projectName, triage, extraction, codebaseSummary, recentCommits, generatedAt } = input; + const dateStr = generatedAt.toISOString().split('T')[0]; + + const sections: string[] = []; + let sectionsPopulated = 0; + + // Header + sections.push(`# Repo Profile: ${projectName}`); + sections.push(`> Generated by \`lisa onboard\` on ${dateStr}. Last updated: ${dateStr}.`); + sections.push(''); + + // 1. Project Overview + sections.push('## Project Overview'); + if (codebaseSummary) { + sections.push(''); + sections.push(codebaseSummary); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No codebase summary available. Run `lisa onboard` with init-review to generate.*'); + } + sections.push(''); + + // 2. Architecture Evolution + sections.push('## Architecture Evolution'); + const archContent = renderArchitectureEvolution(triage?.tags, extractFactsByType(extraction?.facts, 'decision')); + if (archContent) { + sections.push(''); + sections.push(archContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No version tags or architectural decisions captured yet.*'); + } + sections.push(''); + + // 3. Hotspots + sections.push('## Hotspots'); + const hotspotsContent = renderHotspots(triage?.hotspots, options.maxHotspots); + if (hotspotsContent) { + sections.push(''); + sections.push(hotspotsContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No hotspot data available.*'); + } + sections.push(''); + + // 4. Feature Waves + sections.push('## Feature Waves'); + const wavesContent = renderFeatureWaves(triage?.tags); + if (wavesContent) { + sections.push(''); + sections.push(wavesContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No version tag data available.*'); + } + sections.push(''); + + // 5. Key Decisions + sections.push('## Key Decisions'); + const decisions = extractFactsByType(extraction?.facts, 'decision'); + const decisionsContent = renderFactList(decisions, options.maxFactsPerSection); + if (decisionsContent) { + sections.push(''); + sections.push(decisionsContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No decisions captured yet.*'); + } + sections.push(''); + + // 6. Known Gotchas + sections.push('## Known Gotchas'); + const gotchas = extractFactsByType(extraction?.facts, 'gotcha'); + const gotchasContent = renderFactList(gotchas, options.maxFactsPerSection); + if (gotchasContent) { + sections.push(''); + sections.push(gotchasContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No gotchas captured yet.*'); + } + sections.push(''); + + // 7. Active Concerns + sections.push('## Active Concerns'); + const conventions = extractFactsByType(extraction?.facts, 'convention'); + const concernsContent = renderFactList(conventions, options.maxFactsPerSection); + if (concernsContent) { + sections.push(''); + sections.push(concernsContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No conventions or active concerns captured yet.*'); + } + sections.push(''); + + // 8. Recent Activity + sections.push('## Recent Activity'); + const activityContent = renderRecentActivity(recentCommits, options.maxRecentCommits); + if (activityContent) { + sections.push(''); + sections.push(activityContent); + sectionsPopulated++; + } else { + sections.push(''); + sections.push('*No recent commits found.*'); + } + sections.push(''); + + return { + markdown: sections.join('\n'), + sectionsPopulated, + }; +} + +/** + * Extract facts of a specific type from extraction results. + */ +function extractFactsByType( + facts: readonly IHeuristicFact[] | undefined, + type: 'decision' | 'gotcha' | 'convention' +): readonly IHeuristicFact[] { + if (!facts) return []; + return facts.filter(f => f.type === type); +} + +/** + * Render the Architecture Evolution section. + * Combines version tags and decision facts. + */ +function renderArchitectureEvolution( + tags: readonly ITagInfo[] | undefined, + decisions: readonly IHeuristicFact[] +): string | null { + const parts: string[] = []; + + // Version timeline + const versionTags = tags?.filter(t => t.isVersionTag) ?? []; + if (versionTags.length > 0) { + parts.push('### Version Timeline'); + parts.push(''); + for (const tag of versionTags.slice(0, 20)) { + parts.push(`- \`${tag.name}\``); + } + if (versionTags.length > 20) { + parts.push(`- ... and ${versionTags.length - 20} more`); + } + } + + // Architectural decisions + if (decisions.length > 0) { + if (parts.length > 0) parts.push(''); + parts.push('### Architectural Decisions'); + parts.push(''); + for (const fact of decisions.slice(0, 5)) { + const ref = formatFactReference(fact); + parts.push(`- ${fact.text}${ref}`); + } + } + + return parts.length > 0 ? parts.join('\n') : null; +} + +/** + * Render the Hotspots section as a markdown table. + */ +function renderHotspots( + hotspots: readonly IFileHotspot[] | undefined, + max: number +): string | null { + if (!hotspots || hotspots.length === 0) return null; + + const limited = hotspots.slice(0, max); + const lines: string[] = []; + + lines.push('| File | Commits | Lines Changed |'); + lines.push('|------|---------|---------------|'); + for (const h of limited) { + lines.push(`| \`${h.path}\` | ${h.commitCount} | ${h.totalLinesChanged.toLocaleString()} |`); + } + + if (hotspots.length > max) { + lines.push(''); + lines.push(`*... and ${hotspots.length - max} more files.*`); + } + + return lines.join('\n'); +} + +/** + * Render the Feature Waves section. + * Groups version tags by major version. + */ +function renderFeatureWaves( + tags: readonly ITagInfo[] | undefined +): string | null { + const versionTags = tags?.filter(t => t.isVersionTag) ?? []; + if (versionTags.length === 0) return null; + + // Group by major version (e.g., v1.x, v2.x) + const groups = new Map(); + for (const tag of versionTags) { + const match = tag.name.match(/^v?(\d+)/); + const major = match ? `v${match[1]}.x` : 'other'; + const group = groups.get(major) ?? []; + group.push(tag); + groups.set(major, group); + } + + const lines: string[] = []; + for (const [major, groupTags] of groups) { + lines.push(`**${major}**: ${groupTags.map(t => `\`${t.name}\``).join(', ')}`); + } + + return lines.join('\n\n'); +} + +/** + * Render a list of facts as bullet points with references. + */ +function renderFactList( + facts: readonly IHeuristicFact[], + max: number +): string | null { + if (facts.length === 0) return null; + + const limited = facts.slice(0, max); + const lines = limited.map(fact => { + const ref = formatFactReference(fact); + return `- ${fact.text}${ref}`; + }); + + if (facts.length > max) { + lines.push(`- *... and ${facts.length - max} more.*`); + } + + return lines.join('\n'); +} + +/** + * Format a fact's PR/issue reference as a parenthetical. + */ +function formatFactReference(fact: IHeuristicFact): string { + const refs: string[] = []; + if (fact.prNumber !== undefined) refs.push(`PR #${fact.prNumber}`); + if (fact.issueNumber !== undefined) refs.push(`Issue #${fact.issueNumber}`); + return refs.length > 0 ? ` *(${refs.join(', ')})* ` : ''; +} + +/** + * Render the Recent Activity section. + */ +function renderRecentActivity( + commits: readonly IGitCommitData[] | undefined, + max: number +): string | null { + if (!commits || commits.length === 0) return null; + + const limited = commits.slice(0, max); + const lines = limited.map(c => `- \`${c.shortSha}\` ${c.subject}`); + + if (commits.length > max) { + lines.push(`- *... and ${commits.length - max} more.*`); + } + + return lines.join('\n'); +} + +/** + * Create a Repo Profile service. + * + * @param fs - File system abstraction for reading/writing the profile + * @param logger - Optional logger + * @returns A Repo Profile service + */ +export function createRepoProfileService( + fs: IFileSystem, + logger?: ILogger +): IRepoProfileService { + return { + async generate( + input: IProfileInput, + options: IProfileGenerateOptions = {} + ): Promise { + const merged: Required = { + ...DEFAULT_OPTIONS, + ...options, + }; + + const filePath = getProfilePath(input.projectRoot); + + logger?.debug('Generating repo profile', { + projectName: input.projectName, + filePath, + hasTriage: !!input.triage, + hasExtraction: !!input.extraction, + hasSummary: !!input.codebaseSummary, + recentCommitCount: input.recentCommits?.length ?? 0, + }); + + const { markdown, sectionsPopulated } = renderMarkdown(input, merged); + + await fs.writeFile(filePath, markdown); + + logger?.info('Repo profile generated', { + filePath, + sectionsPopulated, + totalSections: TOTAL_SECTIONS, + }); + + return { + markdown, + filePath, + sectionsPopulated, + totalSections: TOTAL_SECTIONS, + }; + }, + + async read(projectRoot: string): Promise { + const filePath = getProfilePath(projectRoot); + const stat = await fs.stat(filePath); + const freshness = calculateFreshness(stat?.mtimeMs ?? null); + + let markdown: string | null = null; + if (stat !== null) { + try { + markdown = await fs.readFile(filePath); + } catch (error) { + logger?.warn('Failed to read repo profile', { + filePath, + error: (error as Error).message, + }); + } + } + + return { markdown, freshness, filePath }; + }, + + async checkFreshness(projectRoot: string): Promise { + const filePath = getProfilePath(projectRoot); + const stat = await fs.stat(filePath); + return calculateFreshness(stat?.mtimeMs ?? null); + }, + }; +} diff --git a/src/lib/application/services/index.ts b/src/lib/application/services/index.ts index b280774..c03a67b 100644 --- a/src/lib/application/services/index.ts +++ b/src/lib/application/services/index.ts @@ -13,3 +13,5 @@ export { MemoryContextLoader } from './MemoryContextLoader'; export type { IMemoryLoadResult } from './MemoryContextLoader'; export { GitTriageService } from './GitTriageService'; + +export { createRepoProfileService, renderMarkdown, calculateFreshness } from './RepoProfileService'; diff --git a/src/lib/domain/interfaces/IFileSystem.ts b/src/lib/domain/interfaces/IFileSystem.ts new file mode 100644 index 0000000..b996400 --- /dev/null +++ b/src/lib/domain/interfaces/IFileSystem.ts @@ -0,0 +1,31 @@ +/** + * IFileSystem + * + * Minimal file system abstraction for application-layer services + * that need to read/write files without directly depending on Node's fs. + * + * Infrastructure provides the real implementation (NodeFileSystem); + * tests provide a mock. + */ + +/** + * File stat information. + */ +export interface IFileStat { + /** File modification time in milliseconds since epoch. */ + readonly mtimeMs: number; +} + +/** + * Minimal file system operations. + */ +export interface IFileSystem { + /** Read a file and return its content as a UTF-8 string. Throws if not found. */ + readFile(filePath: string): Promise; + + /** Write content to a file. Creates parent directories if needed. */ + writeFile(filePath: string, content: string): Promise; + + /** Get file stats. Returns null if file does not exist. */ + stat(filePath: string): Promise; +} diff --git a/src/lib/domain/interfaces/IRepoProfileService.ts b/src/lib/domain/interfaces/IRepoProfileService.ts new file mode 100644 index 0000000..3918ba1 --- /dev/null +++ b/src/lib/domain/interfaces/IRepoProfileService.ts @@ -0,0 +1,150 @@ +/** + * IRepoProfileService + * + * Domain interface for generating and maintaining a living repo profile + * markdown document at `.lisa/data/repo-profile.md`. + * + * Consumes pipeline data from Phase 1 (triage) and Phase 3 (extraction) + * plus init-review codebase summary to produce a structured profile + * that gives every session instant understanding of the project. + * + * Part of LISA-11: Repo Profile Generation. + */ + +import type { ITriageResult, IGitCommitData } from './IGitTriageService'; +import type { IHeuristicExtractionResult } from './IGitExtractor'; + +/** + * Freshness state of the repo profile. + * + * - fresh: updated within 24h + * - stale: 1-7 days old + * - expired: older than 7 days + * - missing: file does not exist + */ +export type ProfileFreshness = 'fresh' | 'stale' | 'expired' | 'missing'; + +/** + * All valid profile freshness values. + */ +export const PROFILE_FRESHNESS_VALUES: readonly ProfileFreshness[] = [ + 'fresh', + 'stale', + 'expired', + 'missing', +] as const; + +/** + * Result of checking profile freshness. + */ +export interface IProfileFreshnessResult { + /** Current freshness state. */ + readonly freshness: ProfileFreshness; + /** When the profile was last updated (null if missing). */ + readonly lastUpdated: Date | null; + /** Age in hours (null if missing). */ + readonly ageHours: number | null; + /** Human-readable note about freshness. */ + readonly note: string; +} + +/** + * Input data for generating a repo profile. + * Aggregates data from the git pipeline phases. + */ +export interface IProfileInput { + /** Project name (e.g. from package.json or directory name). */ + readonly projectName: string; + /** Project root path. */ + readonly projectRoot: string; + /** Triage result from Phase 1 (optional - may not have run). */ + readonly triage?: ITriageResult; + /** Heuristic extraction result from Phase 3 (optional). */ + readonly extraction?: IHeuristicExtractionResult; + /** Init-review codebase summary (optional). */ + readonly codebaseSummary?: string; + /** Recent git commits for the "Recent Activity" section. */ + readonly recentCommits?: readonly IGitCommitData[]; + /** Timestamp when profile generation was initiated. */ + readonly generatedAt: Date; +} + +/** + * Result of profile generation. + */ +export interface IProfileGenerateResult { + /** The generated markdown content. */ + readonly markdown: string; + /** Path where the profile was written. */ + readonly filePath: string; + /** Number of sections that had data populated. */ + readonly sectionsPopulated: number; + /** Total number of sections in the template. */ + readonly totalSections: number; +} + +/** + * Result of reading an existing profile. + */ +export interface IProfileReadResult { + /** The markdown content (null if file is missing). */ + readonly markdown: string | null; + /** Freshness information. */ + readonly freshness: IProfileFreshnessResult; + /** Path where the profile would/does live. */ + readonly filePath: string; +} + +/** + * Options for profile generation. + */ +export interface IProfileGenerateOptions { + /** Maximum hotspots to include. Default: 10. */ + readonly maxHotspots?: number; + /** Maximum facts per section (decisions, gotchas, conventions). Default: 10. */ + readonly maxFactsPerSection?: number; + /** Maximum recent commits to include. Default: 10. */ + readonly maxRecentCommits?: number; +} + +/** + * Service for generating and maintaining the repo profile markdown document. + */ +export interface IRepoProfileService { + /** + * Generate a full repo profile from pipeline data. + * Writes the markdown file to `.lisa/data/repo-profile.md`. + * + * @param input - Aggregated pipeline data + * @param options - Generation options + * @returns Generation result with markdown content and stats + */ + generate( + input: IProfileInput, + options?: IProfileGenerateOptions + ): Promise; + + /** + * Read the existing repo profile, if present. + * Returns content and freshness information. + * + * @param projectRoot - Project root directory + * @returns Read result with content and freshness + */ + read(projectRoot: string): Promise; + + /** + * Check the freshness of the existing repo profile. + * Lightweight metadata check without reading full content. + * + * Freshness rules: + * - fresh: updated within 24h + * - stale: 1-7 days old + * - expired: older than 7 days + * - missing: file does not exist + * + * @param projectRoot - Project root directory + * @returns Freshness result + */ + checkFreshness(projectRoot: string): Promise; +} diff --git a/src/lib/domain/interfaces/index.ts b/src/lib/domain/interfaces/index.ts index ba25c7a..66c7144 100644 --- a/src/lib/domain/interfaces/index.ts +++ b/src/lib/domain/interfaces/index.ts @@ -181,6 +181,20 @@ export { type INlCurationResult, type INlCurationService, } from './INlCurationService'; +export { + type IFileStat, + type IFileSystem, +} from './IFileSystem'; +export { + type ProfileFreshness, + PROFILE_FRESHNESS_VALUES, + type IProfileFreshnessResult, + type IProfileInput, + type IProfileGenerateResult, + type IProfileReadResult, + type IProfileGenerateOptions, + type IRepoProfileService, +} from './IRepoProfileService'; // Event interfaces export * from './events'; diff --git a/src/lib/infrastructure/services/NodeFileSystem.ts b/src/lib/infrastructure/services/NodeFileSystem.ts new file mode 100644 index 0000000..5ce6e56 --- /dev/null +++ b/src/lib/infrastructure/services/NodeFileSystem.ts @@ -0,0 +1,36 @@ +/** + * NodeFileSystem + * + * Infrastructure implementation of IFileSystem using node:fs/promises. + * Creates parent directories automatically when writing files. + */ + +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import type { IFileSystem } from '../../domain/interfaces/IFileSystem'; + +/** + * Create a file system service backed by Node's fs/promises. + */ +export function createNodeFileSystem(): IFileSystem { + return { + async readFile(filePath: string): Promise { + return fsp.readFile(filePath, 'utf-8'); + }, + + async writeFile(filePath: string, content: string): Promise { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, content, 'utf-8'); + }, + + async stat(filePath: string): Promise<{ mtimeMs: number } | null> { + try { + const s = await fsp.stat(filePath); + return { mtimeMs: s.mtimeMs }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + }, + }; +} diff --git a/src/lib/infrastructure/services/index.ts b/src/lib/infrastructure/services/index.ts index 201d5cb..7a0aa46 100644 --- a/src/lib/infrastructure/services/index.ts +++ b/src/lib/infrastructure/services/index.ts @@ -20,3 +20,4 @@ export { createSummarizationService } from './SummarizationService'; export { createTranscriptEnricher } from './TranscriptEnricher'; export { createLlmDeduplicationEnhancer } from './LlmDeduplicationEnhancer'; export { createNlCurationService } from './NlCurationService'; +export { createNodeFileSystem } from './NodeFileSystem'; diff --git a/tests/unit/src/lib/application/services/RepoProfileService.test.ts b/tests/unit/src/lib/application/services/RepoProfileService.test.ts new file mode 100644 index 0000000..f242893 --- /dev/null +++ b/tests/unit/src/lib/application/services/RepoProfileService.test.ts @@ -0,0 +1,593 @@ +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert'; +import { + createRepoProfileService, + renderMarkdown, + calculateFreshness, +} from '../../../../../../src/lib/application/services/RepoProfileService'; +import type { IFileSystem } from '../../../../../../src/lib/domain/interfaces/IFileSystem'; +import type { ILogger } from '../../../../../../src/lib/domain/interfaces/ILogger'; +import type { IProfileInput, IProfileGenerateOptions } from '../../../../../../src/lib/domain/interfaces/IRepoProfileService'; +import type { ITriageResult, IFileHotspot, ITagInfo, IGitCommitData } from '../../../../../../src/lib/domain/interfaces/IGitTriageService'; +import type { IHeuristicExtractionResult, IHeuristicFact } from '../../../../../../src/lib/domain/interfaces/IGitExtractor'; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockCommit(overrides: Partial = {}): IGitCommitData { + return { + sha: 'abc1234567890', + shortSha: 'abc1234', + subject: 'feat: add new feature', + body: '', + parentCount: 1, + author: 'Test Author', + authorEmail: 'test@example.com', + timestamp: new Date('2026-02-01'), + refs: [], + ...overrides, + }; +} + +function createMockFact(overrides: Partial = {}): IHeuristicFact { + return { + text: 'Test decision about architecture', + type: 'decision', + source: 'pr-description', + confidence: 'medium', + tags: ['typescript'], + prNumber: 42, + ...overrides, + }; +} + +function createMockHotspot(overrides: Partial = {}): IFileHotspot { + return { + path: 'src/lib/services/MyService.ts', + commitCount: 15, + totalLinesChanged: 500, + ...overrides, + }; +} + +function createMockTag(overrides: Partial = {}): ITagInfo { + return { + name: 'v1.0.0', + sha: 'tag-sha-1', + isVersionTag: true, + ...overrides, + }; +} + +function createMockTriageResult(overrides: Partial = {}): ITriageResult { + return { + totalCommits: 100, + belowThreshold: 80, + minorInterest: 10, + highInterest: [], + linkedToPRs: 5, + linkedToTags: 3, + hotspots: [createMockHotspot()], + tags: [createMockTag()], + durationMs: 500, + ...overrides, + }; +} + +function createMockExtraction(overrides: Partial = {}): IHeuristicExtractionResult { + return { + facts: [ + createMockFact({ text: 'Decided to use TypeScript strict mode', type: 'decision' }), + createMockFact({ text: 'Beware of null returns from findById', type: 'gotcha', prNumber: 55 }), + createMockFact({ text: 'All interfaces must be prefixed with I', type: 'convention' }), + ], + prsProcessed: 5, + prsSkipped: 0, + patternsMatched: 3, + ...overrides, + }; +} + +function createMockInput(overrides: Partial = {}): IProfileInput { + return { + projectName: 'test-project', + projectRoot: '/home/user/test-project', + generatedAt: new Date('2026-02-05'), + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('RepoProfileService', () => { + let mockFs: IFileSystem; + let mockLogger: ILogger; + + beforeEach(() => { + mockFs = { + readFile: mock.fn(async () => '# Existing Profile'), + writeFile: mock.fn(async () => {}), + stat: mock.fn(async () => ({ mtimeMs: Date.now() })), + } as unknown as IFileSystem; + + mockLogger = { + debug: mock.fn(() => {}), + info: mock.fn(() => {}), + warn: mock.fn(() => {}), + error: mock.fn(() => {}), + } as unknown as ILogger; + }); + + describe('generate', () => { + it('should generate profile with all data populated', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + triage: createMockTriageResult(), + extraction: createMockExtraction(), + codebaseSummary: 'A TypeScript project with clean architecture.', + recentCommits: [createMockCommit()], + }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('# Repo Profile: test-project')); + assert.ok(result.markdown.includes('A TypeScript project with clean architecture.')); + assert.ok(result.markdown.includes('## Hotspots')); + assert.ok(result.markdown.includes('## Key Decisions')); + assert.ok(result.markdown.includes('## Known Gotchas')); + assert.ok(result.markdown.includes('## Active Concerns')); + assert.ok(result.markdown.includes('## Recent Activity')); + assert.strictEqual(result.totalSections, 8); + assert.strictEqual(result.sectionsPopulated, 8); + assert.ok(result.filePath.endsWith('.lisa/data/repo-profile.md')); + }); + + it('should write the profile to the correct path', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ projectRoot: '/my/project' }); + + await service.generate(input); + + const writeCalls = (mockFs.writeFile as ReturnType).mock.calls; + assert.strictEqual(writeCalls.length, 1); + assert.ok(writeCalls[0].arguments[0].includes('.lisa/data/repo-profile.md')); + assert.ok(writeCalls[0].arguments[0].startsWith('/my/project')); + }); + + it('should handle missing triage data gracefully', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput(); // No triage + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('*No hotspot data available.*')); + assert.ok(result.markdown.includes('*No version tag data available.*')); + }); + + it('should handle missing extraction data gracefully', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput(); // No extraction + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('*No decisions captured yet.*')); + assert.ok(result.markdown.includes('*No gotchas captured yet.*')); + assert.ok(result.markdown.includes('*No conventions or active concerns captured yet.*')); + }); + + it('should handle missing codebase summary gracefully', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput(); // No summary + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('*No codebase summary available.')); + }); + + it('should handle missing recent commits gracefully', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput(); // No commits + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('*No recent commits found.*')); + }); + + it('should include PR and issue references in facts', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + extraction: createMockExtraction({ + facts: [ + createMockFact({ text: 'Decision with PR', type: 'decision', prNumber: 42, issueNumber: 10 }), + ], + }), + }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('PR #42')); + assert.ok(result.markdown.includes('Issue #10')); + }); + + it('should limit hotspots to maxHotspots option', async () => { + const hotspots = Array.from({ length: 20 }, (_, i) => + createMockHotspot({ path: `file-${i}.ts`, commitCount: 20 - i }) + ); + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + triage: createMockTriageResult({ hotspots }), + }); + + const result = await service.generate(input, { maxHotspots: 5 }); + + // Should include 5 files plus "and N more" message + assert.ok(result.markdown.includes('file-0.ts')); + assert.ok(result.markdown.includes('file-4.ts')); + assert.ok(result.markdown.includes('... and 15 more')); + }); + + it('should limit facts per section to maxFactsPerSection option', async () => { + const facts = Array.from({ length: 15 }, (_, i) => + createMockFact({ text: `Decision ${i}`, type: 'decision' }) + ); + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + extraction: createMockExtraction({ facts }), + }); + + const result = await service.generate(input, { maxFactsPerSection: 3 }); + + assert.ok(result.markdown.includes('Decision 0')); + assert.ok(result.markdown.includes('Decision 2')); + assert.ok(result.markdown.includes('... and 12 more')); + }); + + it('should limit recent commits to maxRecentCommits option', async () => { + const commits = Array.from({ length: 15 }, (_, i) => + createMockCommit({ shortSha: `sha${i}`, subject: `Commit ${i}` }) + ); + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ recentCommits: commits }); + + const result = await service.generate(input, { maxRecentCommits: 3 }); + + assert.ok(result.markdown.includes('sha0')); + assert.ok(result.markdown.includes('sha2')); + assert.ok(result.markdown.includes('... and 12 more')); + }); + + it('should report correct sectionsPopulated count with partial data', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + codebaseSummary: 'Summary text', + recentCommits: [createMockCommit()], + }); + + const result = await service.generate(input); + + // Only overview and recent activity should be populated + assert.strictEqual(result.sectionsPopulated, 2); + assert.strictEqual(result.totalSections, 8); + }); + + it('should include date in header', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ generatedAt: new Date('2026-02-05') }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('2026-02-05')); + }); + + it('should render hotspot table with correct format', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + triage: createMockTriageResult({ + hotspots: [createMockHotspot({ path: 'src/main.ts', commitCount: 10, totalLinesChanged: 200 })], + }), + }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('| File | Commits | Lines Changed |')); + assert.ok(result.markdown.includes('| `src/main.ts` | 10 | 200 |')); + }); + + it('should group version tags by major version in feature waves', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + const input = createMockInput({ + triage: createMockTriageResult({ + tags: [ + createMockTag({ name: 'v1.0.0' }), + createMockTag({ name: 'v1.1.0' }), + createMockTag({ name: 'v2.0.0' }), + ], + }), + }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('v1.x')); + assert.ok(result.markdown.includes('v2.x')); + }); + + it('should work without logger', async () => { + const service = createRepoProfileService(mockFs); // No logger + const input = createMockInput({ + codebaseSummary: 'Test summary', + }); + + const result = await service.generate(input); + + assert.ok(result.markdown.includes('Test summary')); + assert.strictEqual(result.sectionsPopulated, 1); + }); + }); + + describe('read', () => { + it('should return markdown content when file exists', async () => { + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.read('/my/project'); + + assert.strictEqual(result.markdown, '# Existing Profile'); + assert.ok(result.filePath.endsWith('.lisa/data/repo-profile.md')); + }); + + it('should return null markdown when file is missing', async () => { + (mockFs.stat as ReturnType).mock.mockImplementation(async () => null); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.read('/my/project'); + + assert.strictEqual(result.markdown, null); + assert.strictEqual(result.freshness.freshness, 'missing'); + }); + + it('should calculate freshness for existing file', async () => { + const recentMtime = Date.now() - (2 * 60 * 60 * 1000); // 2 hours ago + (mockFs.stat as ReturnType).mock.mockImplementation( + async () => ({ mtimeMs: recentMtime }) + ); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.read('/my/project'); + + assert.strictEqual(result.freshness.freshness, 'fresh'); + }); + + it('should handle read error gracefully', async () => { + (mockFs.readFile as ReturnType).mock.mockImplementation( + async () => { throw new Error('Permission denied'); } + ); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.read('/my/project'); + + assert.strictEqual(result.markdown, null); + // Freshness should still be calculated from stat + assert.notStrictEqual(result.freshness.freshness, 'missing'); + }); + }); + + describe('checkFreshness', () => { + it('should return fresh when file updated recently', async () => { + const recentMtime = Date.now() - (1 * 60 * 60 * 1000); // 1 hour ago + (mockFs.stat as ReturnType).mock.mockImplementation( + async () => ({ mtimeMs: recentMtime }) + ); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.checkFreshness('/my/project'); + + assert.strictEqual(result.freshness, 'fresh'); + assert.ok(result.note.includes('up to date')); + }); + + it('should return stale when file is 3 days old', async () => { + const staleMtime = Date.now() - (3 * 24 * 60 * 60 * 1000); // 3 days ago + (mockFs.stat as ReturnType).mock.mockImplementation( + async () => ({ mtimeMs: staleMtime }) + ); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.checkFreshness('/my/project'); + + assert.strictEqual(result.freshness, 'stale'); + assert.ok(result.note.includes('stale')); + }); + + it('should return expired when file is 10 days old', async () => { + const expiredMtime = Date.now() - (10 * 24 * 60 * 60 * 1000); // 10 days ago + (mockFs.stat as ReturnType).mock.mockImplementation( + async () => ({ mtimeMs: expiredMtime }) + ); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.checkFreshness('/my/project'); + + assert.strictEqual(result.freshness, 'expired'); + assert.ok(result.note.includes('outdated')); + }); + + it('should return missing when file does not exist', async () => { + (mockFs.stat as ReturnType).mock.mockImplementation(async () => null); + + const service = createRepoProfileService(mockFs, mockLogger); + + const result = await service.checkFreshness('/my/project'); + + assert.strictEqual(result.freshness, 'missing'); + assert.strictEqual(result.lastUpdated, null); + assert.strictEqual(result.ageHours, null); + assert.ok(result.note.includes('lisa onboard')); + }); + }); +}); + +describe('calculateFreshness (pure function)', () => { + const now = new Date('2026-02-05T12:00:00Z').getTime(); + + it('should return missing when mtimeMs is null', () => { + const result = calculateFreshness(null, now); + assert.strictEqual(result.freshness, 'missing'); + assert.strictEqual(result.lastUpdated, null); + assert.strictEqual(result.ageHours, null); + }); + + it('should return fresh for file updated 1 hour ago', () => { + const oneHourAgo = now - (1 * 60 * 60 * 1000); + const result = calculateFreshness(oneHourAgo, now); + assert.strictEqual(result.freshness, 'fresh'); + assert.ok(result.ageHours !== null && result.ageHours <= 1.1); + }); + + it('should return fresh for file updated exactly 24 hours ago', () => { + const exactly24h = now - (24 * 60 * 60 * 1000); + const result = calculateFreshness(exactly24h, now); + assert.strictEqual(result.freshness, 'fresh'); + }); + + it('should return stale for file updated 25 hours ago', () => { + const hours25 = now - (25 * 60 * 60 * 1000); + const result = calculateFreshness(hours25, now); + assert.strictEqual(result.freshness, 'stale'); + }); + + it('should return stale for file updated 6 days ago', () => { + const days6 = now - (6 * 24 * 60 * 60 * 1000); + const result = calculateFreshness(days6, now); + assert.strictEqual(result.freshness, 'stale'); + }); + + it('should return stale for file updated exactly 7 days ago', () => { + const exactly7d = now - (7 * 24 * 60 * 60 * 1000); + const result = calculateFreshness(exactly7d, now); + assert.strictEqual(result.freshness, 'stale'); + }); + + it('should return expired for file updated 8 days ago', () => { + const days8 = now - (8 * 24 * 60 * 60 * 1000); + const result = calculateFreshness(days8, now); + assert.strictEqual(result.freshness, 'expired'); + }); + + it('should return expired for very old file', () => { + const days30 = now - (30 * 24 * 60 * 60 * 1000); + const result = calculateFreshness(days30, now); + assert.strictEqual(result.freshness, 'expired'); + assert.ok(result.note.includes('outdated')); + assert.ok(result.note.includes('lisa onboard --refresh')); + }); + + it('should include age in hours', () => { + const hours48 = now - (48 * 60 * 60 * 1000); + const result = calculateFreshness(hours48, now); + assert.ok(result.ageHours !== null); + assert.ok(result.ageHours >= 47.9 && result.ageHours <= 48.1); + }); + + it('should include lastUpdated date', () => { + const mtime = now - (2 * 60 * 60 * 1000); + const result = calculateFreshness(mtime, now); + assert.ok(result.lastUpdated instanceof Date); + assert.strictEqual(result.lastUpdated.getTime(), mtime); + }); +}); + +describe('renderMarkdown (pure function)', () => { + const defaultOptions: Required = { + maxHotspots: 10, + maxFactsPerSection: 10, + maxRecentCommits: 10, + }; + + it('should include project name in header', () => { + const input = createMockInput({ projectName: 'my-awesome-project' }); + const { markdown } = renderMarkdown(input, defaultOptions); + assert.ok(markdown.includes('# Repo Profile: my-awesome-project')); + }); + + it('should render all 8 section headers', () => { + const input = createMockInput(); + const { markdown } = renderMarkdown(input, defaultOptions); + + assert.ok(markdown.includes('## Project Overview')); + assert.ok(markdown.includes('## Architecture Evolution')); + assert.ok(markdown.includes('## Hotspots')); + assert.ok(markdown.includes('## Feature Waves')); + assert.ok(markdown.includes('## Key Decisions')); + assert.ok(markdown.includes('## Known Gotchas')); + assert.ok(markdown.includes('## Active Concerns')); + assert.ok(markdown.includes('## Recent Activity')); + }); + + it('should count 0 populated sections when no data provided', () => { + const input = createMockInput(); + const { sectionsPopulated } = renderMarkdown(input, defaultOptions); + assert.strictEqual(sectionsPopulated, 0); + }); + + it('should count all populated sections when full data provided', () => { + const input = createMockInput({ + codebaseSummary: 'Summary', + triage: createMockTriageResult(), + extraction: createMockExtraction(), + recentCommits: [createMockCommit()], + }); + const { sectionsPopulated } = renderMarkdown(input, defaultOptions); + assert.strictEqual(sectionsPopulated, 8); + }); + + it('should render recent commits with short SHA', () => { + const input = createMockInput({ + recentCommits: [ + createMockCommit({ shortSha: 'abc1234', subject: 'feat: add login page' }), + ], + }); + const { markdown } = renderMarkdown(input, defaultOptions); + assert.ok(markdown.includes('`abc1234`')); + assert.ok(markdown.includes('feat: add login page')); + }); + + it('should separate different fact types into correct sections', () => { + const input = createMockInput({ + extraction: createMockExtraction({ + facts: [ + createMockFact({ text: 'Only decision fact', type: 'decision' }), + createMockFact({ text: 'Only gotcha fact', type: 'gotcha' }), + createMockFact({ text: 'Only convention fact', type: 'convention' }), + ], + }), + }); + const { markdown } = renderMarkdown(input, defaultOptions); + + // Find each section and verify the right facts appear in them + const decisionsIdx = markdown.indexOf('## Key Decisions'); + const gotchasIdx = markdown.indexOf('## Known Gotchas'); + const conventionsIdx = markdown.indexOf('## Active Concerns'); + const recentIdx = markdown.indexOf('## Recent Activity'); + + const decisionsSection = markdown.substring(decisionsIdx, gotchasIdx); + const gotchasSection = markdown.substring(gotchasIdx, conventionsIdx); + const conventionsSection = markdown.substring(conventionsIdx, recentIdx); + + assert.ok(decisionsSection.includes('Only decision fact')); + assert.ok(!decisionsSection.includes('Only gotcha fact')); + + assert.ok(gotchasSection.includes('Only gotcha fact')); + assert.ok(!gotchasSection.includes('Only decision fact')); + + assert.ok(conventionsSection.includes('Only convention fact')); + assert.ok(!conventionsSection.includes('Only gotcha fact')); + }); +});