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.
@@ -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. Pre-Spec-11 this
99
- // warning only hit stderr and the file lost all of its routes
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
  }
@@ -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-tier anchor selection
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 over the
173
- * spec's 50ms target). Re-indexing creates a new store instance, so
174
- * the cache lives only as long as the index it was built from.
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
- // ── Tier A — route path/method ────────────────────────────────────
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
- // ── Tier B — file path tokens (IDF-weighted) ──────────────────────
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
- // ── Tier C — exported symbol names (camelCase split + IDF) ────────
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
- // ── Tier D — domain name match ────────────────────────────────────
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) {
@@ -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/spec/stories file
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 drift %.
548
- * Warns if total>=10 && drift>5%.
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');