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
package/docs/scale.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Carto at scale
|
|
2
|
+
|
|
3
|
+
> Empirical scale data for the bitmap engine — synthetic stress sweep + every real-world repo in the Carto corpus. Reproducer commands at the bottom.
|
|
4
|
+
|
|
5
|
+
The bitmap-backed graph engine is designed to handle 100K-1M files cleanly. This page is the empirical receipt. It captures both the **dense-synth stress sweep** (deterministic worst-case fan-out distribution) and the **real-world corpus** (4 of the 7 larger repos, the largest open-source codebases under MIT/permissive licenses we routinely test against).
|
|
6
|
+
|
|
7
|
+
## What's measured
|
|
8
|
+
|
|
9
|
+
For each run we capture:
|
|
10
|
+
|
|
11
|
+
- **Init time** — full `runSyncV2` from a wiped `.carto/` directory.
|
|
12
|
+
- **Sync time** — second `runSyncV2` immediately after, no files changed (mtime+hash skip path).
|
|
13
|
+
- **DB size** — `.carto/carto.db` bytes on disk.
|
|
14
|
+
- **bitmap.bin size** — the sidecar bytes on disk.
|
|
15
|
+
- **Sidecar (RAM)** — sum of all bitmap word-array byte lengths held in memory.
|
|
16
|
+
- **Peak RSS** — process resident memory after init and during query workloads.
|
|
17
|
+
- **MCP query p50 / p99** — 1000 calls each (50-call warmup) for the 5 production bitmap tools (`blastRadius`, `crossDomain`, `highImpactFiles`, `similarPatterns`) plus `simulate_change_impact`. Random query inputs are seeded so identical `--seed` produces identical workloads.
|
|
18
|
+
|
|
19
|
+
The query stage opens SQLite **read-only** — invariant honored. The bitmap-as-derived-disposable invariant is also honored: the runner only reads from `bitmap.bin`, never writes.
|
|
20
|
+
|
|
21
|
+
All numbers below were captured on the maintainer dev box: Apple silicon, Node 20.20, macOS, NVMe SSD. Recapture via the commands at the bottom.
|
|
22
|
+
|
|
23
|
+
## Synthetic stress sweep (dense fan-out)
|
|
24
|
+
|
|
25
|
+
Source: `bench/scale-test/`. Generates a deterministic-for-seed TypeScript repo with a worst-case-shaped import graph: 5 domain-like top-level dirs (`auth`/`payments`/`database`/`events`/`core`), Pareto(α=2) fan-out clipped to [1,8], 75% same-domain / 25% cross-domain, hotspot-biased targets so the first ~10% of file ids accumulate most in-edges. Imports always reference an earlier file id (acyclic).
|
|
26
|
+
|
|
27
|
+
Edge density ≈ 1.5 per file. **This is denser than every real-world repo in the corpus** — real codebases have many leaf files with zero imports, so the synth deliberately exercises the worst case for the bitmap layer.
|
|
28
|
+
|
|
29
|
+
| Files | Edges | Init | Sync | DB | bitmap.bin | Sidecar (RAM) | Peak RSS |
|
|
30
|
+
|------:|------:|-----:|-----:|---:|-----------:|--------------:|---------:|
|
|
31
|
+
| 1,000 | 1,392 | 533ms | 70ms | 756 KB | 222 KB | 186 KB | 149 MB |
|
|
32
|
+
| 10,000 | 14,876 | 3.28s | 627ms | 6.5 MB | 17.0 MB | 18.1 MB | 350 MB |
|
|
33
|
+
| 50,000 | 76,145 | 1.1m | 5.14s | 36.9 MB | 414.9 MB | 488.4 MB | 1.35 GB |
|
|
34
|
+
|
|
35
|
+
| Files | blastRadius p50/p99 | crossDomain p50/p99 | highImpactFiles p50/p99 | similarPatterns p50/p99 | simulate_change_impact p50/p99 |
|
|
36
|
+
|------:|---------------------|---------------------|------------------------|------------------------|--------------------------------|
|
|
37
|
+
| 1,000 | 1.6µs / 24.4µs | 19.5µs / 25.0µs | 709ns / 4.3µs | 276µs / 484µs | 7.0µs / 78.8µs |
|
|
38
|
+
| 10,000 | 6.6µs / 67.7µs | 705µs / 914µs | 750ns / 1.4µs | 17.0ms / 19.3ms | 11.6µs / 113µs |
|
|
39
|
+
| 50,000 | 22.4µs / 472µs | 28.1ms / 28.9ms | 750ns / 1.3µs | 462ms / 798ms | 49.9µs / 455µs |
|
|
40
|
+
|
|
41
|
+
### What 50K reveals
|
|
42
|
+
|
|
43
|
+
Three distinct failure modes show up at the dense-synth 50K row, **all of them addressable by a Roaring-bitmap upgrade on the roadmap**:
|
|
44
|
+
|
|
45
|
+
1. **Storage scales linearly with N** — every file gets its own forward + reverse bitmap of size ~`N/8` bytes. At N=50K bitmap.bin is 415 MB; at N=100K linear extrapolation puts it at ~1.7 GB. The current pure-Node `Uint32Array` bitset (`src/bitmap/bitset.js`) trades storage efficiency for raw query speed and zero native deps. Roaring would cut this 5-50× depending on density.
|
|
46
|
+
2. **`crossDomain` p50 hits 28 ms** — linear sweep over the `crossForward` Map. Fixable with a precomputed cross-domain edge bitmap that's OR-aggregated once at build time, not iterated per query.
|
|
47
|
+
3. **`similarPatterns` p50 hits 462 ms** — Jaccard over import sets at scale costs O(candidates × set-intersection). The MCP layer calls this with `top_k=5` so user-facing latency is well under the raw bench number, but the absolute cost is real.
|
|
48
|
+
|
|
49
|
+
Three things stay healthy at 50K: `blastRadius` p50 22µs, `highImpactFiles` p50 750ns (popcount index is O(1) array slice), `simulate_change_impact` p50 50µs.
|
|
50
|
+
|
|
51
|
+
### Pending (not yet captured on this box)
|
|
52
|
+
|
|
53
|
+
| Files | Notes |
|
|
54
|
+
|------:|-------|
|
|
55
|
+
| 100,000 | Stress-test for the dense-bitset implementation. ~3-5 min walltime expected. Worth running before any Roaring work to lock in the regression baseline. |
|
|
56
|
+
| 500,000 | Practical ceiling for the dense-bitset; expect bitmap.bin > 4 GB, possibly hitting OOM on 16 GB-RAM dev boxes. |
|
|
57
|
+
| 1,000,000 | Roaring-bitmap upgrade target. Expect dense-bitset OOM. |
|
|
58
|
+
|
|
59
|
+
Reproducible via `npm run bench:scale -- --size 100000 --keep --out /tmp/carto-100k`.
|
|
60
|
+
|
|
61
|
+
## Real-world corpus
|
|
62
|
+
|
|
63
|
+
These are the 4 largest repos in `~/carto-test-repos`, all already indexed by `~/carto-test-repos/run-bench.sh` (the bench harness baseline). The query latency below was measured against those existing indexes via `--queries-only` so this measurement does not perturb the indexes used by `node test/accuracy-corpus.js`.
|
|
64
|
+
|
|
65
|
+
| Repo | Indexed files | Edges | DB | bitmap.bin | Sidecar (RAM) |
|
|
66
|
+
|------|--------------:|------:|---:|-----------:|--------------:|
|
|
67
|
+
| [cal.com](https://github.com/calcom/cal.com) | 4,351 | 3,478 | 3.1 MB | 1.9 MB | 2.0 MB |
|
|
68
|
+
| [nextjs](https://github.com/vercel/next.js) | 6,193 | 7,930 | 15.0 MB | 4.4 MB | 4.3 MB |
|
|
69
|
+
| [supabase](https://github.com/supabase/supabase) | 6,330 | 5,189 | 4.8 MB | 4.4 MB | 4.4 MB |
|
|
70
|
+
| [vscode](https://github.com/microsoft/vscode) | 7,567 | 13,335 | 14.3 MB | 4.1 MB | 4.5 MB |
|
|
71
|
+
|
|
72
|
+
| Repo | blastRadius p50/p99 | crossDomain p50/p99 | highImpactFiles p50/p99 | similarPatterns p50/p99 | simulate_change_impact p50/p99 |
|
|
73
|
+
|------|---------------------|---------------------|------------------------|------------------------|--------------------------------|
|
|
74
|
+
| cal.com | 4.3µs / 51µs | 148µs / 279µs | 667ns / 1.0µs | 1.0µs / 1.99ms | 10.3µs / 71.0µs |
|
|
75
|
+
| nextjs | 5.0µs / 155µs | 196µs / 284µs | 750ns / 1.3µs | 1.2µs / 3.01ms | 28.9µs / 211µs |
|
|
76
|
+
| supabase | 5.5µs / 47.9µs | 201µs / 260µs | 708ns / 1.0µs | 1.5µs / 3.19ms | 10.8µs / 65.9µs |
|
|
77
|
+
| vscode | 2.7µs / 428µs | 1.23ms / 1.47ms | 750ns / 1.7µs | 834ns / 4.03ms | 19.3µs / 637µs |
|
|
78
|
+
|
|
79
|
+
### Real-world vs dense-synth
|
|
80
|
+
|
|
81
|
+
The 7,567-file vscode row vs the 10,000-file synth row tells the most important story:
|
|
82
|
+
|
|
83
|
+
- vscode `similarPatterns` p50 is **834ns**; synth-10K is **17ms**. Real codebases have far sparser per-file import sets — most files import 0-3 things, a handful import many. Synth is a deliberate worst case (Pareto fan-out always ≥1, often 4-8). Real Jaccard-over-import-sets fits in cache; synth doesn't.
|
|
84
|
+
- vscode `crossDomain` p50 is **1.23ms**; synth-10K is **704µs**. Cross-domain edges scale with edge density; synth is denser. Both are sub-2ms at any sensible scale.
|
|
85
|
+
- vscode `bitmap.bin` is **4.1 MB** at 7,567 files; synth-10K is **17 MB** at 10,000 files. ~4× density difference, reflected directly in storage.
|
|
86
|
+
|
|
87
|
+
The takeaway: **dense-synth identifies the failure modes; real-world data sets the actual user expectations.** Both are needed.
|
|
88
|
+
|
|
89
|
+
## Linux kernel and Chromium
|
|
90
|
+
|
|
91
|
+
Block 1.B in PEAK §10 explicitly calls out the Linux kernel and Chromium as scale targets. Neither is in `~/carto-test-repos` today (multi-GB clones, gated on the maintainer's disk budget). The `bench/scale-test/real-world.js` driver lands here so capturing those rows is one command once a clone exists:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone --depth=1 https://github.com/torvalds/linux ~/clones/linux
|
|
95
|
+
node bench/scale-test/real-world.js --repo ~/clones/linux --name linux-kernel
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Chromium follows the official `fetch` flow; expect a 30+ GB clone.
|
|
100
|
+
node bench/scale-test/real-world.js --repo ~/clones/chromium/src --name chromium
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
These rows are intentionally not yet captured. The 50K dense-synth + 7,567-file vscode + 6,330-file supabase rows above already establish the scaling profile of the current implementation; whether kernel/Chromium clear or fail is *known* in the bitmap-storage sense (linear in N, hits the GB scale at 100K+ files in the worst case). Capturing them on the maintainer box is a confirm-the-known activity, not a discover-the-unknown one.
|
|
104
|
+
|
|
105
|
+
## Reproducing this page
|
|
106
|
+
|
|
107
|
+
Every number above is mechanically reproducible. The exact commands:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Synth sweep (deterministic, dense-fan-out worst case)
|
|
111
|
+
npm run bench:scale -- --size 1000
|
|
112
|
+
npm run bench:scale -- --size 10000
|
|
113
|
+
npm run bench:scale -- --size 50000
|
|
114
|
+
|
|
115
|
+
# Real-world corpus (uses existing indexes; doesn't re-index)
|
|
116
|
+
node bench/scale-test/real-world.js --repo ~/carto-test-repos/tier-b/vscode --name vscode --queries-only
|
|
117
|
+
node bench/scale-test/real-world.js --repo ~/carto-test-repos/tier-b/supabase --name supabase --queries-only
|
|
118
|
+
node bench/scale-test/real-world.js --repo ~/carto-test-repos/tier-b/nextjs --name nextjs --queries-only
|
|
119
|
+
node bench/scale-test/real-world.js --repo ~/carto-test-repos/tier-b/cal.com --name cal.com --queries-only
|
|
120
|
+
|
|
121
|
+
# Larger (maintainer-machine work)
|
|
122
|
+
npm run bench:scale -- --size 100000 --keep --out /tmp/carto-100k
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Each run writes `bench/scale-test/results/<tag>-<ISO-timestamp>.json` and refreshes `bench/scale-test/REPORT.md`. The aggregator keeps the latest run per (kind, label) so re-running a row replaces the old number.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
_See `bench/scale-test/README.md` for full harness documentation._
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carto-md",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Structural intelligence layer for AI coding tools. Indexes your codebase into SQLite — routes, models, import graph, blast radius, domains — and exposes
|
|
3
|
+
"version": "2.0.9",
|
|
4
|
+
"description": "Structural intelligence layer for AI coding tools. Indexes your codebase into SQLite — routes, models, import graph, blast radius, domains — and exposes 22 MCP tools for Kiro, Cursor, and Claude.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"carto": "src/cli/index.js"
|
|
7
7
|
},
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"test:benchmark": "node test/benchmark.js",
|
|
13
13
|
"test:bench-ci": "node test/bench-ci.js",
|
|
14
14
|
"postinstall": "node scripts/postinstall.js",
|
|
15
|
-
"bench:bitmap": "node bench/bitmap-validation/index.js"
|
|
15
|
+
"bench:bitmap": "node bench/bitmap-validation/index.js",
|
|
16
|
+
"bench:scale": "node bench/scale-test/index.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@agentclientprotocol/sdk": "^0.22.1",
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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)
|
|
6
17
|
|
|
7
|
-
|
|
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 ----
|
|
8
25
|
|
|
9
26
|
const GRAMMARS = [
|
|
10
27
|
{ pkg: 'tree-sitter-javascript', langs: 'JavaScript' },
|
|
@@ -17,30 +34,380 @@ const GRAMMARS = [
|
|
|
17
34
|
{ pkg: 'tree-sitter-c-sharp', langs: 'C#' },
|
|
18
35
|
];
|
|
19
36
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|
|
23
231
|
}
|
|
24
232
|
|
|
25
|
-
|
|
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');
|
|
26
252
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
}
|
|
31
266
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
];
|
|
39
294
|
}
|
|
40
295
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.
|
|
46
|
-
|
|
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/agents/leiden.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
*/
|
|
@@ -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
|
|
19
|
+
* outputs stay stable after the V1 → V2 cleanup.
|
|
20
20
|
*/
|
|
21
21
|
const IGNORE_DIRS = new Set([
|
|
22
22
|
'node_modules',
|