carto-md 2.0.7 → 2.0.9

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.
Files changed (43) hide show
  1. package/README.md +290 -26
  2. package/docs/anci/v0.1-DRAFT.md +420 -0
  3. package/docs/scale.md +129 -0
  4. package/package.json +10 -5
  5. package/scripts/postinstall.js +413 -0
  6. package/src/acp/agent.js +5 -5
  7. package/src/acp/providers/index.js +2 -2
  8. package/src/agents/leiden.js +11 -17
  9. package/src/agents/scan-structure.js +1 -1
  10. package/src/anci/consumer.js +305 -0
  11. package/src/anci/deserialize.js +160 -0
  12. package/src/anci/emit.js +85 -0
  13. package/src/anci/serialize.js +264 -0
  14. package/src/anci/yaml.js +401 -0
  15. package/src/bitmap/bitset.js +190 -0
  16. package/src/bitmap/index.js +121 -0
  17. package/src/bitmap/sidecar.js +545 -0
  18. package/src/bitmap/tools.js +310 -0
  19. package/src/cli/anci.js +237 -0
  20. package/src/cli/check.js +57 -0
  21. package/src/cli/index.js +28 -2
  22. package/src/cli/init.js +297 -65
  23. package/src/cli/inspect.js +295 -0
  24. package/src/cli/pr-impact.js +497 -0
  25. package/src/cli/serve.js +1 -1
  26. package/src/cli/watch.js +6 -0
  27. package/src/engine/worker.js +24 -4
  28. package/src/extractors/imports.js +176 -0
  29. package/src/extractors/languages/html.js +4 -1
  30. package/src/extractors/languages/javascript.js +5 -0
  31. package/src/extractors/languages/prisma.js +4 -1
  32. package/src/extractors/languages/python.js +5 -1
  33. package/src/extractors/languages/r.js +4 -1
  34. package/src/extractors/languages/typescript.js +2 -0
  35. package/src/extractors/tree-sitter-parser.js +15 -0
  36. package/src/mcp/change-plan.js +8 -8
  37. package/src/mcp/diff-parser.js +246 -0
  38. package/src/mcp/server-v2.js +489 -8
  39. package/src/mcp/validate.js +304 -0
  40. package/src/store/config-loader.js +77 -0
  41. package/src/store/sqlite-store.js +389 -4
  42. package/src/store/sync-v2.js +472 -97
  43. package/BENCHMARK_RESULTS.md +0 -34
@@ -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
+ };
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `carto anci` — ANCI subcommand dispatcher.
5
+ *
6
+ * Subcommands:
7
+ * publish Re-emit `.carto/anci.{yaml,bin}` from the
8
+ * existing index. Does NOT re-sync — assumes
9
+ * the index is already populated.
10
+ * show Print a human-readable summary of the
11
+ * published ANCI files.
12
+ * validate <dir> Validate an external `anci.{yaml,bin}` pair
13
+ * at <dir>. Exits 0 on valid, 1 on invalid.
14
+ *
15
+ * All paths in this module are anchored at `process.cwd()` unless an
16
+ * explicit directory is passed.
17
+ *
18
+ * The `serve` MCP server is unaffected — ANCI is the *export* format,
19
+ * not Carto's runtime format. SQLite + bitmap remain the runtime path.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ function printUsage() {
26
+ console.log(`
27
+ Usage: carto anci <subcommand>
28
+
29
+ Subcommands:
30
+ publish Re-emit .carto/anci.{yaml,bin} from the existing
31
+ SQLite index + bitmap sidecar. No re-sync.
32
+ show Print a summary of the published ANCI files.
33
+ validate <dir> Validate an external anci.{yaml,bin} pair at <dir>.
34
+ Exit 0 on valid, 1 on invalid.
35
+
36
+ Examples:
37
+ carto anci publish
38
+ carto anci show
39
+ carto anci validate ./.carto
40
+ `);
41
+ }
42
+
43
+ /**
44
+ * run({ argv }) → exit code (0 ok, 1 error).
45
+ *
46
+ * argv shape: array of args AFTER `carto anci` (e.g. `['publish']`,
47
+ * `['validate', './.carto']`). The top-level `cli/index.js` slices the
48
+ * full process.argv before passing it in.
49
+ */
50
+ function run({ argv }) {
51
+ const sub = argv[0];
52
+
53
+ if (!sub || sub === '--help' || sub === '-h') {
54
+ printUsage();
55
+ return 0;
56
+ }
57
+
58
+ if (sub === 'publish') return runPublish(process.cwd());
59
+ if (sub === 'show') return runShow(process.cwd());
60
+ if (sub === 'validate') {
61
+ const dir = argv[1] || path.join(process.cwd(), '.carto');
62
+ return runValidate(dir);
63
+ }
64
+
65
+ console.error(`[CARTO] Unknown anci subcommand: ${sub}`);
66
+ printUsage();
67
+ return 1;
68
+ }
69
+
70
+ /**
71
+ * publish — Re-emit ANCI files from the existing index.
72
+ *
73
+ * Reads the SQLiteStore (read-write so a fresh sidecar can be persisted
74
+ * if the on-disk one is stale) + bitmap sidecar, then writes
75
+ * `anci.{yaml,bin}` atomically. Prints sizes and elapsed time.
76
+ *
77
+ * Exits 1 if no `.carto/carto.db` is present.
78
+ */
79
+ function runPublish(projectRoot) {
80
+ const cartoDir = path.join(projectRoot, '.carto');
81
+ const dbPath = path.join(cartoDir, 'carto.db');
82
+ if (!fs.existsSync(dbPath)) {
83
+ console.error(`[CARTO] No index found at ${dbPath}. Run \`carto init\` first.`);
84
+ return 1;
85
+ }
86
+
87
+ const start = Date.now();
88
+ const { SQLiteStore } = require('../store/sqlite-store');
89
+ const { ensureBitmapFresh } = require('../bitmap/index');
90
+ const { emitToCartoDir } = require('../anci/emit');
91
+
92
+ const store = new SQLiteStore(projectRoot);
93
+ store.open();
94
+ try {
95
+ const sidecar = ensureBitmapFresh(cartoDir, store);
96
+ const { yamlPath, binPath, bodyBytes } = emitToCartoDir({
97
+ cartoDir, sidecar, store,
98
+ });
99
+ const elapsed = Date.now() - start;
100
+ const yamlBytes = fs.statSync(yamlPath).size;
101
+
102
+ console.log(`[CARTO] Published ANCI v0.1 in ${elapsed}ms`);
103
+ console.log(` ${path.relative(projectRoot, yamlPath)} (${formatBytes(yamlBytes)})`);
104
+ console.log(` ${path.relative(projectRoot, binPath)} (${formatBytes(bodyBytes)})`);
105
+ return 0;
106
+ } finally {
107
+ try { store.close(); } catch {}
108
+ }
109
+ }
110
+
111
+ /**
112
+ * show — Print a summary of the published ANCI files.
113
+ *
114
+ * Loads via the consumer library so this surface dogfoods the same
115
+ * code path partner integrations will use. If either file is missing
116
+ * or malformed, prints the error and exits 1.
117
+ */
118
+ function runShow(projectRoot) {
119
+ const cartoDir = path.join(projectRoot, '.carto');
120
+ let reader;
121
+ try {
122
+ const { loadAnci } = require('../anci/consumer');
123
+ reader = loadAnci(cartoDir);
124
+ } catch (err) {
125
+ console.error(`[CARTO] ${err.message}`);
126
+ console.error(`[CARTO] Run \`carto anci publish\` to (re)generate.`);
127
+ return 1;
128
+ }
129
+
130
+ const h = reader.header;
131
+ const lines = [];
132
+ lines.push('');
133
+ lines.push('── ANCI ────────────────────────────────────────────────');
134
+ lines.push('');
135
+ lines.push(` spec version : ${h.anci.version}`);
136
+ lines.push(` generator : ${h.anci.generator}`);
137
+ lines.push(` generated_at : ${h.anci.generated_at}`);
138
+ lines.push(` body file : ${h.anci.body.file} (${formatBytes(h.anci.body.bytes)})`);
139
+ lines.push('');
140
+ if (h.project) {
141
+ lines.push('Project');
142
+ lines.push(` files : ${h.project.total_files}`);
143
+ lines.push(` routes : ${h.project.total_routes}`);
144
+ lines.push(` models : ${h.project.total_models}`);
145
+ lines.push(` import edges : ${h.project.total_import_edges}`);
146
+ lines.push('');
147
+ }
148
+ if (reader.domains.length > 0) {
149
+ lines.push(`Domains (${reader.domains.length})`);
150
+ for (const d of reader.domains) {
151
+ lines.push(` ${d.name.padEnd(16)} ${String(d.file_count).padStart(5)} files ` +
152
+ `${String(d.route_count || 0).padStart(4)} routes ` +
153
+ `${String(d.model_count || 0).padStart(4)} models`);
154
+ }
155
+ lines.push('');
156
+ }
157
+ if (reader.high_impact && reader.high_impact.length > 0) {
158
+ lines.push('Top impact (transitive dependents)');
159
+ const topN = Math.min(10, reader.high_impact.length);
160
+ for (let i = 0; i < topN; i++) {
161
+ const e = reader.high_impact[i];
162
+ lines.push(` ${String(e.transitive_dependents).padStart(4)} ${e.file}`);
163
+ }
164
+ lines.push('');
165
+ }
166
+ lines.push('────────────────────────────────────────────────────────');
167
+ lines.push('');
168
+ process.stdout.write(lines.join('\n'));
169
+ return 0;
170
+ }
171
+
172
+ /**
173
+ * validate <dir> — Validate an external ANCI pair.
174
+ *
175
+ * Cleanly distinguishes "structural problem with the file" (return 1
176
+ * with message) from "this consumer can't handle this version"
177
+ * (return 1 with message naming the version) from "all checks pass"
178
+ * (return 0).
179
+ */
180
+ function runValidate(dir) {
181
+ const yaml = require('../anci/yaml');
182
+ const { deserializeBody } = require('../anci/deserialize');
183
+ const { ANCI_BIN_FILENAME, ANCI_YAML_FILENAME } = require('../anci/serialize');
184
+
185
+ const yamlPath = path.join(dir, ANCI_YAML_FILENAME);
186
+ const binPath = path.join(dir, ANCI_BIN_FILENAME);
187
+
188
+ const errors = [];
189
+ if (!fs.existsSync(yamlPath)) errors.push(`missing ${yamlPath}`);
190
+ if (!fs.existsSync(binPath)) errors.push(`missing ${binPath}`);
191
+ if (errors.length > 0) {
192
+ for (const e of errors) console.error(`[CARTO] ${e}`);
193
+ return 1;
194
+ }
195
+
196
+ let header;
197
+ try {
198
+ header = yaml.parse(fs.readFileSync(yamlPath, 'utf-8'));
199
+ } catch (err) {
200
+ console.error(`[CARTO] header parse failed: ${err.message}`);
201
+ return 1;
202
+ }
203
+ if (!header || !header.anci || typeof header.anci.version !== 'string') {
204
+ console.error('[CARTO] header missing required `anci.version`');
205
+ return 1;
206
+ }
207
+
208
+ const bodyBuf = fs.readFileSync(binPath);
209
+ const body = deserializeBody(bodyBuf);
210
+ if (!body) {
211
+ console.error('[CARTO] body failed to parse (corrupt magic, version, or section)');
212
+ return 1;
213
+ }
214
+
215
+ // Optional cross-check: header.body.bytes vs actual file size.
216
+ if (header.anci.body && typeof header.anci.body.bytes === 'number') {
217
+ if (header.anci.body.bytes !== bodyBuf.length) {
218
+ console.error(
219
+ `[CARTO] header/body size mismatch: header says ${header.anci.body.bytes}, ` +
220
+ `actual ${bodyBuf.length} bytes`
221
+ );
222
+ return 1;
223
+ }
224
+ }
225
+
226
+ console.log(`[CARTO] ANCI valid: ${header.anci.version}, ${body.size} files indexed.`);
227
+ return 0;
228
+ }
229
+
230
+ function formatBytes(n) {
231
+ if (n === null || n === undefined) return 'n/a';
232
+ if (n < 1024) return `${n} B`;
233
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
234
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
235
+ }
236
+
237
+ module.exports = { run };
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,24 @@ 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
19
+ pr-impact Render a PR impact report (markdown or JSON) for the
20
+ diff between two git refs. Used by the carto GitHub
21
+ Action; works standalone too.
15
22
  check Report cross-domain deps, high-risk uncommitted changes, domain health
23
+ inspect Read-only diagnostic: prints index paths, sizes, freshness,
24
+ bitmap sidecar shape, top-impact files, schema version,
25
+ sync timestamps, extraction errors. Use --json for piping.
26
+ anci ANCI v0.1 DRAFT export. Subcommands: publish, show,
27
+ validate. Writes/reads .carto/anci.{yaml,bin} — the
28
+ public, tool-neutral architecture description spec.
16
29
  remove Remove AGENTS.md and .carto/ from this project
17
30
  serve Start MCP server for AI tool integration
18
31
  agent Start ACP agent mode (for Zed, JetBrains, VS Code)
@@ -50,11 +63,24 @@ if (command === 'init') {
50
63
  } else if (command === 'impact') {
51
64
  const fileArg = process.argv[3];
52
65
  require('./impact').run(process.cwd(), fileArg);
66
+ } else if (command === 'pr-impact') {
67
+ // pr-impact owns its own argv parsing + error handling + exit code.
68
+ const code = require('./pr-impact').run({ argv: process.argv.slice(3) });
69
+ process.exit(code);
53
70
  } else if (command === 'check') {
54
71
  require('./check').run(process.cwd()).catch(err => {
55
72
  console.error(`[CARTO] Fatal error: ${err.message}`);
56
73
  process.exit(1);
57
74
  });
75
+ } else if (command === 'inspect') {
76
+ // Read-only diagnostic — no async, no rebuild. Pass --json through.
77
+ const json = process.argv.slice(3).includes('--json');
78
+ const code = require('./inspect').run(process.cwd(), { json });
79
+ process.exit(code);
80
+ } else if (command === 'anci') {
81
+ // anci has its own subcommand parser; pass argv after `carto anci`.
82
+ const code = require('./anci').run({ argv: process.argv.slice(3) });
83
+ process.exit(code);
58
84
  } else if (command === 'remove') {
59
85
  require('./remove').run(process.cwd());
60
86
  } else if (command === 'serve') {