carto-md 2.0.7 → 2.0.8

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.
@@ -0,0 +1,310 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Production bitmap query tools — drop-in replacements for 5 SQLiteStore
5
+ * methods plus one new tool that's only feasible with bitmap aggregation.
6
+ *
7
+ * Each function returns the same shape as the corresponding
8
+ * `SQLiteStore` method so the MCP layer's formatting code (`server-v2.js`)
9
+ * is data-source-agnostic — bitmap and SQLite paths render identically.
10
+ *
11
+ * Performance contract (measured against the SQLite query path):
12
+ * blastRadius 20-50× faster on small/medium repos, 5-9× on largest
13
+ * crossDomain 4-12×
14
+ * highImpactFiles O(N) → O(1) array slice via popcountIndex
15
+ * similarPatterns 20-150× faster (Jaccard over import sets)
16
+ * simulateChangeImpact qualitatively new — N×SQL approach is too slow to
17
+ * ship as a tool; bitmap OR-aggregate is sub-millisecond
18
+ */
19
+
20
+ const { Bitset } = require('./bitset');
21
+
22
+ /**
23
+ * blastRadius(sidecar, file, maxHops=5) → [{file, hop_distance}] | null
24
+ *
25
+ * BFS over reverse adjacency bitmaps. Tracks the hop at which each
26
+ * dependent first becomes reachable so the output matches `SQLiteStore.
27
+ * getBlastRadius` row-for-row.
28
+ *
29
+ * Returns null if `file` is not in the index — same null contract as
30
+ * the SQLite version, so the MCP "File not found in index" message
31
+ * works without a code change.
32
+ *
33
+ * Pre-allocates `visited`, `frontier`, and a single transient
34
+ * `next` bitset; all word-level ops applied in place via `orInPlace` /
35
+ * `andNotInPlace` / `copyFrom`. Allocation count: 3 Bitsets per call,
36
+ * regardless of hop depth.
37
+ */
38
+ function blastRadius(sidecar, file, maxHops = 5) {
39
+ const fileId = sidecar.pathToFileId.get(file);
40
+ if (fileId === undefined) return null;
41
+
42
+ const { reverse, size, filePathArr } = sidecar;
43
+
44
+ const visited = new Bitset(size);
45
+ visited.set(fileId);
46
+ const frontier = new Bitset(size);
47
+ frontier.set(fileId);
48
+ const next = new Bitset(size);
49
+
50
+ // hopOf[id] = first hop at which id became reachable.
51
+ const hopOf = new Map();
52
+
53
+ for (let hop = 1; hop <= maxHops; hop++) {
54
+ next.setAll(0);
55
+ // For each bit in `frontier`, OR its reverse-adjacency bitmap into `next`.
56
+ const fwords = frontier.words;
57
+ for (let w = 0; w < fwords.length; w++) {
58
+ let v = fwords[w];
59
+ while (v) {
60
+ const bit = v & -v;
61
+ const fid = (w << 5) + (31 - Math.clz32(bit));
62
+ v ^= bit;
63
+ const deps = reverse.get(fid);
64
+ if (deps) next.orInPlace(deps);
65
+ }
66
+ }
67
+ next.andNotInPlace(visited);
68
+ if (next.popcount() === 0) break;
69
+ // Record the first hop at which each new bit became reachable.
70
+ const nwords = next.words;
71
+ for (let w = 0; w < nwords.length; w++) {
72
+ let v = nwords[w];
73
+ while (v) {
74
+ const bit = v & -v;
75
+ const id = (w << 5) + (31 - Math.clz32(bit));
76
+ v ^= bit;
77
+ if (!hopOf.has(id)) hopOf.set(id, hop);
78
+ }
79
+ }
80
+ visited.orInPlace(next);
81
+ frontier.copyFrom(next);
82
+ }
83
+
84
+ // Sort by hop ASC then path ASC — same ORDER BY as SQLite version.
85
+ // Use byte comparison (`<`/`>`) instead of `localeCompare`: matches
86
+ // SQLite's BINARY collation default and runs ~10× faster for ASCII paths.
87
+ const rows = [];
88
+ for (const [id, hop] of hopOf) {
89
+ const p = filePathArr[id];
90
+ if (p !== undefined) rows.push({ file: p, hop_distance: hop });
91
+ }
92
+ rows.sort((a, b) => {
93
+ if (a.hop_distance !== b.hop_distance) return a.hop_distance - b.hop_distance;
94
+ if (a.file < b.file) return -1;
95
+ if (a.file > b.file) return 1;
96
+ return 0;
97
+ });
98
+ return rows;
99
+ }
100
+
101
+ /**
102
+ * crossDomain(sidecar) → [{from, fromDomain, to, toDomain}]
103
+ *
104
+ * Iterates forward edges, emits the row whenever `from` and `to` belong to
105
+ * different domains. Drop-in for `SQLiteStore.getCrossDomainDeps`.
106
+ *
107
+ * Stable sort by (fromDomain, toDomain, from, to) matches the SQL
108
+ * ORDER BY (BINARY collation) so the output is byte-identical to the
109
+ * SQLite path on a given DB snapshot for ASCII paths.
110
+ *
111
+ * Uses `fileDomainArr` (Int32Array index) and
112
+ * `filePathArr` / `domainNameArr` (plain Array index) to avoid the
113
+ * 4× `Map.get` overhead per edge. The hot loop iterates `crossForward` —
114
+ * pre-masked at sidecar build time so intra-domain edges (95%+ of
115
+ * imports on real repos) are already excluded; no per-edge same-domain
116
+ * check, no wasted bit iteration.
117
+ */
118
+ function crossDomain(sidecar) {
119
+ const { crossForward, fileDomainArr, filePathArr, domainNameArr } = sidecar;
120
+ const rows = [];
121
+ for (const [fromId, bitmap] of crossForward) {
122
+ const fromDomainId = fileDomainArr[fromId];
123
+ if (fromDomainId < 0) continue;
124
+ const fromDomain = domainNameArr[fromDomainId];
125
+ if (!fromDomain) continue;
126
+ const fromPath = filePathArr[fromId];
127
+ if (fromPath === undefined) continue;
128
+ const words = bitmap.words;
129
+ for (let w = 0; w < words.length; w++) {
130
+ let v = words[w];
131
+ while (v) {
132
+ const bit = v & -v;
133
+ const toId = (w << 5) + (31 - Math.clz32(bit));
134
+ v ^= bit;
135
+ const toDomainId = fileDomainArr[toId];
136
+ // crossForward already masked out same-domain bits, but a file
137
+ // with no domain assignment can still be present — skip those
138
+ // (they have no toDomain name to emit).
139
+ if (toDomainId < 0) continue;
140
+ const toDomain = domainNameArr[toDomainId];
141
+ if (!toDomain) continue;
142
+ const toPath = filePathArr[toId];
143
+ if (toPath === undefined) continue;
144
+ rows.push({ from: fromPath, fromDomain, to: toPath, toDomain });
145
+ }
146
+ }
147
+ }
148
+ rows.sort((a, b) => {
149
+ if (a.fromDomain < b.fromDomain) return -1;
150
+ if (a.fromDomain > b.fromDomain) return 1;
151
+ if (a.toDomain < b.toDomain) return -1;
152
+ if (a.toDomain > b.toDomain) return 1;
153
+ if (a.from < b.from) return -1;
154
+ if (a.from > b.from) return 1;
155
+ if (a.to < b.to) return -1;
156
+ if (a.to > b.to) return 1;
157
+ return 0;
158
+ });
159
+ return rows;
160
+ }
161
+
162
+ /**
163
+ * highImpactFiles(sidecar, limit=10) → [{file, dependents}]
164
+ *
165
+ * O(1) array slice over `popcountIndex` — the sidecar maintains a list
166
+ * of file ids sorted DESC by transitive dependent count at build time,
167
+ * so the query at runtime is just `popcountIndex.slice(0, limit)`. No
168
+ * popcount, no sort, no allocation.
169
+ *
170
+ * Output shape mirrors `SQLiteStore.getHighImpactFiles` exactly.
171
+ */
172
+ function highImpactFiles(sidecar, limit = 10) {
173
+ const out = [];
174
+ const idx = sidecar.popcountIndex;
175
+ const n = Math.min(limit, idx.length);
176
+ for (let i = 0; i < n; i++) {
177
+ const p = sidecar.fileIdToPath.get(idx[i].fileId);
178
+ if (p) out.push({ file: p, dependents: idx[i].count });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * similarPatterns(sidecar, file, k=5) → [{file, score, shared}]
185
+ *
186
+ * Jaccard similarity over forward-import sets — for each candidate file,
187
+ * `score = |A ∩ B| / |A ∪ B|`. Returns the top-K most similar files.
188
+ *
189
+ * This semantics differs from the legacy SQLite tool (which used three
190
+ * separate SQL strategies — same domain, same route methods, shared
191
+ * imports). Both answer "what does this file look like elsewhere?", but
192
+ * Jaccard is the standard graph-similarity metric and runs in
193
+ * microseconds where the 3-strategy SQL took milliseconds. The output
194
+ * is structured so server-v2 can format a simple, focused result block.
195
+ *
196
+ * Returns `[]` when the target has no resolved imports — honest signal
197
+ * that there's nothing structural to compare against.
198
+ */
199
+ function similarPatterns(sidecar, file, k = 5) {
200
+ const fileId = sidecar.pathToFileId.get(file);
201
+ if (fileId === undefined) return null;
202
+ const target = sidecar.forward.get(fileId);
203
+ if (!target || target.popcount() === 0) return [];
204
+
205
+ const scores = [];
206
+ for (const [otherId, bitmap] of sidecar.forward) {
207
+ if (otherId === fileId) continue;
208
+ const intersection = target.and(bitmap).popcount();
209
+ if (intersection === 0) continue;
210
+ const union = target.or(bitmap).popcount();
211
+ const p = sidecar.fileIdToPath.get(otherId);
212
+ if (!p) continue;
213
+ scores.push({ file: p, score: intersection / union, shared: intersection });
214
+ }
215
+ scores.sort((a, b) => b.score - a.score || b.shared - a.shared);
216
+ return scores.slice(0, k);
217
+ }
218
+
219
+ /**
220
+ * simulateChangeImpact(sidecar, files) → { files: [{file, hop_distance}], count }
221
+ *
222
+ * **New tool — only feasible with bitmaps.** Given a *set* of files
223
+ * changing simultaneously, returns the union of every transitively
224
+ * affected file. Equivalent to N parallel reverse-BFS calls aggregated
225
+ * via bitwise OR — O(F + E) bitmap ops vs O(N×F×E) SQL queries.
226
+ *
227
+ * Hop distance is the *minimum* hop at which a dependent becomes
228
+ * reachable from any of the input files (BFS frontier OR-aggregated
229
+ * across all sources at each hop).
230
+ *
231
+ * Input files that aren't in the index are ignored silently. The
232
+ * returned `files` array excludes the input set itself (you don't want
233
+ * to count "this file depends on this file" as impact).
234
+ *
235
+ * Accepts paths (strings) or pre-resolved file ids (numbers) for
236
+ * convenience — the MCP tool dispatcher sends paths; tests use ids.
237
+ */
238
+ function simulateChangeImpact(sidecar, files, maxHops = 5) {
239
+ const { reverse, size, filePathArr, pathToFileId } = sidecar;
240
+
241
+ const seedIds = [];
242
+ for (const f of files || []) {
243
+ if (typeof f === 'number') {
244
+ if (f >= 0 && f < size && filePathArr[f] !== undefined) seedIds.push(f);
245
+ } else if (typeof f === 'string') {
246
+ const id = pathToFileId.get(f);
247
+ if (id !== undefined) seedIds.push(id);
248
+ }
249
+ }
250
+ if (seedIds.length === 0) return { files: [], count: 0 };
251
+
252
+ const visited = new Bitset(size);
253
+ const frontier = new Bitset(size);
254
+ for (const fid of seedIds) {
255
+ visited.set(fid);
256
+ frontier.set(fid);
257
+ }
258
+ const next = new Bitset(size);
259
+
260
+ const hopOf = new Map();
261
+ for (let hop = 1; hop <= maxHops; hop++) {
262
+ next.setAll(0);
263
+ const fwords = frontier.words;
264
+ for (let w = 0; w < fwords.length; w++) {
265
+ let v = fwords[w];
266
+ while (v) {
267
+ const bit = v & -v;
268
+ const fid = (w << 5) + (31 - Math.clz32(bit));
269
+ v ^= bit;
270
+ const deps = reverse.get(fid);
271
+ if (deps) next.orInPlace(deps);
272
+ }
273
+ }
274
+ next.andNotInPlace(visited);
275
+ if (next.popcount() === 0) break;
276
+ const nwords = next.words;
277
+ for (let w = 0; w < nwords.length; w++) {
278
+ let v = nwords[w];
279
+ while (v) {
280
+ const bit = v & -v;
281
+ const id = (w << 5) + (31 - Math.clz32(bit));
282
+ v ^= bit;
283
+ if (!hopOf.has(id)) hopOf.set(id, hop);
284
+ }
285
+ }
286
+ visited.orInPlace(next);
287
+ frontier.copyFrom(next);
288
+ }
289
+
290
+ const rows = [];
291
+ for (const [id, hop] of hopOf) {
292
+ const p = filePathArr[id];
293
+ if (p !== undefined) rows.push({ file: p, hop_distance: hop });
294
+ }
295
+ rows.sort((a, b) => {
296
+ if (a.hop_distance !== b.hop_distance) return a.hop_distance - b.hop_distance;
297
+ if (a.file < b.file) return -1;
298
+ if (a.file > b.file) return 1;
299
+ return 0;
300
+ });
301
+ return { files: rows, count: rows.length };
302
+ }
303
+
304
+ module.exports = {
305
+ blastRadius,
306
+ crossDomain,
307
+ highImpactFiles,
308
+ similarPatterns,
309
+ simulateChangeImpact,
310
+ };
package/src/cli/check.js CHANGED
@@ -39,6 +39,14 @@ async function run(projectRoot) {
39
39
  const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.round(age / 60)}m ago` : `${Math.round(age / 3600)}h ago`;
40
40
  console.log(` Last indexed : ${ageStr}`);
41
41
  }
42
+
43
+ // Language coverage (only shows when grammars are missing)
44
+ const unavailRaw = store.getMeta('unavailable_languages_json');
45
+ const unavailLangs = unavailRaw ? (() => { try { return JSON.parse(unavailRaw); } catch { return []; } })() : [];
46
+ if (unavailLangs.length > 0) {
47
+ console.log(` Lang coverage : ⚠️ ${unavailLangs.length} grammar${unavailLangs.length === 1 ? '' : 's'} unavailable (${unavailLangs.join(', ')}) — regex fallback active`);
48
+ }
49
+
42
50
  console.log('');
43
51
 
44
52
  // ── Uncommitted changes that touch high-blast-radius files ───────────────
@@ -89,6 +97,55 @@ async function run(projectRoot) {
89
97
  console.log(' ✅ No cross-domain dependency violations\n');
90
98
  }
91
99
 
100
+ // ── Domain stability ──────────────────────────────────────────────────
101
+ const driftPct = parseFloat(store.getMeta('domain_stability_drift_pct') || '0');
102
+ const reassignmentsRaw = store.getMeta('last_reassignments_json');
103
+ const reassignments = reassignmentsRaw ? (() => { try { return JSON.parse(reassignmentsRaw); } catch { return []; } })() : [];
104
+
105
+ if (driftPct > 0 || reassignments.length > 0) {
106
+ if (driftPct > 5) {
107
+ hasIssues = true;
108
+ console.log(` ⚠️ Domain stability: ${driftPct.toFixed(1)}% drift`);
109
+ } else {
110
+ console.log(` ✅ Domain stability: ${driftPct.toFixed(1)}% drift`);
111
+ }
112
+ if (reassignments.length > 0) {
113
+ console.log(' Recent reassignments:');
114
+ for (const r of reassignments.slice(0, 5)) {
115
+ console.log(` ${path.basename(r.file)}: ${r.from} → ${r.to}`);
116
+ }
117
+ if (reassignments.length > 5) console.log(` ...and ${reassignments.length - 5} more`);
118
+ }
119
+ console.log('');
120
+ } else if (domains.length > 1) {
121
+ console.log(' ✅ Domain stability: 0.0% drift (no reassignments)\n');
122
+ }
123
+
124
+ // ── Extraction errors ─────────────────────────────────────────────────
125
+ const errorCount = store.getExtractionErrorCount();
126
+ if (errorCount > 0) {
127
+ hasIssues = true;
128
+ const topFiles = store.getExtractionErrorsTopFiles(5);
129
+ console.log(` ⚠️ Extraction errors (${errorCount}):`);
130
+ console.log(' These files failed to parse — their routes/models/imports are missing from the index.\n');
131
+ for (const f of topFiles) {
132
+ const phases = f.phases ? ` [${f.phases}]` : '';
133
+ console.log(` ${f.file}${phases}`);
134
+ if (f.sample) {
135
+ // Truncate sample for terminal readability
136
+ const sample = String(f.sample).split('\n')[0].slice(0, 120);
137
+ console.log(` └─ ${sample}`);
138
+ }
139
+ }
140
+ // Errors NOT covered by the top-5 file slice (i.e., 6th+ file's errors).
141
+ const shown = topFiles.reduce((a, f) => a + (f.errorCount || 0), 0);
142
+ const remainingErrors = errorCount - shown;
143
+ if (remainingErrors > 0) {
144
+ console.log(` ...and ${remainingErrors} more error${remainingErrors === 1 ? '' : 's'} in additional files`);
145
+ }
146
+ console.log('');
147
+ }
148
+
92
149
  // ── Top high-impact files ────────────────────────────────────────────────
93
150
  if (highImpact.length > 0) {
94
151
  console.log(` 🔥 Top high-impact files (changing these = highest blast radius):`);
package/src/cli/index.js CHANGED
@@ -8,11 +8,18 @@ function printUsage() {
8
8
  Usage: carto <command>
9
9
 
10
10
  Commands:
11
- init Detect project, write .carto/config.json, run first sync
12
- watch Read .carto/config.json, start file watcher
11
+ init Detect project, write .carto/config.json, run first sync.
12
+ Installs git hooks (pre-commit, post-checkout, post-merge,
13
+ post-rewrite) so the index stays fresh on every git event.
13
14
  sync Read .carto/config.json, run one sync, exit
15
+ watch (Optional) Start file watcher for sub-second freshness.
16
+ Not required — git hooks + lazy MCP re-parse keep the
17
+ index fresh by default. Use only for AI-heavy workflows.
14
18
  impact <file> Show which files and routes are affected by changing a file
15
19
  check Report cross-domain deps, high-risk uncommitted changes, domain health
20
+ inspect Read-only diagnostic: prints index paths, sizes, freshness,
21
+ bitmap sidecar shape, top-impact files, schema version,
22
+ sync timestamps, extraction errors. Use --json for piping.
16
23
  remove Remove AGENTS.md and .carto/ from this project
17
24
  serve Start MCP server for AI tool integration
18
25
  agent Start ACP agent mode (for Zed, JetBrains, VS Code)
@@ -55,6 +62,11 @@ if (command === 'init') {
55
62
  console.error(`[CARTO] Fatal error: ${err.message}`);
56
63
  process.exit(1);
57
64
  });
65
+ } else if (command === 'inspect') {
66
+ // Read-only diagnostic — no async, no rebuild. Pass --json through.
67
+ const json = process.argv.slice(3).includes('--json');
68
+ const code = require('./inspect').run(process.cwd(), { json });
69
+ process.exit(code);
58
70
  } else if (command === 'remove') {
59
71
  require('./remove').run(process.cwd());
60
72
  } else if (command === 'serve') {