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.
- package/README.md +290 -26
- package/docs/anci/v0.1-DRAFT.md +420 -0
- package/docs/scale.md +129 -0
- package/package.json +10 -5
- package/scripts/postinstall.js +413 -0
- package/src/acp/agent.js +5 -5
- package/src/acp/providers/index.js +2 -2
- package/src/agents/leiden.js +11 -17
- package/src/agents/scan-structure.js +1 -1
- package/src/anci/consumer.js +305 -0
- package/src/anci/deserialize.js +160 -0
- package/src/anci/emit.js +85 -0
- package/src/anci/serialize.js +264 -0
- package/src/anci/yaml.js +401 -0
- package/src/bitmap/bitset.js +190 -0
- package/src/bitmap/index.js +121 -0
- package/src/bitmap/sidecar.js +545 -0
- package/src/bitmap/tools.js +310 -0
- package/src/cli/anci.js +237 -0
- package/src/cli/check.js +57 -0
- package/src/cli/index.js +28 -2
- package/src/cli/init.js +297 -65
- package/src/cli/inspect.js +295 -0
- package/src/cli/pr-impact.js +497 -0
- package/src/cli/serve.js +1 -1
- package/src/cli/watch.js +6 -0
- package/src/engine/worker.js +24 -4
- package/src/extractors/imports.js +176 -0
- package/src/extractors/languages/html.js +4 -1
- package/src/extractors/languages/javascript.js +5 -0
- package/src/extractors/languages/prisma.js +4 -1
- package/src/extractors/languages/python.js +5 -1
- package/src/extractors/languages/r.js +4 -1
- package/src/extractors/languages/typescript.js +2 -0
- package/src/extractors/tree-sitter-parser.js +15 -0
- package/src/mcp/change-plan.js +8 -8
- package/src/mcp/diff-parser.js +246 -0
- package/src/mcp/server-v2.js +489 -8
- package/src/mcp/validate.js +304 -0
- package/src/store/config-loader.js +77 -0
- package/src/store/sqlite-store.js +389 -4
- package/src/store/sync-v2.js +472 -97
- 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
|
+
};
|
package/src/cli/anci.js
ADDED
|
@@ -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
|
-
|
|
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') {
|