Add codebase indexing system (Milestone 4)
- Add project type detection (Node.js, Python, Rust, Go, generic) - Add file scanner with ignore patterns and hash tracking - Add TypeScript/JavaScript parser (exports, imports, classes, functions) - Add Python parser (imports, classes, functions, __all__) - Add relationship mapper for import dependencies - Add architecture summary generation with tech stack detection - Add incremental update support via file hash comparison - Add CLI command: cortex index [path] [--update] [--dry-run] - Add MCP tools: memory_index, memory_components
This commit is contained in:
61
src/cli/commands/index-cmd.ts
Normal file
61
src/cli/commands/index-cmd.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { indexProject } from '../../core/indexer';
|
||||
|
||||
export const indexCommand = new Command('index')
|
||||
.description('Index a codebase to create component nodes')
|
||||
.argument('[path]', 'Path to index', '.')
|
||||
.option('--update', 'Only update changed files (incremental)')
|
||||
.option('--dry-run', 'Preview what would be indexed without making changes')
|
||||
.option('--depth <n>', 'Maximum directory depth', '10')
|
||||
.option('--lang <language>', 'Only index specific language (ts, js, py)')
|
||||
.option('--ignore <patterns>', 'Additional ignore patterns (comma-separated)')
|
||||
.action(async (inputPath: string, opts) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log(chalk.yellow('Dry run mode - no changes will be made\n'));
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(`Indexing ${inputPath}...`));
|
||||
|
||||
try {
|
||||
const result = await indexProject(inputPath, {
|
||||
update: opts.update,
|
||||
dryRun: opts.dryRun,
|
||||
maxDepth: parseInt(opts.depth),
|
||||
language: opts.lang,
|
||||
ignore: opts.ignore?.split(',').map((s: string) => s.trim()),
|
||||
});
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
console.log();
|
||||
console.log(chalk.green(`✓ Indexed ${result.projectName} (${result.projectType})`));
|
||||
console.log();
|
||||
console.log(` Files scanned: ${result.files.length}`);
|
||||
console.log(` Components created: ${result.componentsCreated}`);
|
||||
console.log(` Components updated: ${result.componentsUpdated}`);
|
||||
console.log(` Components removed: ${result.componentsRemoved}`);
|
||||
console.log(` Relationships: ${result.relationshipsCreated}`);
|
||||
if (result.architectureNodeId) {
|
||||
console.log(` Architecture node: ${result.architectureNodeId.slice(0, 8)}`);
|
||||
}
|
||||
console.log();
|
||||
console.log(chalk.dim(`Completed in ${elapsed}s`));
|
||||
|
||||
if (opts.dryRun && result.files.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.yellow('Files that would be indexed:'));
|
||||
for (const file of result.files.slice(0, 20)) {
|
||||
console.log(` ${file}`);
|
||||
}
|
||||
if (result.files.length > 20) {
|
||||
console.log(chalk.dim(` ... and ${result.files.length - 20} more`));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(`Error: ${err.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { diffCommand } from './commands/diff';
|
||||
import { restoreCommand } from './commands/restore';
|
||||
import { captureCommand, captureHookCommand, configCommand } from './commands/capture';
|
||||
import { contextCommand, contextHookCommand } from './commands/context';
|
||||
import { indexCommand } from './commands/index-cmd';
|
||||
import { closeDb } from '../core/db';
|
||||
|
||||
const program = new Command();
|
||||
@@ -44,6 +45,7 @@ program.addCommand(captureHookCommand);
|
||||
program.addCommand(contextCommand);
|
||||
program.addCommand(contextHookCommand);
|
||||
program.addCommand(configCommand);
|
||||
program.addCommand(indexCommand);
|
||||
|
||||
program.hook('postAction', () => {
|
||||
closeDb();
|
||||
|
||||
79
src/core/indexer/architecture.ts
Normal file
79
src/core/indexer/architecture.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as path from 'path';
|
||||
import { Node } from '../../types';
|
||||
import { ProjectInfo } from './detector';
|
||||
import { getDirectoryTree } from './scanner';
|
||||
|
||||
export interface ArchitectureSummary {
|
||||
projectName: string;
|
||||
projectType: string;
|
||||
description: string;
|
||||
techStack: string[];
|
||||
keyComponents: { name: string; path: string; exports: number }[];
|
||||
directoryStructure: string;
|
||||
}
|
||||
|
||||
export function generateArchitectureSummary(
|
||||
projectRoot: string,
|
||||
projectInfo: ProjectInfo,
|
||||
components: Node[]
|
||||
): ArchitectureSummary {
|
||||
// Determine tech stack from dependencies
|
||||
const techStack: string[] = [projectInfo.type];
|
||||
const deps = [...projectInfo.dependencies, ...(projectInfo.devDependencies || [])];
|
||||
|
||||
// Detect frameworks/libraries
|
||||
if (deps.includes('react') || deps.includes('react-dom')) techStack.push('React');
|
||||
if (deps.includes('vue')) techStack.push('Vue');
|
||||
if (deps.includes('express')) techStack.push('Express');
|
||||
if (deps.includes('fastify')) techStack.push('Fastify');
|
||||
if (deps.includes('next')) techStack.push('Next.js');
|
||||
if (deps.includes('typescript')) techStack.push('TypeScript');
|
||||
if (deps.includes('prisma')) techStack.push('Prisma');
|
||||
if (deps.includes('sequelize')) techStack.push('Sequelize');
|
||||
if (deps.includes('mongoose')) techStack.push('MongoDB');
|
||||
if (deps.includes('better-sqlite3') || deps.includes('sqlite3')) techStack.push('SQLite');
|
||||
|
||||
// Get key components (most exports or largest)
|
||||
const keyComponents = components
|
||||
.map(n => ({
|
||||
name: n.title,
|
||||
path: n.metadata?.filePath as string || '',
|
||||
exports: (n.metadata?.exports as string[])?.length || 0,
|
||||
}))
|
||||
.sort((a, b) => b.exports - a.exports)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
projectName: projectInfo.name,
|
||||
projectType: projectInfo.type,
|
||||
description: projectInfo.description || `A ${projectInfo.type} project`,
|
||||
techStack,
|
||||
keyComponents,
|
||||
directoryStructure: getDirectoryTree(projectRoot, 3),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatArchitectureAsMarkdown(summary: ArchitectureSummary): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push(`# ${summary.projectName} Architecture\n`);
|
||||
sections.push(`${summary.description}\n`);
|
||||
|
||||
sections.push(`## Tech Stack\n`);
|
||||
sections.push(summary.techStack.map(t => `- ${t}`).join('\n') + '\n');
|
||||
|
||||
if (summary.keyComponents.length > 0) {
|
||||
sections.push(`## Key Components\n`);
|
||||
for (const comp of summary.keyComponents) {
|
||||
sections.push(`- **${comp.name}** (${comp.path}) - ${comp.exports} exports`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
sections.push(`## Directory Structure\n`);
|
||||
sections.push('```');
|
||||
sections.push(summary.directoryStructure);
|
||||
sections.push('```');
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
145
src/core/indexer/detector.ts
Normal file
145
src/core/indexer/detector.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type ProjectType = 'nodejs' | 'python' | 'rust' | 'go' | 'generic';
|
||||
|
||||
export interface ProjectInfo {
|
||||
type: ProjectType;
|
||||
name: string;
|
||||
description?: string;
|
||||
dependencies: string[];
|
||||
devDependencies?: string[];
|
||||
scripts?: Record<string, string>;
|
||||
entryPoints: string[];
|
||||
}
|
||||
|
||||
export async function detectProjectType(root: string): Promise<ProjectInfo> {
|
||||
const absRoot = path.resolve(root);
|
||||
|
||||
// Check for Node.js (package.json)
|
||||
const packageJsonPath = path.join(absRoot, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
return parseNodeProject(absRoot, packageJsonPath);
|
||||
}
|
||||
|
||||
// Check for Python
|
||||
const pyprojectPath = path.join(absRoot, 'pyproject.toml');
|
||||
const setupPyPath = path.join(absRoot, 'setup.py');
|
||||
const requirementsPath = path.join(absRoot, 'requirements.txt');
|
||||
if (fs.existsSync(pyprojectPath) || fs.existsSync(setupPyPath) || fs.existsSync(requirementsPath)) {
|
||||
return parsePythonProject(absRoot);
|
||||
}
|
||||
|
||||
// Check for Rust (Cargo.toml)
|
||||
const cargoPath = path.join(absRoot, 'Cargo.toml');
|
||||
if (fs.existsSync(cargoPath)) {
|
||||
return parseRustProject(absRoot, cargoPath);
|
||||
}
|
||||
|
||||
// Check for Go (go.mod)
|
||||
const goModPath = path.join(absRoot, 'go.mod');
|
||||
if (fs.existsSync(goModPath)) {
|
||||
return parseGoProject(absRoot, goModPath);
|
||||
}
|
||||
|
||||
return parseGenericProject(absRoot);
|
||||
}
|
||||
|
||||
function parseNodeProject(root: string, packageJsonPath: string): ProjectInfo {
|
||||
const content = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
const pkg = JSON.parse(content);
|
||||
const deps = Object.keys(pkg.dependencies || {});
|
||||
const devDeps = Object.keys(pkg.devDependencies || {});
|
||||
const entryPoints: string[] = [];
|
||||
if (pkg.main) entryPoints.push(pkg.main);
|
||||
if (pkg.bin) {
|
||||
if (typeof pkg.bin === 'string') entryPoints.push(pkg.bin);
|
||||
else entryPoints.push(...Object.values(pkg.bin) as string[]);
|
||||
}
|
||||
return {
|
||||
type: 'nodejs',
|
||||
name: pkg.name || path.basename(root),
|
||||
description: pkg.description,
|
||||
dependencies: deps,
|
||||
devDependencies: devDeps,
|
||||
scripts: pkg.scripts,
|
||||
entryPoints,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePythonProject(root: string): ProjectInfo {
|
||||
const name = path.basename(root);
|
||||
const deps: string[] = [];
|
||||
const entryPoints: string[] = [];
|
||||
const reqPath = path.join(root, 'requirements.txt');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
const content = fs.readFileSync(reqPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const pkg = trimmed.split(/[=<>!]/)[0].trim();
|
||||
if (pkg) deps.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of ['main.py', 'app.py', '__main__.py']) {
|
||||
if (fs.existsSync(path.join(root, entry))) entryPoints.push(entry);
|
||||
}
|
||||
return { type: 'python', name, dependencies: deps, entryPoints };
|
||||
}
|
||||
|
||||
function parseRustProject(root: string, cargoPath: string): ProjectInfo {
|
||||
const content = fs.readFileSync(cargoPath, 'utf-8');
|
||||
const nameMatch = content.match(/^name\s*=\s*["']([^"']+)["']/m);
|
||||
const descMatch = content.match(/^description\s*=\s*["']([^"']+)["']/m);
|
||||
const deps: string[] = [];
|
||||
const depsSection = content.match(/\[dependencies\]([\s\S]*?)(?:\[|$)/);
|
||||
if (depsSection) {
|
||||
const depLines = depsSection[1].match(/^(\w[\w-]*)\s*=/gm);
|
||||
if (depLines) deps.push(...depLines.map(d => d.replace(/\s*=.*/, '')));
|
||||
}
|
||||
const entryPoints: string[] = [];
|
||||
if (fs.existsSync(path.join(root, 'src/main.rs'))) entryPoints.push('src/main.rs');
|
||||
if (fs.existsSync(path.join(root, 'src/lib.rs'))) entryPoints.push('src/lib.rs');
|
||||
return {
|
||||
type: 'rust',
|
||||
name: nameMatch?.[1] || path.basename(root),
|
||||
description: descMatch?.[1],
|
||||
dependencies: deps,
|
||||
entryPoints,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGoProject(root: string, goModPath: string): ProjectInfo {
|
||||
const content = fs.readFileSync(goModPath, 'utf-8');
|
||||
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
||||
const deps: string[] = [];
|
||||
const requireMatch = content.match(/require\s*\(([\s\S]*?)\)/);
|
||||
if (requireMatch) {
|
||||
const reqLines = requireMatch[1].match(/^\s*(\S+)\s+v/gm);
|
||||
if (reqLines) deps.push(...reqLines.map(d => d.trim().split(/\s/)[0]));
|
||||
}
|
||||
const entryPoints: string[] = [];
|
||||
if (fs.existsSync(path.join(root, 'main.go'))) entryPoints.push('main.go');
|
||||
return {
|
||||
type: 'go',
|
||||
name: moduleMatch?.[1]?.split('/').pop() || path.basename(root),
|
||||
dependencies: deps,
|
||||
entryPoints,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenericProject(root: string): ProjectInfo {
|
||||
const name = path.basename(root);
|
||||
let description: string | undefined;
|
||||
for (const readme of ['README.md', 'README.txt', 'README']) {
|
||||
const readmePath = path.join(root, readme);
|
||||
if (fs.existsSync(readmePath)) {
|
||||
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||
const firstPara = content.split(/\n\n/)[0].replace(/^#.*\n/, '').trim();
|
||||
if (firstPara) description = firstPara.slice(0, 200);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { type: 'generic', name, description, dependencies: [], entryPoints: [] };
|
||||
}
|
||||
6
src/core/indexer/index.ts
Normal file
6
src/core/indexer/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { detectProjectType, type ProjectType, type ProjectInfo } from './detector';
|
||||
export { scanFiles, getDirectoryTree, type ScanOptions, type ScannedFile } from './scanner';
|
||||
export { mapRelationships, resolveImportPath, loadIndexState, saveIndexState, type ComponentNode, type MappedRelationship, type IndexState } from './mapper';
|
||||
export { generateArchitectureSummary, formatArchitectureAsMarkdown, type ArchitectureSummary } from './architecture';
|
||||
export { indexProject, type IndexOptions, type IndexResult } from './indexProject';
|
||||
export * from './parsers';
|
||||
246
src/core/indexer/indexProject.ts
Normal file
246
src/core/indexer/indexProject.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import * as path from 'path';
|
||||
import { addNode, addEdge, listNodes, updateNode, removeNode } from '../store';
|
||||
import { detectProjectType, ProjectInfo } from './detector';
|
||||
import { scanFiles, ScannedFile } from './scanner';
|
||||
import { parseTypeScript, parsePython } from './parsers';
|
||||
import { mapRelationships, loadIndexState, saveIndexState, IndexState, ComponentNode } from './mapper';
|
||||
import { generateArchitectureSummary, formatArchitectureAsMarkdown } from './architecture';
|
||||
import { Node } from '../../types';
|
||||
|
||||
export interface IndexOptions {
|
||||
update?: boolean;
|
||||
dryRun?: boolean;
|
||||
maxDepth?: number;
|
||||
language?: string;
|
||||
ignore?: string[];
|
||||
}
|
||||
|
||||
export interface IndexResult {
|
||||
projectName: string;
|
||||
projectType: string;
|
||||
componentsCreated: number;
|
||||
componentsUpdated: number;
|
||||
componentsRemoved: number;
|
||||
relationshipsCreated: number;
|
||||
architectureNodeId: string | null;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export async function indexProject(root: string, options: IndexOptions = {}): Promise<IndexResult> {
|
||||
const absRoot = path.resolve(root);
|
||||
const projectInfo = await detectProjectType(absRoot);
|
||||
const state = options.update ? loadIndexState(absRoot) : null;
|
||||
|
||||
// Filter extensions by language if specified
|
||||
let extensions: string[] | undefined;
|
||||
if (options.language) {
|
||||
const langMap: Record<string, string[]> = {
|
||||
ts: ['.ts', '.tsx'],
|
||||
typescript: ['.ts', '.tsx'],
|
||||
js: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
javascript: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
py: ['.py'],
|
||||
python: ['.py'],
|
||||
};
|
||||
extensions = langMap[options.language.toLowerCase()];
|
||||
}
|
||||
|
||||
// Scan files
|
||||
const files = await scanFiles(absRoot, {
|
||||
maxDepth: options.maxDepth,
|
||||
ignore: options.ignore,
|
||||
extensions,
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
projectName: projectInfo.name,
|
||||
projectType: projectInfo.type,
|
||||
componentsCreated: files.length,
|
||||
componentsUpdated: 0,
|
||||
componentsRemoved: 0,
|
||||
relationshipsCreated: 0,
|
||||
architectureNodeId: null,
|
||||
files: files.map(f => f.relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
// Track what we're creating
|
||||
const newState: IndexState = {
|
||||
projectPath: absRoot,
|
||||
projectName: projectInfo.name,
|
||||
lastIndexed: Date.now(),
|
||||
fileHashes: {},
|
||||
nodeIds: {},
|
||||
};
|
||||
|
||||
const componentNodes: ComponentNode[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
// Process each file
|
||||
for (const file of files) {
|
||||
// Check if file changed (for incremental updates)
|
||||
if (state && state.fileHashes[file.relativePath] === file.hash) {
|
||||
// File unchanged, keep existing node
|
||||
if (state.nodeIds[file.relativePath]) {
|
||||
newState.fileHashes[file.relativePath] = file.hash;
|
||||
newState.nodeIds[file.relativePath] = state.nodeIds[file.relativePath];
|
||||
// Still need to add to componentNodes for relationship mapping
|
||||
const existingNode = listNodes({ limit: 1 }).find(n => n.id === state.nodeIds[file.relativePath]);
|
||||
if (existingNode) {
|
||||
componentNodes.push({
|
||||
node: existingNode,
|
||||
filePath: file.relativePath,
|
||||
imports: (existingNode.metadata?.imports as string[]) || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse file based on extension
|
||||
let parsed: { exports: any[]; imports: any[]; classes: any[]; functions: any[]; loc: number } | null = null;
|
||||
try {
|
||||
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(file.extension)) {
|
||||
parsed = await parseTypeScript(file.path);
|
||||
} else if (file.extension === '.py') {
|
||||
parsed = await parsePython(file.path);
|
||||
}
|
||||
} catch {
|
||||
// Skip files that fail to parse
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed) continue;
|
||||
|
||||
// Create component title from file path
|
||||
const fileName = path.basename(file.relativePath, file.extension);
|
||||
const dirName = path.dirname(file.relativePath);
|
||||
const title = dirName === '.' ? fileName : `${dirName}/${fileName}`;
|
||||
|
||||
// Build content summary
|
||||
const exportNames = parsed.exports.map((e: any) => e.name || e).slice(0, 20);
|
||||
const classNames = parsed.classes.map((c: any) => c.name);
|
||||
const funcNames = parsed.functions.map((f: any) => f.name).slice(0, 10);
|
||||
|
||||
const contentParts: string[] = [];
|
||||
if (exportNames.length) contentParts.push(`Exports: ${exportNames.join(', ')}`);
|
||||
if (classNames.length) contentParts.push(`Classes: ${classNames.join(', ')}`);
|
||||
if (funcNames.length) contentParts.push(`Functions: ${funcNames.join(', ')}`);
|
||||
contentParts.push(`Lines: ${parsed.loc}`);
|
||||
|
||||
const content = contentParts.join('\n');
|
||||
const imports = parsed.imports.map((i: any) => i.source || i.module);
|
||||
|
||||
// Check if node exists
|
||||
const existingNodeId = state?.nodeIds[file.relativePath];
|
||||
let node: Node;
|
||||
|
||||
if (existingNodeId) {
|
||||
// Update existing
|
||||
const updatedNode = await updateNode(existingNodeId, {
|
||||
content,
|
||||
metadata: {
|
||||
filePath: file.relativePath,
|
||||
extension: file.extension,
|
||||
exports: exportNames,
|
||||
imports,
|
||||
loc: parsed.loc,
|
||||
indexedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
if (updatedNode) {
|
||||
node = updatedNode;
|
||||
updated++;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
node = await addNode({
|
||||
kind: 'component',
|
||||
title,
|
||||
content,
|
||||
tags: [projectInfo.name, file.extension.slice(1), 'indexed'],
|
||||
metadata: {
|
||||
filePath: file.relativePath,
|
||||
extension: file.extension,
|
||||
exports: exportNames,
|
||||
imports,
|
||||
loc: parsed.loc,
|
||||
indexedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
newState.fileHashes[file.relativePath] = file.hash;
|
||||
newState.nodeIds[file.relativePath] = node.id;
|
||||
componentNodes.push({ node, filePath: file.relativePath, imports });
|
||||
}
|
||||
|
||||
// Remove nodes for deleted files
|
||||
let removed = 0;
|
||||
if (state) {
|
||||
for (const [filePath, nodeId] of Object.entries(state.nodeIds)) {
|
||||
if (!newState.nodeIds[filePath]) {
|
||||
removeNode(nodeId, true);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map relationships
|
||||
const relationships = mapRelationships(componentNodes, absRoot);
|
||||
let relCreated = 0;
|
||||
for (const rel of relationships) {
|
||||
try {
|
||||
addEdge(rel.fromId, rel.toId, rel.type);
|
||||
relCreated++;
|
||||
} catch {
|
||||
// Edge might already exist
|
||||
}
|
||||
}
|
||||
|
||||
// Create/update architecture summary node
|
||||
let archNodeId: string | null = null;
|
||||
const archTitle = `${projectInfo.name} Architecture`;
|
||||
const existingArch = listNodes({ kind: 'component', tags: [projectInfo.name, 'architecture'] });
|
||||
|
||||
const summary = generateArchitectureSummary(absRoot, projectInfo, componentNodes.map(c => c.node));
|
||||
const archContent = formatArchitectureAsMarkdown(summary);
|
||||
|
||||
if (existingArch.length > 0) {
|
||||
await updateNode(existingArch[0].id, { content: archContent });
|
||||
archNodeId = existingArch[0].id;
|
||||
} else {
|
||||
const archNode = await addNode({
|
||||
kind: 'component',
|
||||
title: archTitle,
|
||||
content: archContent,
|
||||
tags: [projectInfo.name, 'architecture', 'indexed'],
|
||||
metadata: {
|
||||
projectType: projectInfo.type,
|
||||
techStack: summary.techStack,
|
||||
componentCount: componentNodes.length,
|
||||
indexedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
archNodeId = archNode.id;
|
||||
}
|
||||
|
||||
// Save state
|
||||
saveIndexState(absRoot, newState);
|
||||
|
||||
return {
|
||||
projectName: projectInfo.name,
|
||||
projectType: projectInfo.type,
|
||||
componentsCreated: created,
|
||||
componentsUpdated: updated,
|
||||
componentsRemoved: removed,
|
||||
relationshipsCreated: relCreated,
|
||||
architectureNodeId: archNodeId,
|
||||
files: files.map(f => f.relativePath),
|
||||
};
|
||||
}
|
||||
109
src/core/indexer/mapper.ts
Normal file
109
src/core/indexer/mapper.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Node } from '../../types';
|
||||
|
||||
export interface ComponentNode {
|
||||
node: Node;
|
||||
filePath: string;
|
||||
imports: string[];
|
||||
}
|
||||
|
||||
export interface MappedRelationship {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
fromPath: string;
|
||||
toPath: string;
|
||||
type: 'depends_on' | 'contains';
|
||||
}
|
||||
|
||||
export function mapRelationships(components: ComponentNode[], projectRoot: string): MappedRelationship[] {
|
||||
const relationships: MappedRelationship[] = [];
|
||||
const pathToComponent = new Map<string, ComponentNode>();
|
||||
|
||||
// Build lookup map
|
||||
for (const comp of components) {
|
||||
pathToComponent.set(comp.filePath, comp);
|
||||
// Also map without extension for JS/TS imports
|
||||
const withoutExt = comp.filePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
|
||||
pathToComponent.set(withoutExt, comp);
|
||||
}
|
||||
|
||||
// Map import relationships
|
||||
for (const comp of components) {
|
||||
for (const importPath of comp.imports) {
|
||||
const resolved = resolveImportPath(importPath, comp.filePath, projectRoot);
|
||||
if (resolved) {
|
||||
const target = pathToComponent.get(resolved) || pathToComponent.get(resolved.replace(/\.(ts|tsx|js|jsx)$/, ''));
|
||||
if (target && target.node.id !== comp.node.id) {
|
||||
relationships.push({
|
||||
fromId: comp.node.id,
|
||||
toId: target.node.id,
|
||||
fromPath: comp.filePath,
|
||||
toPath: target.filePath,
|
||||
type: 'depends_on',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
export function resolveImportPath(importSource: string, fromFile: string, projectRoot: string): string | null {
|
||||
// Skip external packages
|
||||
if (!importSource.startsWith('.') && !importSource.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDir = path.dirname(fromFile);
|
||||
let resolved: string;
|
||||
|
||||
if (importSource.startsWith('.')) {
|
||||
resolved = path.resolve(fromDir, importSource);
|
||||
} else {
|
||||
resolved = path.resolve(projectRoot, importSource);
|
||||
}
|
||||
|
||||
// Try with various extensions
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', '/index.js'];
|
||||
for (const ext of extensions) {
|
||||
const withExt = resolved + ext;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return path.relative(projectRoot, withExt);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it exists as-is
|
||||
if (fs.existsSync(resolved)) {
|
||||
return path.relative(projectRoot, resolved);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface IndexState {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
lastIndexed: number;
|
||||
fileHashes: Record<string, string>;
|
||||
nodeIds: Record<string, string>;
|
||||
}
|
||||
|
||||
const STATE_FILE = '.cortex-index.json';
|
||||
|
||||
export function loadIndexState(projectRoot: string): IndexState | null {
|
||||
const statePath = path.join(projectRoot, STATE_FILE);
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
try {
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveIndexState(projectRoot: string, state: IndexState): void {
|
||||
const statePath = path.join(projectRoot, STATE_FILE);
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
2
src/core/indexer/parsers/index.ts
Normal file
2
src/core/indexer/parsers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseTypeScript, type ParsedTSFile, type ExportInfo, type ImportInfo, type ClassInfo, type FunctionInfo } from './typescript';
|
||||
export { parsePython, type ParsedPyFile, type PyImport, type PyClass, type PyFunction } from './python';
|
||||
120
src/core/indexer/parsers/python.ts
Normal file
120
src/core/indexer/parsers/python.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
export interface ParsedPyFile {
|
||||
filePath: string;
|
||||
language: 'python';
|
||||
imports: PyImport[];
|
||||
classes: PyClass[];
|
||||
functions: PyFunction[];
|
||||
exports: string[];
|
||||
loc: number;
|
||||
}
|
||||
|
||||
export interface PyImport {
|
||||
module: string;
|
||||
names: string[];
|
||||
isRelative: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface PyClass {
|
||||
name: string;
|
||||
bases: string[];
|
||||
methods: string[];
|
||||
decorators: string[];
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface PyFunction {
|
||||
name: string;
|
||||
isAsync: boolean;
|
||||
decorators: string[];
|
||||
line: number;
|
||||
}
|
||||
|
||||
export async function parsePython(filePath: string): Promise<ParsedPyFile> {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
const imports: PyImport[] = [];
|
||||
const classes: PyClass[] = [];
|
||||
const functions: PyFunction[] = [];
|
||||
let exports: string[] = [];
|
||||
|
||||
let currentDecorators: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// __all__ exports
|
||||
const allMatch = line.match(/^__all__\s*=\s*\[([^\]]+)\]/);
|
||||
if (allMatch) {
|
||||
exports = allMatch[1].match(/['"](\w+)['"]/g)?.map(s => s.slice(1, -1)) || [];
|
||||
}
|
||||
|
||||
// Decorators
|
||||
const decoMatch = line.match(/^@(\w+)/);
|
||||
if (decoMatch) {
|
||||
currentDecorators.push(decoMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import statements
|
||||
const importMatch = line.match(/^import\s+(\S+)/);
|
||||
if (importMatch) {
|
||||
imports.push({ module: importMatch[1], names: [importMatch[1]], isRelative: false, line: lineNum });
|
||||
currentDecorators = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromMatch = line.match(/^from\s+(\S+)\s+import\s+(.+)/);
|
||||
if (fromMatch) {
|
||||
const module = fromMatch[1];
|
||||
const names = fromMatch[2].split(',').map(n => n.trim().split(/\s+as\s+/)[0]).filter(Boolean);
|
||||
imports.push({ module, names, isRelative: module.startsWith('.'), line: lineNum });
|
||||
currentDecorators = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Class definitions
|
||||
const classMatch = line.match(/^class\s+(\w+)(?:\(([^)]*)\))?:/);
|
||||
if (classMatch) {
|
||||
const className = classMatch[1];
|
||||
const bases = classMatch[2]?.split(',').map(b => b.trim()).filter(Boolean) || [];
|
||||
const methods: string[] = [];
|
||||
|
||||
// Find methods
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const methodLine = lines[j];
|
||||
if (methodLine.match(/^\S/) && !methodLine.match(/^\s*#/) && !methodLine.match(/^\s*$/)) break;
|
||||
const methodMatch = methodLine.match(/^\s+(?:async\s+)?def\s+(\w+)/);
|
||||
if (methodMatch) methods.push(methodMatch[1]);
|
||||
}
|
||||
|
||||
classes.push({ name: className, bases, methods, decorators: currentDecorators, line: lineNum });
|
||||
currentDecorators = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function definitions (top-level only)
|
||||
const funcMatch = line.match(/^(async\s+)?def\s+(\w+)/);
|
||||
if (funcMatch) {
|
||||
functions.push({
|
||||
name: funcMatch[2],
|
||||
isAsync: !!funcMatch[1],
|
||||
decorators: currentDecorators,
|
||||
line: lineNum,
|
||||
});
|
||||
currentDecorators = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset decorators if we hit a non-decorator, non-function/class line
|
||||
if (!line.match(/^\s*$/) && !line.match(/^\s*#/)) {
|
||||
currentDecorators = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, language: 'python', imports, classes, functions, exports, loc: lines.length };
|
||||
}
|
||||
132
src/core/indexer/parsers/typescript.ts
Normal file
132
src/core/indexer/parsers/typescript.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface ParsedTSFile {
|
||||
filePath: string;
|
||||
language: 'typescript' | 'javascript';
|
||||
exports: ExportInfo[];
|
||||
imports: ImportInfo[];
|
||||
classes: ClassInfo[];
|
||||
functions: FunctionInfo[];
|
||||
loc: number;
|
||||
}
|
||||
|
||||
export interface ExportInfo {
|
||||
name: string;
|
||||
kind: 'function' | 'class' | 'interface' | 'type' | 'const' | 'default' | 'enum';
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ImportInfo {
|
||||
source: string;
|
||||
names: string[];
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ClassInfo {
|
||||
name: string;
|
||||
extends?: string;
|
||||
implements: string[];
|
||||
methods: string[];
|
||||
isExported: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string;
|
||||
isAsync: boolean;
|
||||
isExported: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export async function parseTypeScript(filePath: string): Promise<ParsedTSFile> {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const language = ext === '.ts' || ext === '.tsx' ? 'typescript' : 'javascript';
|
||||
return parseContent(filePath, content, language);
|
||||
}
|
||||
|
||||
function parseContent(filePath: string, content: string, language: 'typescript' | 'javascript'): ParsedTSFile {
|
||||
const lines = content.split('\n');
|
||||
const exports: ExportInfo[] = [];
|
||||
const imports: ImportInfo[] = [];
|
||||
const classes: ClassInfo[] = [];
|
||||
const functions: FunctionInfo[] = [];
|
||||
|
||||
// Parse imports
|
||||
const importRegex = /^import\s+.*?\s+from\s+['"]([^'"]+)['"]/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const line = content.slice(0, match.index).split('\n').length;
|
||||
const clause = match[0].replace(/^import\s+/, '').replace(/\s+from.*/, '');
|
||||
const names: string[] = [];
|
||||
const namedMatch = clause.match(/\{([^}]+)\}/);
|
||||
if (namedMatch) names.push(...namedMatch[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0]).filter(Boolean));
|
||||
const defaultMatch = clause.match(/^(\w+)/);
|
||||
if (defaultMatch && !clause.startsWith('{') && !clause.startsWith('*')) names.push(defaultMatch[1]);
|
||||
if (clause.includes('*')) names.push('*');
|
||||
imports.push({ source: match[1], names, line });
|
||||
}
|
||||
|
||||
// Parse line by line
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// Export const/let
|
||||
const constMatch = line.match(/^export\s+(const|let|var)\s+(\w+)/);
|
||||
if (constMatch) exports.push({ name: constMatch[2], kind: 'const', line: lineNum });
|
||||
|
||||
// Export default
|
||||
if (line.match(/^export\s+default\s+/)) {
|
||||
const m = line.match(/^export\s+default\s+(?:class|function)?\s*(\w+)?/);
|
||||
exports.push({ name: m?.[1] || 'default', kind: 'default', line: lineNum });
|
||||
}
|
||||
|
||||
// Export type/interface
|
||||
const typeMatch = line.match(/^export\s+(type|interface)\s+(\w+)/);
|
||||
if (typeMatch) exports.push({ name: typeMatch[2], kind: typeMatch[1] as 'type' | 'interface', line: lineNum });
|
||||
|
||||
// Export enum
|
||||
const enumMatch = line.match(/^export\s+enum\s+(\w+)/);
|
||||
if (enumMatch) exports.push({ name: enumMatch[1], kind: 'enum', line: lineNum });
|
||||
|
||||
// Classes
|
||||
const classMatch = line.match(/^(export\s+)?(abstract\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([^{]+))?/);
|
||||
if (classMatch) {
|
||||
const isExported = !!classMatch[1];
|
||||
const className = classMatch[3];
|
||||
const methods: string[] = [];
|
||||
for (let j = i + 1; j < lines.length && j < i + 100; j++) {
|
||||
if (lines[j].match(/^\s*\}/) && !lines[j].match(/^\s*\}\s*\)/)) break;
|
||||
const methodMatch = lines[j].match(/^\s+(async\s+)?(\w+)\s*\(/);
|
||||
if (methodMatch && methodMatch[2] !== 'constructor') methods.push(methodMatch[2]);
|
||||
}
|
||||
classes.push({
|
||||
name: className,
|
||||
extends: classMatch[4],
|
||||
implements: classMatch[5]?.split(',').map(s => s.trim()).filter(Boolean) || [],
|
||||
methods,
|
||||
isExported,
|
||||
line: lineNum,
|
||||
});
|
||||
if (isExported) exports.push({ name: className, kind: 'class', line: lineNum });
|
||||
}
|
||||
|
||||
// Functions
|
||||
const funcMatch = line.match(/^(export\s+)?(async\s+)?function\s+(\w+)/);
|
||||
if (funcMatch) {
|
||||
const isExported = !!funcMatch[1];
|
||||
functions.push({ name: funcMatch[3], isAsync: !!funcMatch[2], isExported, line: lineNum });
|
||||
if (isExported) exports.push({ name: funcMatch[3], kind: 'function', line: lineNum });
|
||||
}
|
||||
|
||||
// Arrow function exports
|
||||
const arrowMatch = line.match(/^export\s+(const|let)\s+(\w+)\s*=\s*(async\s+)?[\(]/);
|
||||
if (arrowMatch) {
|
||||
functions.push({ name: arrowMatch[2], isAsync: !!arrowMatch[3], isExported: true, line: lineNum });
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, language, exports, imports, classes, functions, loc: lines.length };
|
||||
}
|
||||
108
src/core/indexer/scanner.ts
Normal file
108
src/core/indexer/scanner.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface ScanOptions {
|
||||
maxDepth?: number;
|
||||
ignore?: string[];
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
export interface ScannedFile {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DEFAULT_IGNORE = [
|
||||
'node_modules', '.git', 'dist', 'build', '__pycache__', '.pytest_cache',
|
||||
'.mypy_cache', '.env', 'coverage', '.next', '.nuxt', 'target', 'vendor',
|
||||
'.idea', '.vscode', '.DS_Store', 'Thumbs.db',
|
||||
];
|
||||
|
||||
const CODE_EXTENSIONS = [
|
||||
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
||||
'.py', '.rs', '.go', '.java', '.kt', '.c', '.cpp', '.h', '.hpp',
|
||||
'.cs', '.rb', '.php', '.swift', '.vue', '.svelte',
|
||||
];
|
||||
|
||||
export async function scanFiles(root: string, options: ScanOptions = {}): Promise<ScannedFile[]> {
|
||||
const absRoot = path.resolve(root);
|
||||
const maxDepth = options.maxDepth ?? 10;
|
||||
const ignorePatterns = [...DEFAULT_IGNORE, ...(options.ignore || [])];
|
||||
const extensions = options.extensions || CODE_EXTENSIONS;
|
||||
const files: ScannedFile[] = [];
|
||||
|
||||
function shouldIgnore(name: string): boolean {
|
||||
for (const pattern of ignorePatterns) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
if (name.endsWith(pattern.slice(1))) return true;
|
||||
} else if (name === pattern) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function walk(dir: string, depth: number): void {
|
||||
if (depth > maxDepth) return;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
if (shouldIgnore(entry.name)) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (extensions.includes(ext)) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
const content = fs.readFileSync(fullPath);
|
||||
files.push({
|
||||
path: fullPath,
|
||||
relativePath: path.relative(absRoot, fullPath),
|
||||
extension: ext,
|
||||
size: stat.size,
|
||||
hash: crypto.createHash('md5').update(content).digest('hex'),
|
||||
});
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(absRoot, 0);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function getDirectoryTree(root: string, maxDepth: number = 3): string {
|
||||
const absRoot = path.resolve(root);
|
||||
const lines: string[] = [path.basename(absRoot) + '/'];
|
||||
|
||||
function walk(dir: string, prefix: string, depth: number): void {
|
||||
if (depth > maxDepth) return;
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
|
||||
const filtered = entries.filter(e => !DEFAULT_IGNORE.includes(e.name) && !e.name.startsWith('.'));
|
||||
const dirs = filtered.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const files = filtered.filter(e => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = [...dirs, ...files];
|
||||
|
||||
sorted.forEach((entry, i) => {
|
||||
const isLast = i === sorted.length - 1;
|
||||
lines.push(prefix + (isLast ? '└── ' : '├── ') + entry.name + (entry.isDirectory() ? '/' : ''));
|
||||
if (entry.isDirectory()) {
|
||||
walk(path.join(dir, entry.name), prefix + (isLast ? ' ' : '│ '), depth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
walk(absRoot, '', 0);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -590,6 +590,57 @@ server.tool(
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_index ---
|
||||
import { indexProject } from '../core/indexer';
|
||||
|
||||
server.tool(
|
||||
'memory_index',
|
||||
'Index a codebase to create component nodes. Scans files, extracts exports/imports, and maps relationships.',
|
||||
{
|
||||
path: z.string().optional().describe('Path to index (default: current directory)'),
|
||||
update: z.boolean().optional().describe('Only update changed files (incremental)'),
|
||||
language: z.string().optional().describe('Only index specific language (ts, js, py)'),
|
||||
maxDepth: z.number().optional().describe('Maximum directory depth (default: 10)'),
|
||||
},
|
||||
async ({ path: inputPath, update, language, maxDepth }) => {
|
||||
const result = await indexProject(inputPath || '.', {
|
||||
update,
|
||||
language,
|
||||
maxDepth,
|
||||
});
|
||||
return { content: [{ type: 'text' as const, text: serialize(result) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_components ---
|
||||
server.tool(
|
||||
'memory_components',
|
||||
'List indexed components for a project',
|
||||
{
|
||||
project: z.string().optional().describe('Project name to filter by'),
|
||||
limit: z.number().optional().describe('Max results (default: 50)'),
|
||||
},
|
||||
async ({ project, limit }) => {
|
||||
const tags = project ? [project, 'indexed'] : ['indexed'];
|
||||
const components = listNodes({ kind: 'component' as NodeKind, tags, limit: limit || 50 });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: serialize({
|
||||
count: components.length,
|
||||
components: components.map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
filePath: c.metadata?.filePath,
|
||||
exports: (c.metadata?.exports as string[])?.length || 0,
|
||||
loc: c.metadata?.loc,
|
||||
})),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
Reference in New Issue
Block a user