devibe
Version:
Intelligent repository cleanup with auto mode, AI learning, markdown consolidation, auto-consolidate workflow, context-aware classification, and cost optimization
539 lines • 21.3 kB
JavaScript
/**
* Project Convention Analyzer
*
* Intelligently analyzes existing project structure and conventions to ensure
* devibe respects and follows existing patterns rather than imposing new ones.
*
* Features:
* - Detects existing folder structures (docs, scripts, tests, etc.)
* - Analyzes documentation organization patterns
* - Identifies where root files (README, CHANGELOG, etc.) are placed
* - Uses AI to understand project-specific conventions
* - Falls back to best practices when no conventions exist
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { AIClassifierFactory } from './ai-classifier.js';
export class ProjectConventionAnalyzer {
aiAvailable = false;
async initialize() {
this.aiAvailable = await AIClassifierFactory.isAvailable();
}
/**
* Analyze project conventions comprehensively
*/
async analyze(rootPath, repositories) {
await this.initialize();
const [docsFolder, scriptsFolder, rootFileConventions, testConventions] = await Promise.all([
this.analyzeDocsFolder(rootPath),
this.analyzeScriptsFolder(rootPath),
this.analyzeRootFiles(rootPath),
this.analyzeTestConventions(rootPath),
]);
// Generate recommendations based on findings
const recommendations = this.generateRecommendations({
docsFolder,
scriptsFolder,
rootFileConventions,
testConventions,
});
// Use AI to refine understanding if available
const conventions = {
docsFolder,
scriptsFolder,
rootFileConventions,
testConventions,
recommendations,
analyzedAt: new Date().toISOString(),
};
if (this.aiAvailable) {
await this.enhanceWithAI(conventions, rootPath);
}
return conventions;
}
/**
* Analyze documentation folder structure
*/
async analyzeDocsFolder(rootPath) {
const possibleNames = ['docs', 'doc', 'documentation', 'documents'];
for (const name of possibleNames) {
const docsPath = path.join(rootPath, name);
try {
const stats = await fs.stat(docsPath);
if (stats.isDirectory()) {
const structure = await this.analyzeDocsFolderStructure(docsPath);
return {
path: name,
exists: true,
structure,
};
}
}
catch {
// Folder doesn't exist, continue checking
}
}
return {
path: 'docs',
exists: false,
};
}
/**
* Analyze the internal structure of docs folder
*/
async analyzeDocsFolderStructure(docsPath) {
try {
const entries = await fs.readdir(docsPath, { withFileTypes: true });
const folders = entries.filter(e => e.isDirectory()).map(e => e.name.toLowerCase());
return {
hasSpecifications: folders.some(f => f.includes('spec') || f === 'specifications'),
hasImplementation: folders.some(f => f.includes('implementation') || f === 'impl'),
hasGuides: folders.some(f => f.includes('guide') || f === 'guides'),
hasAPI: folders.some(f => f.includes('api') || f === 'reference'),
customFolders: folders.filter(f => !['spec', 'specifications', 'implementation', 'impl', 'guides', 'guide', 'api', 'reference'].includes(f)),
};
}
catch {
return {};
}
}
/**
* Analyze scripts folder
*/
async analyzeScriptsFolder(rootPath) {
const possibleNames = ['scripts', 'script', 'bin', 'tools'];
for (const name of possibleNames) {
const scriptsPath = path.join(rootPath, name);
try {
const stats = await fs.stat(scriptsPath);
if (stats.isDirectory()) {
const types = await this.categorizeScripts(scriptsPath);
return {
path: name,
exists: true,
types,
};
}
}
catch {
// Folder doesn't exist, continue checking
}
}
return {
path: 'scripts',
exists: false,
};
}
/**
* Categorize existing scripts by type
*/
async categorizeScripts(scriptsPath) {
try {
const entries = await fs.readdir(scriptsPath);
const types = new Set();
for (const entry of entries) {
const lower = entry.toLowerCase();
if (lower.includes('build'))
types.add('build');
if (lower.includes('deploy'))
types.add('deploy');
if (lower.includes('test'))
types.add('test');
if (lower.includes('setup') || lower.includes('install'))
types.add('setup');
if (lower.includes('clean'))
types.add('cleanup');
if (lower.includes('version') || lower.includes('bump'))
types.add('versioning');
}
return Array.from(types);
}
catch {
return [];
}
}
/**
* Analyze root file placement conventions
*/
async analyzeRootFiles(rootPath) {
const rootFiles = await fs.readdir(rootPath);
const rootFilesLower = rootFiles.map(f => f.toLowerCase());
const commonRootFiles = [
'readme.md', 'readme.txt', 'readme',
'changelog.md', 'changelog.txt', 'changelog',
'contributing.md', 'contributing.txt', 'contributing',
'license', 'license.md', 'license.txt',
'code_of_conduct.md', 'security.md',
];
const customRootFiles = rootFiles.filter(f => {
const lower = f.toLowerCase();
return ((lower.endsWith('.md') || lower.endsWith('.txt')) &&
!commonRootFiles.includes(lower) &&
!lower.startsWith('.'));
});
return {
readmeInRoot: rootFilesLower.some(f => f.startsWith('readme')),
changelogInRoot: rootFilesLower.some(f => f.startsWith('changelog')),
contributingInRoot: rootFilesLower.some(f => f.startsWith('contributing')),
licenseInRoot: rootFilesLower.some(f => f.startsWith('license')),
customRootFiles: customRootFiles.length > 0 ? customRootFiles : undefined,
};
}
/**
* Analyze test organization conventions
*/
async analyzeTestConventions(rootPath) {
let location = 'centralized';
let folderName;
// Check for centralized test folder
const testFolders = ['tests', 'test', '__tests__', 'spec'];
for (const folder of testFolders) {
try {
const stats = await fs.stat(path.join(rootPath, folder));
if (stats.isDirectory()) {
location = 'centralized';
folderName = folder;
break;
}
}
catch {
// Continue checking
}
}
// Check for colocated tests in src/
const srcPath = path.join(rootPath, 'src');
try {
if (await this.hasColocatedTests(srcPath)) {
location = folderName ? 'mixed' : 'colocated';
}
}
catch {
// src doesn't exist or not accessible
}
return {
location,
folderName,
};
}
/**
* Check if directory has colocated test files
*/
async hasColocatedTests(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && this.isTestFile(entry.name)) {
return true;
}
if (entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('node_modules')) {
if (await this.hasColocatedTests(fullPath)) {
return true;
}
}
}
return false;
}
catch {
return false;
}
}
/**
* Check if filename indicates a test file
*/
isTestFile(filename) {
const lower = filename.toLowerCase();
return (lower.endsWith('.test.ts') ||
lower.endsWith('.test.js') ||
lower.endsWith('.spec.ts') ||
lower.endsWith('.spec.js') ||
lower.endsWith('.test.tsx') ||
lower.endsWith('.spec.tsx'));
}
/**
* Generate recommendations based on analysis
*/
generateRecommendations(analysis) {
const recommendations = {
shouldCreateDocsFolder: false,
shouldCreateScriptsFolder: false,
keepFilesInRoot: [],
};
// Don't create folders if they already exist
if (!analysis.docsFolder?.exists) {
recommendations.shouldCreateDocsFolder = true;
recommendations.recommendedDocsStructure = ['specifications', 'guides'];
}
if (!analysis.scriptsFolder?.exists) {
recommendations.shouldCreateScriptsFolder = true;
}
// Best practice: Keep these files in root
const rootFiles = ['README.md', 'CHANGELOG.md', 'CONTRIBUTING.md', 'LICENSE', 'CODE_OF_CONDUCT.md'];
recommendations.keepFilesInRoot = rootFiles;
// If project already has root files, recommend keeping them
if (analysis.rootFileConventions) {
if (analysis.rootFileConventions.readmeInRoot) {
recommendations.keepFilesInRoot = recommendations.keepFilesInRoot || [];
}
if (analysis.rootFileConventions.changelogInRoot) {
recommendations.keepFilesInRoot.push('CHANGELOG.md');
}
if (analysis.rootFileConventions.contributingInRoot) {
recommendations.keepFilesInRoot.push('CONTRIBUTING.md');
}
}
return recommendations;
}
/**
* Use AI to enhance convention understanding
*/
async enhanceWithAI(conventions, rootPath) {
try {
// Check if AI is available
if (!this.aiAvailable) {
return;
}
// For now, we trust our heuristic analysis
// AI enhancement can be added in the future with more sophisticated prompting
}
catch (error) {
// AI enhancement failed, continue with heuristic analysis
if (process.env.VERBOSE) {
console.warn('AI enhancement failed:', error);
}
}
}
/**
* Build context string for AI analysis
*/
buildConventionsContext(conventions) {
const lines = [];
lines.push('PROJECT CONVENTIONS ANALYSIS:');
lines.push('');
// Docs folder
if (conventions.docsFolder?.exists) {
lines.push(`✓ Documentation folder: ${conventions.docsFolder.path}/`);
if (conventions.docsFolder.structure) {
const { structure } = conventions.docsFolder;
if (structure.hasSpecifications)
lines.push(' - Has specifications/ subfolder');
if (structure.hasImplementation)
lines.push(' - Has implementation/ subfolder');
if (structure.hasGuides)
lines.push(' - Has guides/ subfolder');
if (structure.hasAPI)
lines.push(' - Has API documentation');
if (structure.customFolders?.length) {
lines.push(` - Custom folders: ${structure.customFolders.join(', ')}`);
}
}
}
else {
lines.push('✗ No documentation folder found');
}
lines.push('');
// Scripts folder
if (conventions.scriptsFolder?.exists) {
lines.push(`✓ Scripts folder: ${conventions.scriptsFolder.path}/`);
if (conventions.scriptsFolder.types?.length) {
lines.push(` - Script types: ${conventions.scriptsFolder.types.join(', ')}`);
}
}
else {
lines.push('✗ No scripts folder found');
}
lines.push('');
// Root files
if (conventions.rootFileConventions) {
lines.push('Root file conventions:');
const rc = conventions.rootFileConventions;
lines.push(` - README in root: ${rc.readmeInRoot ? '✓' : '✗'}`);
lines.push(` - CHANGELOG in root: ${rc.changelogInRoot ? '✓' : '✗'}`);
lines.push(` - CONTRIBUTING in root: ${rc.contributingInRoot ? '✓' : '✗'}`);
lines.push(` - LICENSE in root: ${rc.licenseInRoot ? '✓' : '✗'}`);
if (rc.customRootFiles?.length) {
lines.push(` - Custom root docs: ${rc.customRootFiles.join(', ')}`);
}
}
lines.push('');
// Test conventions
if (conventions.testConventions) {
lines.push(`Test organization: ${conventions.testConventions.location}`);
if (conventions.testConventions.folderName) {
lines.push(` - Test folder: ${conventions.testConventions.folderName}/`);
}
}
return lines.join('\n');
}
/**
* Get human-readable summary of conventions
*/
getSummary(conventions) {
const lines = [];
lines.push('\n📋 Project Conventions Analysis:');
lines.push('═══════════════════════════════════════\n');
// Documentation
if (conventions.docsFolder?.exists) {
lines.push(`✓ Documentation organized in [${conventions.docsFolder.path}](${conventions.docsFolder.path}/)`);
if (conventions.docsFolder.structure) {
const folders = [];
if (conventions.docsFolder.structure.hasSpecifications)
folders.push('specifications');
if (conventions.docsFolder.structure.hasImplementation)
folders.push('implementation');
if (conventions.docsFolder.structure.hasGuides)
folders.push('guides');
if (folders.length > 0) {
lines.push(` Structure: ${folders.join(', ')}`);
}
}
}
else {
lines.push('• No docs folder exists (will follow best practices)');
}
// Scripts
if (conventions.scriptsFolder?.exists) {
lines.push(`✓ Scripts organized in [${conventions.scriptsFolder.path}](${conventions.scriptsFolder.path}/)`);
}
else {
lines.push('• No scripts folder exists (will create if needed)');
}
// Root files
if (conventions.rootFileConventions) {
const rootDocs = [];
if (conventions.rootFileConventions.readmeInRoot)
rootDocs.push('README');
if (conventions.rootFileConventions.changelogInRoot)
rootDocs.push('CHANGELOG');
if (conventions.rootFileConventions.contributingInRoot)
rootDocs.push('CONTRIBUTING');
if (conventions.rootFileConventions.licenseInRoot)
rootDocs.push('LICENSE');
if (rootDocs.length > 0) {
lines.push(`✓ Root documentation: ${rootDocs.join(', ')}`);
}
}
// Recommendations
if (conventions.recommendations) {
lines.push('\n💡 Recommendations:');
if (conventions.recommendations.shouldCreateDocsFolder) {
lines.push(` • Create ${conventions.docsFolder?.path || 'docs'}/ folder for documentation`);
if (conventions.recommendations.recommendedDocsStructure?.length) {
lines.push(` → Suggested structure: ${conventions.recommendations.recommendedDocsStructure.join(', ')}`);
}
}
if (conventions.recommendations.shouldCreateScriptsFolder) {
lines.push(` • Create ${conventions.scriptsFolder?.path || 'scripts'}/ folder for scripts`);
}
if (conventions.recommendations.keepFilesInRoot?.length) {
lines.push(` • Keep in root: ${conventions.recommendations.keepFilesInRoot.join(', ')}`);
}
}
lines.push('═══════════════════════════════════════\n');
return lines.join('\n');
}
/**
* Check if a file should be placed in root based on conventions
*/
shouldKeepInRoot(fileName, conventions) {
const lower = fileName.toLowerCase();
// Always keep these in root (best practice)
const alwaysInRoot = ['readme', 'changelog', 'contributing', 'license', 'code_of_conduct', 'security'];
if (alwaysInRoot.some(name => lower.startsWith(name))) {
return true;
}
// Check project-specific conventions
if (conventions.recommendations?.keepFilesInRoot?.some(f => f.toLowerCase() === lower)) {
return true;
}
return false;
}
/**
* Get target folder for a file based on conventions
*/
getTargetFolder(fileName, fileType, conventions) {
// Check if should stay in root
if (this.shouldKeepInRoot(fileName, conventions)) {
return null; // null means "keep in root"
}
switch (fileType) {
case 'doc':
if (conventions.docsFolder?.exists) {
return conventions.docsFolder.path;
}
return conventions.recommendations?.shouldCreateDocsFolder ? 'docs' : null;
case 'script':
if (conventions.scriptsFolder?.exists) {
return conventions.scriptsFolder.path;
}
return conventions.recommendations?.shouldCreateScriptsFolder ? 'scripts' : null;
case 'test':
if (conventions.testConventions && conventions.testConventions.location === 'centralized' && conventions.testConventions.folderName) {
return conventions.testConventions.folderName;
}
return 'tests';
default:
return null;
}
}
/**
* Save conventions to cache file
*/
async saveToCache(rootPath, conventions) {
const cacheDir = path.join(rootPath, '.devibe');
const cacheFile = path.join(cacheDir, 'project-conventions.json');
try {
// Ensure .devibe directory exists
await fs.mkdir(cacheDir, { recursive: true });
// Write conventions to cache
await fs.writeFile(cacheFile, JSON.stringify(conventions, null, 2), 'utf-8');
}
catch (error) {
// Cache write failed, continue without caching
if (process.env.VERBOSE) {
console.warn('Failed to save conventions to cache:', error);
}
}
}
/**
* Load conventions from cache if available
*/
async loadFromCache(rootPath) {
const cacheFile = path.join(rootPath, '.devibe', 'project-conventions.json');
try {
const content = await fs.readFile(cacheFile, 'utf-8');
const conventions = JSON.parse(content);
// Check if cache is recent (within last 24 hours)
const cacheAge = Date.now() - new Date(conventions.analyzedAt).getTime();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (cacheAge < maxAge) {
return conventions;
}
// Cache is stale
return null;
}
catch {
// Cache doesn't exist or is invalid
return null;
}
}
/**
* Analyze with caching support
*/
async analyzeWithCache(rootPath, repositories) {
// Try to load from cache first
const cached = await this.loadFromCache(rootPath);
if (cached) {
if (process.env.VERBOSE) {
console.log('📦 Using cached project conventions');
}
return cached;
}
// Perform fresh analysis
const conventions = await this.analyze(rootPath, repositories);
// Save to cache
await this.saveToCache(rootPath, conventions);
return conventions;
}
}
//# sourceMappingURL=project-convention-analyzer.js.map