@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.
- package/.cairn/bundles/full-source.txt +2959 -0
- package/.cairn/index.db +0 -0
- package/.cairn/index.db-shm +0 -0
- package/.cairn/index.db-wal +0 -0
- package/.cairn/session.json +7 -0
- package/README.md +22 -15
- package/bin/cairn-cli.js +111 -0
- package/bin/cairn.js +21 -0
- package/how-to-use.md +134 -74
- package/package.json +3 -2
- package/src/graph/db.js +1 -9
|
@@ -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
|
+
}
|