carto-md 1.1.3 → 1.1.4

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
@@ -20,13 +20,13 @@ Framework-specific route and model extraction lives in `src/extractors/`. Each f
20
20
 
21
21
  Currently supported:
22
22
  - **JS/TS**: Express, Next.js (App + Pages Router), tRPC, Drizzle, Zod
23
- - **Python**: FastAPI, Pydantic, SQLAlchemy, Django (models + URLs)
24
- - **Go**: Gin, Echo, Chi, net/http
23
+ - **Python**: FastAPI, Flask, Pydantic, SQLAlchemy, Django (models + URLs)
24
+ - **Go**: Gin, Echo, Chi, Fiber, net/http — routes, structs, import graph
25
25
  - **Schema**: Prisma
26
26
  - **Frontend**: HTML fetch()
27
27
  - **R**: Plumber, Shiny, R6, S7
28
28
 
29
- Wanted: Rails, Laravel, NestJS, Hono, Spring, Flask, Fastify.
29
+ Wanted: Rails, Laravel, NestJS, Hono, Spring, Fastify.
30
30
 
31
31
  ### Tier 3 — Core (review carefully before merging)
32
32
 
@@ -83,12 +83,27 @@ module.exports = {
83
83
 
84
84
  ## How to add a domain keyword
85
85
 
86
- Domain clustering lives in `src/agents/domains.js`. The `DOMAIN_MAP` array maps keywords to domain names. If your framework creates a new domain category, add it:
86
+ Domain clustering lives in `src/agents/domains.js`. The `DEFAULT_DOMAIN_MAP` array maps keywords to domain names. If your framework creates a new domain category, add it:
87
87
 
88
88
  ```js
89
89
  { keywords: ['graphql', 'resolver', 'mutation'], domain: 'GRAPHQL' },
90
90
  ```
91
91
 
92
+ ### Project-level custom domains
93
+
94
+ For non-web repos (CLIs, desktop apps, compilers), users can define their own domains in `carto.config.json` at the project root without touching `domains.js`:
95
+
96
+ ```json
97
+ {
98
+ "domains": {
99
+ "EDITOR": ["editor", "monaco", "text"],
100
+ "PLATFORM": ["platform", "service", "registry"]
101
+ }
102
+ }
103
+ ```
104
+
105
+ Custom config overrides the default domain map entirely for that project.
106
+
92
107
  ---
93
108
 
94
109
  ## Ground rules
package/README.md CHANGED
@@ -138,11 +138,11 @@ File saved → re-parse 1 file → update graph → ~130ms
138
138
 
139
139
  | Category | What Carto finds |
140
140
  |----------|-----------------|
141
- | **Routes** | FastAPI, Express, Next.js App/Pages Router, React Router (JSX + createBrowserRouter), tRPC procedures, Django URLs, Gin/Echo (Go) |
142
- | **Models** | Prisma, Pydantic, SQLAlchemy, Django ORM, TypeScript interfaces/types, Zod schemas, Drizzle tables |
143
- | **Graph** | Full import graph: who imports what, transitive dependencies up to 5 hops |
141
+ | **Routes** | FastAPI, Flask, Express, Next.js App/Pages Router, React Router (JSX + createBrowserRouter), tRPC procedures, Django URLs, Gin/Echo/Chi/Fiber (Go) |
142
+ | **Models** | Prisma, Pydantic, SQLAlchemy, Django ORM, TypeScript interfaces/types, Zod schemas, Drizzle tables, Go structs |
143
+ | **Graph** | Full import graph: who imports what, transitive dependencies up to 5 hops — JS/TS, Python, Go, R |
144
144
  | **Blast radius** | Risk level (HIGH/MEDIUM/LOW) per file and per route |
145
- | **Domains** | AUTH, PAYMENTS, DATABASE, EVENTS, TRPC, NOTIFICATIONS, CORE, auto-clustered from imports |
145
+ | **Domains** | AUTO-clustered from imports. Defaults: AUTH, PAYMENTS, DATABASE, EVENTS, TRPC, NOTIFICATIONS, CORE. Custom domains via `carto.config.json`. |
146
146
  | **Events** | EventEmitter listeners, webhook handlers, queue jobs, cron schedules |
147
147
  | **Env vars** | Every `process.env` / `os.Getenv` call (names only, never values) |
148
148
  | **Functions** | Signatures with param names and return types |
@@ -154,8 +154,8 @@ File saved → re-parse 1 file → update graph → ~130ms
154
154
  | Language | Frameworks |
155
155
  |----------|------------|
156
156
  | TypeScript / JavaScript | Express, Next.js (App + Pages Router), React Router, tRPC, Drizzle, Zod |
157
- | Python | FastAPI, Pydantic, SQLAlchemy, Django |
158
- | Go | Gin, Echo, Chi, net/http |
157
+ | Python | FastAPI, Flask, Pydantic, SQLAlchemy, Django |
158
+ | Go | Gin, Echo, Chi, Fiber, net/http — including full import graph |
159
159
  | R | Plumber, Shiny, R6, S7 |
160
160
  | Schema | Prisma |
161
161
  | HTML | fetch() calls |
@@ -164,6 +164,25 @@ More via community. See [CONTRIBUTING.md](CONTRIBUTING.md).
164
164
 
165
165
  ---
166
166
 
167
+ ## Custom domains (`carto.config.json`)
168
+
169
+ By default Carto clusters into web-app domains (AUTH, PAYMENTS, etc.). For any other architecture — desktop apps, CLIs, compilers, monorepos — define your own:
170
+
171
+ ```json
172
+ {
173
+ "domains": {
174
+ "EDITOR": ["editor", "monaco", "text", "cursor"],
175
+ "WORKBENCH": ["workbench", "layout", "panel", "sidebar"],
176
+ "PLATFORM": ["platform", "service", "registry"],
177
+ "BASE": ["base", "common", "util"]
178
+ }
179
+ }
180
+ ```
181
+
182
+ Drop `carto.config.json` in your project root. Carto picks it up on the next `carto init` or `carto sync`. The import graph and blast radius always work regardless — custom domains only affect how files are clustered and labeled.
183
+
184
+ ---
185
+
167
186
  ## Commands
168
187
 
169
188
  | Command | What it does |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carto-md",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "The context layer for AI-native development.",
5
5
  "bin": {
6
6
  "carto": "src/cli/index.js"
@@ -1,6 +1,6 @@
1
1
  const path = require('path');
2
2
 
3
- const DOMAIN_MAP = [
3
+ const DEFAULT_DOMAIN_MAP = [
4
4
  { keywords: ['auth', 'login', 'session', 'oauth', 'token', 'jwt', 'password', 'credential'], domain: 'AUTH' },
5
5
  { keywords: ['payment', 'billing', 'stripe', 'invoice', 'charge', 'subscription', 'checkout'], domain: 'PAYMENTS' },
6
6
  { keywords: ['trpc', 'router', 'routers', 'procedure'], domain: 'TRPC' },
@@ -9,6 +9,29 @@ const DOMAIN_MAP = [
9
9
  { keywords: ['email', 'notification', 'mail', 'sms', 'alert'], domain: 'NOTIFICATIONS' },
10
10
  ];
11
11
 
12
+ // Active domain map — replaced by setDomainMap() when carto.config.json is present
13
+ let DOMAIN_MAP = DEFAULT_DOMAIN_MAP;
14
+
15
+ /**
16
+ * setDomainMap(customDomains)
17
+ * Override the domain map from carto.config.json.
18
+ *
19
+ * customDomains format:
20
+ * { "EDITOR": ["editor", "monaco"], "WORKBENCH": ["workbench", "panel"] }
21
+ *
22
+ * Pass null to reset to defaults.
23
+ */
24
+ function setDomainMap(customDomains) {
25
+ if (!customDomains || typeof customDomains !== 'object' || Array.isArray(customDomains)) {
26
+ DOMAIN_MAP = DEFAULT_DOMAIN_MAP;
27
+ return;
28
+ }
29
+ DOMAIN_MAP = Object.entries(customDomains).map(([domain, keywords]) => ({
30
+ domain: domain.toUpperCase(),
31
+ keywords: keywords.map(k => String(k).toLowerCase()),
32
+ }));
33
+ }
34
+
12
35
  /**
13
36
  * getDomainForFile(relPath) → domain string or null
14
37
  * Returns domain if path matches a keyword, null if no match.
@@ -112,7 +135,7 @@ function clusterByDomain(data) {
112
135
 
113
136
  function getCluster(domain) {
114
137
  if (!clusters[domain]) {
115
- clusters[domain] = { routes: [], models: [], functions: {}, envVars: [], dbTables: [], fileMap: [] };
138
+ clusters[domain] = { routes: [], models: [], functions: {}, envVars: [], dbTables: [], fileMap: [], files: [] };
116
139
  }
117
140
  return clusters[domain];
118
141
  }
@@ -182,7 +205,12 @@ function clusterByDomain(data) {
182
205
  getCluster(domain).fileMap.push(entry);
183
206
  }
184
207
 
208
+ // Files — populate from assignments so cluster.files is always set
209
+ for (const [file, domain] of assignments.entries()) {
210
+ getCluster(domain).files.push(file);
211
+ }
212
+
185
213
  return clusters;
186
214
  }
187
215
 
188
- module.exports = { clusterByDomain, getDomainForFile, buildFileAssignments };
216
+ module.exports = { clusterByDomain, getDomainForFile, buildFileAssignments, setDomainMap };
@@ -7,6 +7,7 @@ const IGNORE_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 've
7
7
  const PYTHON_PRIORITY = ['fastapi', 'django', 'flask', 'python-generic'];
8
8
  const JS_PRIORITY = ['nextjs', 'express', 'react', 'node-generic'];
9
9
  const R_PRIORITY = ['plumber', 'shiny', 'r-generic'];
10
+ const GO_PRIORITY = ['gin', 'echo', 'chi', 'fiber', 'go-generic'];
10
11
 
11
12
  /**
12
13
  * detectFramework(projectRoot) → { framework, language, confidence, secondaryFramework?, secondaryLanguage? }
@@ -17,7 +18,7 @@ const R_PRIORITY = ['plumber', 'shiny', 'r-generic'];
17
18
  * (primary = highest priority overall, secondary = the other language).
18
19
  */
19
20
  function detectFramework(projectRoot) {
20
- const candidates = findFile(projectRoot, ['requirements.txt', 'package.json', 'pyproject.toml', 'DESCRIPTION'], 3);
21
+ const candidates = findFile(projectRoot, ['requirements.txt', 'package.json', 'pyproject.toml', 'DESCRIPTION', 'go.mod'], 3);
21
22
 
22
23
  const pythonDetections = new Set();
23
24
  const jsDetections = new Set();
@@ -43,9 +44,15 @@ function detectFramework(projectRoot) {
43
44
  for (const r of detectAllFromRFiles(projectRoot)) rDetections.add(r);
44
45
  }
45
46
 
47
+ const goDetections = new Set();
48
+ for (const f of candidates.filter(f => path.basename(f) === 'go.mod')) {
49
+ for (const r of detectAllFromGoMod(f)) goDetections.add(r);
50
+ }
51
+
46
52
  const bestPython = PYTHON_PRIORITY.find(fw => pythonDetections.has(fw)) || null;
47
53
  const bestJS = JS_PRIORITY.find(fw => jsDetections.has(fw)) || null;
48
54
  const bestR = R_PRIORITY.find(fw => rDetections.has(fw)) || null;
55
+ const bestGo = GO_PRIORITY.find(fw => goDetections.has(fw)) || null;
49
56
 
50
57
  if (bestPython && bestJS) {
51
58
  return {
@@ -57,17 +64,10 @@ function detectFramework(projectRoot) {
57
64
  };
58
65
  }
59
66
 
60
- if (bestPython) {
61
- return { framework: bestPython, language: 'python', confidence: 'high' };
62
- }
63
-
64
- if (bestJS) {
65
- return { framework: bestJS, language: 'javascript', confidence: 'high' };
66
- }
67
-
68
- if (bestR) {
69
- return { framework: bestR, language: 'r', confidence: 'high' };
70
- }
67
+ if (bestPython) return { framework: bestPython, language: 'python', confidence: 'high' };
68
+ if (bestJS) return { framework: bestJS, language: 'javascript', confidence: 'high' };
69
+ if (bestGo) return { framework: bestGo, language: 'go', confidence: 'high' };
70
+ if (bestR) return { framework: bestR, language: 'r', confidence: 'high' };
71
71
 
72
72
  return { framework: 'unknown', language: 'unknown', confidence: 'none' };
73
73
  }
@@ -141,6 +141,18 @@ function detectAllFromPackageJson(filePath) {
141
141
  return detected;
142
142
  }
143
143
 
144
+ function detectAllFromGoMod(filePath) {
145
+ const detected = [];
146
+ let content;
147
+ try { content = fs.readFileSync(filePath, 'utf-8').toLowerCase(); } catch { return detected; }
148
+ if (content.includes('gin-gonic/gin')) detected.push('gin');
149
+ if (content.includes('labstack/echo')) detected.push('echo');
150
+ if (content.includes('go-chi/chi')) detected.push('chi');
151
+ if (content.includes('gofiber/fiber')) detected.push('fiber');
152
+ if (!detected.length) detected.push('go-generic');
153
+ return detected;
154
+ }
155
+
144
156
  function detectAllFromRDescription(filePath) {
145
157
  const detected = [];
146
158
  let content;
@@ -11,7 +11,7 @@ const { WorkerPool } = require('./worker-pool');
11
11
  const { loadLanguagePlugins, getPluginForFile } = require('../extractors/loader');
12
12
  const { buildImportGraph } = require('../extractors/imports');
13
13
  const { buildStackLine } = require('../extractors/stack');
14
- const { getDomainForFile, buildFileAssignments } = require('../agents/domains');
14
+ const { getDomainForFile, buildFileAssignments, setDomainMap } = require('../agents/domains');
15
15
  const { extractImports } = require('../extractors/imports');
16
16
 
17
17
  const plugins = loadLanguagePlugins();
@@ -39,6 +39,7 @@ class Carto extends EventEmitter {
39
39
  this._cache = null;
40
40
  this._projectRoot = null;
41
41
  this._pool = null;
42
+ this._domainByFile = null; // reverse map: relPath → domainName
42
43
  }
43
44
 
44
45
  // ─── Indexing ────────────────────────────────────────────────────────────
@@ -54,6 +55,14 @@ class Carto extends EventEmitter {
54
55
 
55
56
  this.emit('status', { state: 'indexing', progress: 0 });
56
57
 
58
+ // Load custom domain config if present
59
+ try {
60
+ const config = JSON.parse(fs.readFileSync(path.join(projectRoot, 'carto.config.json'), 'utf-8'));
61
+ setDomainMap(config.domains || null);
62
+ } catch {
63
+ setDomainMap(null); // reset to defaults
64
+ }
65
+
57
66
  const cartoDir = path.join(projectRoot, '.carto');
58
67
  try { fs.mkdirSync(cartoDir, { recursive: true }); } catch {}
59
68
 
@@ -146,6 +155,7 @@ class Carto extends EventEmitter {
146
155
  }
147
156
 
148
157
  recomputeGraphMetrics(this._cache);
158
+ this._buildDomainMap();
149
159
  this._cache.meta.indexDuration = Date.now() - start;
150
160
  this._cache.meta.lastIndexed = new Date().toISOString();
151
161
  this._cache.generated = new Date().toISOString();
@@ -185,6 +195,7 @@ class Carto extends EventEmitter {
185
195
  updateFileHash(this._projectRoot, relPath, content);
186
196
  saveGraphCache(this._projectRoot, this._cache);
187
197
 
198
+ this._buildDomainMap();
188
199
  const blastRadius = this.getBlastRadius(relPath);
189
200
  this.emit('status', { state: 'ready' });
190
201
  this.emit('updated', { file: relPath, blastRadius });
@@ -555,12 +566,17 @@ class Carto extends EventEmitter {
555
566
  return null;
556
567
  }
557
568
 
558
- _getDomainForFile(relPath) {
559
- const domains = this._cache.domains || {};
560
- for (const [name, cluster] of Object.entries(domains)) {
561
- if ((cluster.files || []).includes(relPath)) return name;
569
+ _buildDomainMap() {
570
+ this._domainByFile = {};
571
+ for (const [name, cluster] of Object.entries(this._cache.domains || {})) {
572
+ for (const file of (cluster.files || [])) {
573
+ this._domainByFile[file] = name;
574
+ }
562
575
  }
563
- return null;
576
+ }
577
+
578
+ _getDomainForFile(relPath) {
579
+ return this._domainByFile ? (this._domainByFile[relPath] || null) : null;
564
580
  }
565
581
 
566
582
  _discoverFiles(projectRoot) {
@@ -39,6 +39,8 @@ function extractImports(content, filePath, projectRoot) {
39
39
  rawImports = extractPythonImports(content, filePath, projectRoot);
40
40
  } else if (ext === '.r') {
41
41
  return extractRImports(content, filePath, projectRoot);
42
+ } else if (ext === '.go') {
43
+ return extractGoImports(content, filePath, projectRoot);
42
44
  }
43
45
 
44
46
  // Resolve and deduplicate
@@ -220,6 +222,79 @@ function resolveImportPath(importPath, fileDir, projectRoot, sourceExt) {
220
222
  return null;
221
223
  }
222
224
 
225
+ // ─── Go imports ──────────────────────────────────────────────────────────────
226
+
227
+ // Cache go.mod module name per projectRoot — read once, reuse
228
+ const _goModCache = new Map();
229
+
230
+ function _getGoModuleName(projectRoot) {
231
+ if (_goModCache.has(projectRoot)) return _goModCache.get(projectRoot);
232
+ try {
233
+ const content = fs.readFileSync(path.join(projectRoot, 'go.mod'), 'utf-8');
234
+ const m = content.match(/^module\s+(\S+)/m);
235
+ const name = m ? m[1] : null;
236
+ _goModCache.set(projectRoot, name);
237
+ return name;
238
+ } catch {
239
+ _goModCache.set(projectRoot, null);
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * extractGoImports(content, filePath, projectRoot) → Array<string>
246
+ *
247
+ * Resolves local Go imports (same module) to actual .go files.
248
+ * Requires go.mod at projectRoot to determine the module name.
249
+ *
250
+ * Handles:
251
+ * import "module/path/pkg"
252
+ * import ( "module/path/pkg1" \n "module/path/pkg2" )
253
+ * import alias "module/path/pkg"
254
+ */
255
+ function extractGoImports(content, filePath, projectRoot) {
256
+ const moduleName = _getGoModuleName(projectRoot);
257
+ if (!moduleName) return [];
258
+
259
+ const results = new Set();
260
+
261
+ // Collect all import paths from both single and block imports
262
+ const importPaths = [];
263
+
264
+ // Single-line: import "path" or import alias "path"
265
+ const singleRe = /^import\s+(?:\w+\s+)?"([^"]+)"/gm;
266
+ let m;
267
+ while ((m = singleRe.exec(content)) !== null) importPaths.push(m[1]);
268
+
269
+ // Block: import ( ... )
270
+ const blockRe = /import\s*\(([^)]+)\)/g;
271
+ while ((m = blockRe.exec(content)) !== null) {
272
+ const block = m[1];
273
+ const lineRe = /"([^"]+)"/g;
274
+ let lm;
275
+ while ((lm = lineRe.exec(block)) !== null) importPaths.push(lm[1]);
276
+ }
277
+
278
+ // Resolve local imports only (starts with this module's name)
279
+ const prefix = moduleName + '/';
280
+ for (const imp of importPaths) {
281
+ if (!imp.startsWith(prefix)) continue;
282
+ const localPkg = imp.slice(prefix.length); // e.g. "internal/auth"
283
+ const pkgDir = path.join(projectRoot, localPkg);
284
+
285
+ // Find first non-test .go file in the package directory
286
+ try {
287
+ const entries = fs.readdirSync(pkgDir);
288
+ const goFile = entries.find(e => e.endsWith('.go') && !e.endsWith('_test.go'));
289
+ if (goFile) {
290
+ results.add(path.relative(projectRoot, path.join(pkgDir, goFile)));
291
+ }
292
+ } catch { /* directory doesn't exist or can't be read */ }
293
+ }
294
+
295
+ return [...results].sort();
296
+ }
297
+
223
298
  /**
224
299
  * buildImportGraph(fileContents, projectRoot) → { 'relative/path.js': ['relative/dep.js', ...] }
225
300
  *
@@ -25,11 +25,12 @@ function collapseMultilineDecorators(content) {
25
25
  }
26
26
 
27
27
  /**
28
- * Extracts HTTP routes from FastAPI and Django files.
28
+ * Extracts HTTP routes from FastAPI, Flask, and Django files.
29
29
  */
30
30
  function extractRoutes(content) {
31
31
  return [
32
32
  ...extractFastAPIRoutes(content),
33
+ ...extractFlaskRoutes(content),
33
34
  ...extractDjangoRoutes(content),
34
35
  ];
35
36
  }
@@ -61,6 +62,45 @@ function extractFastAPIRoutes(content) {
61
62
  return routes;
62
63
  }
63
64
 
65
+ // ─── Flask ───────────────────────────────────────────────────────────────────
66
+
67
+ function extractFlaskRoutes(content) {
68
+ const routes = [];
69
+
70
+ if (!content.includes('.route(') && !content.includes('from flask')) return routes;
71
+
72
+ // @app.route('/path') or @bp.route('/path', methods=['GET', 'POST'])
73
+ // Also handles: @api.route, @blueprint.route, any @x.route pattern
74
+ const decoratorRe = /@\w+\.route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)/g;
75
+ const funcRe = /(?:async\s+)?def\s+(\w+)/;
76
+
77
+ const lines = content.split('\n');
78
+
79
+ for (let i = 0; i < lines.length; i++) {
80
+ decoratorRe.lastIndex = 0;
81
+ const match = decoratorRe.exec(lines[i]);
82
+ if (!match) continue;
83
+
84
+ const routePath = match[1];
85
+ const methodsRaw = match[2];
86
+ const methods = methodsRaw
87
+ ? methodsRaw.split(',').map(m => m.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean)
88
+ : ['GET'];
89
+
90
+ let functionName = '[anonymous]';
91
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
92
+ const fm = lines[j].match(funcRe);
93
+ if (fm) { functionName = fm[1]; break; }
94
+ }
95
+
96
+ for (const method of methods) {
97
+ routes.push({ method, path: routePath, functionName });
98
+ }
99
+ }
100
+
101
+ return routes;
102
+ }
103
+
64
104
  // ─── Django ───────────────────────────────────────────────────────────────────
65
105
 
66
106
  function extractDjangoRoutes(content) {