@misterhuydo/cairn-mcp 1.1.1 → 1.2.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,2959 @@
1
+ ### File: cairn-mcp-plan.md
2
+ # Cairn MCP Server — Implementation Plan
3
+ ## Giving Claude Code Persistent Institutional Memory (Polyglot Edition)
4
+ ---
5
+ ## The Problem
6
+ Claude is **stateless**. Every session starts at zero knowledge. On a multi-repo enterprise system
7
+ with Java backends, TypeScript frontends, and Vue components, this means 50% of every session is
8
+ "Archaeology" — just finding where things live before any real work can begin.
9
+ **The fix:** A local MCP server that acts as a persistent polyglot knowledge graph. Index once, query forever.
10
+ ---
11
+ ## Supported Languages
12
+ | Language | Extensions | What Cairn Extracts |
13
+ |----------|-----------|---------------------|
14
+ | Java | `.java` | package, class/interface/enum/record, imports, methods |
15
+ | TypeScript | `.ts`, `.tsx` | imports, exports, classes, interfaces, functions, types |
16
+ | JavaScript | `.js`, `.jsx`, `.mjs` | imports, exports, classes, functions |
17
+ | Vue | `.vue` | extracts `<script>` block → parsed as TS/JS |
18
+ | Python | `.py` | imports, classes, functions, decorators |
19
+ | XML/HTML | `.xml`, `.html` | bean ids, component names, key attributes |
20
+ | Config | `.yml`, `.yaml`, `.properties`, `.env` | top-level keys, service names |
21
+ | SQL | `.sql` | table names, stored procedures, views |
22
+ | Markdown | `.md` | headings, used for README/doc indexing |
23
+ | Build files | `pom.xml`, `package.json`, `build.gradle` | dependency declarations |
24
+ ---
25
+ ## Architecture Overview
26
+ ```
27
+ Your Polyglot Repos (Java / TS / Vue / Python / ...)
28
+
29
+ ├──────────────────────────────────────────┐
30
+ ▼ ▼
31
+ [NAVIGATION PATH] [CONTENT READ PATH]
32
+ cairn_maintain cairn_bundle
33
+ File Walker → Parser Factory File Walker → Minifier
34
+ → SQLite + FTS5 → .cairn/bundles/<name>.txt
35
+ (.cairn/index.db) (comments, whitespace,
36
+ answers: WHERE does X live? empty lines, styles stripped)
37
+ │ │
38
+ └──────────────────┬───────────────────────┘
39
+
40
+ Claude Code
41
+ cairn_search → WHERE it is
42
+ cairn_bundle → WHAT it says
43
+ ```
44
+ **Two complementary paths, one tool:**
45
+ - **Navigation path** (SQLite index) — *"where does X live?"* — symbol graph, FTS5 search, dependency graph
46
+ - **Content path** (bundle file) — *"what does X actually contain?"* — compressed readable source, sized to fit Claude's context window
47
+ > **Mental model: Cairn works like `git`.** You don't tell `git` where your repo is — it finds `.git/` in the current directory. Cairn works identically: all tools read from `.cairn/` in the current working directory. No roots, no config paths. Just `cd` into your project and run.
48
+ **Why SQLite + FTS5?**
49
+ - Zero infrastructure — ships as a single file
50
+ - Full-text search built in (FTS5 extension)
51
+ - Survives session restarts (persistent)
52
+ - Fast enough for 300K+ lines across 40+ mixed repos
53
+ ---
54
+ ## Project Layout
55
+ ### The `.cairn/` directory (auto-created in your project root)
56
+ ```
57
+ your-project/ ← wherever Claude Code is opened
58
+ ├── .cairn/ ← created automatically on first cairn_maintain
59
+ │ ├── index.db ← SQLite knowledge graph (navigation path)
60
+ │ ├── bundles/ ← minified content snapshots (content path)
61
+ │ │ ├── default.txt
62
+ │ │ ├── checkout.txt
63
+ │ │ └── *.mtime.json ← incremental tracking per bundle
64
+ │ └── cairn.json ← project config (ignore patterns, language overrides)
65
+ ├── src/
66
+ ├── pom.xml / package.json
67
+ └── CLAUDE.md
68
+ ```
69
+ This mirrors exactly how `.git/` works — local, self-contained, no global state.
70
+ > **Tip:** Add `.cairn/` to your `.gitignore`. It's generated data, not source — each developer runs `cairn_maintain` to build their own local index.
71
+ ---
72
+ ## MCP Server Structure
73
+ ```
74
+ cairn-mcp/
75
+ ├── package.json
76
+ ├── index.js ← MCP server entry point
77
+ └── src/
78
+ ├── indexer/
79
+ │ ├── fileWalker.js ← Recursive scanner, extension filter
80
+ │ ├── parserFactory.js ← Routes file → correct parser
81
+ │ ├── parsers/
82
+ │ │ ├── javaParser.js ← .java
83
+ │ │ ├── tsParser.js ← .ts, .tsx, .js, .jsx, .mjs
84
+ │ │ ├── vueParser.js ← .vue (extracts <script>, delegates to tsParser)
85
+ │ │ ├── pythonParser.js ← .py
86
+ │ │ ├── sqlParser.js ← .sql
87
+ │ │ ├── configParser.js ← .yml, .yaml, .properties, .env
88
+ │ │ ├── xmlParser.js ← .xml, .html
89
+ │ │ └── markdownParser.js ← .md
90
+ │ ├── buildParsers/
91
+ │ │ ├── mavenParser.js ← pom.xml
92
+ │ │ ├── npmParser.js ← package.json
93
+ │ │ └── gradleParser.js ← build.gradle (basic)
94
+ │ └── securityScanner.js ← Polyglot pattern-based vuln detection
95
+ ├── graph/
96
+ │ ├── db.js ← SQLite connection + schema
97
+ │ ├── nodes.js ← Symbols, files, repos as nodes
98
+ │ └── edges.js ← Dependency/call/import relationships
99
+ ├── bundler/
100
+ │ ├── minifier.js ← Per-language comment/whitespace stripping
101
+ │ ├── vueMinifier.js ← Vue 3-block minifier (<template>/<script>/<style>)
102
+ │ └── bundleWriter.js ← Writes ### File: headers, tracks mtime for incremental runs
103
+ └── tools/
104
+ ├── search.js ← cairn_search tool
105
+ ├── describe.js ← cairn_describe tool
106
+ ├── codeGraph.js ← cairn_code_graph tool
107
+ ├── security.js ← cairn_security tool
108
+ ├── maintain.js ← cairn_maintain tool (re-index)
109
+ └── bundle.js ← cairn_bundle tool (minified content bundle)
110
+ ```
111
+ ---
112
+ ## Phase 1 — Core Infrastructure (Day 1)
113
+ ### 1.1 Setup
114
+ ```bash
115
+ mkdir cairn-mcp && cd cairn-mcp
116
+ npm init -y
117
+ npm install @modelcontextprotocol/sdk better-sqlite3 fast-glob
118
+ ```
119
+ ### 1.2 CWD Resolution — The Git-like Core (`src/graph/cwd.js`)
120
+ Every tool resolves `.cairn/` relative to the process working directory — the directory
121
+ Claude Code was opened in. No paths are ever passed by the caller.
122
+ ```javascript
123
+ import path from 'path';
124
+ import fs from 'fs';
125
+ // Called once at startup — finds .cairn/ in cwd or creates it
126
+ export function resolveCairnDir() {
127
+ const cairnDir = path.join(process.cwd(), '.cairn');
128
+ fs.mkdirSync(path.join(cairnDir, 'bundles'), { recursive: true });
129
+ return cairnDir;
130
+ }
131
+ export function dbPath() { return path.join(resolveCairnDir(), 'index.db'); }
132
+ export function bundleDir() { return path.join(resolveCairnDir(), 'bundles'); }
133
+ export function configPath() { return path.join(resolveCairnDir(), 'cairn.json'); }
134
+ // Read optional project config (.cairn/cairn.json)
135
+ export function loadConfig() {
136
+ const p = configPath();
137
+ if (!fs.existsSync(p)) return {};
138
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
139
+ }
140
+ ```
141
+ ### 1.3 SQLite Schema — Language-Aware (`src/graph/db.js`)
142
+ ```javascript
143
+ const schema = `
144
+ -- Every file in every repo
145
+ CREATE TABLE IF NOT EXISTS files (
146
+ id INTEGER PRIMARY KEY,
147
+ repo TEXT NOT NULL,
148
+ path TEXT NOT NULL UNIQUE,
149
+ language TEXT, -- 'java','typescript','vue','python','sql','config','markdown'
150
+ file_type TEXT, -- 'source','test','config','build','doc'
151
+ last_indexed INTEGER
152
+ );
153
+ -- Every named symbol extracted from any language
154
+ CREATE TABLE IF NOT EXISTS symbols (
155
+ id INTEGER PRIMARY KEY,
156
+ file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
157
+ fqn TEXT UNIQUE, -- fully qualified: com.example.Auth | src/auth/useAuth.ts::useAuth
158
+ name TEXT NOT NULL,
159
+ kind TEXT, -- 'class','interface','function','component','table','enum','type','record'
160
+ language TEXT,
161
+ exported INTEGER DEFAULT 0, -- 1 if exported/public
162
+ description TEXT -- javadoc / JSDoc / docstring if present
163
+ );
164
+ -- Import/dependency edges between symbols or files
165
+ CREATE TABLE IF NOT EXISTS dependencies (
166
+ from_file_id INTEGER REFERENCES files(id),
167
+ to_fqn TEXT,
168
+ dep_type TEXT -- 'imports','extends','implements','calls','uses'
169
+ );
170
+ -- Build-level dependencies (Maven, npm, Gradle)
171
+ CREATE TABLE IF NOT EXISTS build_deps (
172
+ repo TEXT,
173
+ manager TEXT, -- 'maven','npm','gradle'
174
+ group_id TEXT, -- groupId (Maven) or scope (npm)
175
+ artifact TEXT, -- artifactId or package name
176
+ version TEXT,
177
+ scope TEXT -- compile/test/dev/peer
178
+ );
179
+ -- Security findings (polyglot, CWE-mapped)
180
+ CREATE TABLE IF NOT EXISTS security_findings (
181
+ id INTEGER PRIMARY KEY,
182
+ file_id INTEGER REFERENCES files(id),
183
+ line INTEGER,
184
+ severity TEXT,
185
+ cwe TEXT,
186
+ rule_name TEXT,
187
+ description TEXT,
188
+ code_snippet TEXT
189
+ );
190
+ -- Full-text search across ALL languages
191
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
192
+ fqn, name, description, path, repo, language, kind,
193
+ content='symbols',
194
+ content_rowid='id'
195
+ );
196
+ `;
197
+ ```
198
+ ---
199
+ ## Phase 2 — The Parser Factory (Day 1-2)
200
+ ### 2.1 File Walker + Router (`src/indexer/fileWalker.js`)
201
+ The walker always starts from `process.cwd()` — no root path argument needed.
202
+ ```javascript
203
+ import fg from 'fast-glob';
204
+ import path from 'path';
205
+ import { loadConfig } from '../graph/cwd.js';
206
+ // Files to index by language family
207
+ export const LANGUAGE_MAP = {
208
+ // JVM
209
+ '.java': 'java',
210
+ // JS/TS
211
+ '.ts': 'typescript',
212
+ '.tsx': 'typescript',
213
+ '.js': 'javascript',
214
+ '.jsx': 'javascript',
215
+ '.mjs': 'javascript',
216
+ // Vue
217
+ '.vue': 'vue',
218
+ // Python
219
+ '.py': 'python',
220
+ // Data / Config
221
+ '.sql': 'sql',
222
+ '.yml': 'config',
223
+ '.yaml': 'config',
224
+ '.properties': 'config',
225
+ '.env': 'config',
226
+ // Markup
227
+ '.xml': 'xml',
228
+ '.html': 'html',
229
+ '.md': 'markdown',
230
+ };
231
+ // Build files (handled separately)
232
+ export const BUILD_FILES = ['pom.xml', 'package.json', 'build.gradle'];
233
+ // Directories to always skip
234
+ const IGNORE = [
235
+ '**/node_modules/**', '**/.git/**', '**/dist/**',
236
+ '**/build/**', '**/target/**', '**/.next/**',
237
+ '**/__pycache__/**', '**/*.min.js'
238
+ ];
239
+ // Walk from cwd — like git, no explicit root needed
240
+ export async function walkProject() {
241
+ const root = process.cwd();
242
+ const config = loadConfig();
243
+ const ignore = [...IGNORE, ...( config.ignore || [] ), '.cairn/**'];
244
+ const patterns = Object.keys(LANGUAGE_MAP).map(ext => `**/*${ext}`);
245
+ const files = await fg(patterns, { cwd: root, ignore, absolute: true });
246
+ return { root, files };
247
+ }
248
+ ```
249
+ ### 2.2 Parser Factory (`src/indexer/parserFactory.js`)
250
+ ```javascript
251
+ import path from 'path';
252
+ import { LANGUAGE_MAP } from './fileWalker.js';
253
+ import { parseJava } from './parsers/javaParser.js';
254
+ import { parseTS } from './parsers/tsParser.js';
255
+ import { parseVue } from './parsers/vueParser.js';
256
+ import { parsePython } from './parsers/pythonParser.js';
257
+ import { parseSQL } from './parsers/sqlParser.js';
258
+ import { parseConfig } from './parsers/configParser.js';
259
+ import { parseXML } from './parsers/xmlParser.js';
260
+ import { parseMarkdown } from './parsers/markdownParser.js';
261
+ const PARSERS = {
262
+ java: parseJava,
263
+ typescript: parseTS,
264
+ javascript: parseTS, // JS uses same parser as TS
265
+ vue: parseVue, // Vue extracts <script> then calls parseTS
266
+ python: parsePython,
267
+ sql: parseSQL,
268
+ config: parseConfig,
269
+ xml: parseXML,
270
+ html: parseXML,
271
+ markdown: parseMarkdown,
272
+ };
273
+ export function getParser(filePath) {
274
+ const ext = path.extname(filePath).toLowerCase();
275
+ const language = LANGUAGE_MAP[ext];
276
+ return { language, parser: PARSERS[language] || null };
277
+ }
278
+ // Returns: { language, kind, symbols: [{name, fqn, kind, exported, description}], imports: [] }
279
+ export async function parseFile(filePath, content, repoName) {
280
+ const { language, parser } = getParser(filePath);
281
+ if (!parser) return null;
282
+ return parser(filePath, content, repoName, language);
283
+ }
284
+ ```
285
+ ---
286
+ ## Phase 3 — Language Parsers (Day 2)
287
+ ### 3.1 Java Parser (`src/indexer/parsers/javaParser.js`)
288
+ ```javascript
289
+ export function parseJava(filePath, content, repoName) {
290
+ const pkg = content.match(/^package\s+([\w.]+);/m)?.[1] || '';
291
+ const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)].map(m => m[1]);
292
+ const classMatch = content.match(
293
+ /(?:public|protected)?\s+(?:abstract\s+)?(?:class|interface|enum|record)\s+(\w+)/
294
+ );
295
+ const name = classMatch?.[1] || '';
296
+ const kind = classMatch?.[0]?.match(/class|interface|enum|record/)?.[0] || 'class';
297
+ const fqn = pkg ? `${pkg}.${name}` : name;
298
+ const methods = [...content.matchAll(
299
+ /(?:public|protected|private)[^{;]*\s+(\w+)\s*\([^)]*\)\s*(?:throws[^{]*)?\{/gm
300
+ )].map(m => m[1]);
301
+ const extendsMatch = content.match(/extends\s+([\w.]+)/)?.[1];
302
+ const implementsMatch = content.match(/implements\s+([\w.,\s]+)/)?.[1]
303
+ ?.split(',').map(s => s.trim());
304
+ const javadoc = content.match(/\/\*\*([\s\S]*?)\*\//)?.[1]
305
+ ?.replace(/\s*\*\s?/g, ' ').trim();
306
+ return {
307
+ language: 'java',
308
+ symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }],
309
+ imports,
310
+ extends: extendsMatch ? [extendsMatch] : [],
311
+ implements: implementsMatch || [],
312
+ methods,
313
+ };
314
+ }
315
+ ```
316
+ ### 3.2 TypeScript / JavaScript Parser (`src/indexer/parsers/tsParser.js`)
317
+ ```javascript
318
+ export function parseTS(filePath, content, repoName, language = 'typescript') {
319
+ const symbols = [];
320
+ const imports = [];
321
+ // Named imports: import { Foo, Bar } from './foo'
322
+ for (const m of content.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g))
323
+ imports.push(...m[1].split(',').map(s => s.trim().split(' as ')[0]));
324
+ // Default imports: import Foo from './foo'
325
+ for (const m of content.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g))
326
+ imports.push(m[1]);
327
+ // Classes
328
+ for (const m of content.matchAll(/(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/g)) {
329
+ const exported = m[0].startsWith('export');
330
+ const jsdoc = extractJSDoc(content, m.index);
331
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported, description: jsdoc });
332
+ }
333
+ // Interfaces & Types
334
+ for (const m of content.matchAll(/export\s+(?:interface|type)\s+(\w+)/g))
335
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'interface', exported: true, description: '' });
336
+ // Exported functions (named + arrow)
337
+ for (const m of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) {
338
+ const jsdoc = extractJSDoc(content, m.index);
339
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: jsdoc });
340
+ }
341
+ for (const m of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g))
342
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
343
+ // Enums
344
+ for (const m of content.matchAll(/export\s+(?:const\s+)?enum\s+(\w+)/g))
345
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'enum', exported: true, description: '' });
346
+ return { language, symbols, imports };
347
+ }
348
+ function extractJSDoc(content, index) {
349
+ const before = content.substring(0, index);
350
+ const match = before.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
351
+ return match ? match[1].replace(/\s*\*\s?/g, ' ').trim() : '';
352
+ }
353
+ ```
354
+ ### 3.3 Vue Parser (`src/indexer/parsers/vueParser.js`)
355
+ ```javascript
356
+ import { parseTS } from './tsParser.js';
357
+ export function parseVue(filePath, content, repoName) {
358
+ // Step 1: Extract component name from filename
359
+ const componentName = filePath.split('/').pop().replace('.vue', '');
360
+ // Step 2: Extract <script> or <script setup> block
361
+ const scriptMatch = content.match(/<script(?:\s+setup)?(?:\s+lang=["']ts["'])?>([\s\S]*?)<\/script>/i);
362
+ const scriptContent = scriptMatch?.[1] || '';
363
+ // Step 3: Parse script block as TypeScript
364
+ const parsed = parseTS(filePath, scriptContent, repoName, 'vue');
365
+ // Step 4: Extract child component refs from template
366
+ const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
367
+ const childComponents = [...(templateMatch?.[1]?.matchAll(/<([A-Z][A-Za-z]+)/g) || [])]
368
+ .map(m => m[1]);
369
+ // Step 5: Always register the component itself as a symbol
370
+ parsed.symbols.unshift({
371
+ name: componentName,
372
+ fqn: `${filePath}::${componentName}`,
373
+ kind: 'component',
374
+ exported: true,
375
+ description: `Vue component: ${componentName}`
376
+ });
377
+ parsed.childComponents = [...new Set(childComponents)];
378
+ return parsed;
379
+ }
380
+ ```
381
+ ### 3.4 Python Parser (`src/indexer/parsers/pythonParser.js`)
382
+ ```javascript
383
+ export function parsePython(filePath, content, repoName) {
384
+ const symbols = [];
385
+ const imports = [];
386
+ // Imports
387
+ for (const m of content.matchAll(/^from\s+([\w.]+)\s+import\s+([\w,\s*]+)/gm))
388
+ imports.push(...m[2].split(',').map(s => s.trim()));
389
+ for (const m of content.matchAll(/^import\s+([\w.,\s]+)/gm))
390
+ imports.push(...m[1].split(',').map(s => s.trim()));
391
+ // Classes
392
+ for (const m of content.matchAll(/^class\s+(\w+)(?:\(([^)]*)\))?:/gm)) {
393
+ const docstring = content.slice(m.index).match(/:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
394
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported: true, description: docstring || '' });
395
+ }
396
+ // Top-level functions (skip private _underscore ones)
397
+ for (const m of content.matchAll(/^(?:async\s+)?def\s+(\w+)\s*\(/gm)) {
398
+ if (m[1].startsWith('_')) continue;
399
+ const docstring = content.slice(m.index).match(/\).*?:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
400
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: docstring || '' });
401
+ }
402
+ // Decorators (FastAPI routes, Django views, Celery tasks, etc.)
403
+ for (const m of content.matchAll(/^@([\w.]+)(?:\(([^)]*)\))?/gm))
404
+ symbols.push({ name: m[1], fqn: `${filePath}::@${m[1]}`, kind: 'decorator', exported: false, description: '' });
405
+ return { language: 'python', symbols, imports };
406
+ }
407
+ ```
408
+ ### 3.5 SQL Parser (`src/indexer/parsers/sqlParser.js`)
409
+ ```javascript
410
+ export function parseSQL(filePath, content, repoName) {
411
+ const symbols = [];
412
+ for (const m of content.matchAll(/CREATE\s+TABLE\s+(?:IF NOT EXISTS\s+)?([`"\w.]+)/gi))
413
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'table', exported: true, description: '' });
414
+ for (const m of content.matchAll(/CREATE\s+(?:OR REPLACE\s+)?(?:PROCEDURE|FUNCTION)\s+(\w+)/gi))
415
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
416
+ for (const m of content.matchAll(/CREATE\s+(?:OR REPLACE\s+)?VIEW\s+(\w+)/gi))
417
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'view', exported: true, description: '' });
418
+ return { language: 'sql', symbols, imports: [] };
419
+ }
420
+ ```
421
+ ### 3.6 Config Parser (`src/indexer/parsers/configParser.js`)
422
+ ```javascript
423
+ export function parseConfig(filePath, content, repoName) {
424
+ const symbols = [];
425
+ // YAML/Properties: extract top-level keys
426
+ for (const m of content.matchAll(/^([\w.-]+)\s*[:=]\s*(.+)/gm)) {
427
+ const key = m[1].trim();
428
+ if (key.startsWith('#')) continue;
429
+ symbols.push({
430
+ name: key,
431
+ fqn: `${filePath}::${key}`,
432
+ kind: 'config-key',
433
+ exported: false,
434
+ description: m[2].trim().substring(0, 80)
435
+ });
436
+ }
437
+ return { language: 'config', symbols, imports: [] };
438
+ }
439
+ ```
440
+ ### 3.7 Markdown Parser (`src/indexer/parsers/markdownParser.js`)
441
+ ```javascript
442
+ export function parseMarkdown(filePath, content, repoName) {
443
+ const symbols = [];
444
+ // Index each heading as a searchable symbol
445
+ for (const m of content.matchAll(/^(#{1,3})\s+(.+)/gm)) {
446
+ const level = m[1].length;
447
+ const title = m[2].trim();
448
+ symbols.push({
449
+ name: title,
450
+ fqn: `${filePath}::${title.replace(/\s+/g, '-').toLowerCase()}`,
451
+ kind: level === 1 ? 'doc-title' : 'doc-section',
452
+ exported: true,
453
+ description: ''
454
+ });
455
+ }
456
+ return { language: 'markdown', symbols, imports: [] };
457
+ }
458
+ ```
459
+ ---
460
+ ## Phase 4 — Polyglot Security Scanner (Day 3)
461
+ Security patterns are scoped per-language to minimize false positives:
462
+ ```javascript
463
+ // src/indexer/securityScanner.js
464
+ export const VULN_PATTERNS = [
465
+ // ── Java ──────────────────────────────────────────────────────────────
466
+ { id: "CWE-611", lang: ["java"], severity: "HIGH",
467
+ name: "XXE (XML External Entity)",
468
+ pattern: /builder\.parse\(/,
469
+ negPattern: /setFeature.*FEATURE_SECURE_PROCESSING/,
470
+ fix: "Add dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) before parse()" },
471
+ { id: "CWE-89", lang: ["java"], severity: "HIGH",
472
+ name: "SQL Injection",
473
+ pattern: /"SELECT|INSERT|UPDATE|DELETE.*"\s*\+/i,
474
+ fix: "Use PreparedStatement with parameterized queries" },
475
+ { id: "CWE-326", lang: ["java"], severity: "HIGH",
476
+ name: "Weak Cryptography",
477
+ pattern: /getInstance\("(MD5|SHA1|DES|RC4)"\)/,
478
+ fix: "Use SHA-256 or AES-256" },
479
+ { id: "CWE-502", lang: ["java"], severity: "HIGH",
480
+ name: "Unsafe Deserialization",
481
+ pattern: /new ObjectInputStream|\.readObject\(\)/,
482
+ fix: "Validate input before deserialization; use safe deserialization libraries" },
483
+ { id: "CWE-078", lang: ["java"], severity: "HIGH",
484
+ name: "Command Injection",
485
+ pattern: /Runtime\.getRuntime\(\)\.exec\(/,
486
+ fix: "Use ProcessBuilder with argument list instead of string concatenation" },
487
+ // ── JavaScript / TypeScript / Vue ─────────────────────────────────────
488
+ { id: "CWE-79", lang: ["javascript","typescript","vue"], severity: "HIGH",
489
+ name: "XSS via innerHTML / dangerouslySetInnerHTML",
490
+ pattern: /\.innerHTML\s*=|dangerouslySetInnerHTML/,
491
+ fix: "Use textContent or sanitize with DOMPurify" },
492
+ { id: "CWE-89", lang: ["javascript","typescript"], severity: "HIGH",
493
+ name: "SQL Injection (JS)",
494
+ pattern: /`\s*(SELECT|INSERT|UPDATE|DELETE).*\$\{/i,
495
+ fix: "Use parameterized queries or an ORM" },
496
+ { id: "CWE-798", lang: ["javascript","typescript","vue"], severity: "HIGH",
497
+ name: "Hardcoded Secret",
498
+ pattern: /(api_key|apikey|secret|password|token)\s*[:=]\s*['"][^'"]{8,}['"]/i,
499
+ fix: "Move secrets to environment variables or a secrets manager" },
500
+ { id: "CWE-327", lang: ["javascript","typescript"], severity: "MEDIUM",
501
+ name: "Weak Crypto (Node)",
502
+ pattern: /createHash\(['"]md5['"]\)|createHash\(['"]sha1['"]\)/,
503
+ fix: "Use SHA-256 or stronger" },
504
+ { id: "CWE-601", lang: ["javascript","typescript","vue"], severity: "MEDIUM",
505
+ name: "Open Redirect",
506
+ pattern: /res\.redirect\([^)]*req\.(query|params|body)/,
507
+ fix: "Validate redirect URL against an allowlist" },
508
+ // ── Python ────────────────────────────────────────────────────────────
509
+ { id: "CWE-089", lang: ["python"], severity: "HIGH",
510
+ name: "SQL Injection (Python)",
511
+ pattern: /execute\([f"'].*%(s|d)|execute\(.*format\(/,
512
+ fix: "Use parameterized queries: cursor.execute(sql, params)" },
513
+ { id: "CWE-078", lang: ["python"], severity: "HIGH",
514
+ name: "Command Injection (Python)",
515
+ pattern: /os\.system\(|subprocess\.call\(.*shell=True/,
516
+ fix: "Use subprocess with a list of args and shell=False" },
517
+ { id: "CWE-798", lang: ["python"], severity: "HIGH",
518
+ name: "Hardcoded Secret (Python)",
519
+ pattern: /(api_key|secret|password|token)\s*=\s*['"][^'"]{8,}['"]/i,
520
+ fix: "Use environment variables or a secrets manager like Vault" },
521
+ // ── Universal (all languages) ─────────────────────────────────────────
522
+ { id: "CWE-312", lang: null, severity: "MEDIUM",
523
+ name: "Cleartext Sensitive Data in Comments",
524
+ pattern: /\/\/.*?(password|secret|token)\s*[:=]\s*\S+/i,
525
+ fix: "Remove sensitive data from comments" },
526
+ ];
527
+ export function scanFile(filePath, content, language) {
528
+ const findings = [];
529
+ const lines = content.split('\n');
530
+ for (const rule of VULN_PATTERNS) {
531
+ if (rule.lang && !rule.lang.includes(language)) continue;
532
+ lines.forEach((line, i) => {
533
+ if (!rule.pattern.test(line)) return;
534
+ if (rule.negPattern) {
535
+ // Check surrounding 5-line context for the negating pattern
536
+ const ctx = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
537
+ if (rule.negPattern.test(ctx)) return;
538
+ }
539
+ findings.push({
540
+ severity: rule.severity,
541
+ cwe: rule.id,
542
+ rule_name: rule.name,
543
+ line: i + 1,
544
+ code_snippet: line.trim().substring(0, 120),
545
+ description: rule.fix
546
+ });
547
+ });
548
+ }
549
+ return findings;
550
+ }
551
+ ```
552
+ ---
553
+ ## Phase 5 — The 5 MCP Tools (Day 3)
554
+ ### Tool 1: `cairn_maintain` — Polyglot Indexer
555
+ ```javascript
556
+ // Input — NO arguments required
557
+ {}
558
+ // Optional: { languages: ['java','typescript'] } ← scope to specific languages only
559
+ // What it does:
560
+ // 1. Resolves .cairn/ from process.cwd() — no roots argument
561
+ // 2. Walks cwd recursively, collecting files by extension
562
+ // 3. Routes each file through parserFactory → language-specific parser
563
+ // 4. Writes symbols + dependencies to .cairn/index.db
564
+ // 5. Parses build files (pom.xml, package.json, build.gradle)
565
+ // 6. Rebuilds FTS5 index
566
+ // 7. Runs security scanner, caches findings in .cairn/index.db
567
+ // Output
568
+ {
569
+ "repos_indexed": 12,
570
+ "files_by_language": {
571
+ "java": 412, "typescript": 287, "vue": 94,
572
+ "python": 33, "sql": 18, "config": 67,
573
+ "markdown": 24, "xml": 41
574
+ },
575
+ "symbols_total": 6841,
576
+ "dependencies_mapped": 892,
577
+ "security_findings": 47,
578
+ "duration_ms": 28400
579
+ }
580
+ ```
581
+ ### Tool 2: `cairn_search` — Cross-Language Search
582
+ ```javascript
583
+ // Input
584
+ { query: "user authentication", limit: 10, language: "typescript" } // all args optional except query
585
+ // reads from .cairn/index.db in cwd — no roots needed
586
+ // Output — results ranked across ALL languages by default
587
+ [
588
+ { lang: "java", kind: "class", fqn: "com.example.auth.UserAuthenticator", score: 1.0 },
589
+ { lang: "typescript", kind: "function", fqn: "src/composables/useAuth.ts::useAuth", score: 0.95 },
590
+ { lang: "vue", kind: "component", fqn: "src/components/LoginForm.vue::LoginForm", score: 0.9 },
591
+ { lang: "python", kind: "class", fqn: "auth/authenticator.py::UserAuthenticator", score: 0.85 },
592
+ { lang: "config", kind: "config-key", fqn: "application.yml::spring.security.oauth2", score: 0.7 }
593
+ ]
594
+ ```
595
+ ### Tool 3: `cairn_describe` — Module/Directory Summary
596
+ ```javascript
597
+ // Input
598
+ { path: "src/components/checkout" }
599
+ // Output — works for any language mix in the directory
600
+ {
601
+ "path": "src/components/checkout",
602
+ "languages": ["vue", "typescript"],
603
+ "symbols": {
604
+ "components": ["CheckoutForm.vue", "OrderSummary.vue", "PaymentStep.vue"],
605
+ "composables": ["useCheckout", "usePayment"],
606
+ "types": ["CheckoutState", "PaymentMethod"]
607
+ },
608
+ "imports_from": ["src/store/cart.ts", "src/api/orders.ts", "stripe"],
609
+ "imported_by": ["src/views/CartView.vue", "src/router/index.ts"],
610
+ "external_deps": ["stripe", "@stripe/stripe-js"]
611
+ }
612
+ ```
613
+ ### Tool 4: `cairn_code_graph` — Cross-Language Dependency X-Ray
614
+ ```javascript
615
+ // Input
616
+ { mode: "instability" }
617
+ // Instability = Ce / (Ca + Ce)
618
+ // Ce = efferent (outgoing) deps | Ca = afferent (incoming) deps
619
+ // 0.0 = stable/load-bearing | 1.0 = unstable/safe to change
620
+ // Output — instability calculated across all module types
621
+ {
622
+ "modules": [
623
+ { "name": "src/views", "language": "vue", "instability": 1.0, "status": "safe_to_refactor" },
624
+ { "name": "com.example.api", "language": "java", "instability": 0.8, "status": "safe_to_refactor" },
625
+ { "name": "src/composables", "language": "typescript", "instability": 0.4, "status": "review_before_change" },
626
+ { "name": "src/utils", "language": "typescript", "instability": 0.1, "status": "load_bearing" },
627
+ { "name": "com.example.core","language": "java", "instability": 0.0, "status": "load_bearing" }
628
+ ],
629
+ "cross_language_deps": [
630
+ { "from": "src/api/client.ts", "to": "com.example.api.OrderController", "type": "http_call" }
631
+ ],
632
+ "cycles_detected": [],
633
+ "god_objects": ["OrderService.java (51 deps)", "useStore.ts (38 imports)"]
634
+ }
635
+ ```
636
+ ### Tool 5: `cairn_security` — Polyglot Vulnerability Scanner
637
+ ```javascript
638
+ // Input
639
+ { severity: "HIGH" }
640
+ // Output — findings across all languages
641
+ {
642
+ "total_findings": 71,
643
+ "by_language": { "java": 23, "typescript": 31, "vue": 12, "python": 5 },
644
+ "by_severity": { "HIGH": 29, "MEDIUM": 42 },
645
+ "findings": [
646
+ {
647
+ "severity": "HIGH", "lang": "typescript", "cwe": "CWE-79",
648
+ "file": "src/components/UserProfile.vue", "line": 84,
649
+ "rule": "XSS via innerHTML",
650
+ "snippet": "el.innerHTML = userData.bio",
651
+ "fix": "Use textContent or DOMPurify.sanitize()"
652
+ },
653
+ {
654
+ "severity": "HIGH", "lang": "java", "cwe": "CWE-611",
655
+ "file": "backend/src/.../XmlParser.java", "line": 47,
656
+ "rule": "XXE (XML External Entity)",
657
+ "snippet": "Document dDoc = builder.parse(input);",
658
+ "fix": "Add dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)"
659
+ }
660
+ ]
661
+ }
662
+ ```
663
+ ---
664
+ ## Phase 5b — cairn_bundle: The Minified Content Bridge
665
+ This is the **content read path** — complementing the navigation path. When Claude needs to
666
+ actually read and reason about source code (not just find it), `cairn_bundle` produces a
667
+ compressed single-file snapshot sized to fit Claude's context window.
668
+ ### How it works
669
+ ```
670
+ cairn_bundle called (no roots argument)
671
+
672
+ ├── 1. Resolves .cairn/ from process.cwd()
673
+ ├── 2. Walks cwd recursively (reuses walkProject())
674
+ ├── 3. Filter by paths/language/extensions requested
675
+ ├── 4. Check mtime → skip unchanged files (incremental mode)
676
+ ├── 5. Per-file: route to language minifier
677
+ │ • Strip comments (all languages)
678
+ │ • Strip empty lines (all languages)
679
+ │ • Collapse whitespace (all languages)
680
+ │ • Strip <style> blocks (Vue, optional)
681
+ │ • Aggressive punct. (optional)
682
+ ├── 6. Write ### File: <path> header + minified content
683
+ └── 7. Save to .cairn/bundles/<bundle-name>.txt
684
+ Return: path + stats (original KB → compressed KB, ratio)
685
+ ```
686
+ ### Minifier Implementation (`src/bundler/minifier.js`)
687
+ ```javascript
688
+ // Per-language minification — ported and extended from minify.py
689
+ export function minifyContent(content, language, options = {}) {
690
+ const { noComments = true, noEmptyLines = true, aggressive = false } = options;
691
+ let out = content;
692
+ if (noComments) out = removeComments(out, language);
693
+ if (noEmptyLines) out = removeEmptyLines(out);
694
+ out = collapseWhitespace(out);
695
+ if (aggressive) out = aggressiveMinify(out);
696
+ return out.trim();
697
+ }
698
+ function removeComments(content, language) {
699
+ switch (language) {
700
+ case 'java':
701
+ case 'typescript':
702
+ case 'javascript':
703
+ case 'vue': // vue script/template block
704
+ // Single-line and block comments
705
+ return content
706
+ .replace(/\/\/.*$/gm, '')
707
+ .replace(/\/\*[\s\S]*?\*\//g, '');
708
+ case 'python':
709
+ return content
710
+ .replace(/#.*$/gm, '')
711
+ .replace(/'''[\s\S]*?'''/g, '')
712
+ .replace(/"""[\s\S]*?"""/g, '');
713
+ case 'sql':
714
+ return content
715
+ .replace(/--.*$/gm, '')
716
+ .replace(/\/\*[\s\S]*?\*\//g, '');
717
+ case 'xml':
718
+ case 'html':
719
+ return content.replace(/<!--[\s\S]*?-->/g, '');
720
+ case 'config': // yaml/properties: strip # lines
721
+ return content.replace(/#.*$/gm, '');
722
+ default:
723
+ return content;
724
+ }
725
+ }
726
+ function removeEmptyLines(content) {
727
+ return content
728
+ .split('
729
+ ')
730
+ .filter(line => line.trim().length > 0)
731
+ .join('
732
+ ');
733
+ }
734
+ function collapseWhitespace(content) {
735
+ return content
736
+ .split('
737
+ ')
738
+ .map(line => line.trimEnd()) // strip trailing spaces per line
739
+ .join('
740
+ ')
741
+ .replace(/
742
+ {3,}/g, '
743
+ '); // max 2 consecutive blank lines → but removeEmptyLines handles this
744
+ }
745
+ function aggressiveMinify(content) {
746
+ return content
747
+ .replace(/\s+/g, ' ')
748
+ .replace(/\s*([=+\-*/%<>!&|^~?:;,{}()[\]])\s*/g, '$1')
749
+ .replace(/;\s*}/g, '}')
750
+ .replace(/\(\s+/g, '(')
751
+ .replace(/\s+\)/g, ')')
752
+ .trim();
753
+ }
754
+ ```
755
+ ### Vue Minifier (`src/bundler/vueMinifier.js`)
756
+ Vue needs special handling — 3 separate blocks each minified differently:
757
+ ```javascript
758
+ import { minifyContent } from './minifier.js';
759
+ export function minifyVue(content, options = {}) {
760
+ const { noStyle = false, noComments = true, noEmptyLines = true, aggressive = false } = options;
761
+ const result = [];
762
+ // Extract and minify <template>
763
+ const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
764
+ if (templateMatch) {
765
+ let tpl = templateMatch[1];
766
+ if (noComments) tpl = tpl.replace(/<!--[\s\S]*?-->/g, '');
767
+ if (noEmptyLines) tpl = tpl.split('
768
+ ').filter(l => l.trim()).join('
769
+ ');
770
+ tpl = tpl.replace(/\s+/g, ' ').trim();
771
+ if (tpl) result.push(`<template>${tpl}</template>`);
772
+ }
773
+ // Extract and minify <script> / <script setup>
774
+ const scriptMatch = content.match(/<script([^>]*)>([\s\S]*?)<\/script>/i);
775
+ if (scriptMatch) {
776
+ const attrs = scriptMatch[1];
777
+ let code = minifyContent(scriptMatch[2], 'javascript',
778
+ { noComments, noEmptyLines, aggressive });
779
+ const tag = /setup/i.test(attrs) ? '<script setup>' : '<script>';
780
+ if (code) result.push(`${tag}${code}</script>`);
781
+ }
782
+ // Extract and minify <style> (unless skipped)
783
+ if (!noStyle) {
784
+ const styleMatch = content.match(/<style([^>]*)>([\s\S]*?)<\/style>/i);
785
+ if (styleMatch) {
786
+ let css = styleMatch[2]
787
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip css comments
788
+ .replace(/\s+/g, ' ').trim();
789
+ if (css) result.push(`<style${styleMatch[1]}>${css}</style>`);
790
+ }
791
+ }
792
+ return result.join('
793
+ ');
794
+ }
795
+ ```
796
+ ### Bundle Writer (`src/bundler/bundleWriter.js`)
797
+ ```javascript
798
+ import fs from 'fs';
799
+ import path from 'path';
800
+ import { minifyContent } from './minifier.js';
801
+ import { minifyVue } from './vueMinifier.js';
802
+ import { walkRepo, LANGUAGE_MAP } from '../indexer/fileWalker.js';
803
+ import { bundleDir } from '../graph/cwd.js';
804
+ export async function writeBundle(options = {}) {
805
+ const BUNDLE_DIR = bundleDir();
806
+ const {
807
+ bundleName = 'default',
808
+ noComments = true,
809
+ noEmptyLines = true,
810
+ noStyle = false,
811
+ aggressive = false,
812
+ onlyChanged = false, // incremental: skip files unchanged since last bundle
813
+ filterLang = null, // e.g. 'vue' or ['vue','typescript']
814
+ filterPaths = null, // e.g. ['src/components/checkout']
815
+ maxSizeKB = 800, // safety cap — stop before hitting context window limit
816
+ } = options;
817
+ const outPath = path.join(BUNDLE_DIR, `${bundleName}.txt`);
818
+ const mtimePath = path.join(BUNDLE_DIR, `${bundleName}.mtime.json`);
819
+ const mtimeCache = onlyChanged && fs.existsSync(mtimePath)
820
+ ? JSON.parse(fs.readFileSync(mtimePath, 'utf8')) : {};
821
+ const newMtimes = {};
822
+ const lines = [];
823
+ const stats = { processed: 0, skipped_unchanged: 0, skipped_size: 0,
824
+ original_bytes: 0, compressed_bytes: 0, by_language: {} };
825
+ const langFilter = filterLang
826
+ ? (Array.isArray(filterLang) ? filterLang : [filterLang]) : null;
827
+ const { root, files } = await walkProject();
828
+ {
829
+ for (const filePath of files) {
830
+ // Language filter
831
+ const ext = path.extname(filePath).toLowerCase();
832
+ const language = LANGUAGE_MAP[ext];
833
+ if (!language) continue;
834
+ if (langFilter && !langFilter.includes(language)) continue;
835
+ // Path filter
836
+ if (filterPaths) {
837
+ const rel = path.relative(root, filePath);
838
+ if (!filterPaths.some(fp => rel.startsWith(fp))) continue;
839
+ }
840
+ // Incremental: skip unchanged files
841
+ const mtime = fs.statSync(filePath).mtimeMs;
842
+ newMtimes[filePath] = mtime;
843
+ if (onlyChanged && mtimeCache[filePath] === mtime) {
844
+ stats.skipped_unchanged++;
845
+ continue;
846
+ }
847
+ // Size cap
848
+ const currentKB = Buffer.byteLength(lines.join('
849
+ '), 'utf8') / 1024;
850
+ if (currentKB > maxSizeKB) {
851
+ stats.skipped_size++;
852
+ continue;
853
+ }
854
+ const raw = fs.readFileSync(filePath, 'utf8');
855
+ stats.original_bytes += Buffer.byteLength(raw, 'utf8');
856
+ // Minify
857
+ let minified;
858
+ if (language === 'vue') {
859
+ minified = minifyVue(raw, { noStyle, noComments, noEmptyLines, aggressive });
860
+ } else {
861
+ minified = minifyContent(raw, language, { noComments, noEmptyLines, aggressive });
862
+ }
863
+ if (!minified.trim()) continue;
864
+ const rel = path.relative(process.cwd(), filePath);
865
+ lines.push(`### File: ${rel}`);
866
+ lines.push(minified.trim());
867
+ lines.push(''); // blank separator between files
868
+ stats.compressed_bytes += Buffer.byteLength(minified, 'utf8');
869
+ stats.processed++;
870
+ stats.by_language[language] = (stats.by_language[language] || 0) + 1;
871
+ }
872
+ }
873
+ const output = lines.join('
874
+ ');
875
+ fs.writeFileSync(outPath, output, 'utf8');
876
+ if (onlyChanged) fs.writeFileSync(mtimePath, JSON.stringify(newMtimes), 'utf8');
877
+ const ratio = stats.original_bytes > 0
878
+ ? Math.round((1 - stats.compressed_bytes / stats.original_bytes) * 100) : 0;
879
+ return {
880
+ bundle_path: outPath,
881
+ processed: stats.processed,
882
+ skipped_unchanged: stats.skipped_unchanged,
883
+ skipped_size_cap: stats.skipped_size,
884
+ original_kb: Math.round(stats.original_bytes / 1024),
885
+ compressed_kb: Math.round(stats.compressed_bytes / 1024),
886
+ reduction_pct: ratio,
887
+ by_language: stats.by_language,
888
+ };
889
+ }
890
+ ```
891
+ ### Tool 6: `cairn_bundle` — MCP Tool Definition
892
+ ```javascript
893
+ // In index.js tools list:
894
+ {
895
+ name: "cairn_bundle",
896
+ description: `Produce a minified single-file snapshot of source code for Claude to read.
897
+ Strips comments, empty lines, and optionally styles. Returns the bundle file path and compression stats.
898
+ Use AFTER cairn_search to get a readable version of the files Claude needs to work with.
899
+ Typical workflow: cairn_search → find files → cairn_bundle those paths → Claude reads bundle.`,
900
+ inputSchema: {
901
+ type: "object",
902
+ properties: {
903
+ bundle_name: {
904
+ type: "string", default: "default",
905
+ description: "Name for the bundle file (saved to .cairn/bundles/<name>.txt)"
906
+ },
907
+ filter_paths: {
908
+ type: "array", items: { type: "string" },
909
+ description: "Optional: only include files under these relative paths"
910
+ },
911
+ filter_language: {
912
+ type: "string",
913
+ description: "Optional: only bundle this language (java/typescript/vue/python/...)"
914
+ },
915
+ no_comments: { type: "boolean", default: true, description: "Strip code comments" },
916
+ no_empty_lines:{ type: "boolean", default: true, description: "Remove empty lines" },
917
+ no_style: { type: "boolean", default: false, description: "Skip <style> blocks in Vue files" },
918
+ aggressive: { type: "boolean", default: false, description: "Aggressive whitespace/punctuation minification" },
919
+ only_changed: { type: "boolean", default: false, description: "Incremental: only re-bundle files changed since last run" },
920
+ max_size_kb: { type: "number", default: 800, description: "Safety cap in KB to avoid context window overflow" }
921
+ },
922
+ required: []
923
+ }
924
+ }
925
+ // No roots needed — always reads from .cairn/ in cwd
926
+ // Tool output example:
927
+ {
928
+ "bundle_path": "/Users/you/.cairn/bundles/checkout.txt",
929
+ "processed": 47,
930
+ "skipped_unchanged": 31,
931
+ "skipped_size_cap": 0,
932
+ "original_kb": 284,
933
+ "compressed_kb": 91,
934
+ "reduction_pct": 68,
935
+ "by_language": { "vue": 23, "typescript": 18, "java": 6 }
936
+ }
937
+ ```
938
+ ---
939
+ ### Typical Claude Code Workflow Using Both Paths
940
+ ```
941
+ You: "Refactor the checkout flow to support multi-currency"
942
+ Claude:
943
+ 1. cairn_search "checkout payment currency"
944
+ → finds: src/components/checkout/, com.example.billing.CurrencyService
945
+ 2. cairn_bundle filter_paths=["src/components/checkout"]
946
+ filter_language=vue no_comments=true no_style=true
947
+ → .cairn/bundles/checkout.txt (284 KB → 91 KB, 68% reduction)
948
+ 3. Reads bundle file — understands full checkout component tree in one shot
949
+ 4. cairn_code_graph --mode=instability --module=src/components/checkout
950
+ → knows blast radius before touching anything
951
+ 5. Makes targeted, accurate changes — no hallucinations about file locations
952
+ ```
953
+ ---
954
+ ---
955
+ ## Phase 5c — Session State: `cairn_checkpoint` + `cairn_resume`
956
+ This is the **"resume" system** — the missing piece that makes Claude feel continuous
957
+ across sessions rather than starting from zero every time.
958
+ ### The Problem It Solves
959
+ The index tracks *what exists* in the codebase. But it knows nothing about:
960
+ - What task Claude was working on last session
961
+ - Which files were actively being edited
962
+ - What decisions were made and why
963
+ - What's done vs. still in progress
964
+ Without this, every session starts with Claude asking "so what are we working on?"
965
+ even if you worked together for 3 hours yesterday.
966
+ ### How It Works
967
+ ```
968
+ End of session:
969
+ cairn_checkpoint message="Added multi-currency to CheckoutForm, still need PaymentStep"
970
+
971
+ └── writes to .cairn/session.json:
972
+ {
973
+ "last_checkpoint": "2026-02-24T14:30:00Z",
974
+ "message": "Added multi-currency to CheckoutForm, still need PaymentStep",
975
+ "active_files": ["src/components/checkout/CheckoutForm.vue",
976
+ "src/components/checkout/PaymentStep.vue"],
977
+ "symbols_touched": ["useCheckout", "CurrencySelector", "PaymentStep"],
978
+ "index_snapshot": "sha256-abc123" ← hash of index.db at checkpoint
979
+ }
980
+ Start of next session:
981
+ cairn_resume
982
+
983
+ ├── 1. Load .cairn/session.json → last checkpoint message + context
984
+ ├── 2. Compare current file mtimes vs .cairn/mtime.json
985
+ │ → find files changed since last checkpoint
986
+ ├── 3. Re-index ONLY changed files (incremental cairn_maintain)
987
+ ├── 4. Report to Claude:
988
+ │ "Last session: Added multi-currency to CheckoutForm, still need PaymentStep
989
+ │ Changed since then: PaymentStep.vue (modified), currencyUtils.ts (new)
990
+ │ Unchanged: 847 files — index current, no re-index needed"
991
+ └── 5. Claude can immediately continue — full context restored
992
+ ```
993
+ ### Session State Schema (`.cairn/session.json`)
994
+ ```javascript
995
+ // .cairn/session.json — written by cairn_checkpoint, read by cairn_resume
996
+ {
997
+ "schema_version": 1,
998
+ // What was being worked on
999
+ "message": "Added multi-currency to CheckoutForm, still need PaymentStep",
1000
+ "timestamp": "2026-02-24T14:30:00Z",
1001
+ // Which files were actively open / being modified
1002
+ "active_files": [
1003
+ "src/components/checkout/CheckoutForm.vue",
1004
+ "src/components/checkout/PaymentStep.vue"
1005
+ ],
1006
+ // Which symbols were touched this session
1007
+ "symbols_touched": [
1008
+ "useCheckout",
1009
+ "CurrencySelector",
1010
+ "PaymentMethod",
1011
+ "PaymentStep"
1012
+ ],
1013
+ // Freeform notes Claude wants to remember across sessions
1014
+ "notes": [
1015
+ "CurrencyService in Java backend expects ISO 4217 codes, not symbols",
1016
+ "PaymentStep has a bug with EUR formatting — not fixed yet",
1017
+ "Do NOT touch OrderSummary.vue — it will be rewritten next sprint"
1018
+ ],
1019
+ // Index state at checkpoint — used to detect drift
1020
+ "index_file_count": 847,
1021
+ "mtime_snapshot": {
1022
+ "src/components/checkout/CheckoutForm.vue": 1708785600000,
1023
+ "src/components/checkout/PaymentStep.vue": 1708785600000
1024
+ }
1025
+ }
1026
+ ```
1027
+ ### Tool 7: `cairn_checkpoint` — Save Session State
1028
+ ```javascript
1029
+ // Input
1030
+ {
1031
+ message: "Added multi-currency to CheckoutForm, still need PaymentStep",
1032
+ active_files: ["src/components/checkout/CheckoutForm.vue"], // optional
1033
+ notes: ["CurrencyService expects ISO 4217 codes"] // optional
1034
+ }
1035
+ // What it does:
1036
+ // 1. Writes .cairn/session.json with message + context
1037
+ // 2. Snapshots mtime of active_files for drift detection
1038
+ // 3. Records which symbols were recently searched/described this session
1039
+ // Output
1040
+ {
1041
+ "saved": true,
1042
+ "checkpoint_at": "2026-02-24T14:30:00Z",
1043
+ "active_files_tracked": 2,
1044
+ "notes_saved": 1
1045
+ }
1046
+ ```
1047
+ ### Tool 8: `cairn_resume` — Restore Session State + Smart Re-index
1048
+ ```javascript
1049
+ // Input — NO arguments
1050
+ {}
1051
+ // What it does:
1052
+ // 1. Loads .cairn/session.json
1053
+ // 2. Compares current file mtimes vs snapshot in session.json
1054
+ // → finds exactly which files changed since last checkpoint
1055
+ // 3. Re-indexes ONLY changed files (calls incremental cairn_maintain internally)
1056
+ // 4. Returns full resumption context for Claude to read
1057
+ // Output
1058
+ {
1059
+ "last_checkpoint": "2026-02-24T14:30:00Z", // when you last saved
1060
+ "message": "Added multi-currency to CheckoutForm, still need PaymentStep",
1061
+ "index_status": "incremental", // or "fresh" / "stale"
1062
+ "files_reindexed": 2, // only what changed
1063
+ "files_unchanged": 845, // not touched
1064
+ "changed_since_checkpoint": [
1065
+ { "file": "src/components/checkout/PaymentStep.vue", "change": "modified" },
1066
+ { "file": "src/utils/currencyUtils.ts", "change": "new" }
1067
+ ],
1068
+ "active_files": [
1069
+ "src/components/checkout/CheckoutForm.vue",
1070
+ "src/components/checkout/PaymentStep.vue"
1071
+ ],
1072
+ "notes": [
1073
+ "CurrencyService in Java backend expects ISO 4217 codes, not symbols",
1074
+ "PaymentStep has a bug with EUR formatting — not fixed yet",
1075
+ "Do NOT touch OrderSummary.vue — it will be rewritten next sprint"
1076
+ ],
1077
+ // Ready-to-use summary Claude can speak directly to the user:
1078
+ "resume_summary": "Last session you were adding multi-currency support. CheckoutForm is done. PaymentStep still needs work — and it was modified since your last checkpoint (likely by someone else or a build tool). currencyUtils.ts is a new file that didn't exist last session."
1079
+ }
1080
+ ```
1081
+ ### Implementation (`src/tools/resume.js`)
1082
+ ```javascript
1083
+ import fs from 'fs';
1084
+ import path from 'path';
1085
+ import { resolveCairnDir } from '../graph/cwd.js';
1086
+ import { incrementalMaintain } from './maintain.js';
1087
+ export async function checkpoint(args) {
1088
+ const cairnDir = resolveCairnDir();
1089
+ const sessionPath = path.join(cairnDir, 'session.json');
1090
+ const mtimePath = path.join(cairnDir, 'mtime.json');
1091
+ // Snapshot current mtimes of active files
1092
+ const mtimeSnapshot = {};
1093
+ for (const f of (args.active_files || [])) {
1094
+ const abs = path.resolve(f);
1095
+ if (fs.existsSync(abs))
1096
+ mtimeSnapshot[f] = fs.statSync(abs).mtimeMs;
1097
+ }
1098
+ const session = {
1099
+ schema_version: 1,
1100
+ message: args.message || '',
1101
+ timestamp: new Date().toISOString(),
1102
+ active_files: args.active_files || [],
1103
+ symbols_touched: args.symbols_touched || [],
1104
+ notes: args.notes || [],
1105
+ mtime_snapshot: mtimeSnapshot,
1106
+ };
1107
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
1108
+ return {
1109
+ saved: true,
1110
+ checkpoint_at: session.timestamp,
1111
+ active_files_tracked: session.active_files.length,
1112
+ notes_saved: session.notes.length,
1113
+ };
1114
+ }
1115
+ export async function resume() {
1116
+ const cairnDir = resolveCairnDir();
1117
+ const sessionPath = path.join(cairnDir, 'session.json');
1118
+ // No checkpoint yet — do a fresh full index and return minimal context
1119
+ if (!fs.existsSync(sessionPath)) {
1120
+ await incrementalMaintain();
1121
+ return {
1122
+ last_checkpoint: null,
1123
+ message: 'No previous session found. Fresh index complete.',
1124
+ index_status: 'fresh',
1125
+ files_reindexed: 'all',
1126
+ files_unchanged: 0,
1127
+ changed_since_checkpoint: [],
1128
+ active_files: [],
1129
+ notes: [],
1130
+ resume_summary: 'No previous session found. Codebase has been freshly indexed. Ready to start.',
1131
+ };
1132
+ }
1133
+ const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
1134
+ const snapshot = session.mtime_snapshot || {};
1135
+ // Detect changed files by comparing current mtimes to snapshot
1136
+ const changed = [];
1137
+ for (const [relPath, oldMtime] of Object.entries(snapshot)) {
1138
+ const abs = path.resolve(relPath);
1139
+ if (!fs.existsSync(abs)) {
1140
+ changed.push({ file: relPath, change: 'deleted' });
1141
+ } else if (fs.statSync(abs).mtimeMs !== oldMtime) {
1142
+ changed.push({ file: relPath, change: 'modified' });
1143
+ }
1144
+ }
1145
+ // Incremental re-index — only changed files
1146
+ const { reindexed, unchanged } = await incrementalMaintain();
1147
+ // Build human-readable summary
1148
+ const changedNames = changed.map(c => `${c.file} (${c.change})`).join(', ');
1149
+ const summary = [
1150
+ `Last session: ${session.message}`,
1151
+ changed.length > 0
1152
+ ? `Changed since checkpoint: ${changedNames}`
1153
+ : 'No files changed since last checkpoint — index is current.',
1154
+ session.notes.length > 0
1155
+ ? `Notes from last session: ${session.notes.join(' | ')}`
1156
+ : ''
1157
+ ].filter(Boolean).join(' ');
1158
+ return {
1159
+ last_checkpoint: session.timestamp,
1160
+ message: session.message,
1161
+ index_status: reindexed > 0 ? 'incremental' : 'current',
1162
+ files_reindexed: reindexed,
1163
+ files_unchanged: unchanged,
1164
+ changed_since_checkpoint: changed,
1165
+ active_files: session.active_files,
1166
+ notes: session.notes,
1167
+ resume_summary: summary,
1168
+ };
1169
+ }
1170
+ ```
1171
+ ---
1172
+ ## Phase 6 — MCP Server Entry Point (`index.js`)
1173
+ ```javascript
1174
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1175
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1176
+ const server = new Server(
1177
+ { name: "cairn", version: "1.0.0" },
1178
+ { capabilities: { tools: {} } }
1179
+ );
1180
+ server.setRequestHandler("tools/list", async () => ({
1181
+ tools: [
1182
+ {
1183
+ name: "cairn_maintain",
1184
+ description: "Index the current project into Cairn's polyglot knowledge graph. Works like git — no roots needed, reads from .cairn/ in cwd. Supports Java, TypeScript, JavaScript, Vue, Python, SQL, config, and Markdown.",
1185
+ inputSchema: {
1186
+ type: "object",
1187
+ properties: {
1188
+ languages: { type: "array", items: { type: "string" },
1189
+ description: "Optional: limit to specific languages e.g. ['java','typescript']" }
1190
+ },
1191
+ required: []
1192
+ }
1193
+ },
1194
+ {
1195
+ name: "cairn_search",
1196
+ description: "Search the codebase by concept across all languages. Returns ranked symbols with scores.",
1197
+ inputSchema: {
1198
+ type: "object",
1199
+ properties: {
1200
+ query: { type: "string" },
1201
+ limit: { type: "number", default: 10 },
1202
+ language: { type: "string", description: "Optional: filter by language" },
1203
+ kind: { type: "string", description: "Optional: class/function/component/table/..." }
1204
+ },
1205
+ required: ["query"]
1206
+ }
1207
+ },
1208
+ {
1209
+ name: "cairn_describe",
1210
+ description: "Describe what a directory/module does: symbols, responsibilities, upstream/downstream deps.",
1211
+ inputSchema: {
1212
+ type: "object",
1213
+ properties: { path: { type: "string" } },
1214
+ required: ["path"]
1215
+ }
1216
+ },
1217
+ {
1218
+ name: "cairn_code_graph",
1219
+ description: "Analyze module dependencies and instability metrics across all languages. Use before refactoring.",
1220
+ inputSchema: {
1221
+ type: "object",
1222
+ properties: {
1223
+ module: { type: "string", description: "Optional: scope to a specific module/package" },
1224
+ mode: { type: "string", enum: ["instability", "health", "cycles"] }
1225
+ },
1226
+ required: ["mode"]
1227
+ }
1228
+ },
1229
+ {
1230
+ name: "cairn_security",
1231
+ description: "Scan for security vulnerabilities across all languages (XSS, XXE, SQLi, weak crypto, hardcoded secrets, etc.)",
1232
+ inputSchema: {
1233
+ type: "object",
1234
+ properties: {
1235
+ paths: { type: "array", items: { type: "string" }, description: "Optional: scope to specific paths" },
1236
+ severity: { type: "string", enum: ["HIGH", "MEDIUM", "LOW", "ALL"], default: "HIGH" },
1237
+ language: { type: "string", description: "Optional: scope to a specific language" }
1238
+ }
1239
+ }
1240
+ },
1241
+ {
1242
+ name: "cairn_checkpoint",
1243
+ description: "Save current session state — what you were working on, which files, any notes. Call this before ending a session so cairn_resume can restore full context next time.",
1244
+ inputSchema: {
1245
+ type: "object",
1246
+ properties: {
1247
+ message: { type: "string", description: "What you were working on (required)" },
1248
+ active_files: { type: "array", items: { type: "string" }, description: "Files actively being worked on" },
1249
+ notes: { type: "array", items: { type: "string" }, description: "Anything Claude should remember next session" }
1250
+ },
1251
+ required: ["message"]
1252
+ }
1253
+ },
1254
+ {
1255
+ name: "cairn_resume",
1256
+ description: "Restore full session state at the start of a new session. Loads last checkpoint, detects changed files, re-indexes only what changed, and returns a ready-to-use summary. Always call this first — replaces cairn_maintain.",
1257
+ inputSchema: {
1258
+ type: "object",
1259
+ properties: {},
1260
+ required: []
1261
+ }
1262
+ }
1263
+ ]
1264
+ }));
1265
+ server.setRequestHandler("tools/call", async (request) => {
1266
+ const { name, arguments: args } = request.params;
1267
+ switch (name) {
1268
+ case "cairn_maintain": return await maintain(args);
1269
+ case "cairn_search": return await search(args);
1270
+ case "cairn_describe": return await describe(args);
1271
+ case "cairn_code_graph": return await codeGraph(args);
1272
+ case "cairn_security": return await security(args);
1273
+ case "cairn_checkpoint": return await checkpoint(args);
1274
+ case "cairn_resume": return await resume();
1275
+ }
1276
+ });
1277
+ const transport = new StdioServerTransport();
1278
+ await server.connect(transport);
1279
+ ```
1280
+ ---
1281
+ ## Phase 7 — Claude Code Integration
1282
+ ### Register in `~/.claude/claude.json`
1283
+ ```json
1284
+ {
1285
+ "mcpServers": {
1286
+ "cairn": {
1287
+ "command": "node",
1288
+ "args": ["/absolute/path/to/cairn-mcp/index.js"]
1289
+ }
1290
+ }
1291
+ }
1292
+ // No env vars needed — Cairn resolves .cairn/ from whatever directory
1293
+ // Claude Code is opened in. Just like git.
1294
+ ```
1295
+ ### CLAUDE.md for your project
1296
+ This is the only setup required in each project. No paths, no config, no roots.
1297
+ ```markdown
1298
+ ## Cairn
1299
+ ### START OF EVERY SESSION — run this first, always:
1300
+ `cairn_resume`
1301
+ This single command does everything needed to restore full working state:
1302
+ - Detects changed files since last index → re-indexes only those (incremental)
1303
+ - Loads the last session's work log (what you were doing, what files were open)
1304
+ - Reports what changed so you can continue exactly where you left off
1305
+ You do NOT need to decide whether to re-index. cairn_resume handles it.
1306
+ ### Before modifying code:
1307
+ - `cairn_search` to find relevant symbols
1308
+ - `cairn_describe` to understand a module's responsibilities
1309
+ - `cairn_code_graph mode=instability` to check blast radius
1310
+ ### Before reading files:
1311
+ - `cairn_bundle` to compress them — saves context window
1312
+ ### Before security reviews:
1313
+ - `cairn_security severity=HIGH`
1314
+ ### To save session state before ending:
1315
+ - `cairn_checkpoint message="what you were working on"`
1316
+ ```
1317
+ ---
1318
+ ## Build Timeline
1319
+ | Day | Work |
1320
+ |-----|------|
1321
+ | **Day 1** | Setup, SQLite schema, file walker, parser factory skeleton |
1322
+ | **Day 2** | All language parsers (Java, TS, Vue, Python, SQL, Config, Markdown), `cairn_maintain` |
1323
+ | **Day 3** | Polyglot security scanner, `cairn_search`, `cairn_describe`, MCP server wiring |
1324
+ | **Day 4** | `cairn_code_graph`, `cairn_security`, `cairn_bundle` (minifier + bundle writer) |
1325
+ | **Day 5** | `cairn_checkpoint`, `cairn_resume` (session state + incremental re-index) |
1326
+ | **Day 6** | CLAUDE.md template, end-to-end resume testing, tuning |
1327
+ ---
1328
+ ## Key Design Decisions
1329
+ | Decision | Choice | Why |
1330
+ |----------|--------|-----|
1331
+ | Storage | SQLite + FTS5 | Zero infra, persistent, built-in full-text search |
1332
+ | Parsing | Regex (not full AST) | 10x faster, sufficient for navigation-level extraction |
1333
+ | Vue handling | Two-pass (extract `<script>` → parseTS) | Clean separation, no special-case logic |
1334
+ | Security patterns | Per-language scoped | Eliminates false positives from cross-language pattern bleeding |
1335
+ | MCP transport | stdio | Works natively with Claude Code, zero config |
1336
+ | Index location | `.cairn/index.db` (local to project) | Like `.git/` — one index per project, no global state |
1337
+ | Bundle location | `.cairn/bundles/<n>.txt` (local to project) | Named bundles per feature/module, isolated per project |
1338
+ | Bundle incremental | mtime cache (`.mtime.json`) | Re-bundle only changed files — fast after first run |
1339
+ | Bundle size cap | `max_size_kb` (default 800 KB) | Prevents accidental context window overflow |
1340
+ | Aggressive minify | Optional, off by default | Safe for orientation; risky if Claude needs to write precise diffs |
1341
+ | Language filter | Optional on all tools | Search/bundle everything by default, narrow when needed |
1342
+ | Session state | `.cairn/session.json` | Persists work context across sessions — what, why, notes |
1343
+ | Resume trigger | Change-based (mtime diff) | Re-index only if files changed, not on arbitrary time interval |
1344
+ | Checkpoint | Explicit (`cairn_checkpoint`) | Claude saves state when you're done — you control what's remembered |
1345
+ ---
1346
+ ## What This Eliminates
1347
+ - ❌ "Where is auth handled in the Vue app?" → ✅ `cairn_search "authentication" --language=vue`
1348
+ - ❌ "What Java services does the frontend call?" → ✅ `cairn_code_graph --mode=health`
1349
+ - ❌ Explaining folder structure every session → ✅ CLAUDE.md + persistent index
1350
+ - ❌ Manual XSS hunting across 90 Vue components → ✅ `cairn_security --language=vue`
1351
+ - ❌ Guessing what's safe to refactor → ✅ `cairn_code_graph --mode=instability`
1352
+ - ❌ "Where is the SQL schema defined?" → ✅ `cairn_search "orders table" --language=sql`
1353
+ - ❌ Hitting context window reading raw files → ✅ `cairn_bundle` (284 KB → 91 KB, 68% smaller)
1354
+ - ❌ Re-bundling entire codebase after small edits → ✅ `cairn_bundle only_changed=true` (mtime cache)
1355
+ - ❌ "I need to read the checkout module" + context overflow → ✅ `cairn_bundle filter_paths=["src/components/checkout"]`
1356
+ - ❌ "Where is the .cairn DB stored?" → ✅ Always `.cairn/` in your project root — like `.git/`
1357
+ **The archaeology tax drops from 50% to ~2% — across every language in your stack.**
1358
+ **And the context window goes 3x further — on the files that actually matter.**
1359
+ **And sessions resume in seconds — not with "so what were we working on?" but with full context.**
1360
+ ### The Complete Session Lifecycle
1361
+ ```
1362
+ ─── START OF SESSION ──────────────────────────────────────────
1363
+ You: "Hey Claude, please resume and continue"
1364
+ Claude: cairn_resume
1365
+ → "Last session: adding multi-currency to checkout.
1366
+ PaymentStep.vue was modified since checkpoint (2 files changed, re-indexed).
1367
+ Notes: CurrencyService expects ISO 4217 codes. PaymentStep EUR bug not fixed yet.
1368
+ Ready to continue."
1369
+ ─── DURING SESSION ────────────────────────────────────────────
1370
+ Claude uses: cairn_search, cairn_bundle, cairn_code_graph, cairn_security
1371
+ as needed — all reading from .cairn/index.db in cwd
1372
+ ─── END OF SESSION ────────────────────────────────────────────
1373
+ You: "Ok let's stop here for today"
1374
+ Claude: cairn_checkpoint
1375
+ message="Fixed EUR formatting in PaymentStep, multi-currency complete"
1376
+ active_files=["src/components/checkout/PaymentStep.vue"]
1377
+ notes=["Still need to add JPY — no decimal places", "QA needs to test AED"]
1378
+ → "Session saved. Resume anytime with cairn_resume."
1379
+ ```
1380
+
1381
+ ### File: how-to-use.md
1382
+ # Cairn — Quick Start
1383
+ ## 1. Install globally
1384
+ ```bash
1385
+ npm install -g @misterhuydo/cairn-mcp
1386
+ ```
1387
+ That's it — `cairn-mcp` is now available as a global command.
1388
+ ## 2. Register with Claude Code
1389
+ Add to `~/.claude/config.json` (create it if it doesn't exist):
1390
+ ```json
1391
+ {
1392
+ "mcpServers": {
1393
+ "cairn": {
1394
+ "command": "cairn-mcp"
1395
+ }
1396
+ }
1397
+ }
1398
+ ```
1399
+ > If you already have other MCP servers, just add `"cairn": { ... }` inside the existing `"mcpServers"` block.
1400
+ Restart Claude Code. You should see 8 cairn tools available.
1401
+ ## 3. Index your project
1402
+ Run Claude Code from your project root, then:
1403
+ ```
1404
+ cairn_maintain
1405
+ ```
1406
+ No paths needed — Cairn works like git and finds your project from the current directory. The index is stored in `.cairn/index.db` inside your project.
1407
+ Run this once at the start of each session (or use `cairn_resume` to pick up where you left off).
1408
+ ## 4. Tools at a glance
1409
+ | Tool | What to use it for |
1410
+ |---|---|
1411
+ | `cairn_maintain` | Index the current project (run at session start) |
1412
+ | `cairn_search` | Find classes, functions, components by name or concept |
1413
+ | `cairn_describe` | Summarize what a folder/module does |
1414
+ | `cairn_code_graph` | Check instability, health, or cycles before refactoring |
1415
+ | `cairn_security` | Scan for vulnerabilities (XSS, SQLi, hardcoded secrets, etc.) |
1416
+ | `cairn_bundle` | Package source files into a minified snapshot for Claude to read |
1417
+ | `cairn_checkpoint` | Save what you're working on so the next session can resume |
1418
+ | `cairn_resume` | Restore the last checkpoint + incremental re-index of changed files |
1419
+ ## 5. Typical session
1420
+ **Starting fresh:**
1421
+ ```
1422
+ 1. cairn_maintain → index the project
1423
+ 2. cairn_search → find what you need
1424
+ 3. cairn_bundle → get a readable snapshot of relevant files
1425
+ 4. cairn_describe → understand a module before modifying it
1426
+ 5. cairn_security → check for issues before a PR
1427
+ 6. cairn_checkpoint → save your progress before ending the session
1428
+ ```
1429
+ **Resuming work:**
1430
+ ```
1431
+ 1. cairn_resume → restore last checkpoint + re-index only changed files
1432
+ 2. cairn_search → continue where you left off
1433
+ ```
1434
+ ## 6. Complete session lifecycle
1435
+ ```
1436
+ ─── START OF SESSION ──────────────────────────────────────────
1437
+ You: "Hey Claude, please resume and continue"
1438
+ Claude: cairn_resume
1439
+ → "Last session: adding multi-currency to checkout.
1440
+ PaymentStep.vue was modified since checkpoint (2 files changed, re-indexed).
1441
+ Notes: CurrencyService expects ISO 4217 codes. PaymentStep EUR bug not fixed yet.
1442
+ Ready to continue."
1443
+ ─── DURING SESSION ────────────────────────────────────────────
1444
+ Claude uses: cairn_search, cairn_bundle, cairn_code_graph, cairn_security
1445
+ as needed — all reading from .cairn/index.db in cwd
1446
+ ─── END OF SESSION ────────────────────────────────────────────
1447
+ You: "Ok let's stop here for today"
1448
+ Claude: cairn_checkpoint
1449
+ message="Fixed EUR formatting in PaymentStep, multi-currency complete"
1450
+ active_files=["src/components/checkout/PaymentStep.vue"]
1451
+ notes=["Still need to add JPY — no decimal places", "QA needs to test AED"]
1452
+ → "Session saved. Resume anytime with cairn_resume."
1453
+ ```
1454
+ ---
1455
+ **Requirements:** Node.js >= 22.15.0
1456
+
1457
+ ### File: index.js
1458
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
1459
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
1460
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
1461
+ import { openDB } from './src/graph/db.js';
1462
+ import { maintain } from './src/tools/maintain.js';
1463
+ import { search } from './src/tools/search.js';
1464
+ import { describe } from './src/tools/describe.js';
1465
+ import { codeGraph } from './src/tools/codeGraph.js';
1466
+ import { security } from './src/tools/security.js';
1467
+ import { bundle } from './src/tools/bundle.js';
1468
+ import { checkpoint } from './src/tools/checkpoint.js';
1469
+ import { resume } from './src/tools/resume.js';
1470
+ const db = openDB();
1471
+ const server = new Server(
1472
+ { name: 'cairn', version: '1.0.0' },
1473
+ { capabilities: { tools: {} } }
1474
+ );
1475
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1476
+ tools: [
1477
+ {
1478
+ name: 'cairn_maintain',
1479
+ description: 'Index the current project into Cairn\'s polyglot knowledge graph. Supports Java, TypeScript, JavaScript, Vue, Python, SQL, config, and Markdown. Indexes from the current working directory. Run at session start or after major changes.',
1480
+ inputSchema: {
1481
+ type: 'object',
1482
+ properties: {
1483
+ languages: {
1484
+ type: 'array', items: { type: 'string' },
1485
+ description: 'Optional: limit to specific languages e.g. ["java","typescript"]',
1486
+ },
1487
+ },
1488
+ },
1489
+ },
1490
+ {
1491
+ name: 'cairn_search',
1492
+ description: 'Search the codebase by concept across all languages. Returns ranked symbols with scores.',
1493
+ inputSchema: {
1494
+ type: 'object',
1495
+ properties: {
1496
+ query: { type: 'string' },
1497
+ limit: { type: 'number', default: 10 },
1498
+ language: { type: 'string', description: 'Optional: filter by language' },
1499
+ kind: { type: 'string', description: 'Optional: class/function/component/table/...' },
1500
+ },
1501
+ required: ['query'],
1502
+ },
1503
+ },
1504
+ {
1505
+ name: 'cairn_describe',
1506
+ description: 'Describe what a directory/module does: symbols, responsibilities, upstream/downstream deps.',
1507
+ inputSchema: {
1508
+ type: 'object',
1509
+ properties: {
1510
+ path: { type: 'string', description: 'Directory or file path to describe' },
1511
+ },
1512
+ required: ['path'],
1513
+ },
1514
+ },
1515
+ {
1516
+ name: 'cairn_code_graph',
1517
+ description: 'Analyze module dependencies and instability metrics across all languages. Use before refactoring.',
1518
+ inputSchema: {
1519
+ type: 'object',
1520
+ properties: {
1521
+ module: { type: 'string', description: 'Optional: scope to a specific module/package' },
1522
+ mode: { type: 'string', enum: ['instability', 'health', 'cycles'] },
1523
+ },
1524
+ required: ['mode'],
1525
+ },
1526
+ },
1527
+ {
1528
+ name: 'cairn_security',
1529
+ description: 'Scan for security vulnerabilities across all languages (XSS, XXE, SQLi, weak crypto, hardcoded secrets, etc.)',
1530
+ inputSchema: {
1531
+ type: 'object',
1532
+ properties: {
1533
+ paths: { type: 'array', items: { type: 'string' }, description: 'Optional: scope to specific paths' },
1534
+ severity: { type: 'string', enum: ['HIGH', 'MEDIUM', 'LOW', 'ALL'], default: 'HIGH' },
1535
+ language: { type: 'string', description: 'Optional: scope to a specific language' },
1536
+ },
1537
+ },
1538
+ },
1539
+ {
1540
+ name: 'cairn_bundle',
1541
+ description: `Produce a minified single-file snapshot of source code for Claude to read.
1542
+ Strips comments, empty lines, and optionally styles. Returns the bundle file path and compression stats.
1543
+ Use AFTER cairn_search to get a readable version of the files Claude needs to work with.
1544
+ Typical workflow: cairn_search → find files → cairn_bundle those paths → Claude reads bundle.`,
1545
+ inputSchema: {
1546
+ type: 'object',
1547
+ properties: {
1548
+ bundle_name: {
1549
+ type: 'string', default: 'default',
1550
+ description: 'Name for the bundle file (saved to .cairn/bundles/<name>.txt)',
1551
+ },
1552
+ filter_paths: {
1553
+ type: 'array', items: { type: 'string' },
1554
+ description: 'Optional: only include files under these relative paths',
1555
+ },
1556
+ filter_language: {
1557
+ type: 'string',
1558
+ description: 'Optional: only bundle this language (java/typescript/vue/python/...)',
1559
+ },
1560
+ no_comments: { type: 'boolean', default: true, description: 'Strip code comments' },
1561
+ no_empty_lines: { type: 'boolean', default: true, description: 'Remove empty lines' },
1562
+ no_style: { type: 'boolean', default: false, description: 'Skip <style> blocks in Vue files' },
1563
+ aggressive: { type: 'boolean', default: false, description: 'Aggressive whitespace/punctuation minification' },
1564
+ only_changed: { type: 'boolean', default: false, description: 'Incremental: only re-bundle files changed since last run' },
1565
+ max_size_kb: { type: 'number', default: 800, description: 'Safety cap in KB to avoid context window overflow' },
1566
+ },
1567
+ },
1568
+ },
1569
+ {
1570
+ name: 'cairn_checkpoint',
1571
+ description: 'Save current session state to .cairn/session.json so it can be restored next session with cairn_resume.',
1572
+ inputSchema: {
1573
+ type: 'object',
1574
+ properties: {
1575
+ message: {
1576
+ type: 'string',
1577
+ description: 'What you were working on — will be shown on resume',
1578
+ },
1579
+ active_files: {
1580
+ type: 'array', items: { type: 'string' },
1581
+ description: 'Optional: absolute paths to files actively being worked on',
1582
+ },
1583
+ notes: {
1584
+ type: 'array', items: { type: 'string' },
1585
+ description: 'Optional: things Claude should remember next session',
1586
+ },
1587
+ },
1588
+ required: ['message'],
1589
+ },
1590
+ },
1591
+ {
1592
+ name: 'cairn_resume',
1593
+ description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Call at the start of a session instead of cairn_maintain when resuming work.',
1594
+ inputSchema: {
1595
+ type: 'object',
1596
+ properties: {},
1597
+ },
1598
+ },
1599
+ ],
1600
+ }));
1601
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1602
+ const { name, arguments: args } = request.params;
1603
+ switch (name) {
1604
+ case 'cairn_maintain': return await maintain(db, args);
1605
+ case 'cairn_search': return search(db, args);
1606
+ case 'cairn_describe': return describe(db, args);
1607
+ case 'cairn_code_graph': return codeGraph(db, args);
1608
+ case 'cairn_security': return security(db, args);
1609
+ case 'cairn_bundle': return await bundle(db, args);
1610
+ case 'cairn_checkpoint': return checkpoint(db, args);
1611
+ case 'cairn_resume': return await resume(db);
1612
+ default: throw new Error(`Unknown tool: ${name}`);
1613
+ }
1614
+ });
1615
+ const transport = new StdioServerTransport();
1616
+ await server.connect(transport);
1617
+
1618
+ ### File: README.md
1619
+ # Cairn MCP
1620
+ > Persistent polyglot knowledge graph for Claude Code. Index once, query forever.
1621
+ Claude is stateless — every session starts at zero. On a multi-repo codebase with Java backends, TypeScript frontends, and Vue components, that means 50% of every session is archaeology: finding where things live before any real work can begin.
1622
+ **Cairn fixes this.** It indexes your project into a local SQLite knowledge graph (stored in `.cairn/`) and exposes 8 MCP tools that give Claude instant, persistent memory across sessions.
1623
+ ---
1624
+ ## Install
1625
+ ```bash
1626
+ npm install -g @misterhuydo/cairn-mcp
1627
+ ```
1628
+ **Requirements:** Node.js >= 22.15.0
1629
+ ---
1630
+ ## Setup
1631
+ Add to `~/.claude/config.json`:
1632
+ ```json
1633
+ {
1634
+ "mcpServers": {
1635
+ "cairn": {
1636
+ "command": "cairn-mcp"
1637
+ }
1638
+ }
1639
+ }
1640
+ ```
1641
+ Restart Claude Code. Done.
1642
+ ---
1643
+ ## How it works
1644
+ Cairn works like git — it looks for `.cairn/` in your current working directory. Run Claude Code from your project root and Cairn automatically stores the index at `.cairn/index.db` and bundles at `.cairn/bundles/`.
1645
+ No root paths needed. No global config. Just `cd` to your project and go.
1646
+ ---
1647
+ ## Tools
1648
+ ### `cairn_maintain` — Index your project
1649
+ Run once at the start of each session. The index persists in `.cairn/index.db`.
1650
+ ```
1651
+ cairn_maintain()
1652
+ cairn_maintain({ languages: ["typescript", "vue"] }) // limit to specific languages
1653
+ ```
1654
+ ```json
1655
+ {
1656
+ "repos_indexed": 1,
1657
+ "files_by_language": { "java": 412, "typescript": 287, "vue": 94 },
1658
+ "symbols_total": 6841,
1659
+ "security_findings": 47,
1660
+ "duration_ms": 4200
1661
+ }
1662
+ ```
1663
+ ---
1664
+ ### `cairn_search` — Find anything across all languages
1665
+ ```
1666
+ cairn_search({ query: "user authentication", language: "typescript" })
1667
+ ```
1668
+ ```json
1669
+ [
1670
+ { "lang": "typescript", "kind": "function", "fqn": "src/composables/useAuth.ts::useAuth" },
1671
+ { "lang": "vue", "kind": "component", "fqn": "src/components/LoginForm.vue::LoginForm" },
1672
+ { "lang": "java", "kind": "class", "fqn": "com.example.auth.UserAuthenticator" }
1673
+ ]
1674
+ ```
1675
+ ---
1676
+ ### `cairn_bundle` — Minified source snapshot for Claude to read
1677
+ Strips comments and empty lines, writes a compressed `### File:` bundle. Use after `cairn_search` to give Claude readable source without blowing the context window.
1678
+ ```
1679
+ cairn_bundle({ filter_paths: ["src/components/checkout"], bundle_name: "checkout" })
1680
+ ```
1681
+ ```json
1682
+ {
1683
+ "bundle_path": "/your/project/.cairn/bundles/checkout.txt",
1684
+ "original_kb": 284,
1685
+ "compressed_kb": 91,
1686
+ "reduction_pct": 68
1687
+ }
1688
+ ```
1689
+ ---
1690
+ ### `cairn_describe` — Summarize a module
1691
+ ```
1692
+ cairn_describe({ path: "src/components/checkout" })
1693
+ ```
1694
+ ```json
1695
+ {
1696
+ "languages": ["vue", "typescript"],
1697
+ "symbols": {
1698
+ "component": ["CheckoutForm", "OrderSummary", "PaymentStep"],
1699
+ "function": ["useCheckout", "usePayment"]
1700
+ },
1701
+ "imports_from": ["src/store/cart.ts", "src/api/orders.ts"],
1702
+ "imported_by": ["src/views/CartView.vue"],
1703
+ "external_deps": ["stripe", "@stripe/stripe-js"]
1704
+ }
1705
+ ```
1706
+ ---
1707
+ ### `cairn_code_graph` — Dependency health
1708
+ ```
1709
+ cairn_code_graph({ mode: "instability" })
1710
+ ```
1711
+ ```json
1712
+ {
1713
+ "modules": [
1714
+ { "name": "src/views", "instability": 1.0, "status": "safe_to_refactor" },
1715
+ { "name": "src/composables", "instability": 0.4, "status": "review_before_change" },
1716
+ { "name": "src/utils", "instability": 0.1, "status": "load_bearing" }
1717
+ ]
1718
+ }
1719
+ ```
1720
+ Modes: `instability` · `health` · `cycles`
1721
+ ---
1722
+ ### `cairn_security` — Vulnerability scan
1723
+ ```
1724
+ cairn_security({ severity: "HIGH" })
1725
+ ```
1726
+ ```json
1727
+ {
1728
+ "total_findings": 12,
1729
+ "by_language": { "typescript": 7, "java": 5 },
1730
+ "findings": [
1731
+ { "severity": "HIGH", "cwe": "CWE-79", "file": "src/components/UserProfile.vue", "line": 84, "rule": "XSS via innerHTML" },
1732
+ { "severity": "HIGH", "cwe": "CWE-611", "file": "backend/src/.../XmlParser.java", "line": 47, "rule": "XXE" }
1733
+ ]
1734
+ }
1735
+ ```
1736
+ Covers: XSS · XXE · SQL injection · command injection · weak crypto · hardcoded secrets · open redirect · unsafe deserialization
1737
+ ---
1738
+ ### `cairn_checkpoint` — Save session state
1739
+ Tell Cairn what you were working on so the next session can pick up where you left off.
1740
+ ```
1741
+ cairn_checkpoint({
1742
+ message: "Added multi-currency to CheckoutForm, still need PaymentStep",
1743
+ active_files: ["src/components/checkout/CheckoutForm.vue"],
1744
+ notes: ["CurrencyService expects ISO 4217 codes, not symbols"]
1745
+ })
1746
+ ```
1747
+ ```json
1748
+ {
1749
+ "saved": true,
1750
+ "checkpoint_at": "2026-02-25T14:30:00Z",
1751
+ "active_files_tracked": 1,
1752
+ "notes_saved": 1
1753
+ }
1754
+ ```
1755
+ ---
1756
+ ### `cairn_resume` — Restore session + smart re-index
1757
+ Call instead of `cairn_maintain` when resuming work. Detects which files changed and only re-indexes those.
1758
+ ```
1759
+ cairn_resume()
1760
+ ```
1761
+ ```json
1762
+ {
1763
+ "last_checkpoint": "2026-02-25T14:30:00Z",
1764
+ "message": "Added multi-currency to CheckoutForm, still need PaymentStep",
1765
+ "index_status": "incremental",
1766
+ "files_reindexed": 2,
1767
+ "files_unchanged": 845,
1768
+ "changed_since_checkpoint": [
1769
+ { "file": "src/components/checkout/PaymentStep.vue", "change": "modified" }
1770
+ ],
1771
+ "active_files": ["src/components/checkout/CheckoutForm.vue"],
1772
+ "notes": ["CurrencyService expects ISO 4217 codes, not symbols"],
1773
+ "resume_summary": "Last session you were adding multi-currency support..."
1774
+ }
1775
+ ```
1776
+ ---
1777
+ ## Supported Languages
1778
+ | Language | What Cairn extracts |
1779
+ |---|---|
1780
+ | Java | packages, classes, interfaces, enums, records, methods |
1781
+ | TypeScript / JavaScript | classes, interfaces, functions, types, enums |
1782
+ | Vue | components, composables, script block symbols |
1783
+ | Python | classes, functions, decorators |
1784
+ | SQL | tables, views, stored procedures |
1785
+ | XML / HTML | bean ids, component names |
1786
+ | Config (YAML, properties, .env) | top-level keys |
1787
+ | Markdown | headings |
1788
+ | Build files (pom.xml, package.json, build.gradle) | dependencies |
1789
+ ---
1790
+ ## Typical session
1791
+ **Fresh start:**
1792
+ ```
1793
+ 1. cairn_maintain → index project (persists between sessions)
1794
+ 2. cairn_search → find the symbols you need
1795
+ 3. cairn_bundle → get a readable snapshot of relevant files
1796
+ 4. cairn_describe → understand a module before modifying it
1797
+ 5. cairn_security → check for issues before a PR
1798
+ 6. cairn_checkpoint → save what you were working on
1799
+ ```
1800
+ **Resuming work:**
1801
+ ```
1802
+ 1. cairn_resume → restore session + incremental re-index
1803
+ 2. cairn_search → continue where you left off
1804
+ ```
1805
+ ## Complete session lifecycle
1806
+ ```
1807
+ ─── START OF SESSION ──────────────────────────────────────────
1808
+ You: "Hey Claude, please resume and continue"
1809
+ Claude: cairn_resume
1810
+ → "Last session: adding multi-currency to checkout.
1811
+ PaymentStep.vue was modified since checkpoint (2 files changed, re-indexed).
1812
+ Notes: CurrencyService expects ISO 4217 codes. PaymentStep EUR bug not fixed yet.
1813
+ Ready to continue."
1814
+ ─── DURING SESSION ────────────────────────────────────────────
1815
+ Claude uses: cairn_search, cairn_bundle, cairn_code_graph, cairn_security
1816
+ as needed — all reading from .cairn/index.db in cwd
1817
+ ─── END OF SESSION ────────────────────────────────────────────
1818
+ You: "Ok let's stop here for today"
1819
+ Claude: cairn_checkpoint
1820
+ message="Fixed EUR formatting in PaymentStep, multi-currency complete"
1821
+ active_files=["src/components/checkout/PaymentStep.vue"]
1822
+ notes=["Still need to add JPY — no decimal places", "QA needs to test AED"]
1823
+ → "Session saved. Resume anytime with cairn_resume."
1824
+ ```
1825
+ ---
1826
+ ## License
1827
+ MIT
1828
+
1829
+ ### File: bin/cairn-mcp.js
1830
+ #!/usr/bin/env node
1831
+ import { spawn } from 'child_process';
1832
+ import { fileURLToPath } from 'url';
1833
+ import { dirname, join } from 'path';
1834
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1835
+ const indexPath = join(__dirname, '..', 'index.js');
1836
+ const child = spawn(
1837
+ process.execPath,
1838
+ ['--experimental-sqlite', '--no-warnings=ExperimentalWarning', indexPath],
1839
+ { stdio: 'inherit', env: process.env }
1840
+ );
1841
+ child.on('exit', (code, signal) => {
1842
+ if (signal) process.kill(process.pid, signal);
1843
+ else process.exit(code ?? 0);
1844
+ });
1845
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
1846
+ process.on('SIGINT', () => child.kill('SIGINT'));
1847
+
1848
+ ### File: src/bundler/bundleWriter.js
1849
+ import fs from 'fs';
1850
+ import path from 'path';
1851
+ import { minifyContent } from './minifier.js';
1852
+ import { minifyVue } from './vueMinifier.js';
1853
+ import { walkRepo, LANGUAGE_MAP } from '../indexer/fileWalker.js';
1854
+ import { getCairnDir } from '../graph/cwd.js';
1855
+ export async function writeBundle(options = {}) {
1856
+ const {
1857
+ bundleName = 'default',
1858
+ noComments = true,
1859
+ noEmptyLines = true,
1860
+ noStyle = false,
1861
+ aggressive = false,
1862
+ onlyChanged = false,
1863
+ filterLang = null,
1864
+ filterPaths = null,
1865
+ maxSizeKB = 800,
1866
+ } = options;
1867
+ const bundleDir = path.join(getCairnDir(), 'bundles');
1868
+ const root = process.cwd();
1869
+ const outPath = path.join(bundleDir, `${bundleName}.txt`);
1870
+ const mtimePath = path.join(bundleDir, `${bundleName}.mtime.json`);
1871
+ const mtimeCache = onlyChanged && fs.existsSync(mtimePath)
1872
+ ? JSON.parse(fs.readFileSync(mtimePath, 'utf8')) : {};
1873
+ const newMtimes = {};
1874
+ const lines = [];
1875
+ const stats = {
1876
+ processed: 0, skipped_unchanged: 0, skipped_size: 0,
1877
+ original_bytes: 0, compressed_bytes: 0, by_language: {},
1878
+ };
1879
+ const langFilter = filterLang
1880
+ ? (Array.isArray(filterLang) ? filterLang : [filterLang]) : null;
1881
+ const files = await walkRepo(root);
1882
+ for (const filePath of files) {
1883
+ const ext = path.extname(filePath).toLowerCase();
1884
+ const language = LANGUAGE_MAP[ext];
1885
+ if (!language) continue;
1886
+ if (langFilter && !langFilter.includes(language)) continue;
1887
+ if (filterPaths) {
1888
+ const rel = path.relative(root, filePath).replace(/\\/g, '/');
1889
+ if (!filterPaths.some(p => rel.startsWith(p))) continue;
1890
+ }
1891
+ const mtime = fs.statSync(filePath).mtimeMs;
1892
+ newMtimes[filePath] = mtime;
1893
+ if (onlyChanged && mtimeCache[filePath] === mtime) {
1894
+ stats.skipped_unchanged++;
1895
+ continue;
1896
+ }
1897
+ const currentKB = Buffer.byteLength(lines.join('\n'), 'utf8') / 1024;
1898
+ if (currentKB > maxSizeKB) {
1899
+ stats.skipped_size++;
1900
+ continue;
1901
+ }
1902
+ let raw;
1903
+ try {
1904
+ raw = fs.readFileSync(filePath, 'utf8');
1905
+ } catch {
1906
+ continue;
1907
+ }
1908
+ stats.original_bytes += Buffer.byteLength(raw, 'utf8');
1909
+ const minified = language === 'vue'
1910
+ ? minifyVue(raw, { noStyle, noComments, noEmptyLines, aggressive })
1911
+ : minifyContent(raw, language, { noComments, noEmptyLines, aggressive });
1912
+ if (!minified.trim()) continue;
1913
+ const rel = path.relative(root, filePath).replace(/\\/g, '/');
1914
+ lines.push(`### File: ${rel}`);
1915
+ lines.push(minified.trim());
1916
+ lines.push('');
1917
+ stats.compressed_bytes += Buffer.byteLength(minified, 'utf8');
1918
+ stats.processed++;
1919
+ stats.by_language[language] = (stats.by_language[language] || 0) + 1;
1920
+ }
1921
+ const output = lines.join('\n');
1922
+ fs.writeFileSync(outPath, output, 'utf8');
1923
+ if (onlyChanged) fs.writeFileSync(mtimePath, JSON.stringify(newMtimes), 'utf8');
1924
+ const ratio = stats.original_bytes > 0
1925
+ ? Math.round((1 - stats.compressed_bytes / stats.original_bytes) * 100) : 0;
1926
+ return {
1927
+ bundle_path: outPath,
1928
+ processed: stats.processed,
1929
+ skipped_unchanged: stats.skipped_unchanged,
1930
+ skipped_size_cap: stats.skipped_size,
1931
+ original_kb: Math.round(stats.original_bytes / 1024),
1932
+ compressed_kb: Math.round(stats.compressed_bytes / 1024),
1933
+ reduction_pct: ratio,
1934
+ by_language: stats.by_language,
1935
+ };
1936
+ }
1937
+
1938
+ ### File: src/bundler/minifier.js
1939
+ export function minifyContent(content, language, options = {}) {
1940
+ const { noComments = true, noEmptyLines = true, aggressive = false } = options;
1941
+ let out = content;
1942
+ if (noComments) out = removeComments(out, language);
1943
+ if (noEmptyLines) out = removeEmptyLines(out);
1944
+ out = collapseWhitespace(out);
1945
+ if (aggressive) out = aggressiveMinify(out);
1946
+ return out.trim();
1947
+ }
1948
+ function removeComments(content, language) {
1949
+ switch (language) {
1950
+ case 'java':
1951
+ case 'typescript':
1952
+ case 'javascript':
1953
+ case 'vue':
1954
+ return content
1955
+ .replace(/\/\/.*$/gm, '')
1956
+ .replace(/\/\*[\s\S]*?\*\
1957
+ case 'python':
1958
+ return content
1959
+ .replace(/#.*$/gm, '')
1960
+ .replace(/'''[\s\S]*?'''/g, '')
1961
+ .replace(/"""[\s\S]*?"""/g, '');
1962
+ case 'sql':
1963
+ return content
1964
+ .replace(/--.*$/gm, '')
1965
+ .replace(/\/\*[\s\S]*?\*\
1966
+ case 'xml':
1967
+ case 'html':
1968
+ return content.replace(/<!--[\s\S]*?-->/g, '');
1969
+ case 'config':
1970
+ return content.replace(/#.*$/gm, '');
1971
+ default:
1972
+ return content;
1973
+ }
1974
+ }
1975
+ function removeEmptyLines(content) {
1976
+ return content
1977
+ .split('\n')
1978
+ .filter(line => line.trim().length > 0)
1979
+ .join('\n');
1980
+ }
1981
+ function collapseWhitespace(content) {
1982
+ return content
1983
+ .split('\n')
1984
+ .map(line => line.trimEnd())
1985
+ .join('\n');
1986
+ }
1987
+ function aggressiveMinify(content) {
1988
+ return content
1989
+ .replace(/\s+/g, ' ')
1990
+ .replace(/\s*([=+\-*/%<>!&|^~?:;,{}()[\]])\s*/g, '$1')
1991
+ .replace(/;\s*}/g, '}')
1992
+ .replace(/\(\s+/g, '(')
1993
+ .replace(/\s+\)/g, ')')
1994
+ .trim();
1995
+ }
1996
+
1997
+ ### File: src/bundler/vueMinifier.js
1998
+ import { minifyContent } from './minifier.js';
1999
+ export function minifyVue(content, options = {}) {
2000
+ const { noStyle = false, noComments = true, noEmptyLines = true, aggressive = false } = options;
2001
+ const result = [];
2002
+ const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
2003
+ if (templateMatch) {
2004
+ let tpl = templateMatch[1];
2005
+ if (noComments) tpl = tpl.replace(/<!--[\s\S]*?-->/g, '');
2006
+ if (noEmptyLines) tpl = tpl.split('\n').filter(l => l.trim()).join('\n');
2007
+ tpl = tpl.replace(/\s+/g, ' ').trim();
2008
+ if (tpl) result.push(`<template>${tpl}</template>`);
2009
+ }
2010
+ const scriptMatch = content.match(/<script([^>]*)>([\s\S]*?)<\/script>/i);
2011
+ if (scriptMatch) {
2012
+ const attrs = scriptMatch[1];
2013
+ const code = minifyContent(scriptMatch[2], 'javascript',
2014
+ { noComments, noEmptyLines, aggressive });
2015
+ const tag = /setup/i.test(attrs) ? '<script setup>' : '<script>';
2016
+ if (code) result.push(`${tag}${code}</script>`);
2017
+ }
2018
+ if (!noStyle) {
2019
+ const styleMatch = content.match(/<style([^>]*)>([\s\S]*?)<\/style>/i);
2020
+ if (styleMatch) {
2021
+ const css = styleMatch[2]
2022
+ .replace(/\/\*[\s\S]*?\*\
2023
+ .replace(/\s+/g, ' ').trim();
2024
+ if (css) result.push(`<style${styleMatch[1]}>${css}</style>`);
2025
+ }
2026
+ }
2027
+ return result.join('\n\n');
2028
+ }
2029
+
2030
+ ### File: src/graph/cwd.js
2031
+ import fs from 'fs';
2032
+ import path from 'path';
2033
+ export function getCairnDir() {
2034
+ const cairnDir = path.join(process.cwd(), '.cairn');
2035
+ fs.mkdirSync(path.join(cairnDir, 'bundles'), { recursive: true });
2036
+ return cairnDir;
2037
+ }
2038
+
2039
+ ### File: src/graph/db.js
2040
+ import { DatabaseSync } from 'node:sqlite';
2041
+ import path from 'path';
2042
+ import { getCairnDir } from './cwd.js';
2043
+ const SCHEMA = `
2044
+ CREATE TABLE IF NOT EXISTS files (
2045
+ id INTEGER PRIMARY KEY,
2046
+ repo TEXT NOT NULL,
2047
+ path TEXT NOT NULL UNIQUE,
2048
+ language TEXT,
2049
+ file_type TEXT,
2050
+ last_indexed INTEGER
2051
+ );
2052
+ CREATE TABLE IF NOT EXISTS symbols (
2053
+ id INTEGER PRIMARY KEY,
2054
+ file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
2055
+ fqn TEXT UNIQUE,
2056
+ name TEXT NOT NULL,
2057
+ kind TEXT,
2058
+ language TEXT,
2059
+ exported INTEGER DEFAULT 0,
2060
+ description TEXT
2061
+ );
2062
+ CREATE TABLE IF NOT EXISTS dependencies (
2063
+ from_file_id INTEGER REFERENCES files(id),
2064
+ to_fqn TEXT,
2065
+ dep_type TEXT
2066
+ );
2067
+ CREATE TABLE IF NOT EXISTS build_deps (
2068
+ repo TEXT,
2069
+ manager TEXT,
2070
+ group_id TEXT,
2071
+ artifact TEXT,
2072
+ version TEXT,
2073
+ scope TEXT
2074
+ );
2075
+ CREATE TABLE IF NOT EXISTS security_findings (
2076
+ id INTEGER PRIMARY KEY,
2077
+ file_id INTEGER REFERENCES files(id),
2078
+ line INTEGER,
2079
+ severity TEXT,
2080
+ cwe TEXT,
2081
+ rule_name TEXT,
2082
+ description TEXT,
2083
+ code_snippet TEXT
2084
+ );
2085
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
2086
+ fqn, name, description, path, repo, language, kind
2087
+ );
2088
+ `;
2089
+ export function openDB() {
2090
+ const dbPath = path.join(getCairnDir(), 'index.db');
2091
+ const db = new DatabaseSync(dbPath);
2092
+ db.exec('PRAGMA journal_mode = WAL');
2093
+ db.exec('PRAGMA synchronous = NORMAL');
2094
+ db.exec(SCHEMA);
2095
+ return db;
2096
+ }
2097
+
2098
+ ### File: src/graph/edges.js
2099
+ export function insertDependency(db, { from_file_id, to_fqn, dep_type }) {
2100
+ db.prepare('INSERT INTO dependencies (from_file_id, to_fqn, dep_type) VALUES (?, ?, ?)')
2101
+ .run(from_file_id, to_fqn, dep_type);
2102
+ }
2103
+ export function insertBuildDep(db, { repo, manager, group_id, artifact, version, scope }) {
2104
+ const existing = db.prepare(
2105
+ 'SELECT rowid FROM build_deps WHERE repo=? AND manager=? AND artifact=?'
2106
+ ).get(repo, manager, artifact);
2107
+ if (!existing) {
2108
+ db.prepare(
2109
+ 'INSERT INTO build_deps (repo, manager, group_id, artifact, version, scope) VALUES (?, ?, ?, ?, ?, ?)'
2110
+ ).run(repo, manager, group_id, artifact, version, scope);
2111
+ }
2112
+ }
2113
+ export function insertSecurityFinding(db, { file_id, line, severity, cwe, rule_name, description, code_snippet }) {
2114
+ db.prepare(
2115
+ 'INSERT INTO security_findings (file_id, line, severity, cwe, rule_name, description, code_snippet) VALUES (?, ?, ?, ?, ?, ?, ?)'
2116
+ ).run(file_id, line, severity, cwe, rule_name, description, code_snippet);
2117
+ }
2118
+
2119
+ ### File: src/graph/nodes.js
2120
+ export function upsertFile(db, { repo, path, language, file_type }) {
2121
+ const existing = db.prepare('SELECT id FROM files WHERE path = ?').get(path);
2122
+ if (existing) {
2123
+ db.prepare('UPDATE files SET language = ?, file_type = ?, last_indexed = ? WHERE id = ?')
2124
+ .run(language, file_type, Date.now(), existing.id);
2125
+ return existing.id;
2126
+ }
2127
+ const result = db.prepare(
2128
+ 'INSERT INTO files (repo, path, language, file_type, last_indexed) VALUES (?, ?, ?, ?, ?)'
2129
+ ).run(repo, path, language, file_type, Date.now());
2130
+ return result.lastInsertRowid;
2131
+ }
2132
+ export function upsertSymbol(db, { file_id, fqn, name, kind, language, exported, description }) {
2133
+ const existing = db.prepare('SELECT id FROM symbols WHERE fqn = ?').get(fqn);
2134
+ if (existing) {
2135
+ db.prepare(
2136
+ 'UPDATE symbols SET file_id=?, name=?, kind=?, language=?, exported=?, description=? WHERE id=?'
2137
+ ).run(file_id, name, kind, language, exported ? 1 : 0, description || '', existing.id);
2138
+ } else {
2139
+ db.prepare(
2140
+ 'INSERT INTO symbols (file_id, fqn, name, kind, language, exported, description) VALUES (?, ?, ?, ?, ?, ?, ?)'
2141
+ ).run(file_id, fqn, name, kind, language, exported ? 1 : 0, description || '');
2142
+ }
2143
+ }
2144
+ export function clearFileData(db, fileId) {
2145
+ db.prepare('DELETE FROM dependencies WHERE from_file_id = ?').run(fileId);
2146
+ db.prepare('DELETE FROM security_findings WHERE file_id = ?').run(fileId);
2147
+ }
2148
+
2149
+ ### File: src/indexer/fileWalker.js
2150
+ import fg from 'fast-glob';
2151
+ export const LANGUAGE_MAP = {
2152
+ '.java': 'java',
2153
+ '.ts': 'typescript',
2154
+ '.tsx': 'typescript',
2155
+ '.js': 'javascript',
2156
+ '.jsx': 'javascript',
2157
+ '.mjs': 'javascript',
2158
+ '.vue': 'vue',
2159
+ '.py': 'python',
2160
+ '.sql': 'sql',
2161
+ '.yml': 'config',
2162
+ '.yaml': 'config',
2163
+ '.properties': 'config',
2164
+ '.env': 'config',
2165
+ '.xml': 'xml',
2166
+ '.html': 'html',
2167
+ '.md': 'markdown',
2168
+ };
2169
+ export const BUILD_FILES = ['pom.xml', 'package.json', 'build.gradle'];
2170
+ const IGNORE = [
2171
+ '**/node_modules.gitdistbuildtarget.next__pycache__*.min.js',
2172
+ ];
2173
+ export async function walkRepo(repoRoot) {
2174
+ const patterns = Object.keys(LANGUAGE_MAP).map(ext => `**/*${ext}`);
2175
+ const files = await fg(patterns, { cwd: repoRoot, ignore: IGNORE, absolute: true });
2176
+ return files;
2177
+ }
2178
+
2179
+ ### File: src/indexer/parserFactory.js
2180
+ import path from 'path';
2181
+ import { LANGUAGE_MAP } from './fileWalker.js';
2182
+ import { parseJava } from './parsers/javaParser.js';
2183
+ import { parseTS } from './parsers/tsParser.js';
2184
+ import { parseVue } from './parsers/vueParser.js';
2185
+ import { parsePython } from './parsers/pythonParser.js';
2186
+ import { parseSQL } from './parsers/sqlParser.js';
2187
+ import { parseConfig } from './parsers/configParser.js';
2188
+ import { parseXML } from './parsers/xmlParser.js';
2189
+ import { parseMarkdown } from './parsers/markdownParser.js';
2190
+ const PARSERS = {
2191
+ java: parseJava,
2192
+ typescript: parseTS,
2193
+ javascript: parseTS,
2194
+ vue: parseVue,
2195
+ python: parsePython,
2196
+ sql: parseSQL,
2197
+ config: parseConfig,
2198
+ xml: parseXML,
2199
+ html: parseXML,
2200
+ markdown: parseMarkdown,
2201
+ };
2202
+ export function getParser(filePath) {
2203
+ const ext = path.extname(filePath).toLowerCase();
2204
+ const language = LANGUAGE_MAP[ext];
2205
+ return { language, parser: PARSERS[language] || null };
2206
+ }
2207
+ export async function parseFile(filePath, content, repoName) {
2208
+ const { language, parser } = getParser(filePath);
2209
+ if (!parser) return null;
2210
+ return parser(filePath, content, repoName, language);
2211
+ }
2212
+
2213
+ ### File: src/indexer/securityScanner.js
2214
+ export const VULN_PATTERNS = [
2215
+ { id: 'CWE-611', lang: ['java'], severity: 'HIGH',
2216
+ name: 'XXE (XML External Entity)',
2217
+ pattern: /builder\.parse\(/,
2218
+ negPattern: /setFeature.*FEATURE_SECURE_PROCESSING/,
2219
+ fix: 'Add dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) before parse()' },
2220
+ { id: 'CWE-89', lang: ['java'], severity: 'HIGH',
2221
+ name: 'SQL Injection',
2222
+ pattern: /"SELECT|INSERT|UPDATE|DELETE.*"\s*\+/i,
2223
+ fix: 'Use PreparedStatement with parameterized queries' },
2224
+ { id: 'CWE-326', lang: ['java'], severity: 'HIGH',
2225
+ name: 'Weak Cryptography',
2226
+ pattern: /getInstance\("(MD5|SHA1|DES|RC4)"\)/,
2227
+ fix: 'Use SHA-256 or AES-256' },
2228
+ { id: 'CWE-502', lang: ['java'], severity: 'HIGH',
2229
+ name: 'Unsafe Deserialization',
2230
+ pattern: /new ObjectInputStream|\.readObject\(\)/,
2231
+ fix: 'Validate input before deserialization; use safe deserialization libraries' },
2232
+ { id: 'CWE-078', lang: ['java'], severity: 'HIGH',
2233
+ name: 'Command Injection',
2234
+ pattern: /Runtime\.getRuntime\(\)\.exec\(/,
2235
+ fix: 'Use ProcessBuilder with argument list instead of string concatenation' },
2236
+ { id: 'CWE-79', lang: ['javascript', 'typescript', 'vue'], severity: 'HIGH',
2237
+ name: 'XSS via innerHTML / dangerouslySetInnerHTML',
2238
+ pattern: /\.innerHTML\s*=|dangerouslySetInnerHTML/,
2239
+ fix: 'Use textContent or sanitize with DOMPurify' },
2240
+ { id: 'CWE-89', lang: ['javascript', 'typescript'], severity: 'HIGH',
2241
+ name: 'SQL Injection (JS)',
2242
+ pattern: /`\s*(SELECT|INSERT|UPDATE|DELETE).*\$\{/i,
2243
+ fix: 'Use parameterized queries or an ORM' },
2244
+ { id: 'CWE-798', lang: ['javascript', 'typescript', 'vue'], severity: 'HIGH',
2245
+ name: 'Hardcoded Secret',
2246
+ pattern: /(api_key|apikey|secret|password|token)\s*[:=]\s*['"][^'"]{8,}['"]/i,
2247
+ fix: 'Move secrets to environment variables or a secrets manager' },
2248
+ { id: 'CWE-327', lang: ['javascript', 'typescript'], severity: 'MEDIUM',
2249
+ name: 'Weak Crypto (Node)',
2250
+ pattern: /createHash\(['"]md5['"]\)|createHash\(['"]sha1['"]\)/,
2251
+ fix: 'Use SHA-256 or stronger' },
2252
+ { id: 'CWE-601', lang: ['javascript', 'typescript', 'vue'], severity: 'MEDIUM',
2253
+ name: 'Open Redirect',
2254
+ pattern: /res\.redirect\([^)]*req\.(query|params|body)/,
2255
+ fix: 'Validate redirect URL against an allowlist' },
2256
+ { id: 'CWE-089', lang: ['python'], severity: 'HIGH',
2257
+ name: 'SQL Injection (Python)',
2258
+ pattern: /execute\([f"'].*%(s|d)|execute\(.*format\(/,
2259
+ fix: 'Use parameterized queries: cursor.execute(sql, params)' },
2260
+ { id: 'CWE-078', lang: ['python'], severity: 'HIGH',
2261
+ name: 'Command Injection (Python)',
2262
+ pattern: /os\.system\(|subprocess\.call\(.*shell=True/,
2263
+ fix: 'Use subprocess with a list of args and shell=False' },
2264
+ { id: 'CWE-798', lang: ['python'], severity: 'HIGH',
2265
+ name: 'Hardcoded Secret (Python)',
2266
+ pattern: /(api_key|secret|password|token)\s*=\s*['"][^'"]{8,}['"]/i,
2267
+ fix: 'Use environment variables or a secrets manager like Vault' },
2268
+ { id: 'CWE-312', lang: null, severity: 'MEDIUM',
2269
+ name: 'Cleartext Sensitive Data in Comments',
2270
+ pattern: /\/\/.*?(password|secret|token)\s*[:=]\s*\S+/i,
2271
+ fix: 'Remove sensitive data from comments' },
2272
+ ];
2273
+ export function scanFile(filePath, content, language) {
2274
+ const findings = [];
2275
+ const lines = content.split('\n');
2276
+ for (const rule of VULN_PATTERNS) {
2277
+ if (rule.lang && !rule.lang.includes(language)) continue;
2278
+ lines.forEach((line, i) => {
2279
+ if (!rule.pattern.test(line)) return;
2280
+ if (rule.negPattern) {
2281
+ const ctx = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
2282
+ if (rule.negPattern.test(ctx)) return;
2283
+ }
2284
+ findings.push({
2285
+ severity: rule.severity,
2286
+ cwe: rule.id,
2287
+ rule_name: rule.name,
2288
+ line: i + 1,
2289
+ code_snippet: line.trim().substring(0, 120),
2290
+ description: rule.fix,
2291
+ });
2292
+ });
2293
+ }
2294
+ return findings;
2295
+ }
2296
+
2297
+ ### File: src/tools/bundle.js
2298
+ import { writeBundle } from '../bundler/bundleWriter.js';
2299
+ export async function bundle(_db, args) {
2300
+ const {
2301
+ bundle_name = 'default',
2302
+ filter_paths = null,
2303
+ filter_language = null,
2304
+ no_comments = true,
2305
+ no_empty_lines = true,
2306
+ no_style = false,
2307
+ aggressive = false,
2308
+ only_changed = false,
2309
+ max_size_kb = 800,
2310
+ } = args;
2311
+ const result = await writeBundle({
2312
+ bundleName: bundle_name,
2313
+ noComments: no_comments,
2314
+ noEmptyLines: no_empty_lines,
2315
+ noStyle: no_style,
2316
+ aggressive,
2317
+ onlyChanged: only_changed,
2318
+ filterLang: filter_language,
2319
+ filterPaths: filter_paths,
2320
+ maxSizeKB: max_size_kb,
2321
+ });
2322
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2323
+ }
2324
+
2325
+ ### File: src/tools/checkpoint.js
2326
+ import fs from 'fs';
2327
+ import path from 'path';
2328
+ import { getCairnDir } from '../graph/cwd.js';
2329
+ export function checkpoint(_db, { message, active_files = [], notes = [] }) {
2330
+ const cairnDir = getCairnDir();
2331
+ const sessionPath = path.join(cairnDir, 'session.json');
2332
+ const mtimeSnapshot = {};
2333
+ for (const filePath of active_files) {
2334
+ try {
2335
+ mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs;
2336
+ } catch {
2337
+ }
2338
+ }
2339
+ const session = {
2340
+ message,
2341
+ checkpoint_at: new Date().toISOString(),
2342
+ active_files,
2343
+ notes,
2344
+ mtime_snapshot: mtimeSnapshot,
2345
+ };
2346
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
2347
+ return {
2348
+ content: [{
2349
+ type: 'text',
2350
+ text: JSON.stringify({
2351
+ saved: true,
2352
+ checkpoint_at: session.checkpoint_at,
2353
+ active_files_tracked: active_files.length,
2354
+ notes_saved: notes.length,
2355
+ }, null, 2),
2356
+ }],
2357
+ };
2358
+ }
2359
+
2360
+ ### File: src/tools/codeGraph.js
2361
+ export function codeGraph(db, { module: modFilter, mode }) {
2362
+ if (mode === 'health') {
2363
+ const fileCount = db.prepare('SELECT COUNT(*) as c FROM files').get()?.c || 0;
2364
+ const symbolCount = db.prepare('SELECT COUNT(*) as c FROM symbols').get()?.c || 0;
2365
+ const depCount = db.prepare('SELECT COUNT(*) as c FROM dependencies').get()?.c || 0;
2366
+ const findingCount = db.prepare('SELECT COUNT(*) as c FROM security_findings').get()?.c || 0;
2367
+ const byLang = db.prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language').all();
2368
+ const lastIndexed = db.prepare('SELECT MAX(last_indexed) as t FROM files').get()?.t;
2369
+ return {
2370
+ content: [{
2371
+ type: 'text',
2372
+ text: JSON.stringify({
2373
+ files: fileCount, symbols: symbolCount,
2374
+ dependencies: depCount, security_findings: findingCount,
2375
+ by_language: byLang,
2376
+ last_indexed: lastIndexed ? new Date(lastIndexed).toISOString() : null,
2377
+ }, null, 2),
2378
+ }],
2379
+ };
2380
+ }
2381
+ if (mode === 'instability') {
2382
+ const files = db.prepare('SELECT id, path, language FROM files').all();
2383
+ const modules = {};
2384
+ for (const f of files) {
2385
+ const parts = f.path.replace(/\\/g, '/').split('/');
2386
+ const moduleName = parts.slice(0, -1).join('/') || '/';
2387
+ if (modFilter && !moduleName.includes(modFilter)) continue;
2388
+ if (!modules[moduleName]) modules[moduleName] = { name: moduleName, language: f.language, fileIds: [] };
2389
+ modules[moduleName].fileIds.push(f.id);
2390
+ }
2391
+ const results = [];
2392
+ for (const [, mod] of Object.entries(modules)) {
2393
+ if (mod.fileIds.length === 0) continue;
2394
+ const ph = mod.fileIds.map(() => '?').join(',');
2395
+ const ce = db.prepare(
2396
+ `SELECT COUNT(DISTINCT to_fqn) as c FROM dependencies WHERE from_file_id IN (${ph})`
2397
+ ).get(...mod.fileIds)?.c || 0;
2398
+ const fqns = db.prepare(
2399
+ `SELECT fqn FROM symbols WHERE file_id IN (${ph})`
2400
+ ).all(...mod.fileIds).map(s => s.fqn);
2401
+ let ca = 0;
2402
+ if (fqns.length > 0) {
2403
+ const sph = fqns.map(() => '?').join(',');
2404
+ ca = db.prepare(
2405
+ `SELECT COUNT(DISTINCT from_file_id) as c FROM dependencies WHERE to_fqn IN (${sph})`
2406
+ ).get(...fqns)?.c || 0;
2407
+ }
2408
+ const instability = (ca + ce) === 0 ? 0 : ce / (ca + ce);
2409
+ const status = instability >= 0.8 ? 'safe_to_refactor'
2410
+ : instability >= 0.3 ? 'review_before_change'
2411
+ : 'load_bearing';
2412
+ results.push({
2413
+ name: mod.name,
2414
+ language: mod.language,
2415
+ instability: Math.round(instability * 100) / 100,
2416
+ status,
2417
+ });
2418
+ }
2419
+ results.sort((a, b) => b.instability - a.instability);
2420
+ const godObjects = db.prepare(`
2421
+ SELECT s.name || ' (' || COALESCE(s.language,'?') || ')' as label, COUNT(*) as dep_count
2422
+ FROM dependencies d JOIN symbols s ON d.to_fqn = s.fqn
2423
+ GROUP BY d.to_fqn ORDER BY dep_count DESC LIMIT 5
2424
+ `).all().map(r => `${r.label} (${r.dep_count} deps)`);
2425
+ return {
2426
+ content: [{
2427
+ type: 'text',
2428
+ text: JSON.stringify({
2429
+ modules: results.slice(0, 50),
2430
+ god_objects: godObjects,
2431
+ cycles_detected: [],
2432
+ }, null, 2),
2433
+ }],
2434
+ };
2435
+ }
2436
+ if (mode === 'cycles') {
2437
+ const cycles = db.prepare(`
2438
+ SELECT DISTINCT f1.path as from_path, f2.path as to_path
2439
+ FROM dependencies d1
2440
+ JOIN symbols s1 ON d1.to_fqn = s1.fqn
2441
+ JOIN files f1 ON d1.from_file_id = f1.id
2442
+ JOIN files f2 ON s1.file_id = f2.id
2443
+ JOIN dependencies d2 ON d2.from_file_id = f2.id
2444
+ JOIN symbols s2 ON d2.to_fqn = s2.fqn
2445
+ WHERE s2.file_id = d1.from_file_id
2446
+ LIMIT 20
2447
+ `).all();
2448
+ return {
2449
+ content: [{ type: 'text', text: JSON.stringify({ cycles_detected: cycles }, null, 2) }],
2450
+ };
2451
+ }
2452
+ return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown mode: ${mode}` }) }] };
2453
+ }
2454
+
2455
+ ### File: src/tools/describe.js
2456
+ export function describe(db, { path: dirPath }) {
2457
+ const files = db.prepare('SELECT * FROM files WHERE path LIKE ?').all(`${dirPath}%`);
2458
+ if (files.length === 0) {
2459
+ return { content: [{ type: 'text', text: JSON.stringify({ path: dirPath, error: 'No files found' }) }] };
2460
+ }
2461
+ const fileIds = files.map(f => f.id);
2462
+ const languages = [...new Set(files.map(f => f.language).filter(Boolean))];
2463
+ const ph = fileIds.map(() => '?').join(',');
2464
+ const symbols = db.prepare(`SELECT * FROM symbols WHERE file_id IN (${ph})`).all(...fileIds);
2465
+ const grouped = {};
2466
+ for (const sym of symbols) {
2467
+ const key = sym.kind || 'other';
2468
+ if (!grouped[key]) grouped[key] = [];
2469
+ grouped[key].push(sym.name);
2470
+ }
2471
+ const outgoing = db.prepare(
2472
+ `SELECT DISTINCT to_fqn FROM dependencies WHERE from_file_id IN (${ph}) AND dep_type = 'imports'`
2473
+ ).all(...fileIds).map(r => r.to_fqn);
2474
+ let incomingPaths = [];
2475
+ if (symbols.length > 0) {
2476
+ const fqns = symbols.map(s => s.fqn);
2477
+ const sph = fqns.map(() => '?').join(',');
2478
+ incomingPaths = db.prepare(
2479
+ `SELECT DISTINCT f.path FROM dependencies d JOIN files f ON d.from_file_id = f.id WHERE d.to_fqn IN (${sph})`
2480
+ ).all(...fqns).map(r => r.path);
2481
+ }
2482
+ const allFqns = new Set(db.prepare('SELECT fqn FROM symbols').all().map(s => s.fqn));
2483
+ const externalDeps = [...new Set(outgoing.filter(fqn => !allFqns.has(fqn)))].slice(0, 20);
2484
+ const result = {
2485
+ path: dirPath,
2486
+ languages,
2487
+ symbols: grouped,
2488
+ imports_from: outgoing.slice(0, 20),
2489
+ imported_by: incomingPaths.slice(0, 20),
2490
+ external_deps: externalDeps,
2491
+ };
2492
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2493
+ }
2494
+
2495
+ ### File: src/tools/maintain.js
2496
+ import fs from 'fs/promises';
2497
+ import path from 'path';
2498
+ import { walkRepo, BUILD_FILES } from '../indexer/fileWalker.js';
2499
+ import { parseFile } from '../indexer/parserFactory.js';
2500
+ import { scanFile } from '../indexer/securityScanner.js';
2501
+ import { parseMaven } from '../indexer/buildParsers/mavenParser.js';
2502
+ import { parseNpm } from '../indexer/buildParsers/npmParser.js';
2503
+ import { parseGradle } from '../indexer/buildParsers/gradleParser.js';
2504
+ import { upsertFile, upsertSymbol, clearFileData } from '../graph/nodes.js';
2505
+ import { insertDependency, insertBuildDep, insertSecurityFinding } from '../graph/edges.js';
2506
+ function inferFileType(filePath) {
2507
+ const fp = filePath.toLowerCase();
2508
+ if (fp.includes('test') || fp.includes('spec')) return 'test';
2509
+ if (fp.endsWith('pom.xml') || fp.endsWith('package.json') || fp.endsWith('build.gradle')) return 'build';
2510
+ if (fp.endsWith('.yml') || fp.endsWith('.yaml') || fp.endsWith('.properties') || fp.endsWith('.env')) return 'config';
2511
+ if (fp.endsWith('.md') || fp.endsWith('.html')) return 'doc';
2512
+ return 'source';
2513
+ }
2514
+ async function indexFile(db, filePath, repoName, languages, stats) {
2515
+ try {
2516
+ const content = await fs.readFile(filePath, 'utf-8');
2517
+ const result = await parseFile(filePath, content, repoName);
2518
+ if (!result) return;
2519
+ const { language } = result;
2520
+ if (languages && !languages.includes(language)) return;
2521
+ stats.files_by_language[language] = (stats.files_by_language[language] || 0) + 1;
2522
+ const fileId = upsertFile(db, {
2523
+ repo: repoName,
2524
+ path: filePath,
2525
+ language,
2526
+ file_type: inferFileType(filePath),
2527
+ });
2528
+ clearFileData(db, fileId);
2529
+ for (const sym of (result.symbols || [])) {
2530
+ upsertSymbol(db, { ...sym, file_id: fileId, language });
2531
+ stats.symbols_total++;
2532
+ }
2533
+ for (const imp of (result.imports || [])) {
2534
+ insertDependency(db, { from_file_id: fileId, to_fqn: imp, dep_type: 'imports' });
2535
+ stats.dependencies_mapped++;
2536
+ }
2537
+ const findings = scanFile(filePath, content, language);
2538
+ for (const f of findings) {
2539
+ insertSecurityFinding(db, { file_id: fileId, ...f });
2540
+ stats.security_findings++;
2541
+ }
2542
+ } catch {
2543
+ }
2544
+ }
2545
+ function rebuildFts(db) {
2546
+ db.exec(`
2547
+ DELETE FROM fts_index;
2548
+ INSERT INTO fts_index(fqn, name, description, path, repo, language, kind)
2549
+ SELECT s.fqn, s.name, COALESCE(s.description,''), f.path, f.repo, s.language, s.kind
2550
+ FROM symbols s JOIN files f ON s.file_id = f.id;
2551
+ `);
2552
+ }
2553
+ export async function maintain(db, { languages } = {}) {
2554
+ const startTime = Date.now();
2555
+ const root = process.cwd();
2556
+ const repoName = path.basename(root);
2557
+ const stats = {
2558
+ repos_indexed: 1,
2559
+ files_by_language: {},
2560
+ symbols_total: 0,
2561
+ dependencies_mapped: 0,
2562
+ security_findings: 0,
2563
+ };
2564
+ const files = await walkRepo(root);
2565
+ for (const filePath of files) {
2566
+ await indexFile(db, filePath, repoName, languages, stats);
2567
+ }
2568
+ for (const buildFile of BUILD_FILES) {
2569
+ const buildPath = path.join(root, buildFile);
2570
+ try {
2571
+ const content = await fs.readFile(buildPath, 'utf-8');
2572
+ let deps = [];
2573
+ if (buildFile === 'pom.xml') deps = parseMaven(buildPath, content, repoName);
2574
+ else if (buildFile === 'package.json') deps = parseNpm(buildPath, content, repoName);
2575
+ else if (buildFile === 'build.gradle') deps = parseGradle(buildPath, content, repoName);
2576
+ for (const dep of deps) insertBuildDep(db, { repo: repoName, ...dep });
2577
+ } catch {
2578
+ }
2579
+ }
2580
+ rebuildFts(db);
2581
+ stats.duration_ms = Date.now() - startTime;
2582
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
2583
+ }
2584
+ export async function incrementalMaintain(db, changedFiles) {
2585
+ const root = process.cwd();
2586
+ const repoName = path.basename(root);
2587
+ const stats = {
2588
+ files_by_language: {},
2589
+ symbols_total: 0,
2590
+ dependencies_mapped: 0,
2591
+ security_findings: 0,
2592
+ };
2593
+ for (const filePath of changedFiles) {
2594
+ await indexFile(db, filePath, repoName, null, stats);
2595
+ }
2596
+ rebuildFts(db);
2597
+ return stats;
2598
+ }
2599
+
2600
+ ### File: src/tools/resume.js
2601
+ import fs from 'fs';
2602
+ import path from 'path';
2603
+ import { getCairnDir } from '../graph/cwd.js';
2604
+ import { walkRepo } from '../indexer/fileWalker.js';
2605
+ import { incrementalMaintain } from './maintain.js';
2606
+ export async function resume(db) {
2607
+ const cairnDir = getCairnDir();
2608
+ const sessionPath = path.join(cairnDir, 'session.json');
2609
+ if (!fs.existsSync(sessionPath)) {
2610
+ return {
2611
+ content: [{
2612
+ type: 'text',
2613
+ text: JSON.stringify({
2614
+ error: 'No checkpoint found. Run cairn_maintain to index the project, then cairn_checkpoint to save session state.',
2615
+ }, null, 2),
2616
+ }],
2617
+ };
2618
+ }
2619
+ const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
2620
+ const snapshot = session.mtime_snapshot || {};
2621
+ const changed = [];
2622
+ for (const [filePath, savedMtime] of Object.entries(snapshot)) {
2623
+ try {
2624
+ const currentMtime = fs.statSync(filePath).mtimeMs;
2625
+ if (currentMtime !== savedMtime) {
2626
+ changed.push({ file: filePath, change: 'modified' });
2627
+ }
2628
+ } catch {
2629
+ changed.push({ file: filePath, change: 'deleted' });
2630
+ }
2631
+ }
2632
+ try {
2633
+ const allFiles = await walkRepo(process.cwd());
2634
+ for (const filePath of allFiles) {
2635
+ if (!(filePath in snapshot)) {
2636
+ changed.push({ file: filePath, change: 'new' });
2637
+ }
2638
+ }
2639
+ } catch {
2640
+ }
2641
+ const filesToReindex = changed
2642
+ .filter(c => c.change !== 'deleted')
2643
+ .map(c => c.file);
2644
+ let reindexStats = { files_reindexed: 0, files_unchanged: 0 };
2645
+ if (filesToReindex.length > 0) {
2646
+ const stats = await incrementalMaintain(db, filesToReindex);
2647
+ reindexStats.files_reindexed = filesToReindex.length;
2648
+ }
2649
+ reindexStats.files_unchanged = Object.keys(snapshot).length - changed.filter(c => c.change !== 'new').length;
2650
+ const changedSummary = changed.length > 0
2651
+ ? changed.slice(0, 10).map(c => `${c.change}: ${path.relative(process.cwd(), c.file).replace(/\\/g, '/')}`).join(', ')
2652
+ : 'none';
2653
+ const resumeSummary = [
2654
+ `Last session: ${session.message}`,
2655
+ changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
2656
+ session.notes?.length > 0 ? `Notes: ${session.notes.join(' | ')}` : '',
2657
+ ].filter(Boolean).join(' ');
2658
+ return {
2659
+ content: [{
2660
+ type: 'text',
2661
+ text: JSON.stringify({
2662
+ last_checkpoint: session.checkpoint_at,
2663
+ message: session.message,
2664
+ index_status: filesToReindex.length > 0 ? 'incremental' : 'up_to_date',
2665
+ files_reindexed: reindexStats.files_reindexed,
2666
+ files_unchanged: Math.max(0, reindexStats.files_unchanged),
2667
+ changed_since_checkpoint: changed.slice(0, 20),
2668
+ active_files: session.active_files || [],
2669
+ notes: session.notes || [],
2670
+ resume_summary: resumeSummary,
2671
+ }, null, 2),
2672
+ }],
2673
+ };
2674
+ }
2675
+
2676
+ ### File: src/tools/search.js
2677
+ export function search(db, { query, limit = 10, language, kind }) {
2678
+ if (!query?.trim()) {
2679
+ return { content: [{ type: 'text', text: '[]' }] };
2680
+ }
2681
+ let sql = `
2682
+ SELECT fqn, name, kind, language, description, path, repo,
2683
+ bm25(fts_index) as score
2684
+ FROM fts_index
2685
+ WHERE fts_index MATCH ?
2686
+ `;
2687
+ const params = [query];
2688
+ if (language) { sql += ' AND language = ?'; params.push(language); }
2689
+ if (kind) { sql += ' AND kind = ?'; params.push(kind); }
2690
+ sql += ' ORDER BY bm25(fts_index) LIMIT ?';
2691
+ params.push(limit);
2692
+ try {
2693
+ const results = db.prepare(sql).all(...params);
2694
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
2695
+ } catch (err) {
2696
+ return { content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
2697
+ }
2698
+ }
2699
+
2700
+ ### File: src/tools/security.js
2701
+ export function security(db, { paths, severity = 'HIGH', language }) {
2702
+ let sql = `
2703
+ SELECT sf.severity, sf.cwe, sf.rule_name as rule, sf.line,
2704
+ sf.code_snippet as snippet, sf.description as fix,
2705
+ f.path as file, f.language as lang
2706
+ FROM security_findings sf
2707
+ JOIN files f ON sf.file_id = f.id
2708
+ WHERE 1=1
2709
+ `;
2710
+ const params = [];
2711
+ if (severity === 'HIGH') {
2712
+ sql += ` AND sf.severity = 'HIGH'`;
2713
+ } else if (severity === 'MEDIUM') {
2714
+ sql += ` AND sf.severity IN ('HIGH','MEDIUM')`;
2715
+ } else if (severity !== 'ALL') {
2716
+ sql += ' AND sf.severity = ?';
2717
+ params.push(severity);
2718
+ }
2719
+ if (language) {
2720
+ sql += ' AND f.language = ?';
2721
+ params.push(language);
2722
+ }
2723
+ if (paths && paths.length > 0) {
2724
+ sql += ` AND (${paths.map(() => 'f.path LIKE ?').join(' OR ')})`;
2725
+ params.push(...paths.map(p => `${p}%`));
2726
+ }
2727
+ sql += ' ORDER BY sf.severity, f.path';
2728
+ const findings = db.prepare(sql).all(...params);
2729
+ const byLanguage = {};
2730
+ const bySeverity = {};
2731
+ for (const f of findings) {
2732
+ byLanguage[f.lang] = (byLanguage[f.lang] || 0) + 1;
2733
+ bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
2734
+ }
2735
+ return {
2736
+ content: [{
2737
+ type: 'text',
2738
+ text: JSON.stringify({
2739
+ total_findings: findings.length,
2740
+ by_language: byLanguage,
2741
+ by_severity: bySeverity,
2742
+ findings: findings.slice(0, 100),
2743
+ }, null, 2),
2744
+ }],
2745
+ };
2746
+ }
2747
+
2748
+ ### File: src/indexer/buildParsers/gradleParser.js
2749
+ export function parseGradle(filePath, content, repoName) {
2750
+ const deps = [];
2751
+ for (const m of content.matchAll(/(\w+)\s+['"]([^:'"]+):([^:'"]+):([^'"]+)['"]/g)) {
2752
+ const scope = m[1];
2753
+ const group = m[2];
2754
+ const artifact = m[3];
2755
+ const version = m[4];
2756
+ deps.push({ manager: 'gradle', group_id: group, artifact, version, scope });
2757
+ }
2758
+ return deps;
2759
+ }
2760
+
2761
+ ### File: src/indexer/buildParsers/mavenParser.js
2762
+ export function parseMaven(filePath, content, repoName) {
2763
+ const deps = [];
2764
+ for (const m of content.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
2765
+ const block = m[1];
2766
+ const groupId = block.match(/<groupId>([^<]+)<\/groupId>/)?.[1] || '';
2767
+ const artifactId = block.match(/<artifactId>([^<]+)<\/artifactId>/)?.[1] || '';
2768
+ const version = block.match(/<version>([^<]+)<\/version>/)?.[1] || '';
2769
+ const scope = block.match(/<scope>([^<]+)<\/scope>/)?.[1] || 'compile';
2770
+ if (groupId && artifactId)
2771
+ deps.push({ manager: 'maven', group_id: groupId, artifact: artifactId, version, scope });
2772
+ }
2773
+ return deps;
2774
+ }
2775
+
2776
+ ### File: src/indexer/buildParsers/npmParser.js
2777
+ export function parseNpm(filePath, content, repoName) {
2778
+ const deps = [];
2779
+ try {
2780
+ const pkg = JSON.parse(content);
2781
+ for (const [name, version] of Object.entries(pkg.dependencies || {}))
2782
+ deps.push({ manager: 'npm', group_id: 'dependencies', artifact: name, version, scope: 'runtime' });
2783
+ for (const [name, version] of Object.entries(pkg.devDependencies || {}))
2784
+ deps.push({ manager: 'npm', group_id: 'devDependencies', artifact: name, version, scope: 'dev' });
2785
+ for (const [name, version] of Object.entries(pkg.peerDependencies || {}))
2786
+ deps.push({ manager: 'npm', group_id: 'peerDependencies', artifact: name, version, scope: 'peer' });
2787
+ } catch {
2788
+ }
2789
+ return deps;
2790
+ }
2791
+
2792
+ ### File: src/indexer/parsers/configParser.js
2793
+ export function parseConfig(filePath, content, repoName) {
2794
+ const symbols = [];
2795
+ for (const m of content.matchAll(/^([\w.-]+)\s*[:=]\s*(.+)/gm)) {
2796
+ const key = m[1].trim();
2797
+ if (key.startsWith('#')) continue;
2798
+ symbols.push({
2799
+ name: key,
2800
+ fqn: `${filePath}::${key}`,
2801
+ kind: 'config-key',
2802
+ exported: false,
2803
+ description: m[2].trim().substring(0, 80),
2804
+ });
2805
+ }
2806
+ return { language: 'config', symbols, imports: [] };
2807
+ }
2808
+
2809
+ ### File: src/indexer/parsers/javaParser.js
2810
+ export function parseJava(filePath, content, repoName) {
2811
+ const pkg = content.match(/^package\s+([\w.]+);/m)?.[1] || '';
2812
+ const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)].map(m => m[1]);
2813
+ const classMatch = content.match(
2814
+ /(?:public|protected)?\s+(?:abstract\s+)?(?:class|interface|enum|record)\s+(\w+)/
2815
+ );
2816
+ const name = classMatch?.[1] || '';
2817
+ const kind = classMatch?.[0]?.match(/class|interface|enum|record/)?.[0] || 'class';
2818
+ const fqn = pkg ? `${pkg}.${name}` : name;
2819
+ const methods = [...content.matchAll(
2820
+ /(?:public|protected|private)[^{;]*\s+(\w+)\s*\([^)]*\)\s*(?:throws[^{]*)?\{/gm
2821
+ )].map(m => m[1]);
2822
+ const extendsMatch = content.match(/extends\s+([\w.]+)/)?.[1];
2823
+ const implementsMatch = content.match(/implements\s+([\w.,\s]+)/)?.[1]
2824
+ ?.split(',').map(s => s.trim());
2825
+ const javadoc = content.match(/\/\*\*([\s\S]*?)\*\
2826
+ ?.replace(/\s*\*\s?/g, ' ').trim();
2827
+ return {
2828
+ language: 'java',
2829
+ symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }],
2830
+ imports,
2831
+ extends: extendsMatch ? [extendsMatch] : [],
2832
+ implements: implementsMatch || [],
2833
+ methods,
2834
+ };
2835
+ }
2836
+
2837
+ ### File: src/indexer/parsers/markdownParser.js
2838
+ export function parseMarkdown(filePath, content, repoName) {
2839
+ const symbols = [];
2840
+ for (const m of content.matchAll(/^(#{1,3})\s+(.+)/gm)) {
2841
+ const level = m[1].length;
2842
+ const title = m[2].trim();
2843
+ symbols.push({
2844
+ name: title,
2845
+ fqn: `${filePath}::${title.replace(/\s+/g, '-').toLowerCase()}`,
2846
+ kind: level === 1 ? 'doc-title' : 'doc-section',
2847
+ exported: true,
2848
+ description: '',
2849
+ });
2850
+ }
2851
+ return { language: 'markdown', symbols, imports: [] };
2852
+ }
2853
+
2854
+ ### File: src/indexer/parsers/pythonParser.js
2855
+ export function parsePython(filePath, content, repoName) {
2856
+ const symbols = [];
2857
+ const imports = [];
2858
+ for (const m of content.matchAll(/^from\s+([\w.]+)\s+import\s+([\w,\s*]+)/gm))
2859
+ imports.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
2860
+ for (const m of content.matchAll(/^import\s+([\w.,\s]+)/gm))
2861
+ imports.push(...m[1].split(',').map(s => s.trim()).filter(Boolean));
2862
+ for (const m of content.matchAll(/^class\s+(\w+)(?:\(([^)]*)\))?:/gm)) {
2863
+ const docstring = content.slice(m.index).match(/:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
2864
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported: true, description: docstring || '' });
2865
+ }
2866
+ for (const m of content.matchAll(/^(?:async\s+)?def\s+(\w+)\s*\(/gm)) {
2867
+ if (m[1].startsWith('_')) continue;
2868
+ const docstring = content.slice(m.index).match(/\).*?:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
2869
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: docstring || '' });
2870
+ }
2871
+ for (const m of content.matchAll(/^@([\w.]+)(?:\(([^)]*)\))?/gm))
2872
+ symbols.push({ name: m[1], fqn: `${filePath}::@${m[1]}`, kind: 'decorator', exported: false, description: '' });
2873
+ return { language: 'python', symbols, imports };
2874
+ }
2875
+
2876
+ ### File: src/indexer/parsers/sqlParser.js
2877
+ export function parseSQL(filePath, content, repoName) {
2878
+ const symbols = [];
2879
+ for (const m of content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"\w.]+)/gi))
2880
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'table', exported: true, description: '' });
2881
+ for (const m of content.matchAll(/CREATE\s+(?:OR\s+REPLACE\s+)?(?:PROCEDURE|FUNCTION)\s+(\w+)/gi))
2882
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
2883
+ for (const m of content.matchAll(/CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+(\w+)/gi))
2884
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'view', exported: true, description: '' });
2885
+ return { language: 'sql', symbols, imports: [] };
2886
+ }
2887
+
2888
+ ### File: src/indexer/parsers/tsParser.js
2889
+ export function parseTS(filePath, content, repoName, language = 'typescript') {
2890
+ const symbols = [];
2891
+ const imports = [];
2892
+ for (const m of content.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g))
2893
+ imports.push(...m[1].split(',').map(s => s.trim().split(' as ')[0]).filter(Boolean));
2894
+ for (const m of content.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g))
2895
+ imports.push(m[1]);
2896
+ for (const m of content.matchAll(/(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/g)) {
2897
+ const exported = m[0].startsWith('export');
2898
+ const jsdoc = extractJSDoc(content, m.index);
2899
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported, description: jsdoc });
2900
+ }
2901
+ for (const m of content.matchAll(/export\s+(?:interface|type)\s+(\w+)/g))
2902
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'interface', exported: true, description: '' });
2903
+ for (const m of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) {
2904
+ const jsdoc = extractJSDoc(content, m.index);
2905
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: jsdoc });
2906
+ }
2907
+ for (const m of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g))
2908
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
2909
+ for (const m of content.matchAll(/export\s+(?:const\s+)?enum\s+(\w+)/g))
2910
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'enum', exported: true, description: '' });
2911
+ return { language, symbols, imports };
2912
+ }
2913
+ function extractJSDoc(content, index) {
2914
+ const before = content.substring(0, index);
2915
+ const match = before.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
2916
+ return match ? match[1].replace(/\s*\*\s?/g, ' ').trim() : '';
2917
+ }
2918
+
2919
+ ### File: src/indexer/parsers/vueParser.js
2920
+ import { parseTS } from './tsParser.js';
2921
+ export function parseVue(filePath, content, repoName) {
2922
+ const componentName = filePath.split('/').pop().replace('.vue', '');
2923
+ const scriptMatch = content.match(/<script(?:\s+setup)?(?:\s+lang=["']ts["'])?>([\s\S]*?)<\/script>/i);
2924
+ const scriptContent = scriptMatch?.[1] || '';
2925
+ const parsed = parseTS(filePath, scriptContent, repoName, 'vue');
2926
+ const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
2927
+ const childComponents = [...(templateMatch?.[1]?.matchAll(/<([A-Z][A-Za-z]+)/g) || [])]
2928
+ .map(m => m[1]);
2929
+ parsed.symbols.unshift({
2930
+ name: componentName,
2931
+ fqn: `${filePath}::${componentName}`,
2932
+ kind: 'component',
2933
+ exported: true,
2934
+ description: `Vue component: ${componentName}`,
2935
+ });
2936
+ parsed.childComponents = [...new Set(childComponents)];
2937
+ return parsed;
2938
+ }
2939
+
2940
+ ### File: src/indexer/parsers/xmlParser.js
2941
+ const HTML_TAGS = new Set([
2942
+ 'div','span','p','a','ul','ol','li','table','tr','td','th','thead','tbody',
2943
+ 'form','input','button','select','option','textarea','label','img','br','hr',
2944
+ 'h1','h2','h3','h4','h5','h6','head','body','html','script','style','link',
2945
+ 'meta','nav','header','footer','main','section','article','aside','figure',
2946
+ ]);
2947
+ export function parseXML(filePath, content, repoName) {
2948
+ const symbols = [];
2949
+ for (const m of content.matchAll(/\bid\s*=\s*["']([^"']+)["']/g))
2950
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'bean', exported: true, description: '' });
2951
+ const seen = new Set();
2952
+ for (const m of content.matchAll(/<([a-z][a-z0-9-]*)(?:\s|\/?>)/gi)) {
2953
+ const tag = m[1].toLowerCase();
2954
+ if (HTML_TAGS.has(tag) || seen.has(tag)) continue;
2955
+ seen.add(tag);
2956
+ symbols.push({ name: tag, fqn: `${filePath}::${tag}`, kind: 'component', exported: true, description: '' });
2957
+ }
2958
+ return { language: 'xml', symbols, imports: [] };
2959
+ }