carto-md 2.0.7 → 2.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +290 -26
- package/docs/anci/v0.1-DRAFT.md +420 -0
- package/docs/scale.md +129 -0
- package/package.json +10 -5
- package/scripts/postinstall.js +413 -0
- package/src/acp/agent.js +5 -5
- package/src/acp/providers/index.js +2 -2
- package/src/agents/leiden.js +11 -17
- package/src/agents/scan-structure.js +1 -1
- package/src/anci/consumer.js +305 -0
- package/src/anci/deserialize.js +160 -0
- package/src/anci/emit.js +85 -0
- package/src/anci/serialize.js +264 -0
- package/src/anci/yaml.js +401 -0
- package/src/bitmap/bitset.js +190 -0
- package/src/bitmap/index.js +121 -0
- package/src/bitmap/sidecar.js +545 -0
- package/src/bitmap/tools.js +310 -0
- package/src/cli/anci.js +237 -0
- package/src/cli/check.js +57 -0
- package/src/cli/index.js +28 -2
- package/src/cli/init.js +297 -65
- package/src/cli/inspect.js +295 -0
- package/src/cli/pr-impact.js +497 -0
- package/src/cli/serve.js +1 -1
- package/src/cli/watch.js +6 -0
- package/src/engine/worker.js +24 -4
- package/src/extractors/imports.js +176 -0
- package/src/extractors/languages/html.js +4 -1
- package/src/extractors/languages/javascript.js +5 -0
- package/src/extractors/languages/prisma.js +4 -1
- package/src/extractors/languages/python.js +5 -1
- package/src/extractors/languages/r.js +4 -1
- package/src/extractors/languages/typescript.js +2 -0
- package/src/extractors/tree-sitter-parser.js +15 -0
- package/src/mcp/change-plan.js +8 -8
- package/src/mcp/diff-parser.js +246 -0
- package/src/mcp/server-v2.js +489 -8
- package/src/mcp/validate.js +304 -0
- package/src/store/config-loader.js +77 -0
- package/src/store/sqlite-store.js +389 -4
- package/src/store/sync-v2.js +472 -97
- package/BENCHMARK_RESULTS.md +0 -34
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ANCI v0.1 — read-only consumer library.
|
|
5
|
+
*
|
|
6
|
+
* Format reference: docs/anci/v0.1-DRAFT.md.
|
|
7
|
+
*
|
|
8
|
+
* This module is the **public consumer API** for ANCI files. It is what
|
|
9
|
+
* a Cursor / Cline / Continue / Windsurf / Claude Code plugin imports
|
|
10
|
+
* to read a codebase's published architecture without indexing the
|
|
11
|
+
* codebase itself.
|
|
12
|
+
*
|
|
13
|
+
* Any AI tool that calls `loadAnci(dir)` and uses its return value is
|
|
14
|
+
* cooperating with Carto via the open ANCI format — no Carto runtime
|
|
15
|
+
* required.
|
|
16
|
+
*
|
|
17
|
+
* Contract:
|
|
18
|
+
* loadAnci(dir, opts?) → {
|
|
19
|
+
* header, // parsed YAML metadata
|
|
20
|
+
* domains, // [{name, file_count, ...}]
|
|
21
|
+
* routes, // [{method, path, file, ...}]
|
|
22
|
+
* models, // [{name, kind, file}]
|
|
23
|
+
* blastRadius(file, opts?), // 5-hop BFS from `reverse` bitmap
|
|
24
|
+
* simulateChangeImpact(files, opts?),
|
|
25
|
+
* getHighImpactFiles(n?),
|
|
26
|
+
* getDomainOf(file),
|
|
27
|
+
* close?(), // no-op for v0.1 (kept as forward-compat)
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* The shape mirrors Carto's MCP tool surface so existing test
|
|
31
|
+
* fixtures port across with minimal change. Returns shapes match the
|
|
32
|
+
* MCP tools' return shapes verbatim (file/hop_distance/count fields).
|
|
33
|
+
*
|
|
34
|
+
* Failure modes:
|
|
35
|
+
* - Missing files → throws Error('ANCI not found at ...')
|
|
36
|
+
* - Bad version → throws Error('ANCI version unsupported: ...')
|
|
37
|
+
* - Corrupt body → throws Error('ANCI body failed to parse')
|
|
38
|
+
* - Missing header → throws (the header is required)
|
|
39
|
+
*
|
|
40
|
+
* Zero runtime dependencies. Uses only Node built-ins, the bundled
|
|
41
|
+
* Bitset class, and the YAML / deserialize modules in this directory.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
const yaml = require('./yaml');
|
|
47
|
+
const { deserializeBody } = require('./deserialize');
|
|
48
|
+
const {
|
|
49
|
+
ANCI_BIN_FILENAME,
|
|
50
|
+
ANCI_YAML_FILENAME,
|
|
51
|
+
VERSION,
|
|
52
|
+
} = require('./serialize');
|
|
53
|
+
const { Bitset } = require('../bitmap/bitset');
|
|
54
|
+
|
|
55
|
+
const ACCEPTED_VERSION_PREFIX = '0.1.'; // accept any 0.1.x version
|
|
56
|
+
const MAX_BFS_HOPS = 5; // matches Carto reference
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* loadAnci(dir, opts?) → reader object.
|
|
60
|
+
*
|
|
61
|
+
* `dir` should be the directory containing `anci.yaml` and `anci.bin`
|
|
62
|
+
* (typically `.carto/` of a repo, but the format does not constrain
|
|
63
|
+
* location).
|
|
64
|
+
*/
|
|
65
|
+
function loadAnci(dir, opts = {}) {
|
|
66
|
+
const yamlPath = path.join(dir, ANCI_YAML_FILENAME);
|
|
67
|
+
const binPath = path.join(dir, ANCI_BIN_FILENAME);
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(yamlPath)) {
|
|
70
|
+
throw new Error(`ANCI not found: ${yamlPath} does not exist`);
|
|
71
|
+
}
|
|
72
|
+
if (!fs.existsSync(binPath)) {
|
|
73
|
+
throw new Error(`ANCI not found: ${binPath} does not exist`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 1. Parse the YAML header.
|
|
77
|
+
let header;
|
|
78
|
+
try {
|
|
79
|
+
header = yaml.parse(fs.readFileSync(yamlPath, 'utf-8'));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`ANCI header parse failed: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
if (!header || !header.anci || typeof header.anci.version !== 'string') {
|
|
84
|
+
throw new Error('ANCI header missing required `anci.version`');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Reject unsupported versions early.
|
|
88
|
+
if (!header.anci.version.startsWith(ACCEPTED_VERSION_PREFIX)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`ANCI version unsupported: got ${JSON.stringify(header.anci.version)}, ` +
|
|
91
|
+
`this consumer accepts ${ACCEPTED_VERSION_PREFIX}x`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Cross-check body length when the header advertises one.
|
|
96
|
+
const bodyBuf = fs.readFileSync(binPath);
|
|
97
|
+
if (header.anci.body && typeof header.anci.body.bytes === 'number') {
|
|
98
|
+
if (bodyBuf.length !== header.anci.body.bytes) {
|
|
99
|
+
// Permissive: warn but don't fail (consumers SHOULD warn but continue).
|
|
100
|
+
// Suppress by default — tests can opt in via opts.warn.
|
|
101
|
+
if (opts.warn) {
|
|
102
|
+
opts.warn(
|
|
103
|
+
`ANCI body size mismatch: header says ${header.anci.body.bytes}, ` +
|
|
104
|
+
`file is ${bodyBuf.length} bytes`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Parse the binary body.
|
|
111
|
+
const body = deserializeBody(bodyBuf);
|
|
112
|
+
if (!body) {
|
|
113
|
+
throw new Error('ANCI body failed to parse (corrupt magic, version, or section)');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 5. Build the reader.
|
|
117
|
+
return makeReader({ header, body });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function makeReader({ header, body }) {
|
|
121
|
+
// ── Convenience flat projections ─────────────────────────────────
|
|
122
|
+
const domains = header.domains || [];
|
|
123
|
+
const routes = header.routes || [];
|
|
124
|
+
const models = header.models || [];
|
|
125
|
+
const high_impact = header.high_impact || [];
|
|
126
|
+
|
|
127
|
+
// domain id → name and file → domain id are in the body; build a
|
|
128
|
+
// quick path → domainName map for getDomainOf().
|
|
129
|
+
const fileDomainName = new Map();
|
|
130
|
+
for (const [fid, did] of body.fileDomain) {
|
|
131
|
+
const file = body.fileIdToPath.get(fid);
|
|
132
|
+
const name = body.domainIdToName.get(did);
|
|
133
|
+
if (file && name) fileDomainName.set(file, name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getDomainOf(file) {
|
|
137
|
+
return fileDomainName.get(file) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* blastRadius(file, opts?) → { file, hops, count, files: [{file, hop_distance}] }
|
|
142
|
+
*
|
|
143
|
+
* 5-hop BFS over `reverse`. Same semantics as Carto's
|
|
144
|
+
* bitmap blastRadius. Returns null if `file` is unknown.
|
|
145
|
+
*/
|
|
146
|
+
function blastRadius(file, opts = {}) {
|
|
147
|
+
const fid = body.pathToFileId.get(file);
|
|
148
|
+
if (fid === undefined) return null;
|
|
149
|
+
|
|
150
|
+
const maxHops = Math.min(MAX_BFS_HOPS, opts.maxHops || MAX_BFS_HOPS);
|
|
151
|
+
const visited = new Bitset(body.size);
|
|
152
|
+
const frontier = new Bitset(body.size);
|
|
153
|
+
const next = new Bitset(body.size);
|
|
154
|
+
|
|
155
|
+
const direct = body.reverse.get(fid);
|
|
156
|
+
if (!direct) return { file, hops: 0, count: 0, files: [] };
|
|
157
|
+
|
|
158
|
+
frontier.copyFrom(direct);
|
|
159
|
+
visited.orInPlace(frontier);
|
|
160
|
+
const hopOf = new Map();
|
|
161
|
+
for (let h = 1; h <= maxHops; h++) {
|
|
162
|
+
const fwords = frontier.words;
|
|
163
|
+
next.setAll(0);
|
|
164
|
+
for (let w = 0; w < fwords.length; w++) {
|
|
165
|
+
let v = fwords[w];
|
|
166
|
+
while (v) {
|
|
167
|
+
const bit = v & -v;
|
|
168
|
+
const dep = (w << 5) + (31 - Math.clz32(bit));
|
|
169
|
+
v ^= bit;
|
|
170
|
+
if (!hopOf.has(dep)) hopOf.set(dep, h);
|
|
171
|
+
const deps = body.reverse.get(dep);
|
|
172
|
+
if (deps) next.orInPlace(deps);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
next.andNotInPlace(visited);
|
|
176
|
+
if (next.popcount() === 0) break;
|
|
177
|
+
visited.orInPlace(next);
|
|
178
|
+
frontier.copyFrom(next);
|
|
179
|
+
}
|
|
180
|
+
visited.clear(fid);
|
|
181
|
+
hopOf.delete(fid);
|
|
182
|
+
|
|
183
|
+
const files = [];
|
|
184
|
+
for (const [dep, hop] of hopOf) {
|
|
185
|
+
const p = body.fileIdToPath.get(dep);
|
|
186
|
+
if (p) files.push({ file: p, hop_distance: hop });
|
|
187
|
+
}
|
|
188
|
+
files.sort((a, b) => a.hop_distance - b.hop_distance || a.file.localeCompare(b.file));
|
|
189
|
+
return { file, hops: maxHops, count: files.length, files };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* simulateChangeImpact(files, opts?) → { count, files: [{file, hop_distance}] }
|
|
194
|
+
*
|
|
195
|
+
* Union of blast-radius BFSes seeded from each input file.
|
|
196
|
+
* Sub-millisecond on 7K-file repos via OR-aggregation.
|
|
197
|
+
*/
|
|
198
|
+
function simulateChangeImpact(files, opts = {}) {
|
|
199
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
200
|
+
return { count: 0, files: [] };
|
|
201
|
+
}
|
|
202
|
+
const seedIds = [];
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
const fid = body.pathToFileId.get(f);
|
|
205
|
+
if (fid !== undefined) seedIds.push(fid);
|
|
206
|
+
}
|
|
207
|
+
if (seedIds.length === 0) return { count: 0, files: [] };
|
|
208
|
+
|
|
209
|
+
const maxHops = Math.min(MAX_BFS_HOPS, opts.maxHops || MAX_BFS_HOPS);
|
|
210
|
+
const visited = new Bitset(body.size);
|
|
211
|
+
const frontier = new Bitset(body.size);
|
|
212
|
+
const next = new Bitset(body.size);
|
|
213
|
+
const hopOf = new Map();
|
|
214
|
+
|
|
215
|
+
// Seed: union of direct dependents of all input files.
|
|
216
|
+
for (const fid of seedIds) {
|
|
217
|
+
const deps = body.reverse.get(fid);
|
|
218
|
+
if (deps) frontier.orInPlace(deps);
|
|
219
|
+
}
|
|
220
|
+
visited.orInPlace(frontier);
|
|
221
|
+
{
|
|
222
|
+
const fwords = frontier.words;
|
|
223
|
+
for (let w = 0; w < fwords.length; w++) {
|
|
224
|
+
let v = fwords[w];
|
|
225
|
+
while (v) {
|
|
226
|
+
const bit = v & -v;
|
|
227
|
+
hopOf.set((w << 5) + (31 - Math.clz32(bit)), 1);
|
|
228
|
+
v ^= bit;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (let h = 2; h <= maxHops; h++) {
|
|
233
|
+
const fwords = frontier.words;
|
|
234
|
+
next.setAll(0);
|
|
235
|
+
for (let w = 0; w < fwords.length; w++) {
|
|
236
|
+
let v = fwords[w];
|
|
237
|
+
while (v) {
|
|
238
|
+
const bit = v & -v;
|
|
239
|
+
const dep = (w << 5) + (31 - Math.clz32(bit));
|
|
240
|
+
v ^= bit;
|
|
241
|
+
const deps = body.reverse.get(dep);
|
|
242
|
+
if (deps) next.orInPlace(deps);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
next.andNotInPlace(visited);
|
|
246
|
+
if (next.popcount() === 0) break;
|
|
247
|
+
// Record hop distances for newly-reached nodes.
|
|
248
|
+
const nwords = next.words;
|
|
249
|
+
for (let w = 0; w < nwords.length; w++) {
|
|
250
|
+
let v = nwords[w];
|
|
251
|
+
while (v) {
|
|
252
|
+
const bit = v & -v;
|
|
253
|
+
hopOf.set((w << 5) + (31 - Math.clz32(bit)), h);
|
|
254
|
+
v ^= bit;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
visited.orInPlace(next);
|
|
258
|
+
frontier.copyFrom(next);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Drop the input set itself.
|
|
262
|
+
for (const fid of seedIds) {
|
|
263
|
+
visited.clear(fid);
|
|
264
|
+
hopOf.delete(fid);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const result = [];
|
|
268
|
+
for (const [dep, hop] of hopOf) {
|
|
269
|
+
const p = body.fileIdToPath.get(dep);
|
|
270
|
+
if (p) result.push({ file: p, hop_distance: hop });
|
|
271
|
+
}
|
|
272
|
+
result.sort((a, b) => a.hop_distance - b.hop_distance || a.file.localeCompare(b.file));
|
|
273
|
+
return { count: result.length, files: result };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* getHighImpactFiles(n) → top N from popcount index, hydrated.
|
|
278
|
+
* Mirrors Carto's MCP tool of the same name.
|
|
279
|
+
*/
|
|
280
|
+
function getHighImpactFiles(n = 10) {
|
|
281
|
+
const out = [];
|
|
282
|
+
const limit = Math.min(n, body.popcountIndex.length);
|
|
283
|
+
for (let i = 0; i < limit; i++) {
|
|
284
|
+
const e = body.popcountIndex[i];
|
|
285
|
+
const file = body.fileIdToPath.get(e.fileId);
|
|
286
|
+
if (file) out.push({ file, transitive_dependents: e.count });
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
header,
|
|
293
|
+
domains,
|
|
294
|
+
routes,
|
|
295
|
+
models,
|
|
296
|
+
high_impact,
|
|
297
|
+
blastRadius,
|
|
298
|
+
simulateChangeImpact,
|
|
299
|
+
getHighImpactFiles,
|
|
300
|
+
getDomainOf,
|
|
301
|
+
close() { /* no resources to release in v0.1 */ },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { loadAnci, ACCEPTED_VERSION_PREFIX };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ANCI v0.1 — binary body deserializer.
|
|
5
|
+
*
|
|
6
|
+
* Format reference: docs/anci/v0.1-DRAFT.md §5.
|
|
7
|
+
*
|
|
8
|
+
* Symmetric with serialize.js. Validates magic + version up front, then
|
|
9
|
+
* stream-parses each section in declared order.
|
|
10
|
+
*
|
|
11
|
+
* Returns null for malformed inputs (corrupt magic, unsupported version,
|
|
12
|
+
* truncated section). The reference consumer's contract: malformed ANCI
|
|
13
|
+
* is a degraded — not catastrophic — situation. Callers that need a
|
|
14
|
+
* strict error throw should check the return value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { Bitset } = require('../bitmap/bitset');
|
|
20
|
+
const {
|
|
21
|
+
MAGIC,
|
|
22
|
+
VERSION,
|
|
23
|
+
HEADER_BYTES,
|
|
24
|
+
ANCI_BIN_FILENAME,
|
|
25
|
+
} = require('./serialize');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* decodeBitmap(buf, off, size) → { id, bitmap, nextOff }
|
|
29
|
+
*/
|
|
30
|
+
function decodeBitmap(buf, off, size) {
|
|
31
|
+
const id = buf.readUInt32LE(off);
|
|
32
|
+
const wordsLen = buf.readUInt32LE(off + 4);
|
|
33
|
+
const wordBytes = wordsLen * 4;
|
|
34
|
+
const bitmap = new Bitset(size);
|
|
35
|
+
if (bitmap.words.length !== wordsLen) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`decodeBitmap: word-length mismatch (header size=${size} → ${bitmap.words.length} words, ` +
|
|
38
|
+
`record claims ${wordsLen} words)`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const u8 = new Uint8Array(bitmap.words.buffer, bitmap.words.byteOffset, wordBytes);
|
|
42
|
+
for (let i = 0; i < wordBytes; i++) u8[i] = buf[off + 8 + i];
|
|
43
|
+
return { id, bitmap, nextOff: off + 8 + wordBytes };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* deserializeBody(buf) → payload object | null on malformed input.
|
|
48
|
+
*
|
|
49
|
+
* Returned shape mirrors `serializeBody`'s input plus a
|
|
50
|
+
* `pathToFileId` inverse map for consumer convenience:
|
|
51
|
+
*
|
|
52
|
+
* {
|
|
53
|
+
* size,
|
|
54
|
+
* forward, reverse,
|
|
55
|
+
* popcountIndex,
|
|
56
|
+
* fileIdToPath, pathToFileId,
|
|
57
|
+
* fileDomain, domainIdToName,
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
function deserializeBody(buf) {
|
|
61
|
+
try {
|
|
62
|
+
if (!Buffer.isBuffer(buf)) return null;
|
|
63
|
+
if (buf.length < HEADER_BYTES) return null;
|
|
64
|
+
if (buf.readUInt32LE(0) !== MAGIC) return null;
|
|
65
|
+
const version = buf.readUInt8(4);
|
|
66
|
+
if (version !== VERSION) return null;
|
|
67
|
+
const size = buf.readUInt32LE(8);
|
|
68
|
+
|
|
69
|
+
let off = HEADER_BYTES;
|
|
70
|
+
|
|
71
|
+
// forward
|
|
72
|
+
const forward = new Map();
|
|
73
|
+
const fwdCount = buf.readUInt32LE(off); off += 4;
|
|
74
|
+
for (let i = 0; i < fwdCount; i++) {
|
|
75
|
+
const dec = decodeBitmap(buf, off, size);
|
|
76
|
+
forward.set(dec.id, dec.bitmap);
|
|
77
|
+
off = dec.nextOff;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// reverse
|
|
81
|
+
const reverse = new Map();
|
|
82
|
+
const revCount = buf.readUInt32LE(off); off += 4;
|
|
83
|
+
for (let i = 0; i < revCount; i++) {
|
|
84
|
+
const dec = decodeBitmap(buf, off, size);
|
|
85
|
+
reverse.set(dec.id, dec.bitmap);
|
|
86
|
+
off = dec.nextOff;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// popcount
|
|
90
|
+
const popLen = buf.readUInt32LE(off); off += 4;
|
|
91
|
+
const popcountIndex = new Array(popLen);
|
|
92
|
+
for (let i = 0; i < popLen; i++) {
|
|
93
|
+
popcountIndex[i] = {
|
|
94
|
+
fileId: buf.readUInt32LE(off),
|
|
95
|
+
count: buf.readUInt32LE(off + 4),
|
|
96
|
+
};
|
|
97
|
+
off += 8;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// paths
|
|
101
|
+
const fileIdToPath = new Map();
|
|
102
|
+
const pathToFileId = new Map();
|
|
103
|
+
const pathCount = buf.readUInt32LE(off); off += 4;
|
|
104
|
+
for (let i = 0; i < pathCount; i++) {
|
|
105
|
+
const fid = buf.readUInt32LE(off);
|
|
106
|
+
const pLen = buf.readUInt32LE(off + 4);
|
|
107
|
+
const p = buf.slice(off + 8, off + 8 + pLen).toString('utf-8');
|
|
108
|
+
fileIdToPath.set(fid, p);
|
|
109
|
+
pathToFileId.set(p, fid);
|
|
110
|
+
off += 8 + pLen;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// file_domain
|
|
114
|
+
const fileDomain = new Map();
|
|
115
|
+
const fdCount = buf.readUInt32LE(off); off += 4;
|
|
116
|
+
for (let i = 0; i < fdCount; i++) {
|
|
117
|
+
fileDomain.set(buf.readUInt32LE(off), buf.readUInt32LE(off + 4));
|
|
118
|
+
off += 8;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// domain_names
|
|
122
|
+
const domainIdToName = new Map();
|
|
123
|
+
const dnCount = buf.readUInt32LE(off); off += 4;
|
|
124
|
+
for (let i = 0; i < dnCount; i++) {
|
|
125
|
+
const did = buf.readUInt32LE(off);
|
|
126
|
+
const nLen = buf.readUInt32LE(off + 4);
|
|
127
|
+
const name = buf.slice(off + 8, off + 8 + nLen).toString('utf-8');
|
|
128
|
+
domainIdToName.set(did, name);
|
|
129
|
+
off += 8 + nLen;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
size,
|
|
134
|
+
forward,
|
|
135
|
+
reverse,
|
|
136
|
+
popcountIndex,
|
|
137
|
+
fileIdToPath,
|
|
138
|
+
pathToFileId,
|
|
139
|
+
fileDomain,
|
|
140
|
+
domainIdToName,
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* loadBodyFromDisk(cartoDir) → payload | null
|
|
149
|
+
*/
|
|
150
|
+
function loadBodyFromDisk(cartoDir) {
|
|
151
|
+
const target = path.join(cartoDir, ANCI_BIN_FILENAME);
|
|
152
|
+
let buf;
|
|
153
|
+
try { buf = fs.readFileSync(target); } catch { return null; }
|
|
154
|
+
return deserializeBody(buf);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
deserializeBody,
|
|
159
|
+
loadBodyFromDisk,
|
|
160
|
+
};
|
package/src/anci/emit.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ANCI v0.1 — emit orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* Glues serialize.js + yaml.js together and writes both files atomically
|
|
7
|
+
* to `.carto/anci.{yaml,bin}`. Used by the runSyncV2 hook (called after
|
|
8
|
+
* every full sync) and by `carto anci publish`.
|
|
9
|
+
*
|
|
10
|
+
* Atomic write: each file goes to `.tmp` first, then rename. Crash
|
|
11
|
+
* mid-write leaves the previous pair intact (or absent — the consumer's
|
|
12
|
+
* job is to refuse to load when the magic / version don't match).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const yaml = require('./yaml');
|
|
18
|
+
const {
|
|
19
|
+
serializeBody,
|
|
20
|
+
deriveBodyFromSidecar,
|
|
21
|
+
buildHeader,
|
|
22
|
+
ANCI_BIN_FILENAME,
|
|
23
|
+
ANCI_YAML_FILENAME,
|
|
24
|
+
} = require('./serialize');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* emitToCartoDir({ cartoDir, sidecar, store, generator, generatedAt })
|
|
28
|
+
* → { yamlPath, binPath, bodyBytes }
|
|
29
|
+
*
|
|
30
|
+
* Required:
|
|
31
|
+
* cartoDir — absolute path to `.carto/`. Will be created if missing.
|
|
32
|
+
* sidecar — bitmap sidecar object (from `bitmap/sidecar.js`).
|
|
33
|
+
* store — open SQLiteStore (any mode; used for header metadata).
|
|
34
|
+
*
|
|
35
|
+
* Optional:
|
|
36
|
+
* generator — string, defaults to `carto-md@<version>`.
|
|
37
|
+
* generatedAt — ISO-8601 string, defaults to `new Date().toISOString()`.
|
|
38
|
+
*
|
|
39
|
+
* Throws on filesystem failure (caller wraps in try/catch when
|
|
40
|
+
* best-effort behavior is needed — see runSyncV2 hook).
|
|
41
|
+
*/
|
|
42
|
+
function emitToCartoDir({ cartoDir, sidecar, store, generator, generatedAt }) {
|
|
43
|
+
fs.mkdirSync(cartoDir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
// 1. Body — derive from sidecar, serialize, atomic write.
|
|
46
|
+
const body = serializeBody(deriveBodyFromSidecar(sidecar));
|
|
47
|
+
const binPath = path.join(cartoDir, ANCI_BIN_FILENAME);
|
|
48
|
+
const binTmp = binPath + '.tmp';
|
|
49
|
+
fs.writeFileSync(binTmp, body);
|
|
50
|
+
fs.renameSync(binTmp, binPath);
|
|
51
|
+
|
|
52
|
+
// 2. Header — built with the body's actual on-disk byte count so the
|
|
53
|
+
// header's `body.bytes` cross-check matches.
|
|
54
|
+
const header = buildHeader({
|
|
55
|
+
sidecar,
|
|
56
|
+
store,
|
|
57
|
+
generator: generator || resolveGenerator(),
|
|
58
|
+
generatedAt: generatedAt || new Date().toISOString(),
|
|
59
|
+
bodyBytes: body.length,
|
|
60
|
+
});
|
|
61
|
+
const yamlText = yaml.emit(header);
|
|
62
|
+
const yamlPath = path.join(cartoDir, ANCI_YAML_FILENAME);
|
|
63
|
+
const yamlTmp = yamlPath + '.tmp';
|
|
64
|
+
fs.writeFileSync(yamlTmp, yamlText, 'utf-8');
|
|
65
|
+
fs.renameSync(yamlTmp, yamlPath);
|
|
66
|
+
|
|
67
|
+
return { yamlPath, binPath, bodyBytes: body.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* resolveGenerator() → "carto-md@<version>"
|
|
72
|
+
*
|
|
73
|
+
* Defensive: if package.json can't be read for any reason, fall back to
|
|
74
|
+
* a stable opaque string rather than crash the publish.
|
|
75
|
+
*/
|
|
76
|
+
function resolveGenerator() {
|
|
77
|
+
try {
|
|
78
|
+
const pkg = require('../../package.json');
|
|
79
|
+
return `${pkg.name}@${pkg.version}`;
|
|
80
|
+
} catch {
|
|
81
|
+
return 'carto-md@unknown';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { emitToCartoDir };
|