@misterhuydo/cairn-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/cairn-mcp.js +21 -0
- package/how-to-use.md +61 -0
- package/index.js +141 -0
- package/package.json +43 -0
- package/src/bundler/bundleWriter.js +113 -0
- package/src/bundler/minifier.js +69 -0
- package/src/bundler/vueMinifier.js +39 -0
- package/src/graph/db.js +69 -0
- package/src/graph/edges.js +21 -0
- package/src/graph/nodes.js +30 -0
- package/src/indexer/buildParsers/gradleParser.js +14 -0
- package/src/indexer/buildParsers/mavenParser.js +15 -0
- package/src/indexer/buildParsers/npmParser.js +17 -0
- package/src/indexer/fileWalker.js +40 -0
- package/src/indexer/parserFactory.js +35 -0
- package/src/indexer/parsers/configParser.js +17 -0
- package/src/indexer/parsers/javaParser.js +31 -0
- package/src/indexer/parsers/markdownParser.js +17 -0
- package/src/indexer/parsers/pythonParser.js +25 -0
- package/src/indexer/parsers/sqlParser.js +14 -0
- package/src/indexer/parsers/tsParser.js +45 -0
- package/src/indexer/parsers/vueParser.js +25 -0
- package/src/indexer/parsers/xmlParser.js +25 -0
- package/src/indexer/securityScanner.js +103 -0
- package/src/tools/bundle.js +30 -0
- package/src/tools/codeGraph.js +111 -0
- package/src/tools/describe.js +51 -0
- package/src/tools/maintain.js +103 -0
- package/src/tools/search.js +27 -0
- package/src/tools/security.js +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 misterhuydo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/cairn-mcp.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const indexPath = join(__dirname, '..', 'index.js');
|
|
8
|
+
|
|
9
|
+
const child = spawn(
|
|
10
|
+
process.execPath,
|
|
11
|
+
['--experimental-sqlite', '--no-warnings=ExperimentalWarning', indexPath],
|
|
12
|
+
{ stdio: 'inherit', env: process.env }
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
child.on('exit', (code, signal) => {
|
|
16
|
+
if (signal) process.kill(process.pid, signal);
|
|
17
|
+
else process.exit(code ?? 0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
21
|
+
process.on('SIGINT', () => child.kill('SIGINT'));
|
package/how-to-use.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Cairn — Quick Start
|
|
2
|
+
|
|
3
|
+
## 1. Install globally
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @misterhuydo/cairn-mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That's it — `cairn-mcp` is now available as a global command.
|
|
10
|
+
|
|
11
|
+
## 2. Register with Claude Code
|
|
12
|
+
|
|
13
|
+
Add to `~/.claude/config.json` (create it if it doesn't exist):
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"cairn": {
|
|
19
|
+
"command": "cairn-mcp"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> If you already have other MCP servers, just add `"cairn": { ... }` inside the existing `"mcpServers"` block.
|
|
26
|
+
|
|
27
|
+
Restart Claude Code. You should see 6 cairn tools available.
|
|
28
|
+
|
|
29
|
+
## 3. Index your repos
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
cairn_maintain
|
|
33
|
+
roots: ["/path/to/repo1", "/path/to/repo2"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run this once at the start of each session (or whenever code changes significantly).
|
|
37
|
+
|
|
38
|
+
## 4. Tools at a glance
|
|
39
|
+
|
|
40
|
+
| Tool | What to use it for |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `cairn_maintain` | Index repos (run first) |
|
|
43
|
+
| `cairn_search` | Find classes, functions, components by name or concept |
|
|
44
|
+
| `cairn_describe` | Summarize what a folder/module does |
|
|
45
|
+
| `cairn_code_graph` | Check instability, health, or cycles before refactoring |
|
|
46
|
+
| `cairn_security` | Scan for vulnerabilities (XSS, SQLi, hardcoded secrets, etc.) |
|
|
47
|
+
| `cairn_bundle` | Package source files into a minified snapshot for Claude to read |
|
|
48
|
+
|
|
49
|
+
## 5. Typical session
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
1. cairn_maintain → index all repos
|
|
53
|
+
2. cairn_search → find what you need
|
|
54
|
+
3. cairn_bundle → get a readable snapshot of relevant files
|
|
55
|
+
4. cairn_describe → understand a module before modifying it
|
|
56
|
+
5. cairn_security → check for issues before a PR
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
**Requirements:** Node.js >= 22.15.0
|
package/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
|
|
5
|
+
import { openDB } from './src/graph/db.js';
|
|
6
|
+
import { maintain } from './src/tools/maintain.js';
|
|
7
|
+
import { search } from './src/tools/search.js';
|
|
8
|
+
import { describe } from './src/tools/describe.js';
|
|
9
|
+
import { codeGraph } from './src/tools/codeGraph.js';
|
|
10
|
+
import { security } from './src/tools/security.js';
|
|
11
|
+
import { bundle } from './src/tools/bundle.js';
|
|
12
|
+
|
|
13
|
+
const db = openDB();
|
|
14
|
+
|
|
15
|
+
const server = new Server(
|
|
16
|
+
{ name: 'cairn', version: '1.0.0' },
|
|
17
|
+
{ capabilities: { tools: {} } }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: [
|
|
22
|
+
{
|
|
23
|
+
name: 'cairn_maintain',
|
|
24
|
+
description: 'Index all repos into Cairn\'s polyglot knowledge graph. Supports Java, TypeScript, JavaScript, Vue, Python, SQL, config, and Markdown. Run at session start or after major changes.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
roots: {
|
|
29
|
+
type: 'array', items: { type: 'string' },
|
|
30
|
+
description: 'Absolute paths to repo roots to index',
|
|
31
|
+
},
|
|
32
|
+
languages: {
|
|
33
|
+
type: 'array', items: { type: 'string' },
|
|
34
|
+
description: 'Optional: limit to specific languages e.g. ["java","typescript"]',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['roots'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'cairn_search',
|
|
42
|
+
description: 'Search the codebase by concept across all languages. Returns ranked symbols with scores.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
query: { type: 'string' },
|
|
47
|
+
limit: { type: 'number', default: 10 },
|
|
48
|
+
language: { type: 'string', description: 'Optional: filter by language' },
|
|
49
|
+
kind: { type: 'string', description: 'Optional: class/function/component/table/...' },
|
|
50
|
+
},
|
|
51
|
+
required: ['query'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'cairn_describe',
|
|
56
|
+
description: 'Describe what a directory/module does: symbols, responsibilities, upstream/downstream deps.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
path: { type: 'string', description: 'Directory or file path to describe' },
|
|
61
|
+
},
|
|
62
|
+
required: ['path'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'cairn_code_graph',
|
|
67
|
+
description: 'Analyze module dependencies and instability metrics across all languages. Use before refactoring.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
module: { type: 'string', description: 'Optional: scope to a specific module/package' },
|
|
72
|
+
mode: { type: 'string', enum: ['instability', 'health', 'cycles'] },
|
|
73
|
+
},
|
|
74
|
+
required: ['mode'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'cairn_security',
|
|
79
|
+
description: 'Scan for security vulnerabilities across all languages (XSS, XXE, SQLi, weak crypto, hardcoded secrets, etc.)',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
paths: { type: 'array', items: { type: 'string' }, description: 'Optional: scope to specific paths' },
|
|
84
|
+
severity: { type: 'string', enum: ['HIGH', 'MEDIUM', 'LOW', 'ALL'], default: 'HIGH' },
|
|
85
|
+
language: { type: 'string', description: 'Optional: scope to a specific language' },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'cairn_bundle',
|
|
91
|
+
description: `Produce a minified single-file snapshot of source code for Claude to read.
|
|
92
|
+
Strips comments, empty lines, and optionally styles. Returns the bundle file path and compression stats.
|
|
93
|
+
Use AFTER cairn_search to get a readable version of the files Claude needs to work with.
|
|
94
|
+
Typical workflow: cairn_search → find files → cairn_bundle those paths → Claude reads bundle.`,
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
roots: {
|
|
99
|
+
type: 'array', items: { type: 'string' },
|
|
100
|
+
description: 'Repo roots to bundle',
|
|
101
|
+
},
|
|
102
|
+
bundle_name: {
|
|
103
|
+
type: 'string', default: 'default',
|
|
104
|
+
description: 'Name for the bundle file (saved to ~/.cairn/bundles/<name>.txt)',
|
|
105
|
+
},
|
|
106
|
+
filter_paths: {
|
|
107
|
+
type: 'array', items: { type: 'string' },
|
|
108
|
+
description: 'Optional: only include files under these relative paths',
|
|
109
|
+
},
|
|
110
|
+
filter_language: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'Optional: only bundle this language (java/typescript/vue/python/...)',
|
|
113
|
+
},
|
|
114
|
+
no_comments: { type: 'boolean', default: true, description: 'Strip code comments' },
|
|
115
|
+
no_empty_lines: { type: 'boolean', default: true, description: 'Remove empty lines' },
|
|
116
|
+
no_style: { type: 'boolean', default: false, description: 'Skip <style> blocks in Vue files' },
|
|
117
|
+
aggressive: { type: 'boolean', default: false, description: 'Aggressive whitespace/punctuation minification' },
|
|
118
|
+
only_changed: { type: 'boolean', default: false, description: 'Incremental: only re-bundle files changed since last run' },
|
|
119
|
+
max_size_kb: { type: 'number', default: 800, description: 'Safety cap in KB to avoid context window overflow' },
|
|
120
|
+
},
|
|
121
|
+
required: ['roots'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
128
|
+
const { name, arguments: args } = request.params;
|
|
129
|
+
switch (name) {
|
|
130
|
+
case 'cairn_maintain': return await maintain(db, args);
|
|
131
|
+
case 'cairn_search': return search(db, args);
|
|
132
|
+
case 'cairn_describe': return describe(db, args);
|
|
133
|
+
case 'cairn_code_graph': return codeGraph(db, args);
|
|
134
|
+
case 'cairn_security': return security(db, args);
|
|
135
|
+
case 'cairn_bundle': return await bundle(db, args);
|
|
136
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const transport = new StdioServerTransport();
|
|
141
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@misterhuydo/cairn-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Persistent polyglot knowledge graph MCP server for Claude Code. Index once, query forever — across Java, TypeScript, Vue, Python, SQL and more.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cairn-mcp": "./bin/cairn-mcp.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node --experimental-sqlite index.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.15.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"knowledge-graph",
|
|
21
|
+
"codebase-search",
|
|
22
|
+
"polyglot",
|
|
23
|
+
"java",
|
|
24
|
+
"typescript",
|
|
25
|
+
"vue",
|
|
26
|
+
"python",
|
|
27
|
+
"sqlite",
|
|
28
|
+
"developer-tools"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/misterhuydo/Cairn.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/misterhuydo/Cairn#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/misterhuydo/Cairn/issues"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
41
|
+
"fast-glob": "^3.3.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { minifyContent } from './minifier.js';
|
|
5
|
+
import { minifyVue } from './vueMinifier.js';
|
|
6
|
+
import { walkRepo, LANGUAGE_MAP } from '../indexer/fileWalker.js';
|
|
7
|
+
|
|
8
|
+
const BUNDLE_DIR = path.join(os.homedir(), '.cairn', 'bundles');
|
|
9
|
+
|
|
10
|
+
export async function writeBundle(roots, options = {}) {
|
|
11
|
+
const {
|
|
12
|
+
bundleName = 'default',
|
|
13
|
+
noComments = true,
|
|
14
|
+
noEmptyLines = true,
|
|
15
|
+
noStyle = false,
|
|
16
|
+
aggressive = false,
|
|
17
|
+
onlyChanged = false,
|
|
18
|
+
filterLang = null,
|
|
19
|
+
filterPaths = null,
|
|
20
|
+
maxSizeKB = 800,
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
fs.mkdirSync(BUNDLE_DIR, { recursive: true });
|
|
24
|
+
|
|
25
|
+
const outPath = path.join(BUNDLE_DIR, `${bundleName}.txt`);
|
|
26
|
+
const mtimePath = path.join(BUNDLE_DIR, `${bundleName}.mtime.json`);
|
|
27
|
+
const mtimeCache = onlyChanged && fs.existsSync(mtimePath)
|
|
28
|
+
? JSON.parse(fs.readFileSync(mtimePath, 'utf8')) : {};
|
|
29
|
+
|
|
30
|
+
const newMtimes = {};
|
|
31
|
+
const lines = [];
|
|
32
|
+
const stats = {
|
|
33
|
+
processed: 0, skipped_unchanged: 0, skipped_size: 0,
|
|
34
|
+
original_bytes: 0, compressed_bytes: 0, by_language: {},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const langFilter = filterLang
|
|
38
|
+
? (Array.isArray(filterLang) ? filterLang : [filterLang]) : null;
|
|
39
|
+
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
const files = await walkRepo(root);
|
|
42
|
+
|
|
43
|
+
for (const filePath of files) {
|
|
44
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
45
|
+
const language = LANGUAGE_MAP[ext];
|
|
46
|
+
if (!language) continue;
|
|
47
|
+
if (langFilter && !langFilter.includes(language)) continue;
|
|
48
|
+
|
|
49
|
+
// Path filter — match against relative path from repo root
|
|
50
|
+
if (filterPaths) {
|
|
51
|
+
const rel = path.relative(root, filePath).replace(/\\/g, '/');
|
|
52
|
+
if (!filterPaths.some(p => rel.startsWith(p))) continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Incremental: skip files unchanged since last bundle
|
|
56
|
+
const mtime = fs.statSync(filePath).mtimeMs;
|
|
57
|
+
newMtimes[filePath] = mtime;
|
|
58
|
+
if (onlyChanged && mtimeCache[filePath] === mtime) {
|
|
59
|
+
stats.skipped_unchanged++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Size cap — check current accumulated size before adding more
|
|
64
|
+
const currentKB = Buffer.byteLength(lines.join('\n'), 'utf8') / 1024;
|
|
65
|
+
if (currentKB > maxSizeKB) {
|
|
66
|
+
stats.skipped_size++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
stats.original_bytes += Buffer.byteLength(raw, 'utf8');
|
|
77
|
+
|
|
78
|
+
// Minify
|
|
79
|
+
const minified = language === 'vue'
|
|
80
|
+
? minifyVue(raw, { noStyle, noComments, noEmptyLines, aggressive })
|
|
81
|
+
: minifyContent(raw, language, { noComments, noEmptyLines, aggressive });
|
|
82
|
+
|
|
83
|
+
if (!minified.trim()) continue;
|
|
84
|
+
|
|
85
|
+
const rel = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
86
|
+
lines.push(`### File: ${rel}`);
|
|
87
|
+
lines.push(minified.trim());
|
|
88
|
+
lines.push('');
|
|
89
|
+
|
|
90
|
+
stats.compressed_bytes += Buffer.byteLength(minified, 'utf8');
|
|
91
|
+
stats.processed++;
|
|
92
|
+
stats.by_language[language] = (stats.by_language[language] || 0) + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const output = lines.join('\n');
|
|
97
|
+
fs.writeFileSync(outPath, output, 'utf8');
|
|
98
|
+
if (onlyChanged) fs.writeFileSync(mtimePath, JSON.stringify(newMtimes), 'utf8');
|
|
99
|
+
|
|
100
|
+
const ratio = stats.original_bytes > 0
|
|
101
|
+
? Math.round((1 - stats.compressed_bytes / stats.original_bytes) * 100) : 0;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
bundle_path: outPath,
|
|
105
|
+
processed: stats.processed,
|
|
106
|
+
skipped_unchanged: stats.skipped_unchanged,
|
|
107
|
+
skipped_size_cap: stats.skipped_size,
|
|
108
|
+
original_kb: Math.round(stats.original_bytes / 1024),
|
|
109
|
+
compressed_kb: Math.round(stats.compressed_bytes / 1024),
|
|
110
|
+
reduction_pct: ratio,
|
|
111
|
+
by_language: stats.by_language,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function minifyContent(content, language, options = {}) {
|
|
2
|
+
const { noComments = true, noEmptyLines = true, aggressive = false } = options;
|
|
3
|
+
|
|
4
|
+
let out = content;
|
|
5
|
+
|
|
6
|
+
if (noComments) out = removeComments(out, language);
|
|
7
|
+
if (noEmptyLines) out = removeEmptyLines(out);
|
|
8
|
+
out = collapseWhitespace(out);
|
|
9
|
+
if (aggressive) out = aggressiveMinify(out);
|
|
10
|
+
|
|
11
|
+
return out.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function removeComments(content, language) {
|
|
15
|
+
switch (language) {
|
|
16
|
+
case 'java':
|
|
17
|
+
case 'typescript':
|
|
18
|
+
case 'javascript':
|
|
19
|
+
case 'vue':
|
|
20
|
+
return content
|
|
21
|
+
.replace(/\/\/.*$/gm, '')
|
|
22
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
23
|
+
|
|
24
|
+
case 'python':
|
|
25
|
+
return content
|
|
26
|
+
.replace(/#.*$/gm, '')
|
|
27
|
+
.replace(/'''[\s\S]*?'''/g, '')
|
|
28
|
+
.replace(/"""[\s\S]*?"""/g, '');
|
|
29
|
+
|
|
30
|
+
case 'sql':
|
|
31
|
+
return content
|
|
32
|
+
.replace(/--.*$/gm, '')
|
|
33
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
34
|
+
|
|
35
|
+
case 'xml':
|
|
36
|
+
case 'html':
|
|
37
|
+
return content.replace(/<!--[\s\S]*?-->/g, '');
|
|
38
|
+
|
|
39
|
+
case 'config':
|
|
40
|
+
return content.replace(/#.*$/gm, '');
|
|
41
|
+
|
|
42
|
+
default:
|
|
43
|
+
return content;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removeEmptyLines(content) {
|
|
48
|
+
return content
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter(line => line.trim().length > 0)
|
|
51
|
+
.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collapseWhitespace(content) {
|
|
55
|
+
return content
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map(line => line.trimEnd())
|
|
58
|
+
.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function aggressiveMinify(content) {
|
|
62
|
+
return content
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.replace(/\s*([=+\-*/%<>!&|^~?:;,{}()[\]])\s*/g, '$1')
|
|
65
|
+
.replace(/;\s*}/g, '}')
|
|
66
|
+
.replace(/\(\s+/g, '(')
|
|
67
|
+
.replace(/\s+\)/g, ')')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { minifyContent } from './minifier.js';
|
|
2
|
+
|
|
3
|
+
export function minifyVue(content, options = {}) {
|
|
4
|
+
const { noStyle = false, noComments = true, noEmptyLines = true, aggressive = false } = options;
|
|
5
|
+
const result = [];
|
|
6
|
+
|
|
7
|
+
// Extract and minify <template>
|
|
8
|
+
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/i);
|
|
9
|
+
if (templateMatch) {
|
|
10
|
+
let tpl = templateMatch[1];
|
|
11
|
+
if (noComments) tpl = tpl.replace(/<!--[\s\S]*?-->/g, '');
|
|
12
|
+
if (noEmptyLines) tpl = tpl.split('\n').filter(l => l.trim()).join('\n');
|
|
13
|
+
tpl = tpl.replace(/\s+/g, ' ').trim();
|
|
14
|
+
if (tpl) result.push(`<template>${tpl}</template>`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Extract and minify <script> / <script setup>
|
|
18
|
+
const scriptMatch = content.match(/<script([^>]*)>([\s\S]*?)<\/script>/i);
|
|
19
|
+
if (scriptMatch) {
|
|
20
|
+
const attrs = scriptMatch[1];
|
|
21
|
+
const code = minifyContent(scriptMatch[2], 'javascript',
|
|
22
|
+
{ noComments, noEmptyLines, aggressive });
|
|
23
|
+
const tag = /setup/i.test(attrs) ? '<script setup>' : '<script>';
|
|
24
|
+
if (code) result.push(`${tag}${code}</script>`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Extract and minify <style> (unless skipped)
|
|
28
|
+
if (!noStyle) {
|
|
29
|
+
const styleMatch = content.match(/<style([^>]*)>([\s\S]*?)<\/style>/i);
|
|
30
|
+
if (styleMatch) {
|
|
31
|
+
const css = styleMatch[2]
|
|
32
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
33
|
+
.replace(/\s+/g, ' ').trim();
|
|
34
|
+
if (css) result.push(`<style${styleMatch[1]}>${css}</style>`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result.join('\n\n');
|
|
39
|
+
}
|
package/src/graph/db.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const DB_PATH = process.env.CAIRN_DB_PATH || path.join(os.homedir(), '.cairn', 'index.db');
|
|
7
|
+
|
|
8
|
+
const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
10
|
+
id INTEGER PRIMARY KEY,
|
|
11
|
+
repo TEXT NOT NULL,
|
|
12
|
+
path TEXT NOT NULL UNIQUE,
|
|
13
|
+
language TEXT,
|
|
14
|
+
file_type TEXT,
|
|
15
|
+
last_indexed INTEGER
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
19
|
+
id INTEGER PRIMARY KEY,
|
|
20
|
+
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
|
21
|
+
fqn TEXT UNIQUE,
|
|
22
|
+
name TEXT NOT NULL,
|
|
23
|
+
kind TEXT,
|
|
24
|
+
language TEXT,
|
|
25
|
+
exported INTEGER DEFAULT 0,
|
|
26
|
+
description TEXT
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS dependencies (
|
|
30
|
+
from_file_id INTEGER REFERENCES files(id),
|
|
31
|
+
to_fqn TEXT,
|
|
32
|
+
dep_type TEXT
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS build_deps (
|
|
36
|
+
repo TEXT,
|
|
37
|
+
manager TEXT,
|
|
38
|
+
group_id TEXT,
|
|
39
|
+
artifact TEXT,
|
|
40
|
+
version TEXT,
|
|
41
|
+
scope TEXT
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS security_findings (
|
|
45
|
+
id INTEGER PRIMARY KEY,
|
|
46
|
+
file_id INTEGER REFERENCES files(id),
|
|
47
|
+
line INTEGER,
|
|
48
|
+
severity TEXT,
|
|
49
|
+
cwe TEXT,
|
|
50
|
+
rule_name TEXT,
|
|
51
|
+
description TEXT,
|
|
52
|
+
code_snippet TEXT
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
|
|
56
|
+
fqn, name, description, path, repo, language, kind
|
|
57
|
+
);
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
export function openDB() {
|
|
61
|
+
const dir = path.dirname(DB_PATH);
|
|
62
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
const db = new DatabaseSync(DB_PATH);
|
|
65
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
66
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
67
|
+
db.exec(SCHEMA);
|
|
68
|
+
return db;
|
|
69
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function insertDependency(db, { from_file_id, to_fqn, dep_type }) {
|
|
2
|
+
db.prepare('INSERT INTO dependencies (from_file_id, to_fqn, dep_type) VALUES (?, ?, ?)')
|
|
3
|
+
.run(from_file_id, to_fqn, dep_type);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function insertBuildDep(db, { repo, manager, group_id, artifact, version, scope }) {
|
|
7
|
+
const existing = db.prepare(
|
|
8
|
+
'SELECT rowid FROM build_deps WHERE repo=? AND manager=? AND artifact=?'
|
|
9
|
+
).get(repo, manager, artifact);
|
|
10
|
+
if (!existing) {
|
|
11
|
+
db.prepare(
|
|
12
|
+
'INSERT INTO build_deps (repo, manager, group_id, artifact, version, scope) VALUES (?, ?, ?, ?, ?, ?)'
|
|
13
|
+
).run(repo, manager, group_id, artifact, version, scope);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function insertSecurityFinding(db, { file_id, line, severity, cwe, rule_name, description, code_snippet }) {
|
|
18
|
+
db.prepare(
|
|
19
|
+
'INSERT INTO security_findings (file_id, line, severity, cwe, rule_name, description, code_snippet) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
20
|
+
).run(file_id, line, severity, cwe, rule_name, description, code_snippet);
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function upsertFile(db, { repo, path, language, file_type }) {
|
|
2
|
+
const existing = db.prepare('SELECT id FROM files WHERE path = ?').get(path);
|
|
3
|
+
if (existing) {
|
|
4
|
+
db.prepare('UPDATE files SET language = ?, file_type = ?, last_indexed = ? WHERE id = ?')
|
|
5
|
+
.run(language, file_type, Date.now(), existing.id);
|
|
6
|
+
return existing.id;
|
|
7
|
+
}
|
|
8
|
+
const result = db.prepare(
|
|
9
|
+
'INSERT INTO files (repo, path, language, file_type, last_indexed) VALUES (?, ?, ?, ?, ?)'
|
|
10
|
+
).run(repo, path, language, file_type, Date.now());
|
|
11
|
+
return result.lastInsertRowid;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function upsertSymbol(db, { file_id, fqn, name, kind, language, exported, description }) {
|
|
15
|
+
const existing = db.prepare('SELECT id FROM symbols WHERE fqn = ?').get(fqn);
|
|
16
|
+
if (existing) {
|
|
17
|
+
db.prepare(
|
|
18
|
+
'UPDATE symbols SET file_id=?, name=?, kind=?, language=?, exported=?, description=? WHERE id=?'
|
|
19
|
+
).run(file_id, name, kind, language, exported ? 1 : 0, description || '', existing.id);
|
|
20
|
+
} else {
|
|
21
|
+
db.prepare(
|
|
22
|
+
'INSERT INTO symbols (file_id, fqn, name, kind, language, exported, description) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
23
|
+
).run(file_id, fqn, name, kind, language, exported ? 1 : 0, description || '');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearFileData(db, fileId) {
|
|
28
|
+
db.prepare('DELETE FROM dependencies WHERE from_file_id = ?').run(fileId);
|
|
29
|
+
db.prepare('DELETE FROM security_findings WHERE file_id = ?').run(fileId);
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function parseGradle(filePath, content, repoName) {
|
|
2
|
+
const deps = [];
|
|
3
|
+
|
|
4
|
+
// Matches: implementation 'com.example:artifact:1.0.0' or "com.example:artifact:1.0.0"
|
|
5
|
+
for (const m of content.matchAll(/(\w+)\s+['"]([^:'"]+):([^:'"]+):([^'"]+)['"]/g)) {
|
|
6
|
+
const scope = m[1];
|
|
7
|
+
const group = m[2];
|
|
8
|
+
const artifact = m[3];
|
|
9
|
+
const version = m[4];
|
|
10
|
+
deps.push({ manager: 'gradle', group_id: group, artifact, version, scope });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return deps;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function parseMaven(filePath, content, repoName) {
|
|
2
|
+
const deps = [];
|
|
3
|
+
|
|
4
|
+
for (const m of content.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
|
|
5
|
+
const block = m[1];
|
|
6
|
+
const groupId = block.match(/<groupId>([^<]+)<\/groupId>/)?.[1] || '';
|
|
7
|
+
const artifactId = block.match(/<artifactId>([^<]+)<\/artifactId>/)?.[1] || '';
|
|
8
|
+
const version = block.match(/<version>([^<]+)<\/version>/)?.[1] || '';
|
|
9
|
+
const scope = block.match(/<scope>([^<]+)<\/scope>/)?.[1] || 'compile';
|
|
10
|
+
if (groupId && artifactId)
|
|
11
|
+
deps.push({ manager: 'maven', group_id: groupId, artifact: artifactId, version, scope });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return deps;
|
|
15
|
+
}
|