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,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `carto pr-impact` — pull-request-shaped impact report.
|
|
6
|
+
*
|
|
7
|
+
* Computes the impact of a pull request's diff against the current
|
|
8
|
+
* project graph and renders either:
|
|
9
|
+
* - Markdown wrapped in a `<!-- carto-impact-report -->` HTML marker
|
|
10
|
+
* so the GitHub Action can detect-and-update sticky comments.
|
|
11
|
+
* - JSON for programmatic consumers (CI dashboards, custom workflows).
|
|
12
|
+
*
|
|
13
|
+
* Composition:
|
|
14
|
+
* 1. `git diff --unified=3 <base>...<head>` → unified diff text.
|
|
15
|
+
* 2. `validateDiff(store, sidecar, diff)` → violations, blast radius,
|
|
16
|
+
* risk roll-up. (Same engine the MCP `validate_diff` tool uses.)
|
|
17
|
+
* 3. Per-file `StoreAdapter.getBlastRadius(file)` → routes affected,
|
|
18
|
+
* domains impacted (validateDiff returns counts; the comment
|
|
19
|
+
* template wants the actual route list).
|
|
20
|
+
* 4. Render.
|
|
21
|
+
*
|
|
22
|
+
* Exit code:
|
|
23
|
+
* - 0 by default (the comment surfaces the risk; failing the build
|
|
24
|
+
* is opt-in).
|
|
25
|
+
* - Non-zero when `--fail-on HIGH|MEDIUM` is supplied AND the rolled-up
|
|
26
|
+
* risk meets or exceeds the threshold.
|
|
27
|
+
*
|
|
28
|
+
* The command is read-only: it does not write to the SQLite store and
|
|
29
|
+
* does not record episodic-memory rows — those persistence concerns
|
|
30
|
+
* belong to the MCP `validate_diff` tool, not the CI surface.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const { execFileSync } = require('child_process');
|
|
36
|
+
const { SQLiteStore } = require('../store/sqlite-store');
|
|
37
|
+
const { StoreAdapter } = require('../store/store-adapter');
|
|
38
|
+
const { ensureBitmapFresh } = require('../bitmap/index');
|
|
39
|
+
const { validateDiff } = require('../mcp/validate');
|
|
40
|
+
|
|
41
|
+
const MARKER = '<!-- carto-impact-report -->';
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const args = {
|
|
45
|
+
base: null,
|
|
46
|
+
head: 'HEAD',
|
|
47
|
+
format: 'markdown',
|
|
48
|
+
failOn: null, // null | 'HIGH' | 'MEDIUM' | 'LOW'
|
|
49
|
+
diffFile: null, // for tests: read diff from a file instead of git
|
|
50
|
+
projectRoot: process.cwd(),
|
|
51
|
+
help: false,
|
|
52
|
+
};
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const a = argv[i];
|
|
55
|
+
switch (a) {
|
|
56
|
+
case '--base': args.base = argv[++i]; break;
|
|
57
|
+
case '--head': args.head = argv[++i]; break;
|
|
58
|
+
case '--format': args.format = argv[++i]; break;
|
|
59
|
+
case '--fail-on': args.failOn = (argv[++i] || '').toUpperCase(); break;
|
|
60
|
+
case '--diff-file': args.diffFile = argv[++i]; break;
|
|
61
|
+
case '--project': args.projectRoot = path.resolve(argv[++i]); break;
|
|
62
|
+
case '--help':
|
|
63
|
+
case '-h': args.help = true; break;
|
|
64
|
+
default:
|
|
65
|
+
// Unknown flag — fail loudly so typos in CI are visible.
|
|
66
|
+
if (a.startsWith('--')) {
|
|
67
|
+
throw new Error(`unknown flag: ${a}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!['markdown', 'json'].includes(args.format)) {
|
|
72
|
+
throw new Error(`--format must be 'markdown' or 'json' (got '${args.format}')`);
|
|
73
|
+
}
|
|
74
|
+
if (args.failOn && !['HIGH', 'MEDIUM', 'LOW'].includes(args.failOn)) {
|
|
75
|
+
throw new Error(`--fail-on must be HIGH | MEDIUM | LOW (got '${args.failOn}')`);
|
|
76
|
+
}
|
|
77
|
+
return args;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printUsage() {
|
|
81
|
+
process.stdout.write(`
|
|
82
|
+
Usage: carto pr-impact [options]
|
|
83
|
+
|
|
84
|
+
Computes the impact of a PR's diff against the current carto index and
|
|
85
|
+
renders a markdown or JSON report. Designed for the carto GitHub Action
|
|
86
|
+
but usable standalone.
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--base <ref> Git ref the PR branched from (e.g. origin/main)
|
|
90
|
+
--head <ref> Git ref of the PR head (default: HEAD)
|
|
91
|
+
--format <fmt> markdown (default) | json
|
|
92
|
+
--fail-on <severity> Exit non-zero if risk >= severity (HIGH | MEDIUM | LOW)
|
|
93
|
+
--diff-file <path> Read diff text from a file instead of running git diff
|
|
94
|
+
(primarily for testing)
|
|
95
|
+
--project <path> Project root (default: cwd)
|
|
96
|
+
--help, -h Show this help
|
|
97
|
+
|
|
98
|
+
Exit codes:
|
|
99
|
+
0 Normal — comment rendered.
|
|
100
|
+
1 Misuse, missing index, or git failure.
|
|
101
|
+
2 --fail-on threshold tripped.
|
|
102
|
+
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* runGitDiff(projectRoot, base, head) → diffText
|
|
108
|
+
*
|
|
109
|
+
* Captures the unified diff between two refs. Three-dot syntax (`base...head`)
|
|
110
|
+
* matches what GitHub uses for PR diffs — only the changes introduced on
|
|
111
|
+
* the head branch since it diverged from base, not changes that landed on
|
|
112
|
+
* base in the meantime.
|
|
113
|
+
*/
|
|
114
|
+
function runGitDiff(projectRoot, base, head) {
|
|
115
|
+
if (!base) {
|
|
116
|
+
throw new Error('--base is required (or use --diff-file to supply a diff manually)');
|
|
117
|
+
}
|
|
118
|
+
let out;
|
|
119
|
+
try {
|
|
120
|
+
out = execFileSync('git', ['diff', '--unified=3', `${base}...${head}`], {
|
|
121
|
+
cwd: projectRoot,
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
maxBuffer: 64 * 1024 * 1024, // 64 MB — large PRs are rare but real
|
|
124
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const stderr = (err.stderr || '').toString().trim();
|
|
128
|
+
throw new Error(
|
|
129
|
+
`git diff ${base}...${head} failed${stderr ? `: ${stderr}` : ''}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const RISK_BADGE = {
|
|
136
|
+
HIGH: '🔴 HIGH',
|
|
137
|
+
MEDIUM: '🟡 MEDIUM',
|
|
138
|
+
LOW: '🟢 LOW',
|
|
139
|
+
SAFE: '✅ SAFE',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const RISK_RANK = { SAFE: 0, LOW: 1, MEDIUM: 2, HIGH: 3 };
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* collectImpact(projectRoot, diffText) → { result, perFile, domains, highImpact }
|
|
146
|
+
*
|
|
147
|
+
* - `result` — full validateDiff(...) output (violations + blast
|
|
148
|
+
* radius + risk).
|
|
149
|
+
* - `perFile` — Map<path, { blastRadius, routes, domains }> for files
|
|
150
|
+
* in the diff. Skipped for files not in the index
|
|
151
|
+
* (pure adds, renames where the new path didn't exist
|
|
152
|
+
* at sync time).
|
|
153
|
+
* - `domains` — sorted Array<string> of all domains touched by the
|
|
154
|
+
* changed files (used for the headline sentence).
|
|
155
|
+
* - `highImpact` — { file, dependents } for the changed file with the
|
|
156
|
+
* most direct dependents, or null if all are 0.
|
|
157
|
+
*/
|
|
158
|
+
function collectImpact(projectRoot, diffText) {
|
|
159
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
160
|
+
const dbPath = path.join(cartoDir, 'carto.db');
|
|
161
|
+
if (!fs.existsSync(dbPath)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`No carto index at ${dbPath}. Run \`carto init\` (or \`carto sync\` ` +
|
|
164
|
+
`if the index already exists) before \`carto pr-impact\`.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const store = new SQLiteStore(projectRoot);
|
|
169
|
+
store.open({ readonly: true });
|
|
170
|
+
let sidecar = null;
|
|
171
|
+
try {
|
|
172
|
+
sidecar = ensureBitmapFresh(cartoDir, store);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
process.stderr.write(
|
|
175
|
+
`[CARTO] bitmap unavailable, validation will use SQLite-only path: ` +
|
|
176
|
+
`${err.message || err}\n`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = validateDiff(store, sidecar, diffText);
|
|
181
|
+
|
|
182
|
+
// Per-file rich detail via StoreAdapter (matches what `carto impact <file>`
|
|
183
|
+
// returns — routes, domain). We open a second adapter pointed at the same
|
|
184
|
+
// DB rather than re-implementing the formatter here.
|
|
185
|
+
const adapter = new StoreAdapter();
|
|
186
|
+
// Skip the indexer — we know the DB exists. Open the store directly.
|
|
187
|
+
adapter._store = store;
|
|
188
|
+
adapter._projectRoot = projectRoot;
|
|
189
|
+
|
|
190
|
+
const perFile = new Map();
|
|
191
|
+
const domainSet = new Set();
|
|
192
|
+
let highImpact = null;
|
|
193
|
+
for (const f of result.diff) {
|
|
194
|
+
if (f.kind === 'delete') continue;
|
|
195
|
+
let br = null;
|
|
196
|
+
try {
|
|
197
|
+
br = adapter.getBlastRadius(f.path);
|
|
198
|
+
} catch {
|
|
199
|
+
// File not in index — most often a pure add. Leave perFile entry
|
|
200
|
+
// off the map; renderer skips files it doesn't have detail for.
|
|
201
|
+
}
|
|
202
|
+
if (!br) continue;
|
|
203
|
+
perFile.set(f.path, {
|
|
204
|
+
blastRadius: br.dependentFiles.length,
|
|
205
|
+
directlyAffected: br.directlyAffected.files,
|
|
206
|
+
routes: br.routesImpacted,
|
|
207
|
+
domains: br.domainsImpacted,
|
|
208
|
+
});
|
|
209
|
+
for (const d of br.domainsImpacted) domainSet.add(d);
|
|
210
|
+
if (
|
|
211
|
+
!highImpact ||
|
|
212
|
+
br.directlyAffected.files > highImpact.dependents
|
|
213
|
+
) {
|
|
214
|
+
highImpact = {
|
|
215
|
+
file: f.path,
|
|
216
|
+
dependents: br.directlyAffected.files,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
store.close();
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
result,
|
|
225
|
+
perFile,
|
|
226
|
+
domains: [...domainSet].sort(),
|
|
227
|
+
highImpact: highImpact && highImpact.dependents > 0 ? highImpact : null,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* renderMarkdown(impact) → string
|
|
233
|
+
*
|
|
234
|
+
* Renders the PR comment body. Wraps the entire body in a
|
|
235
|
+
* `<!-- carto-impact-report -->` HTML comment marker so the GitHub
|
|
236
|
+
* Action can find-and-update its previous comment instead of posting
|
|
237
|
+
* a duplicate every commit.
|
|
238
|
+
*
|
|
239
|
+
* Sections:
|
|
240
|
+
* - Headline sentence ("This PR touches X and Y domains.")
|
|
241
|
+
* - Metric table (Risk · Blast radius · Files changed · Violations
|
|
242
|
+
* introduced · High-impact file)
|
|
243
|
+
* - Affected routes (collapsible) — only if any
|
|
244
|
+
* - Cross-domain violations (collapsible) — only if any
|
|
245
|
+
* - Suggestions (collapsible) — only if any
|
|
246
|
+
*/
|
|
247
|
+
function renderMarkdown(impact) {
|
|
248
|
+
const { result, perFile, domains, highImpact } = impact;
|
|
249
|
+
const out = [];
|
|
250
|
+
out.push(MARKER);
|
|
251
|
+
out.push('## 🗺️ Carto Impact Report');
|
|
252
|
+
out.push('');
|
|
253
|
+
|
|
254
|
+
if (result.diff.length === 0) {
|
|
255
|
+
out.push('_No file changes detected in this diff._');
|
|
256
|
+
out.push('');
|
|
257
|
+
return out.join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Headline sentence.
|
|
261
|
+
if (domains.length === 0) {
|
|
262
|
+
out.push(`This PR touches **${result.diff.length} file(s)** in unmapped domains.`);
|
|
263
|
+
} else if (domains.length === 1) {
|
|
264
|
+
out.push(`This PR touches the **${domains[0]}** domain.`);
|
|
265
|
+
} else if (domains.length === 2) {
|
|
266
|
+
out.push(`This PR touches **${domains[0]}** and **${domains[1]}** domains.`);
|
|
267
|
+
} else {
|
|
268
|
+
const head = domains.slice(0, -1).map((d) => `**${d}**`).join(', ');
|
|
269
|
+
const tail = `**${domains[domains.length - 1]}**`;
|
|
270
|
+
out.push(`This PR touches ${head}, and ${tail} domains.`);
|
|
271
|
+
}
|
|
272
|
+
out.push('');
|
|
273
|
+
|
|
274
|
+
// Metric table.
|
|
275
|
+
const crossDomainCount = result.violations.filter(
|
|
276
|
+
(v) => v.kind === 'cross_domain'
|
|
277
|
+
).length;
|
|
278
|
+
out.push('| Metric | Value |');
|
|
279
|
+
out.push('|--------|-------|');
|
|
280
|
+
out.push(`| **Risk** | ${RISK_BADGE[result.risk] || result.risk} |`);
|
|
281
|
+
out.push(`| Blast radius (union) | ${result.blast_radius.union} files |`);
|
|
282
|
+
out.push(`| Files changed | ${result.diff.length} |`);
|
|
283
|
+
out.push(`| Cross-domain violations introduced | ${crossDomainCount} |`);
|
|
284
|
+
if (highImpact) {
|
|
285
|
+
out.push(
|
|
286
|
+
`| High-impact file changed | \`${highImpact.file}\` (${highImpact.dependents} direct dependents) |`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
out.push('');
|
|
290
|
+
|
|
291
|
+
// Routes section — flatten per-file routes, dedupe by method+path.
|
|
292
|
+
const routeRows = [];
|
|
293
|
+
const seenRoutes = new Set();
|
|
294
|
+
for (const detail of perFile.values()) {
|
|
295
|
+
for (const r of detail.routes || []) {
|
|
296
|
+
const key = `${r.method} ${r.path}`;
|
|
297
|
+
if (seenRoutes.has(key)) continue;
|
|
298
|
+
seenRoutes.add(key);
|
|
299
|
+
routeRows.push(r);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (routeRows.length > 0) {
|
|
303
|
+
out.push(`<details>`);
|
|
304
|
+
out.push(`<summary>Affected routes (${routeRows.length})</summary>`);
|
|
305
|
+
out.push('');
|
|
306
|
+
for (const r of routeRows) {
|
|
307
|
+
out.push(`- \`${r.method} ${r.path}\` — risk: ${r.risk}`);
|
|
308
|
+
}
|
|
309
|
+
out.push('');
|
|
310
|
+
out.push(`</details>`);
|
|
311
|
+
out.push('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Cross-domain violations.
|
|
315
|
+
const crossViolations = result.violations.filter(
|
|
316
|
+
(v) => v.kind === 'cross_domain'
|
|
317
|
+
);
|
|
318
|
+
if (crossViolations.length > 0) {
|
|
319
|
+
out.push(`<details>`);
|
|
320
|
+
out.push(
|
|
321
|
+
`<summary>Cross-domain violations (${crossViolations.length})</summary>`
|
|
322
|
+
);
|
|
323
|
+
out.push('');
|
|
324
|
+
for (const v of crossViolations) {
|
|
325
|
+
out.push(
|
|
326
|
+
`- \`${v.file}\` now imports from \`${v.toFile}\` (${v.fromDomain}→${v.toDomain})`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
out.push('');
|
|
330
|
+
out.push(`</details>`);
|
|
331
|
+
out.push('');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// High-blast violations as a separate section so reviewers see them
|
|
335
|
+
// even when no cross-domain edges exist.
|
|
336
|
+
const blastViolations = result.violations.filter(
|
|
337
|
+
(v) => v.kind === 'high_blast'
|
|
338
|
+
);
|
|
339
|
+
if (blastViolations.length > 0) {
|
|
340
|
+
out.push(`<details>`);
|
|
341
|
+
out.push(
|
|
342
|
+
`<summary>High-blast files modified (${blastViolations.length})</summary>`
|
|
343
|
+
);
|
|
344
|
+
out.push('');
|
|
345
|
+
for (const v of blastViolations) {
|
|
346
|
+
out.push(`- \`${v.file}\` — ${v.blast_radius} dependents (${v.severity})`);
|
|
347
|
+
}
|
|
348
|
+
out.push('');
|
|
349
|
+
out.push(`</details>`);
|
|
350
|
+
out.push('');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Suggestions.
|
|
354
|
+
if (result.suggestions.length > 0) {
|
|
355
|
+
out.push(`<details>`);
|
|
356
|
+
out.push(`<summary>Suggestions (${result.suggestions.length})</summary>`);
|
|
357
|
+
out.push('');
|
|
358
|
+
for (const s of result.suggestions) {
|
|
359
|
+
out.push(`- ${s.message}`);
|
|
360
|
+
}
|
|
361
|
+
out.push('');
|
|
362
|
+
out.push(`</details>`);
|
|
363
|
+
out.push('');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
out.push(`<sub>Generated by [carto-md](https://www.npmjs.com/package/carto-md).</sub>`);
|
|
367
|
+
return out.join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* renderJson(impact) → object
|
|
372
|
+
*
|
|
373
|
+
* Stable contract for programmatic consumers. Locked-in shape (any
|
|
374
|
+
* additions go at the end of the object, no key removals before a
|
|
375
|
+
* major version bump):
|
|
376
|
+
*
|
|
377
|
+
* {
|
|
378
|
+
* marker: string,
|
|
379
|
+
* risk: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH',
|
|
380
|
+
* files_changed: number,
|
|
381
|
+
* blast_radius_union: number,
|
|
382
|
+
* domains_touched: string[],
|
|
383
|
+
* high_impact_file: { file, dependents } | null,
|
|
384
|
+
* violations: [{ kind, severity, file, message, ... }],
|
|
385
|
+
* suggestions: [{ kind, file, message, ... }],
|
|
386
|
+
* per_file: {
|
|
387
|
+
* [path]: {
|
|
388
|
+
* blast_radius: number,
|
|
389
|
+
* directly_affected: number,
|
|
390
|
+
* domains: string[],
|
|
391
|
+
* routes: [{ method, path, risk }],
|
|
392
|
+
* }
|
|
393
|
+
* }
|
|
394
|
+
* }
|
|
395
|
+
*/
|
|
396
|
+
function renderJson(impact) {
|
|
397
|
+
const { result, perFile, domains, highImpact } = impact;
|
|
398
|
+
const per_file = {};
|
|
399
|
+
for (const [path_, detail] of perFile) {
|
|
400
|
+
per_file[path_] = {
|
|
401
|
+
blast_radius: detail.blastRadius,
|
|
402
|
+
directly_affected: detail.directlyAffected,
|
|
403
|
+
domains: detail.domains,
|
|
404
|
+
routes: detail.routes,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
marker: MARKER,
|
|
409
|
+
risk: result.risk,
|
|
410
|
+
files_changed: result.diff.length,
|
|
411
|
+
blast_radius_union: result.blast_radius.union,
|
|
412
|
+
domains_touched: domains,
|
|
413
|
+
high_impact_file: highImpact,
|
|
414
|
+
violations: result.violations,
|
|
415
|
+
suggestions: result.suggestions,
|
|
416
|
+
per_file,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Decide exit code based on --fail-on threshold.
|
|
422
|
+
* - failOn null → always 0
|
|
423
|
+
* - failOn set → 2 if risk >= threshold, else 0
|
|
424
|
+
*/
|
|
425
|
+
function decideExitCode(risk, failOn) {
|
|
426
|
+
if (!failOn) return 0;
|
|
427
|
+
return RISK_RANK[risk] >= RISK_RANK[failOn] ? 2 : 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* run({ argv, stdout, stderr }) → exitCode
|
|
432
|
+
*
|
|
433
|
+
* Pure function — no side effects on `process`. Tests pass an in-memory
|
|
434
|
+
* argv + capture stdout via a writable stream.
|
|
435
|
+
*/
|
|
436
|
+
function run({ argv, stdout, stderr } = {}) {
|
|
437
|
+
argv = argv || process.argv.slice(3);
|
|
438
|
+
stdout = stdout || process.stdout;
|
|
439
|
+
stderr = stderr || process.stderr;
|
|
440
|
+
|
|
441
|
+
let args;
|
|
442
|
+
try {
|
|
443
|
+
args = parseArgs(argv);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (args.help) {
|
|
450
|
+
printUsage();
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let diffText;
|
|
455
|
+
try {
|
|
456
|
+
if (args.diffFile) {
|
|
457
|
+
diffText = fs.readFileSync(args.diffFile, 'utf8');
|
|
458
|
+
} else {
|
|
459
|
+
diffText = runGitDiff(args.projectRoot, args.base, args.head);
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
463
|
+
return 1;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let impact;
|
|
467
|
+
try {
|
|
468
|
+
impact = collectImpact(args.projectRoot, diffText);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
471
|
+
return 1;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (args.format === 'json') {
|
|
475
|
+
stdout.write(JSON.stringify(renderJson(impact), null, 2) + '\n');
|
|
476
|
+
} else {
|
|
477
|
+
stdout.write(renderMarkdown(impact) + '\n');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return decideExitCode(impact.result.risk, args.failOn);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = {
|
|
484
|
+
run,
|
|
485
|
+
// Exported for tests:
|
|
486
|
+
parseArgs,
|
|
487
|
+
collectImpact,
|
|
488
|
+
renderMarkdown,
|
|
489
|
+
renderJson,
|
|
490
|
+
decideExitCode,
|
|
491
|
+
MARKER,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// CLI entry — when invoked directly (not required for tests):
|
|
495
|
+
if (require.main === module) {
|
|
496
|
+
process.exit(run());
|
|
497
|
+
}
|
package/src/cli/serve.js
CHANGED
|
@@ -7,7 +7,7 @@ function run(projectRoot) {
|
|
|
7
7
|
const dbPath = path.join(projectRoot, '.carto', 'carto.db');
|
|
8
8
|
|
|
9
9
|
if (!fs.existsSync(dbPath)) {
|
|
10
|
-
// Could be
|
|
10
|
+
// Could be an old install with only map.json, or never initialized.
|
|
11
11
|
const mapPath = path.join(projectRoot, '.carto', 'map.json');
|
|
12
12
|
if (fs.existsSync(mapPath)) {
|
|
13
13
|
console.error('[CARTO] Legacy index found (map.json) but no SQLite DB. Run `carto init` to upgrade your index.');
|
package/src/cli/watch.js
CHANGED
|
@@ -169,6 +169,12 @@ async function run(projectRoot) {
|
|
|
169
169
|
process.exit(1);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// `carto watch` is opt-in. Git hooks + lazy MCP re-parse
|
|
173
|
+
// handle the 90% case automatically with no background process. Surface
|
|
174
|
+
// that to anyone who runs this command so they know it's not required.
|
|
175
|
+
console.log('[CARTO] Note: `carto watch` is opt-in. Git hooks + lazy MCP re-parse (installed by `carto init`) keep the index fresh without a background process.');
|
|
176
|
+
console.log('[CARTO] Use `carto watch` for AI-heavy workflows where many files are edited between commits and you want sub-second freshness.');
|
|
177
|
+
|
|
172
178
|
// Initial full sync using V2
|
|
173
179
|
console.log('[CARTO] Starting initial sync...');
|
|
174
180
|
await runSyncV2({
|
package/src/engine/worker.js
CHANGED
|
@@ -15,6 +15,9 @@ parentPort.on('message', (task) => {
|
|
|
15
15
|
try {
|
|
16
16
|
content = fs.readFileSync(filePath, 'utf-8');
|
|
17
17
|
} catch (err) {
|
|
18
|
+
// Real I/O failure (file deleted between discovery and extraction,
|
|
19
|
+
// permission denied, etc.) — no point trying to extract. Caller
|
|
20
|
+
// turns this into a skip; nothing reaches the index.
|
|
18
21
|
parentPort.postMessage({ id, error: err.message, result: null });
|
|
19
22
|
return;
|
|
20
23
|
}
|
|
@@ -27,17 +30,33 @@ parentPort.on('message', (task) => {
|
|
|
27
30
|
return;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
// Capture extractor failures as breadcrumbs instead of
|
|
34
|
+
// dropping the file. The file still gets indexed (with empty
|
|
35
|
+
// extraction arrays) so its existence and imports are visible — and
|
|
36
|
+
// the failure shows up in `carto check` instead of vanishing.
|
|
37
|
+
const errors = [];
|
|
38
|
+
|
|
39
|
+
let extracted = { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
|
|
31
40
|
try {
|
|
32
41
|
extracted = plugin.extract(content, relPath);
|
|
42
|
+
// Plugin-internal failure breadcrumbs — see extractFile().
|
|
43
|
+
if (Array.isArray(extracted._errors) && extracted._errors.length > 0) {
|
|
44
|
+
for (const e of extracted._errors) {
|
|
45
|
+
if (e && e.phase && e.message) errors.push({ phase: e.phase, message: e.message });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
} catch (err) {
|
|
34
|
-
|
|
35
|
-
return;
|
|
49
|
+
errors.push({ phase: 'extract', message: err.message || String(err) });
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
// Use tree-sitter imports if the plugin produced them (faster, no file I/O)
|
|
39
53
|
// Fall back to the regex-based extractImports for resolution
|
|
40
|
-
|
|
54
|
+
let imports = [];
|
|
55
|
+
try {
|
|
56
|
+
imports = extractImports(content, filePath, projectRoot);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
errors.push({ phase: 'imports', message: err.message || String(err) });
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
// Attach tree-sitter symbols if available (richer than legacy functions array)
|
|
43
62
|
const tsSymbols = extracted._tsSymbols || null;
|
|
@@ -56,6 +75,7 @@ parentPort.on('message', (task) => {
|
|
|
56
75
|
storageKeys: extracted.storageKeys || [],
|
|
57
76
|
imports,
|
|
58
77
|
tsSymbols,
|
|
78
|
+
errors,
|
|
59
79
|
}
|
|
60
80
|
});
|
|
61
81
|
});
|