aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,287 lines (1,127 loc) • 40.7 kB
JavaScript
/**
* AIOS Codebase Mapper
*
* Story: 7.2 - Codebase Mapper
* Epic: Epic 7 - Memory Layer
*
* Generates comprehensive maps of the codebase for context generation.
* Used by the Context Generator (Epic 4) to understand project structure.
*
* Features:
* - AC1: Located in `.aios-core/infrastructure/scripts/`
* - AC2: Generates: services, directories, patterns, conventions, dependencies
* - AC3: Output: `.aios/codebase-map.json`
* - AC4: Automatic updates after significant merges
* - AC5: Command `*map-codebase` available globally
* - AC6: Used by Context Generator (Epic 4)
* - AC7: Excludes node_modules, .git, build outputs
*
* @author @architect (Aria)
* @version 1.0.0
*/
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
// ═══════════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════════
const CONFIG = {
// AC3: Output path
outputPath: '.aios/codebase-map.json',
// Schema version
schemaVersion: '1.0',
// Default max depth
defaultMaxDepth: 5,
// AC7: Directories to exclude
excludeDirs: [
'node_modules',
'.git',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.svelte-kit',
'coverage',
'.cache',
'.parcel-cache',
'.turbo',
'__pycache__',
'.pytest_cache',
'venv',
'.venv',
'env',
'.env',
'vendor',
'tmp',
'temp',
'logs',
],
// File patterns to exclude
excludeFiles: [
'*.log',
'*.lock',
'*.map',
'.DS_Store',
'Thumbs.db',
'*.min.js',
'*.min.css',
'*.chunk.js',
'*.bundle.js',
],
// File extensions to analyze for patterns
codeExtensions: [
'.js', '.mjs', '.cjs',
'.ts', '.tsx', '.jsx',
'.py', '.rb', '.go',
'.java', '.kt', '.scala',
'.rs', '.cpp', '.c', '.h',
'.vue', '.svelte',
],
// Config file names to detect
configFiles: [
'package.json',
'tsconfig.json',
'vite.config.ts',
'vite.config.js',
'next.config.js',
'next.config.mjs',
'nuxt.config.ts',
'webpack.config.js',
'.eslintrc',
'.eslintrc.js',
'.prettierrc',
'jest.config.js',
'vitest.config.ts',
'turbo.json',
'pnpm-workspace.yaml',
'lerna.json',
'nx.json',
'docker-compose.yml',
'Dockerfile',
'.env.example',
'pyproject.toml',
'Cargo.toml',
'go.mod',
'Gemfile',
],
};
// ═══════════════════════════════════════════════════════════════════════════════════
// PATTERN DETECTORS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Pattern detection rules for common frameworks and conventions
*/
const PatternDetectors = {
// State management patterns
stateManagement: {
'zustand': {
patterns: [/import.*from ['"]zustand['"]/, /create\s*\(/],
files: ['**/stores/**', '**/store/**'],
},
'redux': {
patterns: [/import.*from ['"]@reduxjs\/toolkit['"]/, /createSlice\(/, /configureStore\(/],
files: ['**/store/**', '**/slices/**', '**/reducers/**'],
},
'mobx': {
patterns: [/import.*from ['"]mobx['"]/, /@observable/, /@action/],
files: ['**/stores/**'],
},
'vuex': {
patterns: [/import.*from ['"]vuex['"]/, /createStore\(/],
files: ['**/store/**'],
},
'pinia': {
patterns: [/import.*from ['"]pinia['"]/, /defineStore\(/],
files: ['**/stores/**'],
},
'context-api': {
patterns: [/createContext\(/, /useContext\(/],
files: ['**/contexts/**', '**/context/**'],
},
},
// API patterns
apiPatterns: {
'fetch-wrapper': {
patterns: [/export.*fetch/, /async.*fetch\(/],
files: ['**/lib/api.*', '**/utils/api.*', '**/services/api.*'],
},
'axios': {
patterns: [/import.*from ['"]axios['"]/, /axios\.create\(/],
files: ['**/lib/axios.*', '**/services/**'],
},
'react-query': {
patterns: [/import.*from ['"]@tanstack\/react-query['"]/, /useQuery\(/, /useMutation\(/],
files: ['**/hooks/**', '**/queries/**'],
},
'swr': {
patterns: [/import.*from ['"]swr['"]/, /useSWR\(/],
files: ['**/hooks/**'],
},
'trpc': {
patterns: [/import.*from ['"]@trpc\//, /trpc\.router\(/],
files: ['**/trpc/**', '**/server/routers/**'],
},
'graphql': {
patterns: [/import.*from ['"]@apollo\/client['"]/, /gql`/, /useQuery\(/],
files: ['**/graphql/**', '**/*.graphql'],
},
},
// Testing patterns
testingPatterns: {
'jest': {
patterns: [/describe\(/, /it\(/, /test\(/, /expect\(/],
files: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'],
},
'vitest': {
patterns: [/import.*from ['"]vitest['"]/, /vi\.fn\(/],
files: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'],
},
'react-testing-library': {
patterns: [/import.*from ['"]@testing-library\/react['"]/, /render\(/, /screen\./],
files: ['**/__tests__/**', '**/*.test.*'],
},
'cypress': {
patterns: [/cy\./, /describe\(/, /it\(/],
files: ['**/cypress/**', '**/*.cy.*'],
},
'playwright': {
patterns: [/import.*from ['"]@playwright\/test['"]/, /test\(/, /expect\(/],
files: ['**/e2e/**', '**/*.spec.ts'],
},
},
// Error handling patterns
errorHandling: {
'try-catch-toast': {
patterns: [/try\s*{[\s\S]*catch[\s\S]*toast\./],
files: null,
},
'error-boundary': {
patterns: [/ErrorBoundary/, /componentDidCatch/],
files: ['**/components/**'],
},
'global-error-handler': {
patterns: [/window\.onerror/, /process\.on\(['"]uncaughtException['"]\)/],
files: null,
},
},
};
// ═══════════════════════════════════════════════════════════════════════════════════
// CODEBASE MAPPER CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
class CodebaseMapper {
/**
* Create a new CodebaseMapper instance
*
* @param {string} rootPath - Project root path
* @param {Object} options - Configuration options
* @param {number} [options.maxDepth] - Maximum directory depth to scan
* @param {string[]} [options.excludeDirs] - Additional directories to exclude
* @param {boolean} [options.quiet] - Suppress output
*/
constructor(rootPath, options = {}) {
this.rootPath = rootPath || process.cwd();
this.maxDepth = options.maxDepth || CONFIG.defaultMaxDepth;
this.excludeDirs = [...CONFIG.excludeDirs, ...(options.excludeDirs || [])];
this.quiet = options.quiet || false;
// Internal state
this._structure = {};
this._services = [];
this._patterns = {};
this._conventions = {};
this._dependencies = { runtime: [], dev: [] };
this._fileIndex = new Map();
this._configFiles = [];
}
// ═══════════════════════════════════════════════════════════════════════════════
// SCANNING
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scan the codebase directory structure
* @returns {Promise<void>}
*/
async scanDirectory() {
if (!this.quiet) {
console.log(`\nScanning codebase: ${this.rootPath}`);
}
this._structure = await this._scanRecursive(this.rootPath, 0);
if (!this.quiet) {
console.log(`Found ${this._fileIndex.size} files`);
}
}
/**
* Recursively scan directories
* @private
*/
async _scanRecursive(dirPath, depth) {
const result = {
type: 'directory',
purpose: this._inferPurpose(path.basename(dirPath)),
children: {},
files: [],
};
if (depth >= this.maxDepth) {
return result;
}
try {
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.relative(this.rootPath, fullPath);
// AC7: Skip excluded directories
if (entry.isDirectory()) {
if (this._isExcluded(entry.name)) {
continue;
}
result.children[entry.name] = await this._scanRecursive(fullPath, depth + 1);
} else if (entry.isFile()) {
// Skip excluded files
if (this._isExcludedFile(entry.name)) {
continue;
}
const fileInfo = await this._analyzeFile(fullPath, relativePath);
result.files.push(entry.name);
this._fileIndex.set(relativePath, fileInfo);
// Track config files
if (CONFIG.configFiles.includes(entry.name)) {
this._configFiles.push({ name: entry.name, path: relativePath });
}
}
}
} catch (error) {
if (!this.quiet) {
console.warn(`Warning: Cannot access ${dirPath}: ${error.message}`);
}
}
return result;
}
/**
* Analyze a single file
* @private
*/
async _analyzeFile(fullPath, relativePath) {
const stats = await fsPromises.stat(fullPath);
const ext = path.extname(fullPath).toLowerCase();
const fileInfo = {
path: relativePath,
size: stats.size,
extension: ext,
modified: stats.mtime.toISOString(),
};
// Only read code files for pattern analysis
if (CONFIG.codeExtensions.includes(ext) && stats.size < 500000) {
try {
const content = await fsPromises.readFile(fullPath, 'utf-8');
fileInfo.lineCount = content.split('\n').length;
fileInfo.hasTests = /\.(test|spec)\.(js|ts|jsx|tsx)$/.test(relativePath);
fileInfo.isComponent = /\.(jsx|tsx|vue|svelte)$/.test(ext);
// Extract imports for dependency graph
fileInfo.imports = this._extractImports(content);
// Extract exports
fileInfo.exports = this._extractExports(content);
} catch {
// Ignore read errors
}
}
return fileInfo;
}
/**
* Check if directory should be excluded (AC7)
* @private
*/
_isExcluded(name) {
return this.excludeDirs.includes(name) || name.startsWith('.');
}
/**
* Check if file should be excluded
* @private
*/
_isExcludedFile(name) {
return CONFIG.excludeFiles.some(pattern => {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
});
}
/**
* Infer directory purpose from name
* @private
*/
_inferPurpose(dirName) {
const purposes = {
src: 'Application source code',
lib: 'Library/utility code',
utils: 'Utility functions',
helpers: 'Helper functions',
components: 'UI components',
pages: 'Page components/routes',
views: 'View components',
stores: 'State management stores',
store: 'State management',
hooks: 'Custom React/Vue hooks',
composables: 'Vue composables',
services: 'Service layer/API clients',
api: 'API routes/endpoints',
server: 'Server-side code',
client: 'Client-side code',
public: 'Static assets',
assets: 'Media/static files',
styles: 'CSS/styling files',
types: 'TypeScript type definitions',
interfaces: 'Interface definitions',
models: 'Data models/schemas',
schemas: 'Data schemas',
config: 'Configuration files',
tests: 'Test files',
__tests__: 'Jest test files',
e2e: 'End-to-end tests',
cypress: 'Cypress tests',
docs: 'Documentation',
scripts: 'Build/utility scripts',
bin: 'CLI/executable scripts',
migrations: 'Database migrations',
seeders: 'Database seeders',
middleware: 'Middleware functions',
plugins: 'Plugin/extension code',
locales: 'i18n/localization files',
i18n: 'Internationalization',
};
return purposes[dirName.toLowerCase()] || null;
}
// ═══════════════════════════════════════════════════════════════════════════════
// PATTERN DETECTION
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Detect patterns in the codebase
* @returns {Promise<Object>}
*/
async detectPatterns() {
if (!this.quiet) {
console.log('Detecting patterns...');
}
const patterns = {
stateManagement: null,
apiCalls: null,
errorHandling: null,
testing: null,
};
// Sample files for pattern detection
const sampleFiles = this._getSampleFiles();
for (const [filePath, fileInfo] of sampleFiles) {
if (!fileInfo.imports) continue;
try {
const fullPath = path.join(this.rootPath, filePath);
const content = await fsPromises.readFile(fullPath, 'utf-8');
// Detect state management
if (!patterns.stateManagement) {
for (const [name, detector] of Object.entries(PatternDetectors.stateManagement)) {
if (this._matchesPattern(content, detector.patterns)) {
patterns.stateManagement = name;
break;
}
}
}
// Detect API patterns
if (!patterns.apiCalls) {
for (const [name, detector] of Object.entries(PatternDetectors.apiPatterns)) {
if (this._matchesPattern(content, detector.patterns)) {
patterns.apiCalls = name;
break;
}
}
}
// Detect testing patterns
if (!patterns.testing && fileInfo.hasTests) {
for (const [name, detector] of Object.entries(PatternDetectors.testingPatterns)) {
if (this._matchesPattern(content, detector.patterns)) {
patterns.testing = name;
break;
}
}
}
// Detect error handling
if (!patterns.errorHandling) {
for (const [name, detector] of Object.entries(PatternDetectors.errorHandling)) {
if (this._matchesPattern(content, detector.patterns)) {
patterns.errorHandling = name;
break;
}
}
}
} catch {
// Skip files that can't be read
}
}
// Try to infer patterns from directory structure
if (!patterns.stateManagement) {
if (this._hasDirectory('stores')) {
patterns.stateManagement = 'store-based (inferred from directory)';
}
}
this._patterns = patterns;
return patterns;
}
/**
* Match content against pattern array
* @private
*/
_matchesPattern(content, patterns) {
return patterns.some(pattern => pattern.test(content));
}
/**
* Get sample files for pattern detection
* @private
*/
_getSampleFiles() {
const samples = [];
const maxSamples = 50;
for (const [filePath, fileInfo] of this._fileIndex) {
if (CONFIG.codeExtensions.includes(fileInfo.extension)) {
samples.push([filePath, fileInfo]);
if (samples.length >= maxSamples) break;
}
}
return samples;
}
/**
* Check if directory exists in structure
* @private
*/
_hasDirectory(name) {
const checkRecursive = (obj) => {
if (obj.children && obj.children[name]) return true;
for (const child of Object.values(obj.children || {})) {
if (checkRecursive(child)) return true;
}
return false;
};
return checkRecursive(this._structure);
}
// ═══════════════════════════════════════════════════════════════════════════════
// CONVENTION DETECTION
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Detect coding conventions
* @returns {Promise<Object>}
*/
async detectConventions() {
if (!this.quiet) {
console.log('Detecting conventions...');
}
const conventions = {
fileNaming: this._detectFileNaming(),
componentNaming: this._detectComponentNaming(),
imports: this._detectImportStyle(),
exports: this._detectExportStyle(),
indentation: null,
quotes: null,
};
// Detect from config files
const eslintConfig = this._configFiles.find(f => f.name.startsWith('.eslintrc'));
const prettierConfig = this._configFiles.find(f => f.name.startsWith('.prettierrc'));
if (eslintConfig || prettierConfig) {
conventions.linting = eslintConfig ? 'ESLint' : 'Prettier';
}
// Detect TypeScript usage
const tsConfig = this._configFiles.find(f => f.name === 'tsconfig.json');
if (tsConfig) {
conventions.language = 'TypeScript';
conventions.typeChecking = 'strict'; // Default assumption
} else {
conventions.language = 'JavaScript';
}
this._conventions = conventions;
return conventions;
}
/**
* Detect file naming convention
* @private
*/
_detectFileNaming() {
const namingPatterns = {
camelCase: 0,
kebabCase: 0,
snakeCase: 0,
PascalCase: 0,
};
for (const [filePath] of this._fileIndex) {
const fileName = path.basename(filePath, path.extname(filePath));
// Skip test files and index files
if (fileName === 'index' || fileName.includes('.test') || fileName.includes('.spec')) {
continue;
}
if (/^[a-z][a-zA-Z0-9]*$/.test(fileName)) {
if (fileName.includes('-')) {
namingPatterns.kebabCase++;
} else if (fileName.includes('_')) {
namingPatterns.snakeCase++;
} else {
namingPatterns.camelCase++;
}
} else if (/^[A-Z][a-zA-Z0-9]*$/.test(fileName)) {
namingPatterns.PascalCase++;
} else if (/^[a-z]+(-[a-z]+)*$/.test(fileName)) {
namingPatterns.kebabCase++;
} else if (/^[a-z]+(_[a-z]+)*$/.test(fileName)) {
namingPatterns.snakeCase++;
}
}
const dominant = Object.entries(namingPatterns)
.sort((a, b) => b[1] - a[1])[0];
if (dominant[1] > 0) {
return `${dominant[0]} (${dominant[1]} files)`;
}
return 'mixed';
}
/**
* Detect component naming convention
* @private
*/
_detectComponentNaming() {
let pascalCount = 0;
let totalComponents = 0;
for (const [filePath, fileInfo] of this._fileIndex) {
if (fileInfo.isComponent) {
totalComponents++;
const fileName = path.basename(filePath, path.extname(filePath));
if (/^[A-Z][a-zA-Z0-9]*$/.test(fileName)) {
pascalCount++;
}
}
}
if (totalComponents === 0) return null;
const ratio = pascalCount / totalComponents;
if (ratio > 0.8) return 'PascalCase';
if (ratio > 0.5) return 'mostly PascalCase';
return 'mixed';
}
/**
* Detect import style
* @private
*/
_detectImportStyle() {
let absoluteCount = 0;
let relativeCount = 0;
for (const [, fileInfo] of this._fileIndex) {
if (!fileInfo.imports) continue;
for (const imp of fileInfo.imports) {
if (imp.startsWith('@/') || imp.startsWith('~/')) {
absoluteCount++;
} else if (imp.startsWith('./') || imp.startsWith('../')) {
relativeCount++;
}
}
}
if (absoluteCount > relativeCount * 2) {
return 'absolute via @/ alias';
} else if (relativeCount > absoluteCount * 2) {
return 'relative paths';
}
return 'mixed (absolute and relative)';
}
/**
* Detect export style
* @private
*/
_detectExportStyle() {
let namedCount = 0;
let defaultCount = 0;
for (const [, fileInfo] of this._fileIndex) {
if (!fileInfo.exports) continue;
namedCount += fileInfo.exports.named || 0;
defaultCount += fileInfo.exports.default ? 1 : 0;
}
if (namedCount > defaultCount * 2) {
return 'named exports preferred';
} else if (defaultCount > namedCount * 2) {
return 'default exports preferred';
}
return 'mixed';
}
// ═══════════════════════════════════════════════════════════════════════════════
// DEPENDENCY EXTRACTION
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Extract dependencies from package.json
* @returns {Promise<Object>}
*/
async extractDependencies() {
if (!this.quiet) {
console.log('Extracting dependencies...');
}
const pkgPath = path.join(this.rootPath, 'package.json');
try {
const content = await fsPromises.readFile(pkgPath, 'utf-8');
const pkg = JSON.parse(content);
this._dependencies = {
runtime: Object.keys(pkg.dependencies || {}),
dev: Object.keys(pkg.devDependencies || {}),
peer: Object.keys(pkg.peerDependencies || {}),
optional: Object.keys(pkg.optionalDependencies || {}),
};
// Detect package manager
if (fs.existsSync(path.join(this.rootPath, 'pnpm-lock.yaml'))) {
this._dependencies.packageManager = 'pnpm';
} else if (fs.existsSync(path.join(this.rootPath, 'yarn.lock'))) {
this._dependencies.packageManager = 'yarn';
} else if (fs.existsSync(path.join(this.rootPath, 'package-lock.json'))) {
this._dependencies.packageManager = 'npm';
} else if (fs.existsSync(path.join(this.rootPath, 'bun.lockb'))) {
this._dependencies.packageManager = 'bun';
}
// Detect monorepo
if (pkg.workspaces) {
this._dependencies.monorepo = true;
this._dependencies.workspaces = pkg.workspaces;
}
// Detect Node.js version requirement
if (pkg.engines?.node) {
this._dependencies.nodeVersion = pkg.engines.node;
}
} catch {
// No package.json or invalid JSON
this._dependencies = { runtime: [], dev: [] };
}
return this._dependencies;
}
/**
* Extract imports from file content
* @private
*/
_extractImports(content) {
const imports = [];
// ES6 imports
const es6Matches = content.matchAll(/import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g);
for (const match of es6Matches) {
imports.push(match[1]);
}
// CommonJS requires
const cjsMatches = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
for (const match of cjsMatches) {
imports.push(match[1]);
}
return [...new Set(imports)];
}
/**
* Extract exports from file content
* @private
*/
_extractExports(content) {
const exports = { named: 0, default: false };
// Named exports
const namedMatches = content.match(/export\s+(const|let|var|function|class|async\s+function)/g);
if (namedMatches) {
exports.named = namedMatches.length;
}
// Export statements
const exportStatements = content.match(/export\s*\{/g);
if (exportStatements) {
exports.named += exportStatements.length;
}
// Default export
if (/export\s+default/.test(content)) {
exports.default = true;
}
return exports;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SERVICE IDENTIFICATION
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Identify services in the codebase
* @returns {Promise<Array>}
*/
async identifyServices() {
if (!this.quiet) {
console.log('Identifying services...');
}
const services = [];
// Check for common service directories
const serviceIndicators = [
{ dir: 'src/services', type: 'internal' },
{ dir: 'services', type: 'internal' },
{ dir: 'src/api', type: 'api' },
{ dir: 'api', type: 'api' },
{ dir: 'server', type: 'backend' },
{ dir: 'backend', type: 'backend' },
{ dir: 'src/server', type: 'backend' },
{ dir: 'packages', type: 'package' },
{ dir: 'apps', type: 'app' },
];
for (const indicator of serviceIndicators) {
const dirPath = path.join(this.rootPath, indicator.dir);
if (fs.existsSync(dirPath)) {
try {
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const servicePath = path.join(indicator.dir, entry.name);
const entrypoint = await this._findEntrypoint(path.join(dirPath, entry.name));
services.push({
name: entry.name,
type: indicator.type,
directory: servicePath,
entrypoint: entrypoint || null,
dependencies: await this._getServiceDependencies(path.join(dirPath, entry.name)),
});
} else if (entry.isFile() && !entry.name.startsWith('.')) {
const ext = path.extname(entry.name);
if (CONFIG.codeExtensions.includes(ext)) {
services.push({
name: path.basename(entry.name, ext),
type: indicator.type,
entrypoint: path.join(indicator.dir, entry.name),
dependencies: [],
});
}
}
}
} catch {
// Directory not accessible
}
}
}
// Detect monorepo packages/apps as services
if (this._dependencies.workspaces) {
const workspaces = Array.isArray(this._dependencies.workspaces)
? this._dependencies.workspaces
: this._dependencies.workspaces.packages || [];
for (const workspace of workspaces) {
const basePath = workspace.replace('/*', '');
const wsPath = path.join(this.rootPath, basePath);
if (fs.existsSync(wsPath)) {
try {
const entries = await fsPromises.readdir(wsPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const pkgJsonPath = path.join(wsPath, entry.name, 'package.json');
if (fs.existsSync(pkgJsonPath)) {
const pkgContent = JSON.parse(await fsPromises.readFile(pkgJsonPath, 'utf-8'));
services.push({
name: pkgContent.name || entry.name,
type: 'workspace',
directory: path.join(basePath, entry.name),
entrypoint: pkgContent.main || null,
dependencies: Object.keys(pkgContent.dependencies || {}),
});
}
}
}
} catch {
// Workspace not accessible
}
}
}
}
this._services = services;
return services;
}
/**
* Find entrypoint file in directory
* @private
*/
async _findEntrypoint(dirPath) {
const entrypoints = ['index.ts', 'index.js', 'main.ts', 'main.js', 'mod.ts', 'mod.js'];
for (const entry of entrypoints) {
const filePath = path.join(dirPath, entry);
if (fs.existsSync(filePath)) {
return path.relative(this.rootPath, filePath);
}
}
// Check for src/index
for (const entry of entrypoints) {
const filePath = path.join(dirPath, 'src', entry);
if (fs.existsSync(filePath)) {
return path.relative(this.rootPath, filePath);
}
}
return null;
}
/**
* Get dependencies for a service
* @private
*/
async _getServiceDependencies(dirPath) {
const pkgPath = path.join(dirPath, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const content = await fsPromises.readFile(pkgPath, 'utf-8');
const pkg = JSON.parse(content);
return Object.keys(pkg.dependencies || {});
} catch {
return [];
}
}
return [];
}
// ═══════════════════════════════════════════════════════════════════════════════
// OUTPUT
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Generate the complete codebase map
* @returns {Promise<Object>}
*/
async generateMap() {
// Run all analysis
await this.scanDirectory();
await this.detectPatterns();
await this.detectConventions();
await this.extractDependencies();
await this.identifyServices();
// Get project name from package.json or directory
let projectName = path.basename(this.rootPath);
const pkgPath = path.join(this.rootPath, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(await fsPromises.readFile(pkgPath, 'utf-8'));
projectName = pkg.name || projectName;
} catch {
// Use directory name
}
}
const map = {
project: projectName,
mappedAt: new Date().toISOString(),
version: CONFIG.schemaVersion,
structure: this._simplifyStructure(this._structure),
services: this._services,
patterns: this._patterns,
conventions: this._conventions,
dependencies: this._dependencies,
stats: {
totalFiles: this._fileIndex.size,
codeFiles: [...this._fileIndex.values()].filter(f =>
CONFIG.codeExtensions.includes(f.extension)
).length,
testFiles: [...this._fileIndex.values()].filter(f => f.hasTests).length,
components: [...this._fileIndex.values()].filter(f => f.isComponent).length,
configFiles: this._configFiles.length,
},
configFiles: this._configFiles,
};
return map;
}
/**
* Simplify structure for output (remove empty directories, limit depth)
* @private
*/
_simplifyStructure(structure, depth = 0) {
if (depth > 3) {
return { type: 'directory', truncated: true };
}
const simplified = {
type: structure.type,
};
if (structure.purpose) {
simplified.purpose = structure.purpose;
}
if (structure.files && structure.files.length > 0) {
simplified.files = structure.files.slice(0, 20);
if (structure.files.length > 20) {
simplified.filesCount = structure.files.length;
simplified.files.push(`... and ${structure.files.length - 20} more`);
}
}
if (structure.children && Object.keys(structure.children).length > 0) {
simplified.children = {};
for (const [name, child] of Object.entries(structure.children)) {
simplified.children[name] = this._simplifyStructure(child, depth + 1);
}
}
return simplified;
}
/**
* Save the codebase map to file
* @param {string} [outputPath] - Custom output path
* @returns {Promise<string>} - Path to saved file
*/
async saveMap(outputPath) {
const map = await this.generateMap();
const savePath = outputPath || path.join(this.rootPath, CONFIG.outputPath);
// Ensure directory exists
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
await fsPromises.mkdir(dir, { recursive: true });
}
await fsPromises.writeFile(savePath, JSON.stringify(map, null, 2), 'utf-8');
if (!this.quiet) {
console.log(`\nCodebase map saved to: ${savePath}`);
}
return savePath;
}
/**
* Get map as JSON object
* @returns {Promise<Object>}
*/
async toJSON() {
return await this.generateMap();
}
/**
* Compare with existing map
* @returns {Promise<Object>}
*/
async diff() {
const existingPath = path.join(this.rootPath, CONFIG.outputPath);
if (!fs.existsSync(existingPath)) {
return { error: 'No existing map found', newMap: true };
}
const existingContent = await fsPromises.readFile(existingPath, 'utf-8');
const existingMap = JSON.parse(existingContent);
const newMap = await this.generateMap();
const diff = {
previousMappedAt: existingMap.mappedAt,
currentMappedAt: newMap.mappedAt,
changes: {
files: {
added: newMap.stats.totalFiles - existingMap.stats.totalFiles,
codeFiles: newMap.stats.codeFiles - existingMap.stats.codeFiles,
},
services: {
previous: existingMap.services.length,
current: newMap.services.length,
},
patterns: {
changed: JSON.stringify(existingMap.patterns) !== JSON.stringify(newMap.patterns),
},
dependencies: {
runtime: {
added: newMap.dependencies.runtime.filter(d => !existingMap.dependencies.runtime.includes(d)),
removed: existingMap.dependencies.runtime.filter(d => !newMap.dependencies.runtime.includes(d)),
},
},
},
};
return diff;
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// CLI INTERFACE
// ═══════════════════════════════════════════════════════════════════════════════════
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Codebase Mapper - AIOS Memory Layer (Story 7.2)
Usage:
node codebase-mapper.js [command] [options]
*map-codebase [command] [options]
Commands:
map Generate codebase map (default)
json Output as JSON to stdout
save Save to .aios/codebase-map.json
diff Compare with existing map
Options:
--root <path> Project root (default: cwd)
--output <path> Custom output path
--depth <n> Max directory depth (default: 5)
--quiet, -q Suppress output
--help, -h Show this help message
Examples:
node codebase-mapper.js
node codebase-mapper.js save
node codebase-mapper.js json
node codebase-mapper.js diff
node codebase-mapper.js --root /path/to/project save
node codebase-mapper.js --depth 3 --output ./custom-map.json
Acceptance Criteria Coverage:
AC1: Located in .aios-core/infrastructure/scripts/
AC2: Generates: services, directories, patterns, conventions, dependencies
AC3: Output: .aios/codebase-map.json
AC4: Automatic updates after significant merges (via git hooks)
AC5: Command *map-codebase available globally
AC6: Used by Context Generator (Epic 4)
AC7: Excludes node_modules, .git, build outputs
`);
process.exit(0);
}
// Parse arguments
let rootPath = process.cwd();
let outputPath = null;
let depth = CONFIG.defaultMaxDepth;
let quiet = false;
let command = 'map';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--root' && args[i + 1]) {
rootPath = args[++i];
if (!path.isAbsolute(rootPath)) {
rootPath = path.join(process.cwd(), rootPath);
}
} else if (arg === '--output' && args[i + 1]) {
outputPath = args[++i];
} else if (arg === '--depth' && args[i + 1]) {
depth = parseInt(args[++i], 10);
} else if (arg === '--quiet' || arg === '-q') {
quiet = true;
} else if (!arg.startsWith('-')) {
command = arg;
}
}
try {
const mapper = new CodebaseMapper(rootPath, { maxDepth: depth, quiet });
switch (command) {
case 'map':
case 'save': {
const savePath = await mapper.saveMap(outputPath);
if (!quiet) {
console.log('\nCodebase map generated successfully.');
const map = await mapper.toJSON();
console.log(`\nStats:`);
console.log(` Total files: ${map.stats.totalFiles}`);
console.log(` Code files: ${map.stats.codeFiles}`);
console.log(` Test files: ${map.stats.testFiles}`);
console.log(` Components: ${map.stats.components}`);
console.log(` Services: ${map.services.length}`);
}
break;
}
case 'json': {
const map = await mapper.toJSON();
console.log(JSON.stringify(map, null, 2));
break;
}
case 'diff': {
const diff = await mapper.diff();
if (diff.error) {
console.log(`\n${diff.error}`);
if (diff.newMap) {
console.log('Run `codebase-mapper save` to create initial map.');
}
} else {
console.log('\nCodebase Map Diff:');
console.log(` Previous: ${diff.previousMappedAt}`);
console.log(` Current: ${diff.currentMappedAt}`);
console.log(`\nChanges:`);
console.log(` Files: ${diff.changes.files.added >= 0 ? '+' : ''}${diff.changes.files.added}`);
console.log(` Services: ${diff.changes.services.previous} -> ${diff.changes.services.current}`);
console.log(` Patterns changed: ${diff.changes.patterns.changed ? 'Yes' : 'No'}`);
if (diff.changes.dependencies.runtime.added.length > 0) {
console.log(` Dependencies added: ${diff.changes.dependencies.runtime.added.join(', ')}`);
}
if (diff.changes.dependencies.runtime.removed.length > 0) {
console.log(` Dependencies removed: ${diff.changes.dependencies.runtime.removed.join(', ')}`);
}
}
break;
}
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
} catch (error) {
console.error(`\nError: ${error.message}`);
process.exit(1);
}
}
// Export for programmatic use
module.exports = {
CodebaseMapper,
CONFIG,
PatternDetectors,
};
// Run CLI if executed directly
if (require.main === module) {
main();
}