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/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, resolveConfig };
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
- // Fallback to V1 if no SQLite DB exists
16
- const mapPath = path.join(projectRoot, '.carto', 'map.json');
17
- if (!fs.existsSync(mapPath)) {
18
- console.error('[CARTO] No index found. Run `carto sync` first.');
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
- console.error('[CARTO] MCP server starting (V1 compat)...');
22
- require('../mcp/server');
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
- // Use V2 SQLite-backed sync
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
- rawImports = extractPythonImports(content, filePath, projectRoot);
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') {
@@ -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 = new SQLiteStore(projectRoot);
21
- store.open();
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
- const s = getStore();
66
- if (!s) return notIndexed();
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
- fields: m.fields_json ? JSON.parse(m.fields_json) : []
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() {
@@ -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 existing = this.getFileByPath(relPath);
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(relPath, language, hash, mtime, size, Date.now());
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
- stack: stack ? JSON.parse(stack) : [],
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 };