carto-md 2.0.5 → 2.0.6

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 CHANGED
@@ -44,9 +44,9 @@ Framework-specific route and model extraction lives inside the language plugins.
44
44
 
45
45
  ---
46
46
 
47
- ## How to add a language (V2 pattern — tree-sitter based)
47
+ ## How to add a language (tree-sitter based)
48
48
 
49
- V2 uses tree-sitter for import and symbol extraction. Babel is only used for deep JS/TS route/model extraction on API handler files.
49
+ Carto uses tree-sitter for import and symbol extraction. Babel is only used for deep JS/TS route/model extraction on API handler files.
50
50
 
51
51
  ### Step 1: Install the grammar
52
52
 
@@ -177,7 +177,7 @@ Test on at least 2 real open-source projects using the framework.
177
177
 
178
178
  ---
179
179
 
180
- ## How domain clustering works (V2)
180
+ ## How domain clustering works
181
181
 
182
182
  Domain detection uses **Leiden+CPM graph clustering** (`src/agents/leiden.js`). Files that import each other heavily cluster together. Domain names are inferred from path tokens, with keyword hints for well-known patterns.
183
183
 
@@ -215,7 +215,7 @@ cd carto
215
215
  npm install
216
216
  node src/cli/index.js init # test in any project
217
217
  node src/cli/index.js serve # test MCP server
218
- npm test # run test suite (35 tests)
218
+ npm test # run test suite (62 tests)
219
219
  node test/correctness.js # run correctness tests (31 tests)
220
220
  node test/benchmark.js # run benchmarks against real repos
221
221
  ```
@@ -258,7 +258,7 @@ npm run test:bench-ci # self-bench (matches what bench.yml runs)
258
258
  - [ ] Extension added to `CODE_EXTS` and `detectLanguage()` in `sync-v2.js`
259
259
  - [ ] No changes to merger logic (unless explicitly fixing a merger bug)
260
260
  - [ ] No network calls added
261
- - [ ] `npm test` passes (35/35)
261
+ - [ ] `npm test` passes (62/62)
262
262
  - [ ] `node test/correctness.js` passes (31/31)
263
263
 
264
264
  ---
package/README.md CHANGED
@@ -138,7 +138,7 @@ Reads `tsconfig.json` / `jsconfig.json` for `paths` config. `@/components/Button
138
138
 
139
139
  ## Domain detection
140
140
 
141
- V2 uses **Leiden+CPM graph clustering** — files that import each other heavily cluster together. Domain names are inferred from path tokens, with keyword hints for well-known patterns (AUTH, PAYMENTS, DATABASE, etc.).
141
+ Carto uses **Leiden+CPM graph clustering** — files that import each other heavily cluster together. Domain names are inferred from path tokens, with keyword hints for well-known patterns (AUTH, PAYMENTS, DATABASE, etc.).
142
142
 
143
143
  Works on any repo — not just SaaS apps. vscode gets AUTH/EVENTS/DATABASE. zed (Rust) gets DATABASE/AUTH/EVENTS. A game engine would get RENDERER/PHYSICS/AUDIO.
144
144
 
@@ -227,27 +227,10 @@ Other agents are smart but blind. Carto sees the architecture:
227
227
 
228
228
  ---
229
229
 
230
- ## V1 → V2 migration
231
-
232
- Run `carto sync` — it auto-migrates your existing `graph-cache.json` and `map.json` into SQLite and renames them to `.bak`. No manual steps.
233
-
234
- What changed under the hood:
235
-
236
- | | V1 | V2 |
237
- |---|---|---|
238
- | Storage | JSON blobs | SQLite (WAL mode, indexed queries) |
239
- | Parsing | Babel-only | tree-sitter for all languages, Babel only for deep route/model extraction |
240
- | File limit | 300 cap | Unlimited |
241
- | Languages | JS/TS/Python/Go/R | + Rust/Java/C++/C#/Ruby |
242
- | Domain detection | Hardcoded keywords | Leiden+CPM graph clustering |
243
- | Watcher | Per-file chokidar | Single recursive directory watch (<20 file descriptors) |
244
- | MCP startup | Re-indexes on start | Opens SQLite instantly (<10ms) |
245
- | Path aliases | Not resolved | `@/`, `~/` resolved via tsconfig |
246
-
247
- ---
248
-
249
230
  ## MCP config (if auto-wire missed your IDE)
250
231
 
232
+ `carto init` auto-wires into Kiro, Cursor, and Claude Desktop. If it missed yours, add manually:
233
+
251
234
  **Kiro** — `~/.kiro/settings/mcp.json`
252
235
  ```json
253
236
  { "mcpServers": { "carto": { "command": "carto", "args": ["serve"], "cwd": "/your/project" } } }
@@ -263,6 +246,16 @@ What changed under the hood:
263
246
  { "mcpServers": { "carto": { "command": "carto", "args": ["serve"], "cwd": "/your/project" } } }
264
247
  ```
265
248
 
249
+ **VS Code Copilot** — `.vscode/mcp.json` in your project root, or Command Palette → `MCP: Add Server`
250
+ ```json
251
+ { "servers": { "carto": { "command": "carto", "args": ["serve"], "cwd": "/your/project" } } }
252
+ ```
253
+
254
+ **Windsurf** — `~/.windsurf/mcp.json`
255
+ ```json
256
+ { "mcpServers": { "carto": { "command": "carto", "args": ["serve"], "cwd": "/your/project" } } }
257
+ ```
258
+
266
259
  ---
267
260
 
268
261
  ## What Carto never does
package/index.js CHANGED
@@ -4,17 +4,27 @@
4
4
  * carto-md — public module API
5
5
  *
6
6
  * Usage:
7
- * const { Carto } = require('carto-md');
8
- * const carto = new Carto();
7
+ * const { StoreAdapter } = require('carto-md');
8
+ * const carto = new StoreAdapter();
9
9
  * await carto.index('/path/to/project');
10
10
  *
11
- * // Get everything Kepler needs for a file
11
+ * // Get everything an editor agent needs for a file
12
12
  * const ctx = carto.getContextForFile('src/auth/auth.service.ts');
13
13
  *
14
- * // Listen for live updates
15
- * carto.on('updated', ({ file, blastRadius }) => { ... });
14
+ * // Read structural facts
15
+ * const routes = carto.getRoutes();
16
+ * const radius = carto.getBlastRadius('src/auth/auth.service.ts');
17
+ * carto.close();
18
+ *
19
+ * Back-compat: `Carto` is exported as an alias for `StoreAdapter`.
20
+ * Existing programs that did `const { Carto } = require('carto-md')`
21
+ * continue to work without changes. The alias (and the `terminate()`
22
+ * method on StoreAdapter) will be removed in 3.0.0.
16
23
  */
17
24
 
18
- const { Carto } = require('./src/engine/carto');
25
+ const { StoreAdapter } = require('./src/store/store-adapter');
19
26
 
20
- module.exports = { Carto };
27
+ module.exports = {
28
+ StoreAdapter,
29
+ Carto: StoreAdapter, // deprecated alias, removed in 3.0.0
30
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carto-md",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "Structural intelligence layer for AI coding tools. Indexes your codebase into SQLite — routes, models, import graph, blast radius, domains — and exposes 16 MCP tools for Kiro, Cursor, and Claude.",
5
5
  "bin": {
6
6
  "carto": "src/cli/index.js"
@@ -3,7 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { Carto } = require('../engine/carto');
6
+ const { StoreAdapter } = require('../store/store-adapter');
7
7
 
8
8
  class Session {
9
9
  constructor(id, workingDir) {
@@ -23,20 +23,16 @@ class Session {
23
23
  if (this._indexed) return null;
24
24
 
25
25
  const dbPath = path.join(this.workingDir, '.carto', 'carto.db');
26
- this.carto = new Carto();
26
+ const wasIndexed = fs.existsSync(dbPath);
27
27
 
28
- if (fs.existsSync(dbPath)) {
29
- // DB exists — just load the index (fast path, <10ms)
30
- await this.carto.index(this.workingDir);
31
- this._indexed = true;
32
- return null;
33
- }
34
-
35
- // No DB — run full indexing
28
+ this.carto = new StoreAdapter();
36
29
  const start = Date.now();
37
- await this.carto.index(this.workingDir, { useWorkers: true });
30
+ await this.carto.index(this.workingDir, { writeOutputs: false });
38
31
  this._indexed = true;
39
32
 
33
+ if (wasIndexed) return null;
34
+
35
+ // Fresh index — surface status message
40
36
  const meta = this.carto.getMeta();
41
37
  const duration = ((Date.now() - start) / 1000).toFixed(1);
42
38
  const domains = this.carto.getDomainsList();
@@ -64,6 +60,8 @@ class SessionManager {
64
60
  }
65
61
 
66
62
  delete(id) {
63
+ const s = this._sessions.get(id);
64
+ if (s && s.carto) s.carto.close();
67
65
  this._sessions.delete(id);
68
66
  }
69
67
  }
package/src/cli/impact.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const path = require('path');
4
- const { Carto } = require('../../index.js');
3
+ const { StoreAdapter } = require('../store/store-adapter');
5
4
  const { checkForUpdate } = require('./update-check');
6
5
 
7
6
  async function run(projectRoot, fileArg) {
@@ -11,9 +10,9 @@ async function run(projectRoot, fileArg) {
11
10
  process.exit(1);
12
11
  }
13
12
 
14
- const carto = new Carto();
13
+ const carto = new StoreAdapter();
15
14
  try {
16
- await carto.index(projectRoot, { useWorkers: false });
15
+ await carto.index(projectRoot);
17
16
  } catch (err) {
18
17
  console.error(`[CARTO] Error loading index: ${err.message}`);
19
18
  process.exit(1);
@@ -22,6 +21,7 @@ async function run(projectRoot, fileArg) {
22
21
  const br = carto.getBlastRadius(fileArg);
23
22
  if (!br) {
24
23
  console.error(`[CARTO] File not found in project graph: ${fileArg}`);
24
+ carto.close();
25
25
  process.exit(1);
26
26
  }
27
27
 
@@ -55,7 +55,7 @@ async function run(projectRoot, fileArg) {
55
55
  }
56
56
 
57
57
  console.log('');
58
- carto.terminate();
58
+ carto.close();
59
59
  }
60
60
 
61
61
  module.exports = { run };
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;
@@ -163,4 +145,4 @@ function wireIDEs(projectRoot) {
163
145
  }
164
146
  }
165
147
 
166
- module.exports = { run, resolveConfig };
148
+ 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
  }
@@ -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;
@@ -213,7 +241,7 @@ class SQLiteStore {
213
241
  // ─── File operations ───────────────────────────────────────────────────
214
242
 
215
243
  getFileByPath(relPath) {
216
- return this._db.prepare('SELECT * FROM files WHERE path = ?').get(relPath);
244
+ return this._db.prepare('SELECT * FROM files WHERE path = ?').get(normalizePath(relPath));
217
245
  }
218
246
 
219
247
  getFileById(id) {
@@ -230,7 +258,8 @@ class SQLiteStore {
230
258
  }
231
259
 
232
260
  upsertFile(relPath, { language, hash, mtime, size }) {
233
- const existing = this.getFileByPath(relPath);
261
+ const norm = normalizePath(relPath);
262
+ const existing = this.getFileByPath(norm);
234
263
  if (existing) {
235
264
  this._db.prepare(
236
265
  'UPDATE files SET language=?, hash=?, mtime=?, size=?, last_indexed_at=? WHERE id=?'
@@ -239,18 +268,18 @@ class SQLiteStore {
239
268
  }
240
269
  const info = this._db.prepare(
241
270
  'INSERT INTO files (path, language, hash, mtime, size, last_indexed_at) VALUES (?,?,?,?,?,?)'
242
- ).run(relPath, language, hash, mtime, size, Date.now());
271
+ ).run(norm, language, hash, mtime, size, Date.now());
243
272
  return info.lastInsertRowid;
244
273
  }
245
274
 
246
275
  updateFileMtime(relPath, mtime, size) {
247
276
  this._db.prepare(
248
277
  'UPDATE files SET mtime=?, size=? WHERE path=?'
249
- ).run(mtime, size, relPath);
278
+ ).run(mtime, size, normalizePath(relPath));
250
279
  }
251
280
 
252
281
  removeFile(relPath) {
253
- this._db.prepare('DELETE FROM files WHERE path = ?').run(relPath);
282
+ this._db.prepare('DELETE FROM files WHERE path = ?').run(normalizePath(relPath));
254
283
  }
255
284
 
256
285
  removeStaleFiles(currentPaths) {
@@ -744,4 +773,4 @@ class SQLiteStore {
744
773
  }
745
774
  }
746
775
 
747
- module.exports = { SQLiteStore };
776
+ 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 };
@@ -24,7 +24,10 @@ const IGNORE_DIRS = new Set([
24
24
  'dist', '.next', '.turbo', 'build', 'coverage', '.carto',
25
25
  'out', '.cache', 'generated', '__generated__',
26
26
  'storybook-static', 'public', 'static',
27
- 'tmp-bench', 'vendor', 'third_party', '.yarn'
27
+ 'tmp-bench', 'vendor', 'third_party', '.yarn',
28
+ // Test directories — ported from V1 detector/files.js
29
+ 'test', 'tests', '__tests__', 'e2e', 'playwright',
30
+ 'cypress', 'fixtures', 'mocks', '__mocks__'
28
31
  ]);
29
32
 
30
33
  const CODE_EXTS = new Set([
@@ -34,6 +37,36 @@ const CODE_EXTS = new Set([
34
37
  '.swift', '.kt'
35
38
  ]);
36
39
 
40
+ const JS_LIKE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
41
+
42
+ /**
43
+ * isTestFile(relPath) → true if the file is a test/spec/stories file
44
+ * Ported from V1 detector/files.js exclusion patterns.
45
+ * R: test_*, test-*, *_test.r (case-insensitive)
46
+ * Python: test_*.py, *_test.py
47
+ * JS/TS: *.test.*, *.spec.*, *.stories.*
48
+ * Path-based directory exclusions (test/, tests/, __tests__/, etc.)
49
+ * are handled by IGNORE_DIRS during the walk — see above.
50
+ */
51
+ function isTestFile(relPath) {
52
+ const base = path.basename(relPath);
53
+ const lbase = base.toLowerCase();
54
+ const ext = path.extname(base).toLowerCase();
55
+
56
+ if (ext === '.r') {
57
+ return lbase.startsWith('test_') || lbase.startsWith('test-') || lbase.endsWith('_test.r');
58
+ }
59
+ if (ext === '.py') {
60
+ return lbase.startsWith('test_') || lbase.endsWith('_test.py');
61
+ }
62
+ if (JS_LIKE_EXTS.has(ext)) {
63
+ if (lbase.includes('.test.') || lbase.includes('.spec.') || lbase.includes('.stories.')) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
37
70
  /**
38
71
  * discoverFiles(projectRoot)
39
72
  * Recursively walks the project tree. No file cap. Respects ignore dirs + .cartoignore.
@@ -55,7 +88,7 @@ function discoverFiles(projectRoot) {
55
88
  walk(full);
56
89
  } else if (entry.isFile()) {
57
90
  const ext = path.extname(entry.name).toLowerCase();
58
- if (CODE_EXTS.has(ext) && !isIgnored(rel)) {
91
+ if (CODE_EXTS.has(ext) && !isIgnored(rel) && !isTestFile(rel)) {
59
92
  results.push(rel);
60
93
  }
61
94
  }
@@ -419,8 +452,8 @@ async function runSyncV2(config) {
419
452
  }
420
453
  }
421
454
 
422
- // 8. Generate outputs (only if files changed)
423
- if (toProcess.length > 0) {
455
+ // 8. Generate outputs (only if files changed AND output not suppressed)
456
+ if (toProcess.length > 0 && config.output !== null && config.output !== false) {
424
457
  await generateOutputs(store, config, projectRoot, store.getImportGraph());
425
458
  }
426
459