carto-md 2.0.8 → 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 +149 -9
- package/docs/anci/v0.1-DRAFT.md +420 -0
- package/docs/scale.md +129 -0
- package/package.json +4 -3
- package/scripts/postinstall.js +391 -24
- package/src/agents/leiden.js +4 -4
- 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/cli/anci.js +237 -0
- package/src/cli/index.js +14 -0
- package/src/cli/init.js +1 -1
- package/src/cli/inspect.js +1 -1
- package/src/cli/pr-impact.js +497 -0
- package/src/extractors/languages/javascript.js +3 -3
- package/src/mcp/change-plan.js +8 -8
- package/src/store/sync-v2.js +52 -3
|
@@ -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
|
+
}
|
|
@@ -95,9 +95,9 @@ module.exports = {
|
|
|
95
95
|
_tsImports: tsImports,
|
|
96
96
|
_tsSymbols: tsSymbols,
|
|
97
97
|
// Surface the parse failure as a breadcrumb so the
|
|
98
|
-
// caller can record it in extraction_errors.
|
|
99
|
-
// warning only hit stderr and the file lost all of its
|
|
100
|
-
// and models silently.
|
|
98
|
+
// caller can record it in extraction_errors. Before this hook,
|
|
99
|
+
// the warning only hit stderr and the file lost all of its
|
|
100
|
+
// routes and models silently.
|
|
101
101
|
_errors: [{ phase: 'parse', message: `Babel parse: ${err.message}` }],
|
|
102
102
|
};
|
|
103
103
|
}
|
package/src/mcp/change-plan.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* tokenize(intent) ──► tokens { content, verbs, paths }
|
|
10
10
|
* └──► IDF over indexed corpus (basenames + symbol names)
|
|
11
|
-
* └──► 4-
|
|
11
|
+
* └──► 4-stage anchor selection
|
|
12
12
|
* A. route path/method (searchRoutes)
|
|
13
13
|
* B. file path tokens (pathTokens × IDF)
|
|
14
14
|
* C. exported symbol names (camelTokens × IDF)
|
|
@@ -169,9 +169,9 @@ function computeIdf(store) {
|
|
|
169
169
|
* symbols: [{ name, path, tokenSet }] }
|
|
170
170
|
*
|
|
171
171
|
* Memoized on the store object. On a 5K-file repo this saves ~30ms per
|
|
172
|
-
* `planChange` call (without it, p95 on cal.com sat at 60ms
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* `planChange` call (without it, p95 on cal.com sat at 60ms, over the
|
|
173
|
+
* 50ms target). Re-indexing creates a new store instance, so the cache
|
|
174
|
+
* lives only as long as the index it was built from.
|
|
175
175
|
*/
|
|
176
176
|
const CACHE_KEY = '__cartoChangePlanCache';
|
|
177
177
|
|
|
@@ -257,7 +257,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
|
|
|
257
257
|
// Reuse the cached corpus index — saves ~30ms p95 on cal.com.
|
|
258
258
|
const corpus = buildCorpusIndex(store);
|
|
259
259
|
|
|
260
|
-
// ──
|
|
260
|
+
// ── Stage A — route path/method ───────────────────────────────────
|
|
261
261
|
// Use searchRoutes for each detected URL-path-like token. Filter by
|
|
262
262
|
// verb when one was extracted.
|
|
263
263
|
const routesSeen = new Set();
|
|
@@ -304,7 +304,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
// ──
|
|
307
|
+
// ── Stage B — file path tokens (IDF-weighted) ─────────────────────
|
|
308
308
|
for (const f of corpus.files) {
|
|
309
309
|
let score = 0;
|
|
310
310
|
const hits = [];
|
|
@@ -339,7 +339,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
// ──
|
|
342
|
+
// ── Stage C — exported symbol names (camelCase split + IDF) ───────
|
|
343
343
|
for (const s of corpus.symbols) {
|
|
344
344
|
let score = 0;
|
|
345
345
|
const hits = [];
|
|
@@ -360,7 +360,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
|
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
// ──
|
|
363
|
+
// ── Stage D — domain name match ───────────────────────────────────
|
|
364
364
|
let domains = [];
|
|
365
365
|
try { domains = store.getDomainsList() || []; } catch { domains = []; }
|
|
366
366
|
for (const d of domains) {
|
package/src/store/sync-v2.js
CHANGED
|
@@ -43,7 +43,7 @@ const CODE_EXTS = new Set([
|
|
|
43
43
|
const JS_LIKE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* isTestFile(relPath) → true if the file is a test
|
|
46
|
+
* isTestFile(relPath) → true if the file is a test or stories file.
|
|
47
47
|
* Ported from V1 detector/files.js exclusion patterns.
|
|
48
48
|
* R: test_*, test-*, *_test.r (case-insensitive)
|
|
49
49
|
* Python: test_*.py, *_test.py
|
|
@@ -461,10 +461,16 @@ async function runSyncV2(config) {
|
|
|
461
461
|
// `.carto/bitmap.bin` aligned with the SQLite source of truth.
|
|
462
462
|
// Best-effort: a build/persist failure is logged but never fails the
|
|
463
463
|
// sync (MCP tools fall back to SQLite when bitmap is unavailable).
|
|
464
|
+
//
|
|
465
|
+
// The same `sidecar` object is reused below to emit the public
|
|
466
|
+
// ANCI v0.1 export (`.carto/anci.{yaml,bin}`) — building it twice
|
|
467
|
+
// would be wasteful, so the build/save sequence shares scope.
|
|
468
|
+
let sidecarForAnci = null;
|
|
464
469
|
try {
|
|
465
470
|
const cartoDir = path.join(projectRoot, '.carto');
|
|
466
471
|
const sidecar = buildBitmap(store);
|
|
467
472
|
saveBitmap(cartoDir, sidecar);
|
|
473
|
+
sidecarForAnci = sidecar;
|
|
468
474
|
// Drop any in-memory cache held by the same Node process — e.g. when
|
|
469
475
|
// sync runs from inside `carto serve`'s lazy reparse path. Next MCP
|
|
470
476
|
// query loads the freshly-saved file.
|
|
@@ -476,6 +482,22 @@ async function runSyncV2(config) {
|
|
|
476
482
|
);
|
|
477
483
|
}
|
|
478
484
|
|
|
485
|
+
// Emit ANCI v0.1 public export.
|
|
486
|
+
// Best-effort: failure logs to stderr, never fails the sync.
|
|
487
|
+
// Skipped when the bitmap build above failed (no sidecar to emit).
|
|
488
|
+
if (sidecarForAnci) {
|
|
489
|
+
try {
|
|
490
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
491
|
+
const { emitToCartoDir } = require('../anci/emit');
|
|
492
|
+
emitToCartoDir({ cartoDir, sidecar: sidecarForAnci, store });
|
|
493
|
+
} catch (err) {
|
|
494
|
+
process.stderr.write(
|
|
495
|
+
`[CARTO] ANCI publish failed (anci.{yaml,bin} not written): ` +
|
|
496
|
+
`${err && err.message ? err.message : err}\n`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
479
501
|
console.log(`[CARTO] Indexed ${toProcess.length} files (${cached} cached) in ${elapsed}ms`);
|
|
480
502
|
console.log(`[CARTO] Total: ${allFiles.length} files, ${structure.meta.totalRoutes} routes, ${structure.meta.totalImportEdges} import edges`);
|
|
481
503
|
|
|
@@ -487,6 +509,33 @@ async function runSyncV2(config) {
|
|
|
487
509
|
}
|
|
488
510
|
|
|
489
511
|
store.close();
|
|
512
|
+
|
|
513
|
+
// store.close() runs `PRAGMA wal_checkpoint(TRUNCATE)` which bumps
|
|
514
|
+
// carto.db's mtime to *after* we wrote bitmap.bin and anci.{yaml,bin}.
|
|
515
|
+
// bitmapIsFresh() (src/bitmap/index.js) and `carto inspect` both rely
|
|
516
|
+
// on the invariant `bitmapMtime >= dbMtime` to decide whether the
|
|
517
|
+
// sidecar can be reused or has to be rebuilt — so re-stamp the
|
|
518
|
+
// derived files explicitly past the DB's now-final mtime. Without
|
|
519
|
+
// this, every cold MCP query after a sync sees the bitmap as "stale"
|
|
520
|
+
// and pays an unnecessary rebuild from SQLite.
|
|
521
|
+
//
|
|
522
|
+
// We stat the DB and stamp bitmap/ANCI to (dbMtime + 1ms) rather than
|
|
523
|
+
// using `new Date()` because the wall clock + filesystem timestamp
|
|
524
|
+
// pipeline has sub-millisecond jitter on APFS that can leave the
|
|
525
|
+
// explicit-set mtime *just* under the DB's real mtime. Reading the
|
|
526
|
+
// DB's actual mtime and adding a fixed offset is race-free.
|
|
527
|
+
try {
|
|
528
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
529
|
+
const dbPath = path.join(cartoDir, 'carto.db');
|
|
530
|
+
const dbStat = fs.statSync(dbPath);
|
|
531
|
+
const bumpedMs = dbStat.mtimeMs + 1;
|
|
532
|
+
const bumpedDate = new Date(bumpedMs);
|
|
533
|
+
for (const name of ['bitmap.bin', 'anci.bin', 'anci.yaml']) {
|
|
534
|
+
const p = path.join(cartoDir, name);
|
|
535
|
+
try { fs.utimesSync(p, bumpedDate, bumpedDate); } catch {}
|
|
536
|
+
}
|
|
537
|
+
} catch {}
|
|
538
|
+
|
|
490
539
|
return { filesProcessed: toProcess.length, totalFiles: allFiles.length, elapsed, extractionErrorCount };
|
|
491
540
|
}
|
|
492
541
|
|
|
@@ -544,8 +593,8 @@ function runKeywordClustering(store, importGraph) {
|
|
|
544
593
|
|
|
545
594
|
/**
|
|
546
595
|
* computeDomainStability(store, fileAssignments)
|
|
547
|
-
* Compares current assignments to previous snapshot, stores
|
|
548
|
-
* Warns if total>=10
|
|
596
|
+
* Compares current assignments to previous snapshot, stores reassignment %.
|
|
597
|
+
* Warns if total>=10 and the reassignment rate exceeds 5%.
|
|
549
598
|
*/
|
|
550
599
|
function computeDomainStability(store, fileAssignments) {
|
|
551
600
|
const prevRaw = store.getMeta('previous_domain_snapshot');
|