@misterhuydo/cairn-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ export function parseNpm(filePath, content, repoName) {
2
+ const deps = [];
3
+
4
+ try {
5
+ const pkg = JSON.parse(content);
6
+ for (const [name, version] of Object.entries(pkg.dependencies || {}))
7
+ deps.push({ manager: 'npm', group_id: 'dependencies', artifact: name, version, scope: 'runtime' });
8
+ for (const [name, version] of Object.entries(pkg.devDependencies || {}))
9
+ deps.push({ manager: 'npm', group_id: 'devDependencies', artifact: name, version, scope: 'dev' });
10
+ for (const [name, version] of Object.entries(pkg.peerDependencies || {}))
11
+ deps.push({ manager: 'npm', group_id: 'peerDependencies', artifact: name, version, scope: 'peer' });
12
+ } catch {
13
+ // Malformed package.json — skip
14
+ }
15
+
16
+ return deps;
17
+ }
@@ -0,0 +1,40 @@
1
+ import fg from 'fast-glob';
2
+
3
+ export const LANGUAGE_MAP = {
4
+ // JVM
5
+ '.java': 'java',
6
+ // JS/TS
7
+ '.ts': 'typescript',
8
+ '.tsx': 'typescript',
9
+ '.js': 'javascript',
10
+ '.jsx': 'javascript',
11
+ '.mjs': 'javascript',
12
+ // Vue
13
+ '.vue': 'vue',
14
+ // Python
15
+ '.py': 'python',
16
+ // Data / Config
17
+ '.sql': 'sql',
18
+ '.yml': 'config',
19
+ '.yaml': 'config',
20
+ '.properties': 'config',
21
+ '.env': 'config',
22
+ // Markup
23
+ '.xml': 'xml',
24
+ '.html': 'html',
25
+ '.md': 'markdown',
26
+ };
27
+
28
+ export const BUILD_FILES = ['pom.xml', 'package.json', 'build.gradle'];
29
+
30
+ const IGNORE = [
31
+ '**/node_modules/**', '**/.git/**', '**/dist/**',
32
+ '**/build/**', '**/target/**', '**/.next/**',
33
+ '**/__pycache__/**', '**/*.min.js',
34
+ ];
35
+
36
+ export async function walkRepo(repoRoot) {
37
+ const patterns = Object.keys(LANGUAGE_MAP).map(ext => `**/*${ext}`);
38
+ const files = await fg(patterns, { cwd: repoRoot, ignore: IGNORE, absolute: true });
39
+ return files;
40
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'path';
2
+ import { LANGUAGE_MAP } from './fileWalker.js';
3
+ import { parseJava } from './parsers/javaParser.js';
4
+ import { parseTS } from './parsers/tsParser.js';
5
+ import { parseVue } from './parsers/vueParser.js';
6
+ import { parsePython } from './parsers/pythonParser.js';
7
+ import { parseSQL } from './parsers/sqlParser.js';
8
+ import { parseConfig } from './parsers/configParser.js';
9
+ import { parseXML } from './parsers/xmlParser.js';
10
+ import { parseMarkdown } from './parsers/markdownParser.js';
11
+
12
+ const PARSERS = {
13
+ java: parseJava,
14
+ typescript: parseTS,
15
+ javascript: parseTS,
16
+ vue: parseVue,
17
+ python: parsePython,
18
+ sql: parseSQL,
19
+ config: parseConfig,
20
+ xml: parseXML,
21
+ html: parseXML,
22
+ markdown: parseMarkdown,
23
+ };
24
+
25
+ export function getParser(filePath) {
26
+ const ext = path.extname(filePath).toLowerCase();
27
+ const language = LANGUAGE_MAP[ext];
28
+ return { language, parser: PARSERS[language] || null };
29
+ }
30
+
31
+ export async function parseFile(filePath, content, repoName) {
32
+ const { language, parser } = getParser(filePath);
33
+ if (!parser) return null;
34
+ return parser(filePath, content, repoName, language);
35
+ }
@@ -0,0 +1,17 @@
1
+ export function parseConfig(filePath, content, repoName) {
2
+ const symbols = [];
3
+
4
+ for (const m of content.matchAll(/^([\w.-]+)\s*[:=]\s*(.+)/gm)) {
5
+ const key = m[1].trim();
6
+ if (key.startsWith('#')) continue;
7
+ symbols.push({
8
+ name: key,
9
+ fqn: `${filePath}::${key}`,
10
+ kind: 'config-key',
11
+ exported: false,
12
+ description: m[2].trim().substring(0, 80),
13
+ });
14
+ }
15
+
16
+ return { language: 'config', symbols, imports: [] };
17
+ }
@@ -0,0 +1,31 @@
1
+ export function parseJava(filePath, content, repoName) {
2
+ const pkg = content.match(/^package\s+([\w.]+);/m)?.[1] || '';
3
+ const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)].map(m => m[1]);
4
+
5
+ const classMatch = content.match(
6
+ /(?:public|protected)?\s+(?:abstract\s+)?(?:class|interface|enum|record)\s+(\w+)/
7
+ );
8
+ const name = classMatch?.[1] || '';
9
+ const kind = classMatch?.[0]?.match(/class|interface|enum|record/)?.[0] || 'class';
10
+ const fqn = pkg ? `${pkg}.${name}` : name;
11
+
12
+ const methods = [...content.matchAll(
13
+ /(?:public|protected|private)[^{;]*\s+(\w+)\s*\([^)]*\)\s*(?:throws[^{]*)?\{/gm
14
+ )].map(m => m[1]);
15
+
16
+ const extendsMatch = content.match(/extends\s+([\w.]+)/)?.[1];
17
+ const implementsMatch = content.match(/implements\s+([\w.,\s]+)/)?.[1]
18
+ ?.split(',').map(s => s.trim());
19
+
20
+ const javadoc = content.match(/\/\*\*([\s\S]*?)\*\//)?.[1]
21
+ ?.replace(/\s*\*\s?/g, ' ').trim();
22
+
23
+ return {
24
+ language: 'java',
25
+ symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }],
26
+ imports,
27
+ extends: extendsMatch ? [extendsMatch] : [],
28
+ implements: implementsMatch || [],
29
+ methods,
30
+ };
31
+ }
@@ -0,0 +1,17 @@
1
+ export function parseMarkdown(filePath, content, repoName) {
2
+ const symbols = [];
3
+
4
+ for (const m of content.matchAll(/^(#{1,3})\s+(.+)/gm)) {
5
+ const level = m[1].length;
6
+ const title = m[2].trim();
7
+ symbols.push({
8
+ name: title,
9
+ fqn: `${filePath}::${title.replace(/\s+/g, '-').toLowerCase()}`,
10
+ kind: level === 1 ? 'doc-title' : 'doc-section',
11
+ exported: true,
12
+ description: '',
13
+ });
14
+ }
15
+
16
+ return { language: 'markdown', symbols, imports: [] };
17
+ }
@@ -0,0 +1,25 @@
1
+ export function parsePython(filePath, content, repoName) {
2
+ const symbols = [];
3
+ const imports = [];
4
+
5
+ for (const m of content.matchAll(/^from\s+([\w.]+)\s+import\s+([\w,\s*]+)/gm))
6
+ imports.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
7
+ for (const m of content.matchAll(/^import\s+([\w.,\s]+)/gm))
8
+ imports.push(...m[1].split(',').map(s => s.trim()).filter(Boolean));
9
+
10
+ for (const m of content.matchAll(/^class\s+(\w+)(?:\(([^)]*)\))?:/gm)) {
11
+ const docstring = content.slice(m.index).match(/:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
12
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported: true, description: docstring || '' });
13
+ }
14
+
15
+ for (const m of content.matchAll(/^(?:async\s+)?def\s+(\w+)\s*\(/gm)) {
16
+ if (m[1].startsWith('_')) continue;
17
+ const docstring = content.slice(m.index).match(/\).*?:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
18
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: docstring || '' });
19
+ }
20
+
21
+ for (const m of content.matchAll(/^@([\w.]+)(?:\(([^)]*)\))?/gm))
22
+ symbols.push({ name: m[1], fqn: `${filePath}::@${m[1]}`, kind: 'decorator', exported: false, description: '' });
23
+
24
+ return { language: 'python', symbols, imports };
25
+ }
@@ -0,0 +1,14 @@
1
+ export function parseSQL(filePath, content, repoName) {
2
+ const symbols = [];
3
+
4
+ for (const m of content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"\w.]+)/gi))
5
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'table', exported: true, description: '' });
6
+
7
+ for (const m of content.matchAll(/CREATE\s+(?:OR\s+REPLACE\s+)?(?:PROCEDURE|FUNCTION)\s+(\w+)/gi))
8
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
9
+
10
+ for (const m of content.matchAll(/CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+(\w+)/gi))
11
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'view', exported: true, description: '' });
12
+
13
+ return { language: 'sql', symbols, imports: [] };
14
+ }
@@ -0,0 +1,45 @@
1
+ export function parseTS(filePath, content, repoName, language = 'typescript') {
2
+ const symbols = [];
3
+ const imports = [];
4
+
5
+ // Named imports: import { Foo, Bar } from './foo'
6
+ for (const m of content.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g))
7
+ imports.push(...m[1].split(',').map(s => s.trim().split(' as ')[0]).filter(Boolean));
8
+
9
+ // Default imports: import Foo from './foo'
10
+ for (const m of content.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g))
11
+ imports.push(m[1]);
12
+
13
+ // Classes
14
+ for (const m of content.matchAll(/(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/g)) {
15
+ const exported = m[0].startsWith('export');
16
+ const jsdoc = extractJSDoc(content, m.index);
17
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported, description: jsdoc });
18
+ }
19
+
20
+ // Interfaces & Types
21
+ for (const m of content.matchAll(/export\s+(?:interface|type)\s+(\w+)/g))
22
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'interface', exported: true, description: '' });
23
+
24
+ // Exported named functions
25
+ for (const m of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) {
26
+ const jsdoc = extractJSDoc(content, m.index);
27
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: jsdoc });
28
+ }
29
+
30
+ // Exported arrow functions: export const foo = (...) =>
31
+ for (const m of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g))
32
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
33
+
34
+ // Enums
35
+ for (const m of content.matchAll(/export\s+(?:const\s+)?enum\s+(\w+)/g))
36
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'enum', exported: true, description: '' });
37
+
38
+ return { language, symbols, imports };
39
+ }
40
+
41
+ function extractJSDoc(content, index) {
42
+ const before = content.substring(0, index);
43
+ const match = before.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
44
+ return match ? match[1].replace(/\s*\*\s?/g, ' ').trim() : '';
45
+ }
@@ -0,0 +1,25 @@
1
+ import { parseTS } from './tsParser.js';
2
+
3
+ export function parseVue(filePath, content, repoName) {
4
+ const componentName = filePath.split('/').pop().replace('.vue', '');
5
+
6
+ const scriptMatch = content.match(/<script(?:\s+setup)?(?:\s+lang=["']ts["'])?>([\s\S]*?)<\/script>/i);
7
+ const scriptContent = scriptMatch?.[1] || '';
8
+
9
+ const parsed = parseTS(filePath, scriptContent, repoName, 'vue');
10
+
11
+ const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
12
+ const childComponents = [...(templateMatch?.[1]?.matchAll(/<([A-Z][A-Za-z]+)/g) || [])]
13
+ .map(m => m[1]);
14
+
15
+ parsed.symbols.unshift({
16
+ name: componentName,
17
+ fqn: `${filePath}::${componentName}`,
18
+ kind: 'component',
19
+ exported: true,
20
+ description: `Vue component: ${componentName}`,
21
+ });
22
+
23
+ parsed.childComponents = [...new Set(childComponents)];
24
+ return parsed;
25
+ }
@@ -0,0 +1,25 @@
1
+ const HTML_TAGS = new Set([
2
+ 'div','span','p','a','ul','ol','li','table','tr','td','th','thead','tbody',
3
+ 'form','input','button','select','option','textarea','label','img','br','hr',
4
+ 'h1','h2','h3','h4','h5','h6','head','body','html','script','style','link',
5
+ 'meta','nav','header','footer','main','section','article','aside','figure',
6
+ ]);
7
+
8
+ export function parseXML(filePath, content, repoName) {
9
+ const symbols = [];
10
+
11
+ // Spring bean IDs, component ids, any id="..." attribute
12
+ for (const m of content.matchAll(/\bid\s*=\s*["']([^"']+)["']/g))
13
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'bean', exported: true, description: '' });
14
+
15
+ // Non-standard (custom/component) tag names
16
+ const seen = new Set();
17
+ for (const m of content.matchAll(/<([a-z][a-z0-9-]*)(?:\s|\/?>)/gi)) {
18
+ const tag = m[1].toLowerCase();
19
+ if (HTML_TAGS.has(tag) || seen.has(tag)) continue;
20
+ seen.add(tag);
21
+ symbols.push({ name: tag, fqn: `${filePath}::${tag}`, kind: 'component', exported: true, description: '' });
22
+ }
23
+
24
+ return { language: 'xml', symbols, imports: [] };
25
+ }
@@ -0,0 +1,103 @@
1
+ export const VULN_PATTERNS = [
2
+ // ── Java ──────────────────────────────────────────────────────────────
3
+ { id: 'CWE-611', lang: ['java'], severity: 'HIGH',
4
+ name: 'XXE (XML External Entity)',
5
+ pattern: /builder\.parse\(/,
6
+ negPattern: /setFeature.*FEATURE_SECURE_PROCESSING/,
7
+ fix: 'Add dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) before parse()' },
8
+
9
+ { id: 'CWE-89', lang: ['java'], severity: 'HIGH',
10
+ name: 'SQL Injection',
11
+ pattern: /"SELECT|INSERT|UPDATE|DELETE.*"\s*\+/i,
12
+ fix: 'Use PreparedStatement with parameterized queries' },
13
+
14
+ { id: 'CWE-326', lang: ['java'], severity: 'HIGH',
15
+ name: 'Weak Cryptography',
16
+ pattern: /getInstance\("(MD5|SHA1|DES|RC4)"\)/,
17
+ fix: 'Use SHA-256 or AES-256' },
18
+
19
+ { id: 'CWE-502', lang: ['java'], severity: 'HIGH',
20
+ name: 'Unsafe Deserialization',
21
+ pattern: /new ObjectInputStream|\.readObject\(\)/,
22
+ fix: 'Validate input before deserialization; use safe deserialization libraries' },
23
+
24
+ { id: 'CWE-078', lang: ['java'], severity: 'HIGH',
25
+ name: 'Command Injection',
26
+ pattern: /Runtime\.getRuntime\(\)\.exec\(/,
27
+ fix: 'Use ProcessBuilder with argument list instead of string concatenation' },
28
+
29
+ // ── JavaScript / TypeScript / Vue ─────────────────────────────────────
30
+ { id: 'CWE-79', lang: ['javascript', 'typescript', 'vue'], severity: 'HIGH',
31
+ name: 'XSS via innerHTML / dangerouslySetInnerHTML',
32
+ pattern: /\.innerHTML\s*=|dangerouslySetInnerHTML/,
33
+ fix: 'Use textContent or sanitize with DOMPurify' },
34
+
35
+ { id: 'CWE-89', lang: ['javascript', 'typescript'], severity: 'HIGH',
36
+ name: 'SQL Injection (JS)',
37
+ pattern: /`\s*(SELECT|INSERT|UPDATE|DELETE).*\$\{/i,
38
+ fix: 'Use parameterized queries or an ORM' },
39
+
40
+ { id: 'CWE-798', lang: ['javascript', 'typescript', 'vue'], severity: 'HIGH',
41
+ name: 'Hardcoded Secret',
42
+ pattern: /(api_key|apikey|secret|password|token)\s*[:=]\s*['"][^'"]{8,}['"]/i,
43
+ fix: 'Move secrets to environment variables or a secrets manager' },
44
+
45
+ { id: 'CWE-327', lang: ['javascript', 'typescript'], severity: 'MEDIUM',
46
+ name: 'Weak Crypto (Node)',
47
+ pattern: /createHash\(['"]md5['"]\)|createHash\(['"]sha1['"]\)/,
48
+ fix: 'Use SHA-256 or stronger' },
49
+
50
+ { id: 'CWE-601', lang: ['javascript', 'typescript', 'vue'], severity: 'MEDIUM',
51
+ name: 'Open Redirect',
52
+ pattern: /res\.redirect\([^)]*req\.(query|params|body)/,
53
+ fix: 'Validate redirect URL against an allowlist' },
54
+
55
+ // ── Python ────────────────────────────────────────────────────────────
56
+ { id: 'CWE-089', lang: ['python'], severity: 'HIGH',
57
+ name: 'SQL Injection (Python)',
58
+ pattern: /execute\([f"'].*%(s|d)|execute\(.*format\(/,
59
+ fix: 'Use parameterized queries: cursor.execute(sql, params)' },
60
+
61
+ { id: 'CWE-078', lang: ['python'], severity: 'HIGH',
62
+ name: 'Command Injection (Python)',
63
+ pattern: /os\.system\(|subprocess\.call\(.*shell=True/,
64
+ fix: 'Use subprocess with a list of args and shell=False' },
65
+
66
+ { id: 'CWE-798', lang: ['python'], severity: 'HIGH',
67
+ name: 'Hardcoded Secret (Python)',
68
+ pattern: /(api_key|secret|password|token)\s*=\s*['"][^'"]{8,}['"]/i,
69
+ fix: 'Use environment variables or a secrets manager like Vault' },
70
+
71
+ // ── Universal (all languages) ─────────────────────────────────────────
72
+ { id: 'CWE-312', lang: null, severity: 'MEDIUM',
73
+ name: 'Cleartext Sensitive Data in Comments',
74
+ pattern: /\/\/.*?(password|secret|token)\s*[:=]\s*\S+/i,
75
+ fix: 'Remove sensitive data from comments' },
76
+ ];
77
+
78
+ export function scanFile(filePath, content, language) {
79
+ const findings = [];
80
+ const lines = content.split('\n');
81
+
82
+ for (const rule of VULN_PATTERNS) {
83
+ if (rule.lang && !rule.lang.includes(language)) continue;
84
+
85
+ lines.forEach((line, i) => {
86
+ if (!rule.pattern.test(line)) return;
87
+ if (rule.negPattern) {
88
+ const ctx = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
89
+ if (rule.negPattern.test(ctx)) return;
90
+ }
91
+ findings.push({
92
+ severity: rule.severity,
93
+ cwe: rule.id,
94
+ rule_name: rule.name,
95
+ line: i + 1,
96
+ code_snippet: line.trim().substring(0, 120),
97
+ description: rule.fix,
98
+ });
99
+ });
100
+ }
101
+
102
+ return findings;
103
+ }
@@ -0,0 +1,30 @@
1
+ import { writeBundle } from '../bundler/bundleWriter.js';
2
+
3
+ export async function bundle(_db, args) {
4
+ const {
5
+ roots,
6
+ bundle_name = 'default',
7
+ filter_paths = null,
8
+ filter_language = null,
9
+ no_comments = true,
10
+ no_empty_lines = true,
11
+ no_style = false,
12
+ aggressive = false,
13
+ only_changed = false,
14
+ max_size_kb = 800,
15
+ } = args;
16
+
17
+ const result = await writeBundle(roots, {
18
+ bundleName: bundle_name,
19
+ noComments: no_comments,
20
+ noEmptyLines: no_empty_lines,
21
+ noStyle: no_style,
22
+ aggressive,
23
+ onlyChanged: only_changed,
24
+ filterLang: filter_language,
25
+ filterPaths: filter_paths,
26
+ maxSizeKB: max_size_kb,
27
+ });
28
+
29
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
30
+ }
@@ -0,0 +1,111 @@
1
+ export function codeGraph(db, { module: modFilter, mode }) {
2
+ if (mode === 'health') {
3
+ const fileCount = db.prepare('SELECT COUNT(*) as c FROM files').get()?.c || 0;
4
+ const symbolCount = db.prepare('SELECT COUNT(*) as c FROM symbols').get()?.c || 0;
5
+ const depCount = db.prepare('SELECT COUNT(*) as c FROM dependencies').get()?.c || 0;
6
+ const findingCount = db.prepare('SELECT COUNT(*) as c FROM security_findings').get()?.c || 0;
7
+ const byLang = db.prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language').all();
8
+ const lastIndexed = db.prepare('SELECT MAX(last_indexed) as t FROM files').get()?.t;
9
+
10
+ return {
11
+ content: [{
12
+ type: 'text',
13
+ text: JSON.stringify({
14
+ files: fileCount, symbols: symbolCount,
15
+ dependencies: depCount, security_findings: findingCount,
16
+ by_language: byLang,
17
+ last_indexed: lastIndexed ? new Date(lastIndexed).toISOString() : null,
18
+ }, null, 2),
19
+ }],
20
+ };
21
+ }
22
+
23
+ if (mode === 'instability') {
24
+ const files = db.prepare('SELECT id, path, language FROM files').all();
25
+
26
+ // Group files by parent directory
27
+ const modules = {};
28
+ for (const f of files) {
29
+ const parts = f.path.replace(/\\/g, '/').split('/');
30
+ const moduleName = parts.slice(0, -1).join('/') || '/';
31
+ if (modFilter && !moduleName.includes(modFilter)) continue;
32
+ if (!modules[moduleName]) modules[moduleName] = { name: moduleName, language: f.language, fileIds: [] };
33
+ modules[moduleName].fileIds.push(f.id);
34
+ }
35
+
36
+ const results = [];
37
+ for (const [, mod] of Object.entries(modules)) {
38
+ if (mod.fileIds.length === 0) continue;
39
+ const ph = mod.fileIds.map(() => '?').join(',');
40
+
41
+ // Efferent (outgoing) deps
42
+ const ce = db.prepare(
43
+ `SELECT COUNT(DISTINCT to_fqn) as c FROM dependencies WHERE from_file_id IN (${ph})`
44
+ ).get(...mod.fileIds)?.c || 0;
45
+
46
+ // Afferent (incoming) deps
47
+ const fqns = db.prepare(
48
+ `SELECT fqn FROM symbols WHERE file_id IN (${ph})`
49
+ ).all(...mod.fileIds).map(s => s.fqn);
50
+
51
+ let ca = 0;
52
+ if (fqns.length > 0) {
53
+ const sph = fqns.map(() => '?').join(',');
54
+ ca = db.prepare(
55
+ `SELECT COUNT(DISTINCT from_file_id) as c FROM dependencies WHERE to_fqn IN (${sph})`
56
+ ).get(...fqns)?.c || 0;
57
+ }
58
+
59
+ const instability = (ca + ce) === 0 ? 0 : ce / (ca + ce);
60
+ const status = instability >= 0.8 ? 'safe_to_refactor'
61
+ : instability >= 0.3 ? 'review_before_change'
62
+ : 'load_bearing';
63
+ results.push({
64
+ name: mod.name,
65
+ language: mod.language,
66
+ instability: Math.round(instability * 100) / 100,
67
+ status,
68
+ });
69
+ }
70
+
71
+ results.sort((a, b) => b.instability - a.instability);
72
+
73
+ const godObjects = db.prepare(`
74
+ SELECT s.name || ' (' || COALESCE(s.language,'?') || ')' as label, COUNT(*) as dep_count
75
+ FROM dependencies d JOIN symbols s ON d.to_fqn = s.fqn
76
+ GROUP BY d.to_fqn ORDER BY dep_count DESC LIMIT 5
77
+ `).all().map(r => `${r.label} (${r.dep_count} deps)`);
78
+
79
+ return {
80
+ content: [{
81
+ type: 'text',
82
+ text: JSON.stringify({
83
+ modules: results.slice(0, 50),
84
+ god_objects: godObjects,
85
+ cycles_detected: [],
86
+ }, null, 2),
87
+ }],
88
+ };
89
+ }
90
+
91
+ if (mode === 'cycles') {
92
+ // Detect mutual imports between files
93
+ const cycles = db.prepare(`
94
+ SELECT DISTINCT f1.path as from_path, f2.path as to_path
95
+ FROM dependencies d1
96
+ JOIN symbols s1 ON d1.to_fqn = s1.fqn
97
+ JOIN files f1 ON d1.from_file_id = f1.id
98
+ JOIN files f2 ON s1.file_id = f2.id
99
+ JOIN dependencies d2 ON d2.from_file_id = f2.id
100
+ JOIN symbols s2 ON d2.to_fqn = s2.fqn
101
+ WHERE s2.file_id = d1.from_file_id
102
+ LIMIT 20
103
+ `).all();
104
+
105
+ return {
106
+ content: [{ type: 'text', text: JSON.stringify({ cycles_detected: cycles }, null, 2) }],
107
+ };
108
+ }
109
+
110
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown mode: ${mode}` }) }] };
111
+ }
@@ -0,0 +1,51 @@
1
+ export function describe(db, { path: dirPath }) {
2
+ const files = db.prepare('SELECT * FROM files WHERE path LIKE ?').all(`${dirPath}%`);
3
+
4
+ if (files.length === 0) {
5
+ return { content: [{ type: 'text', text: JSON.stringify({ path: dirPath, error: 'No files found' }) }] };
6
+ }
7
+
8
+ const fileIds = files.map(f => f.id);
9
+ const languages = [...new Set(files.map(f => f.language).filter(Boolean))];
10
+
11
+ const ph = fileIds.map(() => '?').join(',');
12
+
13
+ // Symbols grouped by kind
14
+ const symbols = db.prepare(`SELECT * FROM symbols WHERE file_id IN (${ph})`).all(...fileIds);
15
+ const grouped = {};
16
+ for (const sym of symbols) {
17
+ const key = sym.kind || 'other';
18
+ if (!grouped[key]) grouped[key] = [];
19
+ grouped[key].push(sym.name);
20
+ }
21
+
22
+ // Outgoing imports
23
+ const outgoing = db.prepare(
24
+ `SELECT DISTINCT to_fqn FROM dependencies WHERE from_file_id IN (${ph}) AND dep_type = 'imports'`
25
+ ).all(...fileIds).map(r => r.to_fqn);
26
+
27
+ // Incoming imports (what files import symbols defined in this path)
28
+ let incomingPaths = [];
29
+ if (symbols.length > 0) {
30
+ const fqns = symbols.map(s => s.fqn);
31
+ const sph = fqns.map(() => '?').join(',');
32
+ incomingPaths = db.prepare(
33
+ `SELECT DISTINCT f.path FROM dependencies d JOIN files f ON d.from_file_id = f.id WHERE d.to_fqn IN (${sph})`
34
+ ).all(...fqns).map(r => r.path);
35
+ }
36
+
37
+ // External deps: outgoing references that don't match any known fqn
38
+ const allFqns = new Set(db.prepare('SELECT fqn FROM symbols').all().map(s => s.fqn));
39
+ const externalDeps = [...new Set(outgoing.filter(fqn => !allFqns.has(fqn)))].slice(0, 20);
40
+
41
+ const result = {
42
+ path: dirPath,
43
+ languages,
44
+ symbols: grouped,
45
+ imports_from: outgoing.slice(0, 20),
46
+ imported_by: incomingPaths.slice(0, 20),
47
+ external_deps: externalDeps,
48
+ };
49
+
50
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
51
+ }