carto-md 2.0.5 → 2.0.7
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/CONTRIBUTING.md +5 -5
- package/README.md +174 -138
- package/docs/screenshots/claude-code-supabase.png +0 -0
- package/index.js +17 -7
- package/package.json +2 -2
- package/src/acp/agent.js +6 -13
- package/src/acp/providers/index.js +4 -12
- package/src/acp/session.js +9 -11
- package/src/cli/impact.js +11 -6
- package/src/cli/init.js +16 -19
- package/src/cli/serve.js +11 -12
- package/src/cli/sync.js +2 -14
- package/src/extractors/imports.js +5 -1
- package/src/mcp/server-v2.js +51 -5
- package/src/security/ignore.js +56 -1
- package/src/store/path-utils.js +50 -0
- package/src/store/sqlite-store.js +66 -11
- package/src/store/store-adapter.js +169 -0
- package/src/store/sync-v2.js +37 -4
- package/src/cache/file-hash.js +0 -84
- package/src/cache/graph-cache.js +0 -77
- package/src/detector/files.js +0 -289
- package/src/engine/carto.js +0 -606
- package/src/engine/incremental.js +0 -149
- package/src/mcp/server.js +0 -431
- package/src/sync.js +0 -317
package/src/cli/init.js
CHANGED
|
@@ -54,8 +54,6 @@ async function run(projectRoot) {
|
|
|
54
54
|
installGitHook(projectRoot);
|
|
55
55
|
|
|
56
56
|
// Run first sync — V2 SQLite-backed indexer.
|
|
57
|
-
// (Previously: V1 runFullSync with empty file lists from resolveConfig — produced
|
|
58
|
-
// a 23ms no-op that left .carto/carto.db missing and AGENTS.md unpopulated.)
|
|
59
57
|
await runSyncV2({
|
|
60
58
|
projectRoot,
|
|
61
59
|
output: path.resolve(projectRoot, config.output || 'AGENTS.md')
|
|
@@ -67,22 +65,6 @@ async function run(projectRoot) {
|
|
|
67
65
|
console.log('[CARTO] AGENTS.md generated. Carto will sync on every git commit.');
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
/**
|
|
71
|
-
* Resolves config paths to absolute paths.
|
|
72
|
-
* V2: no watch file lists — sync-v2 discovers files itself.
|
|
73
|
-
*/
|
|
74
|
-
function resolveConfig(projectRoot, config) {
|
|
75
|
-
return {
|
|
76
|
-
watch: {
|
|
77
|
-
routeFiles: (config.watch && config.watch.routeFiles || []).map(f => path.resolve(projectRoot, f)),
|
|
78
|
-
modelFiles: (config.watch && config.watch.modelFiles || []).map(f => path.resolve(projectRoot, f)),
|
|
79
|
-
frontendFiles: (config.watch && config.watch.frontendFiles || []).map(f => path.resolve(projectRoot, f))
|
|
80
|
-
},
|
|
81
|
-
output: path.resolve(projectRoot, config.output || 'AGENTS.md'),
|
|
82
|
-
projectRoot
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
68
|
function installGitHook(projectRoot) {
|
|
87
69
|
const gitDir = path.join(projectRoot, '.git');
|
|
88
70
|
if (!fs.existsSync(gitDir)) return;
|
|
@@ -113,6 +95,21 @@ function wireIDEs(projectRoot) {
|
|
|
113
95
|
const home = os.homedir();
|
|
114
96
|
const wired = [];
|
|
115
97
|
|
|
98
|
+
// Claude Code (project-scoped). Spec 7: write .mcp.json at the project root.
|
|
99
|
+
// Claude Code reads this file automatically when opened in the project — no
|
|
100
|
+
// host-level config needed, and no detection required (it's a project-local
|
|
101
|
+
// file that only takes effect when the user actually uses Claude Code here).
|
|
102
|
+
try {
|
|
103
|
+
const mcpJsonPath = path.join(projectRoot, '.mcp.json');
|
|
104
|
+
const config = fs.existsSync(mcpJsonPath)
|
|
105
|
+
? JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'))
|
|
106
|
+
: { mcpServers: {} };
|
|
107
|
+
config.mcpServers = config.mcpServers || {};
|
|
108
|
+
config.mcpServers.carto = { command: 'carto', args: ['serve'] };
|
|
109
|
+
fs.writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
110
|
+
wired.push('Claude Code');
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
116
113
|
// Kiro
|
|
117
114
|
const kiroDir = path.join(home, '.kiro', 'settings');
|
|
118
115
|
if (fs.existsSync(path.join(home, '.kiro'))) {
|
|
@@ -163,4 +160,4 @@ function wireIDEs(projectRoot) {
|
|
|
163
160
|
}
|
|
164
161
|
}
|
|
165
162
|
|
|
166
|
-
module.exports = { run
|
|
163
|
+
module.exports = { run };
|
package/src/cli/serve.js
CHANGED
|
@@ -4,22 +4,21 @@ const { checkForUpdate } = require('./update-check');
|
|
|
4
4
|
|
|
5
5
|
function run(projectRoot) {
|
|
6
6
|
checkForUpdate(); // fire and forget
|
|
7
|
-
// Prefer V2 SQLite-backed server
|
|
8
7
|
const dbPath = path.join(projectRoot, '.carto', 'carto.db');
|
|
9
|
-
if (fs.existsSync(dbPath)) {
|
|
10
|
-
console.error('[CARTO] MCP server starting (V2 SQLite)...');
|
|
11
|
-
require('../mcp/server-v2');
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
if (!fs.existsSync(dbPath)) {
|
|
10
|
+
// Could be pre-2.0.4 install with only map.json, or never initialized.
|
|
11
|
+
const mapPath = path.join(projectRoot, '.carto', 'map.json');
|
|
12
|
+
if (fs.existsSync(mapPath)) {
|
|
13
|
+
console.error('[CARTO] Legacy index found (map.json) but no SQLite DB. Run `carto init` to upgrade your index.');
|
|
14
|
+
} else {
|
|
15
|
+
console.error('[CARTO] No index found. Run `carto init` first.');
|
|
16
|
+
}
|
|
19
17
|
process.exit(1);
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
|
|
20
|
+
console.error('[CARTO] MCP server starting...');
|
|
21
|
+
require('../mcp/server-v2');
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
module.exports = { run };
|
package/src/cli/sync.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { runSyncV2 } = require('../store/sync-v2');
|
|
4
|
-
const { runFullSync } = require('../sync');
|
|
5
|
-
const { resolveConfig } = require('./init');
|
|
6
4
|
const { checkForUpdate } = require('./update-check');
|
|
7
5
|
|
|
8
6
|
async function run(projectRoot) {
|
|
@@ -22,20 +20,10 @@ async function run(projectRoot) {
|
|
|
22
20
|
process.exit(1);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
const v2Config = {
|
|
23
|
+
await runSyncV2({
|
|
27
24
|
projectRoot,
|
|
28
25
|
output: path.resolve(projectRoot, config.output || 'AGENTS.md')
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
await runSyncV2(v2Config);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
// Fallback to V1 if V2 fails (e.g., better-sqlite3 not available)
|
|
35
|
-
console.warn(`[CARTO] V2 sync failed (${err.message}), falling back to V1`);
|
|
36
|
-
const resolved = resolveConfig(projectRoot, config);
|
|
37
|
-
await runFullSync(resolved);
|
|
38
|
-
}
|
|
26
|
+
});
|
|
39
27
|
|
|
40
28
|
console.log('[CARTO] Sync complete.');
|
|
41
29
|
}
|
|
@@ -36,7 +36,11 @@ function extractImports(content, filePath, projectRoot) {
|
|
|
36
36
|
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
|
|
37
37
|
rawImports = extractJSImports(content);
|
|
38
38
|
} else if (ext === '.py') {
|
|
39
|
-
|
|
39
|
+
// Python returns absolute paths from resolvePythonRelativeImport / tryResolvePythonModule.
|
|
40
|
+
// Relativize to project root and return early — the JS-style dedup loop below only handles
|
|
41
|
+
// `./` and `@/~/#` prefixed strings, so falling through silently drops every Python edge.
|
|
42
|
+
const abs = extractPythonImports(content, filePath, projectRoot);
|
|
43
|
+
return [...new Set(abs.map(p => path.relative(projectRoot, p)))].sort();
|
|
40
44
|
} else if (ext === '.r') {
|
|
41
45
|
return extractRImports(content, filePath, projectRoot);
|
|
42
46
|
} else if (ext === '.go') {
|
package/src/mcp/server-v2.js
CHANGED
|
@@ -7,9 +7,22 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
|
7
7
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
8
8
|
const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
9
9
|
const { SQLiteStore } = require('../store/sqlite-store');
|
|
10
|
+
const { normalizeFileArg } = require('../store/path-utils');
|
|
10
11
|
|
|
11
12
|
const projectRoot = process.cwd();
|
|
12
13
|
|
|
14
|
+
// Process-level safety nets — Spec 7 Bug 5c. Without these, any error that
|
|
15
|
+
// escapes the request handler (very rare, but possible from native bindings
|
|
16
|
+
// or async stack frames) takes the whole MCP server down, and Claude Code /
|
|
17
|
+
// Kiro surface `-32000 Failed to reconnect`. We log to stderr (which the
|
|
18
|
+
// host logs but never terminates the JSON-RPC channel) and stay alive.
|
|
19
|
+
process.on('uncaughtException', (err) => {
|
|
20
|
+
process.stderr.write(`[CARTO MCP] Uncaught exception: ${err && err.stack ? err.stack : err}\n`);
|
|
21
|
+
});
|
|
22
|
+
process.on('unhandledRejection', (reason) => {
|
|
23
|
+
process.stderr.write(`[CARTO MCP] Unhandled rejection: ${reason && reason.stack ? reason.stack : reason}\n`);
|
|
24
|
+
});
|
|
25
|
+
|
|
13
26
|
// Open SQLite directly — no re-indexing, instant startup
|
|
14
27
|
let store = null;
|
|
15
28
|
|
|
@@ -17,8 +30,13 @@ function getStore() {
|
|
|
17
30
|
if (store) return store;
|
|
18
31
|
const dbPath = path.join(projectRoot, '.carto', 'carto.db');
|
|
19
32
|
if (!fs.existsSync(dbPath)) return null;
|
|
20
|
-
store
|
|
21
|
-
|
|
33
|
+
// Open BEFORE assigning to the module-scoped `store` so that if open()
|
|
34
|
+
// throws (corrupt DB, locked file, schema mismatch) we don't poison the
|
|
35
|
+
// cache with a broken instance — every subsequent call would otherwise
|
|
36
|
+
// return the broken object and never recover.
|
|
37
|
+
const s = new SQLiteStore(projectRoot);
|
|
38
|
+
s.open({ readonly: true }); // Spec 8 — defense in depth: MCP tools never write
|
|
39
|
+
store = s;
|
|
22
40
|
return store;
|
|
23
41
|
}
|
|
24
42
|
|
|
@@ -62,8 +80,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
|
62
80
|
|
|
63
81
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
64
82
|
const { name, arguments: args } = request.params;
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
// Normalize file-arg tools to the canonical SQLite-stored form so
|
|
84
|
+
// `./lib/x.js`, absolute paths, and Windows separators all resolve.
|
|
85
|
+
// Tools that don't take a `file` arg are unaffected.
|
|
86
|
+
if (args && typeof args.file === 'string') {
|
|
87
|
+
args.file = normalizeFileArg(projectRoot, args.file);
|
|
88
|
+
}
|
|
89
|
+
// Wrap entire handler body so any tool error (SQLite, null deref, bad
|
|
90
|
+
// input) returns a structured error response instead of crashing the
|
|
91
|
+
// MCP transport — Spec 7 Bug 5 fix. Pre-2.0.7, an unhandled throw here
|
|
92
|
+
// killed the stdio connection and Claude Code/Kiro would surface
|
|
93
|
+
// `-32000 Failed to reconnect`.
|
|
94
|
+
try {
|
|
95
|
+
const s = getStore();
|
|
96
|
+
if (!s) return notIndexed();
|
|
67
97
|
|
|
68
98
|
if (name === 'get_routes') {
|
|
69
99
|
const routes = s.getRoutes();
|
|
@@ -105,6 +135,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
if (name === 'get_domain') {
|
|
138
|
+
// Guard: AI clients sometimes call this with no/empty `domain` arg.
|
|
139
|
+
// Pre-2.0.7 we'd then call args.domain.toUpperCase() on undefined and crash.
|
|
140
|
+
if (!args.domain || typeof args.domain !== 'string') {
|
|
141
|
+
return text('Missing required argument: domain. Use get_domains_list to see available domains.');
|
|
142
|
+
}
|
|
108
143
|
const domain = s.getDomain(args.domain);
|
|
109
144
|
if (!domain) return text(`Domain not found: ${args.domain}. Use get_domains_list to see available domains.`);
|
|
110
145
|
|
|
@@ -133,7 +168,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
133
168
|
models: domain.models.map(m => ({
|
|
134
169
|
...m,
|
|
135
170
|
className: m.name,
|
|
136
|
-
|
|
171
|
+
// Wrap parse so one corrupt row doesn't take down the whole tool call.
|
|
172
|
+
fields: (() => {
|
|
173
|
+
if (!m.fields_json) return [];
|
|
174
|
+
try { return JSON.parse(m.fields_json); } catch { return []; }
|
|
175
|
+
})()
|
|
137
176
|
})),
|
|
138
177
|
functions: {},
|
|
139
178
|
envVars: [],
|
|
@@ -446,6 +485,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
446
485
|
}
|
|
447
486
|
|
|
448
487
|
return text(`Unknown tool: ${name}`);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
process.stderr.write(`[CARTO MCP] Tool "${name}" error: ${err.stack || err.message || err}\n`);
|
|
490
|
+
return {
|
|
491
|
+
content: [{ type: 'text', text: `Error in ${name}: ${err.message || String(err)}` }],
|
|
492
|
+
isError: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
449
495
|
});
|
|
450
496
|
|
|
451
497
|
async function main() {
|
package/src/security/ignore.js
CHANGED
|
@@ -2,8 +2,11 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
4
|
const DEFAULT_IGNORE_PATTERNS = [
|
|
5
|
+
// Env files
|
|
5
6
|
'.env',
|
|
6
7
|
'.env.*',
|
|
8
|
+
|
|
9
|
+
// Generic secret/credential name globs
|
|
7
10
|
'*secret*',
|
|
8
11
|
'*SECRET*',
|
|
9
12
|
'*password*',
|
|
@@ -12,8 +15,60 @@ const DEFAULT_IGNORE_PATTERNS = [
|
|
|
12
15
|
'*CREDENTIAL*',
|
|
13
16
|
'*private_key*',
|
|
14
17
|
'*PRIVATE_KEY*',
|
|
18
|
+
|
|
19
|
+
// Generic key/cert extensions
|
|
15
20
|
'*.pem',
|
|
16
|
-
'*.key'
|
|
21
|
+
'*.key',
|
|
22
|
+
|
|
23
|
+
// SSH keys (no extension, won't be caught by *.key)
|
|
24
|
+
'id_rsa',
|
|
25
|
+
'id_rsa.pub',
|
|
26
|
+
'id_ed25519',
|
|
27
|
+
'id_ed25519.pub',
|
|
28
|
+
'id_ecdsa',
|
|
29
|
+
'id_ecdsa.pub',
|
|
30
|
+
'id_dsa',
|
|
31
|
+
'id_dsa.pub',
|
|
32
|
+
'authorized_keys',
|
|
33
|
+
'known_hosts',
|
|
34
|
+
|
|
35
|
+
// Package registry credentials
|
|
36
|
+
'.npmrc',
|
|
37
|
+
'.pypirc',
|
|
38
|
+
'.netrc',
|
|
39
|
+
'.pip/pip.conf',
|
|
40
|
+
'.gem/credentials',
|
|
41
|
+
'.cargo/credentials.toml',
|
|
42
|
+
|
|
43
|
+
// Cloud / K8s / Docker
|
|
44
|
+
'kubeconfig',
|
|
45
|
+
'*.kubeconfig',
|
|
46
|
+
'.docker/config.json',
|
|
47
|
+
'.dockercfg',
|
|
48
|
+
'*service-account*.json',
|
|
49
|
+
'*service_account*.json',
|
|
50
|
+
'*-credentials.json',
|
|
51
|
+
'.aws/credentials',
|
|
52
|
+
'.aws/config',
|
|
53
|
+
'.gcp/*',
|
|
54
|
+
'.gnupg/*',
|
|
55
|
+
|
|
56
|
+
// Cert / keystore formats (defense in depth — already excluded by CODE_EXTS today)
|
|
57
|
+
'*.crt',
|
|
58
|
+
'*.cer',
|
|
59
|
+
'*.p12',
|
|
60
|
+
'*.pfx',
|
|
61
|
+
'*.jks',
|
|
62
|
+
'*.keystore',
|
|
63
|
+
'*.pkcs12'
|
|
64
|
+
|
|
65
|
+
// NOTE: deliberately NOT including `*api_key*` / `*api-key*` globs.
|
|
66
|
+
// Real SaaS projects (cal.com, supabase, fastapi, zed) ship legitimate
|
|
67
|
+
// feature code under these filenames — `api_key.py`, `api-keys-service.ts`,
|
|
68
|
+
// `language_model/src/api_key.rs` — and a broad glob would silently delete
|
|
69
|
+
// 17+ files per repo from the import graph. Raw-credential files literally
|
|
70
|
+
// named `api_key.txt` are unusual; the realistic threat surface (.env,
|
|
71
|
+
// *credentials.json, .npmrc, id_rsa, kubeconfig, *secret*) is covered above.
|
|
17
72
|
];
|
|
18
73
|
|
|
19
74
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* normalizeFileArg(projectRoot, fileArg) → string
|
|
7
|
+
*
|
|
8
|
+
* Convert a user-provided file path into the canonical form stored in the
|
|
9
|
+
* SQLite `files.path` column: relative-to-project-root, forward-slashed,
|
|
10
|
+
* no leading `./`.
|
|
11
|
+
*
|
|
12
|
+
* Handles every form a user / IDE / shell tab-completion realistically
|
|
13
|
+
* produces:
|
|
14
|
+
* - relative form already correct ("lib/application.js" → "lib/application.js")
|
|
15
|
+
* - leading "./" ("./lib/application.js" → "lib/application.js")
|
|
16
|
+
* - leading "/" absolute under projectRoot ("/abs/.../lib/application.js" → "lib/application.js")
|
|
17
|
+
* - Windows backslashes ("lib\\application.js" → "lib/application.js")
|
|
18
|
+
* - paths containing "../" (e.g. typed from a sibling dir) — resolved.
|
|
19
|
+
*
|
|
20
|
+
* Falsy / empty input returns the input unchanged so the caller's
|
|
21
|
+
* "missing argument" check still fires the same way.
|
|
22
|
+
*/
|
|
23
|
+
function normalizeFileArg(projectRoot, fileArg) {
|
|
24
|
+
if (typeof fileArg !== 'string' || fileArg.length === 0) return fileArg;
|
|
25
|
+
|
|
26
|
+
let p = fileArg;
|
|
27
|
+
|
|
28
|
+
// Normalize separators first so the absolute-path check works on Windows.
|
|
29
|
+
p = p.split('\\').join('/');
|
|
30
|
+
|
|
31
|
+
// Absolute → relative-to-project-root
|
|
32
|
+
if (path.isAbsolute(p) || /^[a-zA-Z]:\//.test(p)) {
|
|
33
|
+
p = path.relative(projectRoot, p);
|
|
34
|
+
p = p.split(path.sep).join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Strip leading "./" (or repeated "./././")
|
|
38
|
+
while (p.startsWith('./')) p = p.slice(2);
|
|
39
|
+
|
|
40
|
+
// Resolve any embedded "../" segments without going absolute.
|
|
41
|
+
// e.g. "src/foo/../bar.js" → "src/bar.js"
|
|
42
|
+
if (p.includes('/../') || p.startsWith('../')) {
|
|
43
|
+
const abs = path.resolve(projectRoot, p);
|
|
44
|
+
p = path.relative(projectRoot, abs).split(path.sep).join('/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { normalizeFileArg };
|
|
@@ -6,6 +6,34 @@ const fs = require('fs');
|
|
|
6
6
|
|
|
7
7
|
const SCHEMA_VERSION = '1';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* normalizePath(p) — Canonicalize a relative path for storage and query.
|
|
11
|
+
*
|
|
12
|
+
* Converts backslashes to forward slashes (Windows → POSIX), strips leading
|
|
13
|
+
* './', and rejects absolute paths (callers must `path.relative(projectRoot, p)`
|
|
14
|
+
* before passing in).
|
|
15
|
+
*
|
|
16
|
+
* Single-source-of-truth for "what does a row in `files.path` look like?".
|
|
17
|
+
* Apply this at every boundary that writes or queries the column, and every
|
|
18
|
+
* downstream join (imports.from_file_id → files.path) stays consistent.
|
|
19
|
+
*
|
|
20
|
+
* Cross-platform invariant: same code, same DB, same query results on macOS,
|
|
21
|
+
* Linux, and Windows. Closes Bug 2 (carto impact path normalization) and the
|
|
22
|
+
* Windows-only "0 dependents" failures in the Store adapter test suite.
|
|
23
|
+
*/
|
|
24
|
+
function normalizePath(p) {
|
|
25
|
+
if (typeof p !== 'string' || p.length === 0) return p;
|
|
26
|
+
let out = p;
|
|
27
|
+
// Backslash → forward slash (Windows → POSIX). path.sep is '\\' on Windows.
|
|
28
|
+
if (path.sep !== '/') out = out.split(path.sep).join('/');
|
|
29
|
+
// Some inputs may already use mixed separators (e.g., `src\utils/foo.js`).
|
|
30
|
+
// Normalize aggressively.
|
|
31
|
+
out = out.replace(/\\/g, '/');
|
|
32
|
+
// Strip leading './'
|
|
33
|
+
if (out.startsWith('./')) out = out.slice(2);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
class SQLiteStore {
|
|
10
38
|
constructor(projectRoot) {
|
|
11
39
|
this._projectRoot = projectRoot;
|
|
@@ -13,14 +41,35 @@ class SQLiteStore {
|
|
|
13
41
|
}
|
|
14
42
|
|
|
15
43
|
/**
|
|
16
|
-
* open() — Opens or creates the database. Applies pragmas and schema.
|
|
44
|
+
* open(opts) — Opens or creates the database. Applies pragmas and schema.
|
|
45
|
+
*
|
|
46
|
+
* Options:
|
|
47
|
+
* readonly — Open the DB in read-only mode. Used by the MCP server
|
|
48
|
+
* (`carto serve`) so a malformed/buggy tool can never write
|
|
49
|
+
* through the SQLite layer. Skips mkdir, schema bootstrap, and
|
|
50
|
+
* WAL pragma (WAL needs write capability and would otherwise
|
|
51
|
+
* create `carto.db-wal`/`carto.db-shm` in repos that only run
|
|
52
|
+
* the MCP server). Sets `fileMustExist: true` so a missing DB
|
|
53
|
+
* returns a clear SQLite error instead of silently creating an
|
|
54
|
+
* empty file in the wrong location.
|
|
17
55
|
*/
|
|
18
|
-
open() {
|
|
56
|
+
open(opts = {}) {
|
|
19
57
|
const cartoDir = path.join(this._projectRoot, '.carto');
|
|
20
|
-
fs.mkdirSync(cartoDir, { recursive: true });
|
|
21
|
-
|
|
22
58
|
const dbPath = path.join(cartoDir, 'carto.db');
|
|
23
59
|
|
|
60
|
+
if (opts.readonly) {
|
|
61
|
+
// Read-only path: dir must already exist (a writer process — `carto sync`
|
|
62
|
+
// or `carto init` — created the DB). Don't mkdir, don't ensure schema,
|
|
63
|
+
// don't apply write-only pragmas.
|
|
64
|
+
this._db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
65
|
+
// Read-only safe pragmas only:
|
|
66
|
+
this._db.pragma('busy_timeout = 5000');
|
|
67
|
+
this._db.pragma('cache_size = -64000'); // 64MB cache
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fs.mkdirSync(cartoDir, { recursive: true });
|
|
72
|
+
|
|
24
73
|
try {
|
|
25
74
|
this._db = new Database(dbPath);
|
|
26
75
|
} catch (err) {
|
|
@@ -213,7 +262,7 @@ class SQLiteStore {
|
|
|
213
262
|
// ─── File operations ───────────────────────────────────────────────────
|
|
214
263
|
|
|
215
264
|
getFileByPath(relPath) {
|
|
216
|
-
return this._db.prepare('SELECT * FROM files WHERE path = ?').get(relPath);
|
|
265
|
+
return this._db.prepare('SELECT * FROM files WHERE path = ?').get(normalizePath(relPath));
|
|
217
266
|
}
|
|
218
267
|
|
|
219
268
|
getFileById(id) {
|
|
@@ -230,7 +279,8 @@ class SQLiteStore {
|
|
|
230
279
|
}
|
|
231
280
|
|
|
232
281
|
upsertFile(relPath, { language, hash, mtime, size }) {
|
|
233
|
-
const
|
|
282
|
+
const norm = normalizePath(relPath);
|
|
283
|
+
const existing = this.getFileByPath(norm);
|
|
234
284
|
if (existing) {
|
|
235
285
|
this._db.prepare(
|
|
236
286
|
'UPDATE files SET language=?, hash=?, mtime=?, size=?, last_indexed_at=? WHERE id=?'
|
|
@@ -239,18 +289,18 @@ class SQLiteStore {
|
|
|
239
289
|
}
|
|
240
290
|
const info = this._db.prepare(
|
|
241
291
|
'INSERT INTO files (path, language, hash, mtime, size, last_indexed_at) VALUES (?,?,?,?,?,?)'
|
|
242
|
-
).run(
|
|
292
|
+
).run(norm, language, hash, mtime, size, Date.now());
|
|
243
293
|
return info.lastInsertRowid;
|
|
244
294
|
}
|
|
245
295
|
|
|
246
296
|
updateFileMtime(relPath, mtime, size) {
|
|
247
297
|
this._db.prepare(
|
|
248
298
|
'UPDATE files SET mtime=?, size=? WHERE path=?'
|
|
249
|
-
).run(mtime, size, relPath);
|
|
299
|
+
).run(mtime, size, normalizePath(relPath));
|
|
250
300
|
}
|
|
251
301
|
|
|
252
302
|
removeFile(relPath) {
|
|
253
|
-
this._db.prepare('DELETE FROM files WHERE path = ?').run(relPath);
|
|
303
|
+
this._db.prepare('DELETE FROM files WHERE path = ?').run(normalizePath(relPath));
|
|
254
304
|
}
|
|
255
305
|
|
|
256
306
|
removeStaleFiles(currentPaths) {
|
|
@@ -589,7 +639,12 @@ class SQLiteStore {
|
|
|
589
639
|
},
|
|
590
640
|
entryPoints,
|
|
591
641
|
highImpact,
|
|
592
|
-
|
|
642
|
+
// Defensive parse — a corrupt stack_json row must never crash callers.
|
|
643
|
+
// Spec 7 Bug 5f.
|
|
644
|
+
stack: (() => {
|
|
645
|
+
if (!stack) return [];
|
|
646
|
+
try { return JSON.parse(stack); } catch { return []; }
|
|
647
|
+
})(),
|
|
593
648
|
domains
|
|
594
649
|
};
|
|
595
650
|
}
|
|
@@ -744,4 +799,4 @@ class SQLiteStore {
|
|
|
744
799
|
}
|
|
745
800
|
}
|
|
746
801
|
|
|
747
|
-
module.exports = { SQLiteStore };
|
|
802
|
+
module.exports = { SQLiteStore, normalizePath };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { SQLiteStore } = require('./sqlite-store');
|
|
6
|
+
const { runSyncV2 } = require('./sync-v2');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* StoreAdapter — V1-compatible API wrapping SQLiteStore.
|
|
10
|
+
* Drop-in replacement for the V1 Carto class in acp/session.js.
|
|
11
|
+
* tools.js calls the same methods with the same shapes.
|
|
12
|
+
*/
|
|
13
|
+
class StoreAdapter {
|
|
14
|
+
constructor() {
|
|
15
|
+
this._store = null;
|
|
16
|
+
this._projectRoot = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* index(projectRoot, opts?)
|
|
21
|
+
* Opens the SQLite DB; runs runSyncV2 if DB is missing.
|
|
22
|
+
* opts.force — re-extract all files
|
|
23
|
+
* opts.writeOutputs — if false, skip AGENTS.md + context file generation (default: false)
|
|
24
|
+
*/
|
|
25
|
+
async index(projectRoot, opts = {}) {
|
|
26
|
+
this._projectRoot = projectRoot;
|
|
27
|
+
const dbPath = path.join(projectRoot, '.carto', 'carto.db');
|
|
28
|
+
const dbExists = fs.existsSync(dbPath);
|
|
29
|
+
|
|
30
|
+
if (!dbExists || opts.force) {
|
|
31
|
+
await runSyncV2({
|
|
32
|
+
projectRoot,
|
|
33
|
+
output: opts.writeOutputs ? path.resolve(projectRoot, 'AGENTS.md') : null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this._store = new SQLiteStore(projectRoot);
|
|
38
|
+
this._store.open();
|
|
39
|
+
return this.getMeta();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── V1-shaped query API ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
getMeta() {
|
|
45
|
+
const s = this._store.getStructure();
|
|
46
|
+
return s.meta || { totalFiles: 0, totalRoutes: 0, totalImportEdges: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getRoutes() { return this._store.getRoutes(); }
|
|
50
|
+
searchRoutes(q) { return this._store.searchRoutes(q); }
|
|
51
|
+
getModels(domain) { return this._store.getModels(domain); }
|
|
52
|
+
getDomainsList() { return this._store.getDomainsList(); }
|
|
53
|
+
getDomain(name) { return this._store.getDomain(name); }
|
|
54
|
+
getCrossDomainDeps() { return this._store.getCrossDomainDeps(); }
|
|
55
|
+
getHighImpactFiles(n) { return this._store.getHighImpactFiles(n); }
|
|
56
|
+
|
|
57
|
+
getStructure() {
|
|
58
|
+
return this._store.getStructure();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getNeighbors(file, hops = 1) {
|
|
62
|
+
return this._store.getNeighbors(file, hops);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* getBlastRadius(file) — V1 rich shape:
|
|
67
|
+
* { file, risk, directlyAffected, potentiallyAffected,
|
|
68
|
+
* routesImpacted, domainsImpacted, dependentFiles }
|
|
69
|
+
*/
|
|
70
|
+
getBlastRadius(file) {
|
|
71
|
+
const radius = this._store.getBlastRadius(file, 5);
|
|
72
|
+
if (!radius) return null;
|
|
73
|
+
|
|
74
|
+
const directDeps = radius.filter(r => r.hop_distance === 1).map(r => r.file);
|
|
75
|
+
const allDepFiles = radius.map(r => r.file);
|
|
76
|
+
|
|
77
|
+
// Routes impacted
|
|
78
|
+
const allRoutes = this._store.getRoutes();
|
|
79
|
+
const affectedSet = new Set([file, ...allDepFiles]);
|
|
80
|
+
const routesImpacted = allRoutes
|
|
81
|
+
.filter(r => affectedSet.has(r.file))
|
|
82
|
+
.map(r => {
|
|
83
|
+
const isAuth = /auth|login|session/i.test(r.path);
|
|
84
|
+
const isPay = /billing|payment|checkout/i.test(r.path);
|
|
85
|
+
const riskLevel = isAuth || isPay ? 'HIGH'
|
|
86
|
+
: directDeps.length >= 3 ? 'HIGH'
|
|
87
|
+
: directDeps.length >= 2 ? 'MEDIUM' : 'LOW';
|
|
88
|
+
return { method: r.method, path: r.path, risk: riskLevel };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Domains impacted
|
|
92
|
+
const domainsImpacted = new Set();
|
|
93
|
+
const fileDomain = this._store.getDomainForFile(file);
|
|
94
|
+
if (fileDomain) domainsImpacted.add(fileDomain);
|
|
95
|
+
for (const f of allDepFiles) {
|
|
96
|
+
const d = this._store.getDomainForFile(f);
|
|
97
|
+
if (d) domainsImpacted.add(d);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const depCount = directDeps.length;
|
|
101
|
+
const risk = depCount >= 5 ? 'HIGH'
|
|
102
|
+
: depCount >= 3 ? 'HIGH'
|
|
103
|
+
: depCount >= 2 ? 'MEDIUM'
|
|
104
|
+
: depCount >= 1 ? 'LOW' : 'SAFE';
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
file,
|
|
108
|
+
risk,
|
|
109
|
+
directlyAffected: { files: directDeps.length, domains: domainsImpacted.size },
|
|
110
|
+
potentiallyAffected: { files: allDepFiles.length, domains: domainsImpacted.size },
|
|
111
|
+
routesImpacted,
|
|
112
|
+
domainsImpacted: [...domainsImpacted],
|
|
113
|
+
dependentFiles: allDepFiles,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* getContextForFile(file) — composed context matching V1 shape.
|
|
119
|
+
*/
|
|
120
|
+
getContextForFile(file) {
|
|
121
|
+
const fileRow = this._store.getFileByPath(file);
|
|
122
|
+
if (!fileRow) return null;
|
|
123
|
+
|
|
124
|
+
const domain = this._store.getDomainForFile(file) || 'CORE';
|
|
125
|
+
const routes = this._store.getRoutes().filter(r => r.file === file).map(r => `${r.method} ${r.path}`);
|
|
126
|
+
const models = this._store.getModels().filter(m => m.file === file).map(m => m.name);
|
|
127
|
+
const blastRadius = this.getBlastRadius(file);
|
|
128
|
+
const neighbors = this.getNeighbors(file, 2);
|
|
129
|
+
const crossDomain = this._store.getCrossDomainDeps().filter(d => d.from === file || d.to === file);
|
|
130
|
+
|
|
131
|
+
let domainContext = null;
|
|
132
|
+
if (domain && this._projectRoot) {
|
|
133
|
+
const ctxPath = path.join(this._projectRoot, '.carto', 'context', `${domain}.md`);
|
|
134
|
+
try { domainContext = fs.readFileSync(ctxPath, 'utf-8'); } catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
file,
|
|
139
|
+
domain,
|
|
140
|
+
routes,
|
|
141
|
+
models,
|
|
142
|
+
functions: [],
|
|
143
|
+
envVars: [],
|
|
144
|
+
blastRadius,
|
|
145
|
+
neighbors,
|
|
146
|
+
crossDomainDeps: crossDomain,
|
|
147
|
+
domainContext,
|
|
148
|
+
meta: {
|
|
149
|
+
importCount: 0,
|
|
150
|
+
dependentCount: blastRadius ? blastRadius.directlyAffected.files : 0,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
close() {
|
|
156
|
+
if (this._store) { this._store.close(); this._store = null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* terminate() — alias for close().
|
|
161
|
+
* Kept so the V1-style `Carto` export from index.js (which is now an alias
|
|
162
|
+
* for StoreAdapter) remains fully API-compatible. The V1 Carto class
|
|
163
|
+
* exposed terminate(); legacy programmatic users may still call it.
|
|
164
|
+
* Safe to remove in 3.0.0 along with the Carto alias.
|
|
165
|
+
*/
|
|
166
|
+
terminate() { return this.close(); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { StoreAdapter };
|