Skip to main content
Glama
adr-timeline-extractor.ts10.1 kB
/** * ADR Timeline Extractor * * Extracts timeline information from ADRs using git history, * content parsing, or filesystem metadata with smart conditional logic */ import { exec } from 'child_process'; import { promisify } from 'util'; import { stat } from 'fs/promises'; import type { BasicTimeline, TimelineExtractionOptions } from './adr-timeline-types.js'; const execAsync = promisify(exec); /** * Simple in-memory cache for timeline data * Note: The main cache.ts system is designed for AI-driven prompt operations, * but we need synchronous in-memory caching for performance here. */ interface CacheEntry { data: any; timestamp: number; ttl: number; } const timelineCache = new Map<string, CacheEntry>(); function getCachedTimeline(key: string): any | null { const entry = timelineCache.get(key); if (!entry) return null; const age = Date.now() - entry.timestamp; if (age > entry.ttl * 1000) { timelineCache.delete(key); return null; } return entry.data; } function setCachedTimeline(key: string, data: any, ttl: number): void { timelineCache.set(key, { data, timestamp: Date.now(), ttl, }); } /** * Extract basic timeline for an ADR with smart conditional logic * * Only extracts when necessary: * - No timestamp in content * - File modified recently * - Cache miss or stale */ export async function extractBasicTimeline( adrPath: string, adrContent: string, options: TimelineExtractionOptions = {} ): Promise<BasicTimeline> { const { useCache = true, cacheTTL = 3600, // 1 hour default forceExtract = false, } = options; // Check if extraction can be skipped (unless forced) if (!forceExtract) { const decision = await shouldExtractTimeline(adrPath, adrContent); if (!decision.shouldExtract && decision.useExisting) { // Use existing date from content return createTimelineFromContent(adrContent, decision.useExisting, decision.reason); } if (!decision.shouldExtract && !decision.useExisting) { // Use cached data const cached = getCachedTimeline(`timeline:${adrPath}`); if (cached) { return cached as BasicTimeline; } } // Log extraction reason for debugging if (decision.shouldExtract) { console.log(`[Timeline] Extracting for ${adrPath}: ${decision.reason}`); } } // Perform extraction using priority order: git → content → filesystem let timeline: Partial<BasicTimeline> | null = null; // Try git extraction first timeline = await tryGitExtraction(adrPath); // Fallback to content parsing if (!timeline || !timeline.created_at) { const contentDate = extractDateFromContent(adrContent); if (contentDate) { timeline = { created_at: contentDate, updated_at: contentDate, extraction_method: 'content', }; } } // Final fallback to filesystem if (!timeline || !timeline.created_at) { timeline = await fallbackToFilesystem(adrPath); } // Calculate derived fields const stats = await stat(adrPath); const ageDays = calculateAgeDays(timeline.created_at!); const daysSinceUpdate = calculateAgeDays(timeline.updated_at!); const fullTimeline: BasicTimeline = { created_at: timeline.created_at!, updated_at: timeline.updated_at!, age_days: ageDays, days_since_update: daysSinceUpdate, staleness_warnings: generateBasicWarnings(ageDays, daysSinceUpdate), extraction_method: timeline.extraction_method as 'git' | 'content' | 'filesystem', }; // Cache result with file modification time for future checks if (useCache) { setCachedTimeline( `timeline:${adrPath}`, { ...fullTimeline, _fileModifiedAt: stats.mtime.toISOString(), _cachedAt: new Date().toISOString(), }, cacheTTL ); } return fullTimeline; } /** * Determine if timeline extraction should run */ async function shouldExtractTimeline( adrPath: string, adrContent: string ): Promise<{ shouldExtract: boolean; reason: string; useExisting?: string; }> { // Check if ADR content has a date field const dateInContent = extractDateFromContent(adrContent); // Check file modification time const stats = await stat(adrPath); const fileModifiedAt = stats.mtime; // Check cache const cacheKey = `timeline:${adrPath}`; const cached = getCachedTimeline(cacheKey); // CONDITION 1: No date in content → MUST extract if (!dateInContent) { return { shouldExtract: true, reason: 'No timestamp found in ADR content', }; } // CONDITION 2: Has date + cached + file unchanged → SKIP if (cached && cached._fileModifiedAt) { const cachedModTime = new Date(cached._fileModifiedAt); if (fileModifiedAt <= cachedModTime) { return { shouldExtract: false, reason: 'File unchanged since last extraction', useExisting: dateInContent, }; } } // CONDITION 3: Has date + file modified recently → RE-EXTRACT const daysSinceModification = (Date.now() - fileModifiedAt.getTime()) / (1000 * 60 * 60 * 24); if (daysSinceModification < 7) { // Modified within last week return { shouldExtract: true, reason: `File modified ${Math.floor(daysSinceModification)} days ago`, }; } // CONDITION 4: Has date + old file → USE CONTENT DATE return { shouldExtract: false, reason: 'File has valid date and is stable (not recently modified)', useExisting: dateInContent, }; } /** * Extract date from ADR content using regex */ function extractDateFromContent(content: string): string | null { const dateMatch = content.match(/(?:##?\s*date|date:|\*\*date\*\*:)\s*(.+?)(?:\n|$)/i); if (dateMatch && dateMatch[1]) { const dateStr = dateMatch[1].trim(); try { // Validate it's a parseable date const date = new Date(dateStr); if (!isNaN(date.getTime())) { return date.toISOString(); } } catch { // Invalid date format } } return null; } /** * Create timeline from ADR content date (no git extraction needed) */ function createTimelineFromContent( _content: string, dateStr: string, reason: string ): BasicTimeline { let decisionDate: Date; try { decisionDate = new Date(dateStr); } catch { // Fallback to current time if parsing fails decisionDate = new Date(); } const isoDate = decisionDate.toISOString(); const ageDays = calculateAgeDays(isoDate); return { created_at: isoDate, updated_at: isoDate, // Assume same as creation age_days: ageDays, days_since_update: ageDays, staleness_warnings: generateBasicWarnings(ageDays, ageDays), extraction_method: 'content', extraction_skipped: true, skip_reason: reason, }; } /** * Try to extract timeline from git history */ async function tryGitExtraction(adrPath: string): Promise<Partial<BasicTimeline> | null> { try { // Creation date (first commit) const { stdout: created } = await execAsync( `git log --diff-filter=A --follow --format=%aI -- "${adrPath}" | tail -1`, { timeout: 5000 } ); // Last modification date const { stdout: updated } = await execAsync(`git log -1 --format=%aI -- "${adrPath}"`, { timeout: 5000, }); if (!created.trim() || !updated.trim()) { return null; } return { created_at: created.trim(), updated_at: updated.trim(), extraction_method: 'git', }; } catch (error) { // Git not available, not a git repo, or command failed console.warn(`[Timeline] Git extraction failed for ${adrPath}:`, error); return null; } } /** * Fallback to filesystem timestamps */ async function fallbackToFilesystem(adrPath: string): Promise<Partial<BasicTimeline>> { try { const stats = await stat(adrPath); return { created_at: stats.birthtime.toISOString(), updated_at: stats.mtime.toISOString(), extraction_method: 'filesystem', }; } catch { // Last resort: use current time const now = new Date().toISOString(); return { created_at: now, updated_at: now, extraction_method: 'filesystem', }; } } /** * Calculate age in days from ISO timestamp */ function calculateAgeDays(isoTimestamp: string): number { const date = new Date(isoTimestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)); } /** * Generate basic staleness warnings */ function generateBasicWarnings(ageDays: number, daysSinceUpdate: number): string[] { const warnings: string[] = []; // Warning: Old ADR if (ageDays > 180) { warnings.push(`ADR is ${ageDays} days old (>6 months)`); } // Warning: No recent updates if (daysSinceUpdate > 365) { warnings.push(`No updates in ${daysSinceUpdate} days (>1 year)`); } // Warning: Very old dormant ADR if (daysSinceUpdate > 730) { warnings.push(`Dormant for ${Math.floor(daysSinceUpdate / 365)} years - may be obsolete`); } return warnings; } /** * Helper to extract keywords from ADR content for implementation tracking */ export function extractKeywordsFromAdr(title: string, decision?: string): string[] { const text = `${title} ${decision || ''}`.toLowerCase(); const keywords: string[] = []; // Extract technology names const techPatterns = [ /\b(redis|postgres|mongodb|mysql|kafka|rabbitmq|docker|kubernetes|aws|gcp|azure)\b/gi, /\b(react|vue|angular|node|express|fastapi|django|flask|spring)\b/gi, /\b(graphql|rest|grpc|websocket|oauth|jwt|saml)\b/gi, ]; techPatterns.forEach(pattern => { const matches = text.match(pattern); if (matches) { keywords.push(...matches.map(m => m.toLowerCase())); } }); // Extract quoted terms (likely important concepts) const quoted = text.match(/"([^"]+)"/g); if (quoted) { keywords.push(...quoted.map(q => q.replace(/"/g, '').toLowerCase())); } // Remove duplicates return [...new Set(keywords)]; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tosin2013/mcp-adr-analysis-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server