carto-md 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +290 -26
  2. package/docs/anci/v0.1-DRAFT.md +420 -0
  3. package/docs/scale.md +129 -0
  4. package/package.json +10 -5
  5. package/scripts/postinstall.js +413 -0
  6. package/src/acp/agent.js +5 -5
  7. package/src/acp/providers/index.js +2 -2
  8. package/src/agents/leiden.js +11 -17
  9. package/src/agents/scan-structure.js +1 -1
  10. package/src/anci/consumer.js +305 -0
  11. package/src/anci/deserialize.js +160 -0
  12. package/src/anci/emit.js +85 -0
  13. package/src/anci/serialize.js +264 -0
  14. package/src/anci/yaml.js +401 -0
  15. package/src/bitmap/bitset.js +190 -0
  16. package/src/bitmap/index.js +121 -0
  17. package/src/bitmap/sidecar.js +545 -0
  18. package/src/bitmap/tools.js +310 -0
  19. package/src/cli/anci.js +237 -0
  20. package/src/cli/check.js +57 -0
  21. package/src/cli/index.js +28 -2
  22. package/src/cli/init.js +297 -65
  23. package/src/cli/inspect.js +295 -0
  24. package/src/cli/pr-impact.js +497 -0
  25. package/src/cli/serve.js +1 -1
  26. package/src/cli/watch.js +6 -0
  27. package/src/engine/worker.js +24 -4
  28. package/src/extractors/imports.js +176 -0
  29. package/src/extractors/languages/html.js +4 -1
  30. package/src/extractors/languages/javascript.js +5 -0
  31. package/src/extractors/languages/prisma.js +4 -1
  32. package/src/extractors/languages/python.js +5 -1
  33. package/src/extractors/languages/r.js +4 -1
  34. package/src/extractors/languages/typescript.js +2 -0
  35. package/src/extractors/tree-sitter-parser.js +15 -0
  36. package/src/mcp/change-plan.js +8 -8
  37. package/src/mcp/diff-parser.js +246 -0
  38. package/src/mcp/server-v2.js +489 -8
  39. package/src/mcp/validate.js +304 -0
  40. package/src/store/config-loader.js +77 -0
  41. package/src/store/sqlite-store.js +389 -4
  42. package/src/store/sync-v2.js +472 -97
  43. package/BENCHMARK_RESULTS.md +0 -34
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/postinstall.js
5
+ //
6
+ // Three-step resilience flow:
7
+ // 1. Probe each tree-sitter grammar via require(). All ok → exit silent.
8
+ // 2. For each failure, try to fetch a prebuilt tarball from carto-md's
9
+ // GitHub Release and extract into node_modules/<pkg>/. Re-probe.
10
+ // 3. Anything still broken → print OS-specific build guidance.
11
+ //
12
+ // Always exits 0 — install must never fail because of this script.
13
+ //
14
+ // Env opt-outs:
15
+ // CARTO_NO_POSTINSTALL=1 skip the entire script
16
+ // CARTO_NO_PREBUILD=1 skip step 2 (go straight from probe to guidance)
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const https = require('https');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ // ---- Static metadata ----
25
+
26
+ const GRAMMARS = [
27
+ { pkg: 'tree-sitter-javascript', langs: 'JavaScript' },
28
+ { pkg: 'tree-sitter-typescript', langs: 'TypeScript' },
29
+ { pkg: 'tree-sitter-python', langs: 'Python' },
30
+ { pkg: 'tree-sitter-go', langs: 'Go' },
31
+ { pkg: 'tree-sitter-rust', langs: 'Rust' },
32
+ { pkg: 'tree-sitter-java', langs: 'Java' },
33
+ { pkg: 'tree-sitter-cpp', langs: 'C/C++' },
34
+ { pkg: 'tree-sitter-c-sharp', langs: 'C#' },
35
+ ];
36
+
37
+ // tree-sitter core lives in dependencies (not optionalDependencies). If its
38
+ // compile fails the whole `npm install` fails before this script runs, so
39
+ // we don't probe it — but the prebuilds workflow tarballs it too, in case
40
+ // future logic wants to recover from a corrupted node_modules.
41
+ const CORE_PKG = 'tree-sitter';
42
+
43
+ const DEFAULT_RELEASE_BASE_URL =
44
+ 'https://github.com/theanshsonkar/carto/releases/download';
45
+
46
+ // ---- Pure helpers (testable) ----
47
+
48
+ /**
49
+ * detectLibc() → 'glibc' | 'musl' | null
50
+ * Linux only. macOS / Windows return null because libc is part of the OS.
51
+ */
52
+ function detectLibc() {
53
+ if (process.platform !== 'linux') return null;
54
+ try {
55
+ if (fs.existsSync('/etc/alpine-release')) return 'musl';
56
+ } catch { /* ignore */ }
57
+ try {
58
+ const report =
59
+ process.report && typeof process.report.getReport === 'function'
60
+ ? process.report.getReport()
61
+ : null;
62
+ const glibc = report && report.header && report.header.glibcVersionRuntime;
63
+ if (glibc) return 'glibc';
64
+ // process.report exists but no glibc field → musl runtime.
65
+ if (report && report.header) return 'musl';
66
+ } catch { /* ignore */ }
67
+ // Conservative default: assume glibc. If wrong, fetch will 404 and the
68
+ // caller falls through to build-toolchain guidance.
69
+ return 'glibc';
70
+ }
71
+
72
+ /**
73
+ * getPlatformInfo() → { platform, arch, libc, key }
74
+ * key examples:
75
+ * 'linux-x64-glibc' 'linux-x64-musl'
76
+ * 'darwin-arm64' 'darwin-x64'
77
+ * 'win32-x64'
78
+ */
79
+ function getPlatformInfo() {
80
+ const platform = process.platform;
81
+ const arch = process.arch;
82
+ const libc = detectLibc();
83
+ const key =
84
+ platform === 'linux'
85
+ ? `linux-${arch}${libc ? '-' + libc : ''}`
86
+ : `${platform}-${arch}`;
87
+ return { platform, arch, libc, key };
88
+ }
89
+
90
+ /**
91
+ * assetName({ pkg, pkgVersion, platformKey }) → string
92
+ * tree-sitter-typescript-v0.23.2-linux-x64-glibc.tar.gz
93
+ * tree-sitter-v0.25.0-darwin-arm64.tar.gz
94
+ */
95
+ function assetName({ pkg, pkgVersion, platformKey }) {
96
+ return `${pkg}-v${pkgVersion}-${platformKey}.tar.gz`;
97
+ }
98
+
99
+ /**
100
+ * assetUrl({ cartoVersion, name, baseUrl? }) → string
101
+ */
102
+ function assetUrl({ cartoVersion, name, baseUrl }) {
103
+ const base = (baseUrl || DEFAULT_RELEASE_BASE_URL).replace(/\/+$/, '');
104
+ return `${base}/v${cartoVersion}/${name}`;
105
+ }
106
+
107
+ /**
108
+ * probeGrammars(requireFn?) → Array<{ pkg, langs, ok }>
109
+ * Tests pass a fake require function; production passes nothing.
110
+ */
111
+ function probeGrammars(requireFn) {
112
+ const r = requireFn || require;
113
+ return GRAMMARS.map((g) => {
114
+ let ok = true;
115
+ try { r(g.pkg); } catch { ok = false; }
116
+ return { pkg: g.pkg, langs: g.langs, ok };
117
+ });
118
+ }
119
+
120
+ /**
121
+ * selectFailures(probeResults) → Array<{ pkg, langs }>
122
+ */
123
+ function selectFailures(probeResults) {
124
+ return probeResults.filter((r) => !r.ok).map((r) => ({ pkg: r.pkg, langs: r.langs }));
125
+ }
126
+
127
+ /**
128
+ * resolveExpectedVersion(packageRoot, pkg) → string | null
129
+ * Tries the installed copy first, then carto's own pinned constraint.
130
+ * Returns null only if both lookups fail (or carto pins a non-exact range).
131
+ */
132
+ function resolveExpectedVersion(packageRoot, pkg) {
133
+ // 1. Installed copy (rare in the failure path, but possible if the binary
134
+ // is missing despite the JS being present — e.g., a botched rebuild).
135
+ try {
136
+ const installed = path.join(packageRoot, 'node_modules', pkg, 'package.json');
137
+ if (fs.existsSync(installed)) {
138
+ const meta = JSON.parse(fs.readFileSync(installed, 'utf-8'));
139
+ if (meta && meta.version) return meta.version;
140
+ }
141
+ } catch { /* ignore */ }
142
+
143
+ // 2. Constraint from carto's own package.json. Carto pins exact versions
144
+ // (e.g. "0.23.2"), so the raw string IS the version. If a future
145
+ // maintainer changes the pin to a range, this returns null and we
146
+ // skip the prebuild path for that package.
147
+ try {
148
+ const meta = JSON.parse(
149
+ fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'),
150
+ );
151
+ const constraint =
152
+ (meta.optionalDependencies && meta.optionalDependencies[pkg]) ||
153
+ (meta.dependencies && meta.dependencies[pkg]);
154
+ if (constraint && /^[0-9]+\.[0-9]+\.[0-9]+$/.test(constraint)) {
155
+ return constraint;
156
+ }
157
+ } catch { /* ignore */ }
158
+
159
+ return null;
160
+ }
161
+
162
+ // ---- I/O wrappers (stubbable via runMain options) ----
163
+
164
+ /**
165
+ * fetchToFile(url, destPath, opts?) → Promise<void>
166
+ * Throws on non-2xx (including 404). Follows redirects up to maxRedirects.
167
+ */
168
+ function fetchToFile(url, destPath, opts) {
169
+ const timeoutMs = (opts && opts.timeoutMs) || 30_000;
170
+ const maxRedirects = (opts && opts.maxRedirects) != null ? opts.maxRedirects : 5;
171
+
172
+ return new Promise((resolve, reject) => {
173
+ const visited = new Set();
174
+
175
+ const go = (currentUrl, redirectsLeft) => {
176
+ if (visited.has(currentUrl)) {
177
+ return reject(new Error(`redirect loop at ${currentUrl}`));
178
+ }
179
+ visited.add(currentUrl);
180
+
181
+ const req = https.get(currentUrl, { timeout: timeoutMs }, (res) => {
182
+ const sc = res.statusCode || 0;
183
+ if (sc >= 300 && sc < 400 && res.headers.location) {
184
+ if (redirectsLeft <= 0) {
185
+ res.resume();
186
+ return reject(new Error(`too many redirects from ${url}`));
187
+ }
188
+ const next = new URL(res.headers.location, currentUrl).toString();
189
+ res.resume();
190
+ return go(next, redirectsLeft - 1);
191
+ }
192
+ if (sc < 200 || sc >= 300) {
193
+ res.resume();
194
+ return reject(new Error(`HTTP ${sc} fetching ${currentUrl}`));
195
+ }
196
+ const out = fs.createWriteStream(destPath);
197
+ res.pipe(out);
198
+ out.on('finish', () =>
199
+ out.close((err) => (err ? reject(err) : resolve())),
200
+ );
201
+ out.on('error', (err) => {
202
+ try { fs.unlinkSync(destPath); } catch { /* ignore */ }
203
+ reject(err);
204
+ });
205
+ });
206
+ req.on('error', reject);
207
+ req.on('timeout', () => {
208
+ req.destroy(new Error(`timeout fetching ${currentUrl}`));
209
+ });
210
+ };
211
+
212
+ go(url, maxRedirects);
213
+ });
214
+ }
215
+
216
+ /**
217
+ * extractTarGz(tarPath, destDir) → void
218
+ * Uses the system `tar` command. Available on macOS, Linux, and modern
219
+ * Windows (since Win10 1803 / Win Server 2019). If tar is unavailable the
220
+ * call throws and the caller falls through to the build-toolchain guidance.
221
+ */
222
+ function extractTarGz(tarPath, destDir) {
223
+ fs.mkdirSync(destDir, { recursive: true });
224
+ const result = spawnSync('tar', ['-xzf', tarPath, '-C', destDir], {
225
+ stdio: 'pipe', encoding: 'utf-8',
226
+ });
227
+ if (result.status !== 0) {
228
+ const err = result.stderr || result.stdout || `exit ${result.status}`;
229
+ throw new Error(`tar extract failed: ${String(err).trim()}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * tryFetchPrebuild(opts) → Promise<{ ok: boolean, reason?: string, name?: string }>
235
+ * Single-package prebuild attempt. Best-effort: never throws.
236
+ */
237
+ async function tryFetchPrebuild({
238
+ pkg, pkgVersion, cartoVersion, packageRoot, platformInfo,
239
+ baseUrl, fetcher, extractor, log,
240
+ }) {
241
+ const _fetch = fetcher || fetchToFile;
242
+ const _extract = extractor || extractTarGz;
243
+ const _log = log || (() => {});
244
+
245
+ if (!pkgVersion) return { ok: false, reason: 'no-version' };
246
+
247
+ const name = assetName({ pkg, pkgVersion, platformKey: platformInfo.key });
248
+ const url = assetUrl({ cartoVersion, name, baseUrl });
249
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'carto-prebuild-'));
250
+ const tarPath = path.join(tmpDir, name);
251
+ const nodeModules = path.join(packageRoot, 'node_modules');
252
+
253
+ try {
254
+ _log(`[CARTO] fetching ${name}`);
255
+ await _fetch(url, tarPath, {});
256
+ _log(`[CARTO] extracting ${pkg}`);
257
+ fs.mkdirSync(nodeModules, { recursive: true });
258
+ _extract(tarPath, nodeModules);
259
+ return { ok: true, name };
260
+ } catch (err) {
261
+ return { ok: false, name, reason: err && err.message ? err.message : String(err) };
262
+ } finally {
263
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * buildSpec12Message(remainingFailures, platform) → string[]
269
+ * Returns the line array (caller decides how to print). Parameterised over
270
+ * what's still failing.
271
+ */
272
+ function buildSpec12Message(remainingFailures, platform) {
273
+ const langs = remainingFailures.map((g) => g.langs).join(', ');
274
+ let fix;
275
+ if (platform === 'win32') {
276
+ fix =
277
+ 'Install "Desktop development with C++" from ' +
278
+ 'https://aka.ms/vs/17/release/vs_BuildTools.exe then re-run: npm rebuild';
279
+ } else if (platform === 'darwin') {
280
+ fix = 'Run: xcode-select --install && npm rebuild';
281
+ } else {
282
+ fix =
283
+ 'Run: sudo apt-get install -y build-essential && npm rebuild ' +
284
+ '(or equivalent for your distro)';
285
+ }
286
+ return [
287
+ '',
288
+ '[CARTO] ⚠️ Some tree-sitter grammars failed to install.',
289
+ `[CARTO] Affected languages: ${langs}`,
290
+ '[CARTO] These languages will use regex-only extraction (less accurate).',
291
+ `[CARTO] To fix: ${fix}`,
292
+ '',
293
+ ];
294
+ }
295
+
296
+ // ---- Main entry (testable via runMain) ----
297
+
298
+ /**
299
+ * runMain(options) → Promise<{ exitCode, attempted, succeeded, stillFailed, skipped? }>
300
+ * options.env, options.console, options.requireFn, options.fetcher,
301
+ * options.extractor, options.packageRoot, options.cartoVersion,
302
+ * options.platformInfo, options.baseUrl
303
+ */
304
+ async function runMain(options) {
305
+ const opts = options || {};
306
+ const env = opts.env || process.env;
307
+ const writer = opts.console || console;
308
+ const log = (...a) => writer.log(...a);
309
+
310
+ if (env.CARTO_NO_POSTINSTALL === '1') {
311
+ return { exitCode: 0, attempted: 0, succeeded: 0, stillFailed: 0, skipped: true };
312
+ }
313
+
314
+ const initial = probeGrammars(opts.requireFn);
315
+ const failures = selectFailures(initial);
316
+ if (failures.length === 0) {
317
+ return { exitCode: 0, attempted: 0, succeeded: 0, stillFailed: 0 };
318
+ }
319
+
320
+ const skipPrebuild = env.CARTO_NO_PREBUILD === '1';
321
+ let attempted = 0;
322
+ let succeeded = 0;
323
+ // Track which failures remain after prebuild fetch — used by the second
324
+ // probe to know which packages to recheck.
325
+ let remaining = failures.slice();
326
+
327
+ if (!skipPrebuild) {
328
+ const packageRoot = opts.packageRoot || path.resolve(__dirname, '..');
329
+ const cartoVersion =
330
+ opts.cartoVersion ||
331
+ JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version;
332
+ const platformInfo = opts.platformInfo || getPlatformInfo();
333
+
334
+ log(
335
+ `[CARTO] ${failures.length} grammar(s) missing — ` +
336
+ `trying prebuilt binaries for ${platformInfo.key}`,
337
+ );
338
+
339
+ const stillBroken = [];
340
+ for (const failure of failures) {
341
+ attempted += 1;
342
+ const pkgVersion = resolveExpectedVersion(packageRoot, failure.pkg);
343
+ const result = await tryFetchPrebuild({
344
+ pkg: failure.pkg,
345
+ pkgVersion,
346
+ cartoVersion,
347
+ packageRoot,
348
+ platformInfo,
349
+ baseUrl: opts.baseUrl,
350
+ fetcher: opts.fetcher,
351
+ extractor: opts.extractor,
352
+ log,
353
+ });
354
+ if (result.ok) {
355
+ succeeded += 1;
356
+ } else {
357
+ log(`[CARTO] ${failure.pkg}: prebuild unavailable (${result.reason || 'unknown'})`);
358
+ stillBroken.push(failure);
359
+ }
360
+ }
361
+ remaining = stillBroken;
362
+ }
363
+
364
+ // Re-probe to pick up any freshly extracted packages. Node's module cache
365
+ // doesn't cache failed requires, so a retry will see the new files.
366
+ let stillFailing;
367
+ if (succeeded > 0) {
368
+ const second = probeGrammars(opts.requireFn);
369
+ stillFailing = selectFailures(second);
370
+ } else {
371
+ stillFailing = remaining;
372
+ }
373
+
374
+ if (stillFailing.length === 0) {
375
+ log(`[CARTO] ✓ Restored ${succeeded}/${attempted} grammar(s) from prebuilt binaries.`);
376
+ return { exitCode: 0, attempted, succeeded, stillFailed: 0 };
377
+ }
378
+
379
+ for (const line of buildSpec12Message(stillFailing, process.platform)) log(line);
380
+ return { exitCode: 0, attempted, succeeded, stillFailed: stillFailing.length };
381
+ }
382
+
383
+ // ---- Exports for tests ----
384
+
385
+ module.exports = {
386
+ GRAMMARS,
387
+ CORE_PKG,
388
+ DEFAULT_RELEASE_BASE_URL,
389
+ detectLibc,
390
+ getPlatformInfo,
391
+ assetName,
392
+ assetUrl,
393
+ probeGrammars,
394
+ selectFailures,
395
+ resolveExpectedVersion,
396
+ fetchToFile,
397
+ extractTarGz,
398
+ tryFetchPrebuild,
399
+ buildSpec12Message,
400
+ runMain,
401
+ };
402
+
403
+ // ---- Direct invocation ----
404
+
405
+ if (require.main === module) {
406
+ runMain({}).catch((err) => {
407
+ // runMain is supposed to absorb all errors; this is belt + suspenders.
408
+ try {
409
+ console.log(`[CARTO] postinstall internal error: ${err && err.message}`);
410
+ } catch { /* ignore */ }
411
+ process.exit(0);
412
+ });
413
+ }
package/src/acp/agent.js CHANGED
@@ -71,11 +71,11 @@ class CartoAgent {
71
71
 
72
72
  // Provider methods
73
73
  // The custom `unstable_*` provider-management methods (list / set / disable)
74
- // were removed in 2.0.7 (Spec 7 Bug 3). The ACP SDK only dispatches the
75
- // methods declared in its AGENT_METHODS constant, so those custom routes
76
- // returned -32601 "Method not found" anyway. Provider config flows
77
- // through env vars / editor settings; ProviderRegistry remains as the
78
- // internal configuration carrier.
74
+ // were removed. The ACP SDK only dispatches the methods declared in
75
+ // its AGENT_METHODS constant, so those custom routes returned -32601
76
+ // "Method not found" anyway. Provider config flows through env vars /
77
+ // editor settings; ProviderRegistry remains as the internal
78
+ // configuration carrier.
79
79
 
80
80
  // Session list/load stubs
81
81
  async listSessions(_params) { return { sessions: [] }; }
@@ -30,8 +30,8 @@ class ProviderRegistry {
30
30
  this._config = null;
31
31
  }
32
32
 
33
- // list() was removed in 2.0.7 (Spec 7 Bug 3). It was only called by the
34
- // dropped custom provider-list ACP method, which the SDK never dispatched.
33
+ // list() was removed it was only called by a dropped custom
34
+ // provider-list ACP method that the SDK never actually dispatched.
35
35
  // SUPPORTED_PROVIDERS is still exported for external callers / tests.
36
36
 
37
37
  /**
@@ -6,7 +6,7 @@
6
6
  * Pure JS implementation (~250 lines). Zero external dependencies.
7
7
  * Based on: Traag, Waltman & van Eck (2019) "From Louvain to Leiden"
8
8
  *
9
- * Key difference from Louvain: the refinement phase guarantees that
9
+ * Key difference from Louvain: the refinement step guarantees that
10
10
  * every community is a connected subgraph (Louvain can produce
11
11
  * disconnected communities). This matters for import graphs where
12
12
  * disconnected clusters produce nonsensical domain names.
@@ -60,7 +60,7 @@ function leiden(nodes, edges, gamma = 0.03) {
60
60
  improved = false;
61
61
  iterations++;
62
62
 
63
- // Move phase: try to move each node to a neighboring community
63
+ // Local-move pass: try to move each node to a neighboring community
64
64
  const order = shuffleIndices(n);
65
65
  for (const i of order) {
66
66
  const currentComm = community[i];
@@ -109,7 +109,7 @@ function leiden(nodes, edges, gamma = 0.03) {
109
109
  }
110
110
  }
111
111
 
112
- // Refinement phase: split internally disconnected communities
112
+ // Refinement pass: split internally disconnected communities
113
113
  _refinementPhase(n, adj, community);
114
114
  }
115
115
 
@@ -127,7 +127,7 @@ function leiden(nodes, edges, gamma = 0.03) {
127
127
  }
128
128
 
129
129
  /**
130
- * Refinement phase: for each community, check connectivity.
130
+ * Refinement pass: for each community, check connectivity.
131
131
  * If a community is internally disconnected, split it into
132
132
  * connected components. This is the key Leiden improvement over Louvain.
133
133
  */
@@ -300,24 +300,18 @@ function clusterByGraph(importGraph, gamma = 0.03, keywordSeeds = {}) {
300
300
 
301
301
  // Name each community
302
302
  const commNames = new Map();
303
- const usedNames = new Map(); // name → count (for deduplication)
304
303
 
305
304
  for (const [commId, members] of communities) {
306
- let name = nameCommunity(members, keywordSeeds);
307
-
308
- // Deduplicate: if name already used, append a number
309
- if (usedNames.has(name)) {
310
- const count = usedNames.get(name) + 1;
311
- usedNames.set(name, count);
312
- name = `${name}_${count}`;
313
- } else {
314
- usedNames.set(name, 1);
315
- }
316
-
305
+ const name = nameCommunity(members, keywordSeeds);
317
306
  commNames.set(commId, name);
318
307
  }
319
308
 
320
- // Build final file → domain map
309
+ // Build final file → domain map.
310
+ // When multiple communities resolve to the same base name (e.g. with a
311
+ // dense import graph, several disjoint sub-systems can each rank highest
312
+ // for AUTH), merge them into a single domain. The keyword that names the
313
+ // domain is the user-visible truth; preserving "AUTH_2", "AUTH_3", ...
314
+ // suffixes leaks Leiden's internal community count into the UI.
321
315
  const result = new Map();
322
316
  for (const [node, commId] of communityMap) {
323
317
  result.set(node, commNames.get(commId) || 'CORE');
@@ -16,7 +16,7 @@ const fs = require('fs');
16
16
  * the structure block is about to be merged into (`AGENTS.md`).
17
17
  *
18
18
  * Anchored on the original V1 set in src/sync.js so existing AGENTS.md
19
- * outputs do not drift after the V1 → V2 cleanup.
19
+ * outputs stay stable after the V1 → V2 cleanup.
20
20
  */
21
21
  const IGNORE_DIRS = new Set([
22
22
  'node_modules',