Skip to main content
Glama

documcp

by tosin2013
drift-detector.ts23.8 kB
/** * Documentation Drift Detection System (Phase 3) * * Detects when code changes invalidate existing documentation * Provides automatic update suggestions based on code changes */ import { promises as fs } from "fs"; import path from "path"; import { ASTAnalyzer, ASTAnalysisResult, CodeDiff } from "./ast-analyzer.js"; export interface DriftDetectionResult { filePath: string; hasDrift: boolean; severity: "none" | "low" | "medium" | "high" | "critical"; drifts: DocumentationDrift[]; suggestions: DriftSuggestion[]; impactAnalysis: ImpactAnalysis; } export interface DocumentationDrift { type: "outdated" | "incorrect" | "missing" | "breaking"; affectedDocs: string[]; codeChanges: CodeDiff[]; description: string; detectedAt: string; severity: "low" | "medium" | "high" | "critical"; } export interface DriftSuggestion { docFile: string; section: string; currentContent: string; suggestedContent: string; reasoning: string; confidence: number; autoApplicable: boolean; } export interface ImpactAnalysis { breakingChanges: number; majorChanges: number; minorChanges: number; affectedDocFiles: string[]; estimatedUpdateEffort: "low" | "medium" | "high"; requiresManualReview: boolean; } export interface DriftSnapshot { projectPath: string; timestamp: string; files: Map<string, ASTAnalysisResult>; documentation: Map<string, DocumentationSnapshot>; } export interface DocumentationSnapshot { filePath: string; contentHash: string; referencedCode: string[]; lastUpdated: string; sections: DocumentationSection[]; } export interface DocumentationSection { title: string; content: string; referencedFunctions: string[]; referencedClasses: string[]; referencedTypes: string[]; codeExamples: CodeExample[]; startLine: number; endLine: number; } export interface CodeExample { language: string; code: string; description: string; referencedSymbols: string[]; } /** * Main Drift Detector class */ export class DriftDetector { private analyzer: ASTAnalyzer; private snapshotDir: string; private currentSnapshot: DriftSnapshot | null = null; private previousSnapshot: DriftSnapshot | null = null; constructor(projectPath: string, snapshotDir?: string) { this.analyzer = new ASTAnalyzer(); this.snapshotDir = snapshotDir || path.join(projectPath, ".documcp", "snapshots"); } /** * Initialize the drift detector */ async initialize(): Promise<void> { await this.analyzer.initialize(); await fs.mkdir(this.snapshotDir, { recursive: true }); } /** * Create a snapshot of the current codebase and documentation */ async createSnapshot( projectPath: string, docsPath: string, ): Promise<DriftSnapshot> { const files = new Map<string, ASTAnalysisResult>(); const documentation = new Map<string, DocumentationSnapshot>(); // Analyze source files const sourceFiles = await this.findSourceFiles(projectPath); for (const filePath of sourceFiles) { const analysis = await this.analyzer.analyzeFile(filePath); if (analysis) { files.set(filePath, analysis); } } // Analyze documentation files const docFiles = await this.findDocumentationFiles(docsPath); for (const docPath of docFiles) { const docSnapshot = await this.analyzeDocumentation(docPath); if (docSnapshot) { documentation.set(docPath, docSnapshot); } } const snapshot: DriftSnapshot = { projectPath, timestamp: new Date().toISOString(), files, documentation, }; // Save snapshot await this.saveSnapshot(snapshot); return snapshot; } /** * Detect drift between two snapshots */ async detectDrift( oldSnapshot: DriftSnapshot, newSnapshot: DriftSnapshot, ): Promise<DriftDetectionResult[]> { const results: DriftDetectionResult[] = []; // Compare each file for (const [filePath, newAnalysis] of newSnapshot.files) { const oldAnalysis = oldSnapshot.files.get(filePath); if (!oldAnalysis) { // New file - check if documentation is needed continue; } // Detect code changes const codeDiffs = await this.analyzer.detectDrift( oldAnalysis, newAnalysis, ); if (codeDiffs.length > 0) { // Find affected documentation const affectedDocs = this.findAffectedDocumentation( filePath, codeDiffs, newSnapshot.documentation, ); // Report drift even if no documentation is affected // (missing documentation is also a type of drift) const driftResult = await this.analyzeDrift( filePath, codeDiffs, affectedDocs, oldSnapshot, newSnapshot, ); results.push(driftResult); } } return results; } /** * Analyze drift and generate suggestions */ private async analyzeDrift( filePath: string, codeDiffs: CodeDiff[], affectedDocs: string[], oldSnapshot: DriftSnapshot, newSnapshot: DriftSnapshot, ): Promise<DriftDetectionResult> { const drifts: DocumentationDrift[] = []; const suggestions: DriftSuggestion[] = []; // Categorize drifts by severity const breakingChanges = codeDiffs.filter( (d) => d.impactLevel === "breaking", ); const majorChanges = codeDiffs.filter((d) => d.impactLevel === "major"); const minorChanges = codeDiffs.filter((d) => d.impactLevel === "minor"); // Create drift entries for (const diff of codeDiffs) { const drift: DocumentationDrift = { type: this.determineDriftType(diff), affectedDocs, codeChanges: [diff], description: this.generateDriftDescription(diff), detectedAt: new Date().toISOString(), severity: this.mapImpactToSeverity(diff.impactLevel), }; drifts.push(drift); // Generate suggestions for each affected doc for (const docPath of affectedDocs) { const docSnapshot = newSnapshot.documentation.get(docPath); if (docSnapshot) { const docSuggestions = await this.generateSuggestions( diff, docSnapshot, newSnapshot, ); suggestions.push(...docSuggestions); } } } const impactAnalysis: ImpactAnalysis = { breakingChanges: breakingChanges.length, majorChanges: majorChanges.length, minorChanges: minorChanges.length, affectedDocFiles: affectedDocs, estimatedUpdateEffort: this.estimateUpdateEffort(drifts), requiresManualReview: breakingChanges.length > 0 || majorChanges.length > 3, }; const severity = this.calculateOverallSeverity(drifts); return { filePath, hasDrift: drifts.length > 0, severity, drifts, suggestions, impactAnalysis, }; } /** * Generate update suggestions for documentation */ private async generateSuggestions( diff: CodeDiff, docSnapshot: DocumentationSnapshot, snapshot: DriftSnapshot, ): Promise<DriftSuggestion[]> { const suggestions: DriftSuggestion[] = []; // Find sections that reference the changed code for (const section of docSnapshot.sections) { const isAffected = this.isSectionAffected(section, diff); if (isAffected) { const suggestion = await this.createSuggestion( diff, docSnapshot, section, snapshot, ); if (suggestion) { suggestions.push(suggestion); } } } return suggestions; } /** * Create a specific suggestion for a documentation section */ private async createSuggestion( diff: CodeDiff, docSnapshot: DocumentationSnapshot, section: DocumentationSection, snapshot: DriftSnapshot, ): Promise<DriftSuggestion | null> { let suggestedContent = section.content; let reasoning = ""; let confidence = 0.5; let autoApplicable = false; switch (diff.type) { case "removed": reasoning = `The ${diff.category} '${diff.name}' has been removed from the codebase. This section should be updated or removed.`; suggestedContent = this.generateRemovalSuggestion(section, diff); confidence = 0.8; autoApplicable = false; break; case "added": reasoning = `A new ${diff.category} '${diff.name}' has been added. Consider documenting it.`; suggestedContent = this.generateAdditionSuggestion( section, diff, snapshot, ); confidence = 0.6; autoApplicable = false; break; case "modified": reasoning = `The ${diff.category} '${diff.name}' has been modified: ${diff.details}`; suggestedContent = this.generateModificationSuggestion( section, diff, snapshot, ); confidence = 0.7; autoApplicable = diff.impactLevel === "patch"; break; } return { docFile: docSnapshot.filePath, section: section.title, currentContent: section.content, suggestedContent, reasoning, confidence, autoApplicable, }; } /** * Generate suggestion for removed code */ private generateRemovalSuggestion( section: DocumentationSection, diff: CodeDiff, ): string { let content = section.content; // Remove references to the deleted symbol const symbolRegex = new RegExp(`\\b${diff.name}\\b`, "g"); content = content.replace(symbolRegex, `~~${diff.name}~~ (removed)`); // Add deprecation notice const notice = `\n\n> **Note**: The \`${diff.name}\` ${diff.category} has been removed in the latest version.\n`; content = notice + content; return content; } /** * Generate suggestion for added code */ private generateAdditionSuggestion( section: DocumentationSection, diff: CodeDiff, _snapshot: DriftSnapshot, ): string { let content = section.content; // Add new section for the added symbol const additionNotice = `\n\n## ${diff.name}\n\nA new ${diff.category} has been added.\n\n`; // Try to extract signature if available if (diff.newSignature) { content += additionNotice + `\`\`\`typescript\n${diff.newSignature}\n\`\`\`\n`; } else { content += additionNotice + `> **Documentation needed**: Please document the \`${diff.name}\` ${diff.category}.\n`; } return content; } /** * Generate suggestion for modified code */ private generateModificationSuggestion( section: DocumentationSection, diff: CodeDiff, _snapshot: DriftSnapshot, ): string { let content = section.content; // Update signature references if (diff.oldSignature && diff.newSignature) { content = content.replace(diff.oldSignature, diff.newSignature); } // Add update notice const updateNotice = `\n\n> **Updated**: ${diff.details}\n`; content = updateNotice + content; return content; } /** * Check if a section is affected by a code change */ private isSectionAffected( section: DocumentationSection, diff: CodeDiff, ): boolean { switch (diff.category) { case "function": return section.referencedFunctions.includes(diff.name); case "class": return section.referencedClasses.includes(diff.name); case "interface": case "type": return section.referencedTypes.includes(diff.name); default: return false; } } /** * Find documentation files that reference changed code */ private findAffectedDocumentation( filePath: string, codeDiffs: CodeDiff[], documentation: Map<string, DocumentationSnapshot>, ): string[] { const affected: string[] = []; for (const [docPath, docSnapshot] of documentation) { // Check if doc references the changed file if (docSnapshot.referencedCode.includes(filePath)) { affected.push(docPath); continue; } // Check if doc references changed symbols for (const diff of codeDiffs) { for (const section of docSnapshot.sections) { if (this.isSectionAffected(section, diff)) { affected.push(docPath); break; } } } } return [...new Set(affected)]; } /** * Analyze a documentation file */ private async analyzeDocumentation( docPath: string, ): Promise<DocumentationSnapshot | null> { try { const content = await fs.readFile(docPath, "utf-8"); const crypto = await import("crypto"); const contentHash = crypto .createHash("sha256") .update(content) .digest("hex"); const stats = await fs.stat(docPath); const sections = this.extractDocSections(content); const referencedCode = this.extractCodeReferences(content); return { filePath: docPath, contentHash, referencedCode, lastUpdated: stats.mtime.toISOString(), sections, }; } catch (error) { console.warn(`Failed to analyze documentation ${docPath}:`, error); return null; } } /** * Extract sections from documentation */ private extractDocSections(content: string): DocumentationSection[] { const sections: DocumentationSection[] = []; const lines = content.split("\n"); let currentSection: Partial<DocumentationSection> | null = null; let currentContent: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Detect headings const headingMatch = line.match(/^(#{1,6})\s+(.+)/); if (headingMatch) { // Save previous section if (currentSection) { currentSection.content = currentContent.join("\n"); currentSection.endLine = i - 1; sections.push(currentSection as DocumentationSection); } const title = headingMatch[2]; const referencedFunctions: string[] = []; const referencedClasses: string[] = []; // Extract function name from heading if it looks like a function signature // e.g., "## calculate(x: number): number" or "## myFunction()" const funcMatch = title.match(/^([a-z][A-Za-z0-9_]*)\s*\(/); if (funcMatch) { referencedFunctions.push(funcMatch[1]); } // Extract class name from heading if it starts with uppercase const classMatch = title.match(/^([A-Z][A-Za-z0-9_]*)/); if (classMatch && !funcMatch) { referencedClasses.push(classMatch[1]); } // Start new section currentSection = { title, startLine: i, referencedFunctions, referencedClasses, referencedTypes: [], codeExamples: [], }; currentContent = []; } else if (currentSection) { currentContent.push(line); // Extract code examples if (line.startsWith("```")) { const langMatch = line.match(/```(\w+)/); const language = langMatch ? langMatch[1] : "text"; const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].startsWith("```")) { codeLines.push(lines[i]); i++; } const codeExample: CodeExample = { language, code: codeLines.join("\n"), description: "", referencedSymbols: this.extractSymbolsFromCode( codeLines.join("\n"), ), }; currentSection.codeExamples!.push(codeExample); } // Extract inline code references (with or without parentheses for functions) const inlineCodeMatches = line.matchAll( /`([A-Za-z_][A-Za-z0-9_]*)\(\)?`/g, ); for (const match of inlineCodeMatches) { const symbol = match[1]; // Heuristic: CamelCase = class/type, camelCase = function if (/^[A-Z]/.test(symbol)) { if (!currentSection.referencedClasses!.includes(symbol)) { currentSection.referencedClasses!.push(symbol); } } else { if (!currentSection.referencedFunctions!.includes(symbol)) { currentSection.referencedFunctions!.push(symbol); } } } // Also extract identifiers without parentheses const plainIdentifiers = line.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)`/g); for (const match of plainIdentifiers) { const symbol = match[1]; if (/^[A-Z]/.test(symbol)) { if (!currentSection.referencedClasses!.includes(symbol)) { currentSection.referencedClasses!.push(symbol); } } else { if (!currentSection.referencedFunctions!.includes(symbol)) { currentSection.referencedFunctions!.push(symbol); } } } } } // Save last section if (currentSection) { currentSection.content = currentContent.join("\n"); currentSection.endLine = lines.length - 1; sections.push(currentSection as DocumentationSection); } return sections; } /** * Extract code file references from documentation */ private extractCodeReferences(content: string): string[] { const references: string[] = []; // Extract from markdown links const linkMatches = content.matchAll( /\[.*?\]\((.*?\.(ts|js|py|go|rs|java|rb).*?)\)/g, ); for (const match of linkMatches) { references.push(match[1]); } // Extract from inline code const codeMatches = content.matchAll( /`([^`]+\.(ts|js|py|go|rs|java|rb))`/g, ); for (const match of codeMatches) { references.push(match[1]); } return [...new Set(references)]; } /** * Extract symbols from code examples */ private extractSymbolsFromCode(code: string): string[] { const symbols: string[] = []; // Extract function calls const functionMatches = code.matchAll(/\b([a-z][A-Za-z0-9_]*)\s*\(/g); for (const match of functionMatches) { symbols.push(match[1]); } // Extract class/type references const classMatches = code.matchAll(/\b([A-Z][A-Za-z0-9_]*)\b/g); for (const match of classMatches) { symbols.push(match[1]); } return [...new Set(symbols)]; } /** * Find all source files in project */ private async findSourceFiles(projectPath: string): Promise<string[]> { const files: string[] = []; const extensions = [ ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".rb", ]; const walk = async (dir: string) => { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if ( !["node_modules", "dist", "build", ".git", ".next"].includes( entry.name, ) ) { await walk(fullPath); } } else { const ext = path.extname(entry.name); if (extensions.includes(ext)) { files.push(fullPath); } } } } catch (error) { console.warn(`Failed to read directory ${dir}:`, error); } }; await walk(projectPath); return files; } /** * Find all documentation files */ private async findDocumentationFiles(docsPath: string): Promise<string[]> { const files: string[] = []; const walk = async (dir: string) => { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(fullPath); } else if ( entry.name.endsWith(".md") || entry.name.endsWith(".mdx") ) { files.push(fullPath); } } } catch (error) { console.warn(`Failed to read documentation directory ${dir}:`, error); } }; try { await walk(docsPath); } catch { // Docs path doesn't exist } return files; } /** * Save snapshot to disk */ private async saveSnapshot(snapshot: DriftSnapshot): Promise<void> { const timestamp = new Date().toISOString().replace(/:/g, "-"); const snapshotPath = path.join( this.snapshotDir, `snapshot-${timestamp}.json`, ); // Convert Maps to objects for JSON serialization const serializable = { projectPath: snapshot.projectPath, timestamp: snapshot.timestamp, files: Object.fromEntries(snapshot.files), documentation: Object.fromEntries(snapshot.documentation), }; await fs.writeFile(snapshotPath, JSON.stringify(serializable, null, 2)); } /** * Load the latest snapshot */ async loadLatestSnapshot(): Promise<DriftSnapshot | null> { try { const files = await fs.readdir(this.snapshotDir); const snapshotFiles = files .filter((f) => f.startsWith("snapshot-")) .sort() .reverse(); if (snapshotFiles.length === 0) return null; const latestPath = path.join(this.snapshotDir, snapshotFiles[0]); const content = await fs.readFile(latestPath, "utf-8"); const data = JSON.parse(content); return { projectPath: data.projectPath, timestamp: data.timestamp, files: new Map(Object.entries(data.files)), documentation: new Map(Object.entries(data.documentation)), }; } catch { return null; } } // Helper methods private determineDriftType( diff: CodeDiff, ): "outdated" | "incorrect" | "missing" | "breaking" { if (diff.impactLevel === "breaking") return "breaking"; if (diff.type === "removed") return "incorrect"; if (diff.type === "modified") return "outdated"; return "missing"; } private generateDriftDescription(diff: CodeDiff): string { const action = diff.type === "added" ? "added" : diff.type === "removed" ? "removed" : "modified"; return `${diff.category} '${diff.name}' was ${action}: ${diff.details}`; } private mapImpactToSeverity( impact: "breaking" | "major" | "minor" | "patch", ): "low" | "medium" | "high" | "critical" { switch (impact) { case "breaking": return "critical"; case "major": return "high"; case "minor": return "medium"; case "patch": return "low"; } } private estimateUpdateEffort( drifts: DocumentationDrift[], ): "low" | "medium" | "high" { const critical = drifts.filter((d) => d.severity === "critical").length; const high = drifts.filter((d) => d.severity === "high").length; if (critical > 0 || high > 5) return "high"; if (high > 0 || drifts.length > 10) return "medium"; return "low"; } private calculateOverallSeverity( drifts: DocumentationDrift[], ): "none" | "low" | "medium" | "high" | "critical" { if (drifts.length === 0) return "none"; const hasCritical = drifts.some((d) => d.severity === "critical"); if (hasCritical) return "critical"; const hasHigh = drifts.some((d) => d.severity === "high"); if (hasHigh) return "high"; const hasMedium = drifts.some((d) => d.severity === "medium"); if (hasMedium) return "medium"; return "low"; } }

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/documcp'

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