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,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ANCI v0.1 — binary body serializer.
|
|
5
|
+
*
|
|
6
|
+
* Format reference: docs/anci/v0.1-DRAFT.md §5.
|
|
7
|
+
*
|
|
8
|
+
* Wire format (little-endian throughout):
|
|
9
|
+
* header: magic u32 = 0x49434E41 ("ANCI") · version u8 = 1 ·
|
|
10
|
+
* reserved u8×3 · size_bits u32
|
|
11
|
+
* forward: count u32 · count×{ fileId u32 · wordsLen u32 · words u32×wordsLen }
|
|
12
|
+
* reverse: count u32 · same shape
|
|
13
|
+
* popcount: count u32 · count×{ fileId u32 · count u32 } (sorted DESC)
|
|
14
|
+
* paths: count u32 · count×{ fileId u32 · pathLen u32 · UTF-8 bytes }
|
|
15
|
+
* file_domain: count u32 · count×{ fileId u32 · domainId u32 }
|
|
16
|
+
* domain_names: count u32 · count×{ domainId u32 · nameLen u32 · UTF-8 bytes }
|
|
17
|
+
*
|
|
18
|
+
* The body intentionally shares record-level layout with
|
|
19
|
+
* `bitmap/sidecar.js` (Carto's internal cache) so the encoding helpers
|
|
20
|
+
* can be visually paired across files. They are NOT the same file
|
|
21
|
+
* format — distinct magics (`ANCI` vs `CBRT`), different section sets,
|
|
22
|
+
* different versioning policies.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { Bitset } = require('../bitmap/bitset');
|
|
28
|
+
|
|
29
|
+
const MAGIC = 0x49434E41; // "ANCI" little-endian: 'A','N','C','I'
|
|
30
|
+
const VERSION = 1; // ANCI body wire-format version.
|
|
31
|
+
const HEADER_BYTES = 12;
|
|
32
|
+
const ANCI_BIN_FILENAME = 'anci.bin';
|
|
33
|
+
const ANCI_YAML_FILENAME = 'anci.yaml';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* encodeBitmap(id, bitmap) → Buffer of (id u32, wordsLen u32, words bytes).
|
|
37
|
+
*/
|
|
38
|
+
function encodeBitmap(id, bitmap) {
|
|
39
|
+
const wordBytes = bitmap.words.byteLength;
|
|
40
|
+
const buf = Buffer.allocUnsafe(8 + wordBytes);
|
|
41
|
+
buf.writeUInt32LE(id, 0);
|
|
42
|
+
buf.writeUInt32LE(bitmap.words.length, 4);
|
|
43
|
+
Buffer.from(bitmap.words.buffer, bitmap.words.byteOffset, wordBytes).copy(buf, 8);
|
|
44
|
+
return buf;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encode an Array<{fileId, count}> popcount index as a single Buffer.
|
|
49
|
+
* Caller has already sorted DESC.
|
|
50
|
+
*/
|
|
51
|
+
function encodePopcountTable(entries) {
|
|
52
|
+
if (entries.length === 0) return Buffer.alloc(0);
|
|
53
|
+
const buf = Buffer.allocUnsafe(entries.length * 8);
|
|
54
|
+
let off = 0;
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
buf.writeUInt32LE(e.fileId, off);
|
|
57
|
+
buf.writeUInt32LE(e.count, off + 4);
|
|
58
|
+
off += 8;
|
|
59
|
+
}
|
|
60
|
+
return buf;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encode a Map<id, string> as length-prefixed UTF-8 records.
|
|
65
|
+
*/
|
|
66
|
+
function encodeStringTable(map) {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
for (const [id, s] of map) {
|
|
69
|
+
const bytes = Buffer.from(String(s), 'utf-8');
|
|
70
|
+
const rec = Buffer.allocUnsafe(8 + bytes.length);
|
|
71
|
+
rec.writeUInt32LE(id, 0);
|
|
72
|
+
rec.writeUInt32LE(bytes.length, 4);
|
|
73
|
+
bytes.copy(rec, 8);
|
|
74
|
+
chunks.push(rec);
|
|
75
|
+
}
|
|
76
|
+
return Buffer.concat(chunks);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Encode a Map<id, id> as fixed 8-byte records.
|
|
81
|
+
*/
|
|
82
|
+
function encodeIdMap(map) {
|
|
83
|
+
if (map.size === 0) return Buffer.alloc(0);
|
|
84
|
+
const buf = Buffer.allocUnsafe(map.size * 8);
|
|
85
|
+
let off = 0;
|
|
86
|
+
for (const [k, v] of map) {
|
|
87
|
+
buf.writeUInt32LE(k, off);
|
|
88
|
+
buf.writeUInt32LE(v, off + 4);
|
|
89
|
+
off += 8;
|
|
90
|
+
}
|
|
91
|
+
return buf;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* serializeBody(payload) → Buffer
|
|
96
|
+
*
|
|
97
|
+
* payload shape:
|
|
98
|
+
* {
|
|
99
|
+
* size: u32,
|
|
100
|
+
* forward: Map<fileId, Bitset>,
|
|
101
|
+
* reverse: Map<fileId, Bitset>,
|
|
102
|
+
* popcountIndex: Array<{fileId, count}> — sorted DESC by count
|
|
103
|
+
* fileIdToPath: Map<fileId, string>,
|
|
104
|
+
* fileDomain: Map<fileId, domainId>,
|
|
105
|
+
* domainIdToName: Map<domainId, string>,
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
function serializeBody(payload) {
|
|
109
|
+
if (typeof payload.size !== 'number') {
|
|
110
|
+
throw new Error('serializeBody: payload.size required (max fileId + 1)');
|
|
111
|
+
}
|
|
112
|
+
const chunks = [];
|
|
113
|
+
|
|
114
|
+
// Fixed header
|
|
115
|
+
const header = Buffer.allocUnsafe(HEADER_BYTES);
|
|
116
|
+
header.writeUInt32LE(MAGIC, 0);
|
|
117
|
+
header.writeUInt8(VERSION, 4);
|
|
118
|
+
header.writeUInt8(0, 5);
|
|
119
|
+
header.writeUInt8(0, 6);
|
|
120
|
+
header.writeUInt8(0, 7);
|
|
121
|
+
header.writeUInt32LE(payload.size, 8);
|
|
122
|
+
chunks.push(header);
|
|
123
|
+
|
|
124
|
+
// forward
|
|
125
|
+
const fwdCount = Buffer.allocUnsafe(4);
|
|
126
|
+
fwdCount.writeUInt32LE(payload.forward.size, 0);
|
|
127
|
+
chunks.push(fwdCount);
|
|
128
|
+
for (const [fid, bm] of payload.forward) chunks.push(encodeBitmap(fid, bm));
|
|
129
|
+
|
|
130
|
+
// reverse
|
|
131
|
+
const revCount = Buffer.allocUnsafe(4);
|
|
132
|
+
revCount.writeUInt32LE(payload.reverse.size, 0);
|
|
133
|
+
chunks.push(revCount);
|
|
134
|
+
for (const [fid, bm] of payload.reverse) chunks.push(encodeBitmap(fid, bm));
|
|
135
|
+
|
|
136
|
+
// popcount
|
|
137
|
+
const popCount = Buffer.allocUnsafe(4);
|
|
138
|
+
popCount.writeUInt32LE(payload.popcountIndex.length, 0);
|
|
139
|
+
chunks.push(popCount);
|
|
140
|
+
chunks.push(encodePopcountTable(payload.popcountIndex));
|
|
141
|
+
|
|
142
|
+
// paths
|
|
143
|
+
const pathCount = Buffer.allocUnsafe(4);
|
|
144
|
+
pathCount.writeUInt32LE(payload.fileIdToPath.size, 0);
|
|
145
|
+
chunks.push(pathCount);
|
|
146
|
+
chunks.push(encodeStringTable(payload.fileIdToPath));
|
|
147
|
+
|
|
148
|
+
// file_domain
|
|
149
|
+
const fdCount = Buffer.allocUnsafe(4);
|
|
150
|
+
fdCount.writeUInt32LE(payload.fileDomain.size, 0);
|
|
151
|
+
chunks.push(fdCount);
|
|
152
|
+
chunks.push(encodeIdMap(payload.fileDomain));
|
|
153
|
+
|
|
154
|
+
// domain_names
|
|
155
|
+
const dnCount = Buffer.allocUnsafe(4);
|
|
156
|
+
dnCount.writeUInt32LE(payload.domainIdToName.size, 0);
|
|
157
|
+
chunks.push(dnCount);
|
|
158
|
+
chunks.push(encodeStringTable(payload.domainIdToName));
|
|
159
|
+
|
|
160
|
+
return Buffer.concat(chunks);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* deriveBodyFromSidecar(sidecar) → payload accepted by serializeBody.
|
|
165
|
+
*
|
|
166
|
+
* Drops `crossForward` and `domainBitmaps` (internal optimizations not
|
|
167
|
+
* part of the ANCI wire format — consumers re-derive on load).
|
|
168
|
+
*/
|
|
169
|
+
function deriveBodyFromSidecar(sidecar) {
|
|
170
|
+
return {
|
|
171
|
+
size: sidecar.size,
|
|
172
|
+
forward: sidecar.forward,
|
|
173
|
+
reverse: sidecar.reverse,
|
|
174
|
+
popcountIndex: sidecar.popcountIndex,
|
|
175
|
+
fileIdToPath: sidecar.fileIdToPath,
|
|
176
|
+
fileDomain: sidecar.fileDomain,
|
|
177
|
+
domainIdToName: sidecar.domainIdToName,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* buildHeader(sidecar, store, opts?) → object suitable for yaml.emit().
|
|
183
|
+
*
|
|
184
|
+
* The full ANCI header. Pulls metadata from the SQLite `store`
|
|
185
|
+
* (routes, models, domain counts) and architecture from the bitmap
|
|
186
|
+
* `sidecar` (high-impact files). Both arguments are required because
|
|
187
|
+
* the binary body alone does not carry route/model semantics.
|
|
188
|
+
*/
|
|
189
|
+
function buildHeader({ sidecar, store, generator, generatedAt, bodyBytes }) {
|
|
190
|
+
// ── anci block ───────────────────────────────────────────────────
|
|
191
|
+
const anciBlock = {
|
|
192
|
+
version: '0.1.0-DRAFT',
|
|
193
|
+
generator: generator || 'carto-md@unknown',
|
|
194
|
+
generated_at: generatedAt || new Date().toISOString(),
|
|
195
|
+
body: {
|
|
196
|
+
file: ANCI_BIN_FILENAME,
|
|
197
|
+
bytes: typeof bodyBytes === 'number' ? bodyBytes : 0,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── project block ────────────────────────────────────────────────
|
|
202
|
+
const structure = store.getStructure();
|
|
203
|
+
const totalModels = store.db
|
|
204
|
+
.prepare('SELECT COUNT(*) AS cnt FROM models').get().cnt;
|
|
205
|
+
const project = {
|
|
206
|
+
total_files: structure.meta.totalFiles,
|
|
207
|
+
total_routes: structure.meta.totalRoutes,
|
|
208
|
+
total_models: totalModels,
|
|
209
|
+
total_import_edges: structure.meta.totalImportEdges,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ── domains block ────────────────────────────────────────────────
|
|
213
|
+
const domains = store.getDomainsList().map(d => ({
|
|
214
|
+
name: d.name,
|
|
215
|
+
file_count: d.fileCount,
|
|
216
|
+
route_count: d.routeCount,
|
|
217
|
+
model_count: d.modelCount,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
// ── high_impact (top 15 from popcount index, with hydrated paths) ─
|
|
221
|
+
const high_impact = [];
|
|
222
|
+
const N = Math.min(15, sidecar.popcountIndex.length);
|
|
223
|
+
for (let i = 0; i < N; i++) {
|
|
224
|
+
const e = sidecar.popcountIndex[i];
|
|
225
|
+
const file = sidecar.fileIdToPath.get(e.fileId);
|
|
226
|
+
if (file) high_impact.push({ file, transitive_dependents: e.count });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── routes ───────────────────────────────────────────────────────
|
|
230
|
+
const routes = store.getRoutes().map(r => ({
|
|
231
|
+
method: r.method,
|
|
232
|
+
path: r.path,
|
|
233
|
+
file: r.file,
|
|
234
|
+
framework: r.framework || '',
|
|
235
|
+
handler: r.handler_name || '',
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
// ── models ───────────────────────────────────────────────────────
|
|
239
|
+
const models = store.getModels().map(m => ({
|
|
240
|
+
name: m.name,
|
|
241
|
+
kind: m.kind,
|
|
242
|
+
file: m.file,
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
anci: anciBlock,
|
|
247
|
+
project,
|
|
248
|
+
domains,
|
|
249
|
+
high_impact,
|
|
250
|
+
routes,
|
|
251
|
+
models,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
MAGIC,
|
|
257
|
+
VERSION,
|
|
258
|
+
HEADER_BYTES,
|
|
259
|
+
ANCI_BIN_FILENAME,
|
|
260
|
+
ANCI_YAML_FILENAME,
|
|
261
|
+
serializeBody,
|
|
262
|
+
deriveBodyFromSidecar,
|
|
263
|
+
buildHeader,
|
|
264
|
+
};
|
package/src/anci/yaml.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal YAML emit + parse for the ANCI v0.1 strict subset.
|
|
5
|
+
*
|
|
6
|
+
* What the strict subset is:
|
|
7
|
+
* - 2-space indentation
|
|
8
|
+
* - `key: value` pairs only (no flow style {} / [])
|
|
9
|
+
* - All string values double-quoted (`"..."`)
|
|
10
|
+
* - Numbers and booleans bare (`123`, `true`, `false`, `null`)
|
|
11
|
+
* - Lists use `- ` prefix at the indent of their parent key, then the
|
|
12
|
+
* item's own content indented one further level.
|
|
13
|
+
* - No multi-line strings, no anchors/aliases/tags, no doc separators.
|
|
14
|
+
*
|
|
15
|
+
* What the subset is NOT:
|
|
16
|
+
* - A general YAML implementation. We hand-roll exactly what ANCI
|
|
17
|
+
* v0.1 needs because pulling `js-yaml` (≈100 KB) for a fixed,
|
|
18
|
+
* well-shaped file is overkill.
|
|
19
|
+
* - Tolerant of arbitrary YAML inputs. Anything outside the subset
|
|
20
|
+
* either parses garbled or throws — which is fine because the
|
|
21
|
+
* reference implementation only emits the subset, and consumers
|
|
22
|
+
* pin to the reference until v1.0.
|
|
23
|
+
*
|
|
24
|
+
* Round-trip property: parse(emit(obj)) deepEquals obj for any object
|
|
25
|
+
* whose leaf values are all string / number / boolean / null and whose
|
|
26
|
+
* shape is plain object | array of plain objects | array of leaves.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// ── EMIT ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function emitScalar(v) {
|
|
32
|
+
if (v === null || v === undefined) return 'null';
|
|
33
|
+
if (typeof v === 'number') {
|
|
34
|
+
if (!Number.isFinite(v)) {
|
|
35
|
+
throw new Error(`yaml.emit: cannot serialize non-finite number: ${v}`);
|
|
36
|
+
}
|
|
37
|
+
return String(v);
|
|
38
|
+
}
|
|
39
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
40
|
+
if (typeof v === 'string') {
|
|
41
|
+
// Double-quoted string. Escape backslash, double-quote, and control
|
|
42
|
+
// chars. Newlines as `\n`, tabs as `\t`. Everything else passes
|
|
43
|
+
// through as UTF-8 since the file is UTF-8.
|
|
44
|
+
let out = '"';
|
|
45
|
+
for (let i = 0; i < v.length; i++) {
|
|
46
|
+
const ch = v.charCodeAt(i);
|
|
47
|
+
const c = v[i];
|
|
48
|
+
if (c === '\\' || c === '"') { out += '\\' + c; }
|
|
49
|
+
else if (ch === 0x0A) { out += '\\n'; }
|
|
50
|
+
else if (ch === 0x0D) { out += '\\r'; }
|
|
51
|
+
else if (ch === 0x09) { out += '\\t'; }
|
|
52
|
+
else if (ch < 0x20) { out += '\\u' + ch.toString(16).padStart(4, '0'); }
|
|
53
|
+
else { out += c; }
|
|
54
|
+
}
|
|
55
|
+
out += '"';
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`yaml.emit: unsupported scalar type: ${typeof v}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isLeaf(v) {
|
|
62
|
+
return v === null || typeof v !== 'object';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emitNode(value, indent) {
|
|
66
|
+
const pad = ' '.repeat(indent);
|
|
67
|
+
const lines = [];
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
if (value.length === 0) {
|
|
71
|
+
// An empty list at top level needs to be expressed somehow. We use
|
|
72
|
+
// YAML flow `[]` for empties only — it's still strict enough that
|
|
73
|
+
// a hand-written parser handles it deterministically.
|
|
74
|
+
// Callers that emit a list under a key write `key: []` directly
|
|
75
|
+
// (see emitObject); this branch is reached only for nested arrays
|
|
76
|
+
// of arrays, which the ANCI schema does not contain.
|
|
77
|
+
lines.push(`${pad}[]`);
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
for (const item of value) {
|
|
81
|
+
if (isLeaf(item)) {
|
|
82
|
+
lines.push(`${pad}- ${emitScalar(item)}`);
|
|
83
|
+
} else if (Array.isArray(item)) {
|
|
84
|
+
throw new Error('yaml.emit: nested arrays are not in the ANCI subset');
|
|
85
|
+
} else {
|
|
86
|
+
// Object item: emit `- key: value` for first key, then indent the
|
|
87
|
+
// rest at indent+1 with two-space prefix to align under the dash.
|
|
88
|
+
const keys = Object.keys(item);
|
|
89
|
+
if (keys.length === 0) {
|
|
90
|
+
lines.push(`${pad}- {}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < keys.length; i++) {
|
|
94
|
+
const k = keys[i];
|
|
95
|
+
const v = item[k];
|
|
96
|
+
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
|
|
97
|
+
if (isLeaf(v)) {
|
|
98
|
+
lines.push(`${prefix}${k}: ${emitScalar(v)}`);
|
|
99
|
+
} else if (Array.isArray(v) && v.length === 0) {
|
|
100
|
+
lines.push(`${prefix}${k}: []`);
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`${prefix}${k}:`);
|
|
103
|
+
const inner = emitNode(v, indent + 2);
|
|
104
|
+
for (const ln of inner) lines.push(ln);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Plain object
|
|
113
|
+
const keys = Object.keys(value);
|
|
114
|
+
for (const k of keys) {
|
|
115
|
+
const v = value[k];
|
|
116
|
+
if (isLeaf(v)) {
|
|
117
|
+
lines.push(`${pad}${k}: ${emitScalar(v)}`);
|
|
118
|
+
} else if (Array.isArray(v) && v.length === 0) {
|
|
119
|
+
lines.push(`${pad}${k}: []`);
|
|
120
|
+
} else {
|
|
121
|
+
lines.push(`${pad}${k}:`);
|
|
122
|
+
const inner = emitNode(v, indent + 1);
|
|
123
|
+
for (const ln of inner) lines.push(ln);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* emit(obj) → string
|
|
131
|
+
*
|
|
132
|
+
* Produces a UTF-8 YAML document conforming to the ANCI v0.1 strict
|
|
133
|
+
* subset. Trailing newline included.
|
|
134
|
+
*/
|
|
135
|
+
function emit(obj) {
|
|
136
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
137
|
+
throw new Error('yaml.emit: top-level value must be a plain object');
|
|
138
|
+
}
|
|
139
|
+
const lines = emitNode(obj, 0);
|
|
140
|
+
return lines.join('\n') + '\n';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── PARSE ──────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function parseScalar(s, ctx) {
|
|
146
|
+
const t = s.trim();
|
|
147
|
+
if (t === '' || t === 'null' || t === '~') return null;
|
|
148
|
+
if (t === 'true') return true;
|
|
149
|
+
if (t === 'false') return false;
|
|
150
|
+
if (t === '[]') return [];
|
|
151
|
+
if (t === '{}') return {};
|
|
152
|
+
// Quoted string
|
|
153
|
+
if (t.length >= 2 && t[0] === '"' && t[t.length - 1] === '"') {
|
|
154
|
+
return parseQuotedString(t, ctx);
|
|
155
|
+
}
|
|
156
|
+
// Number — integer or float
|
|
157
|
+
if (/^-?\d+$/.test(t)) {
|
|
158
|
+
const n = parseInt(t, 10);
|
|
159
|
+
if (Number.isSafeInteger(n)) return n;
|
|
160
|
+
return Number(t); // fall back to float repr for very large ints
|
|
161
|
+
}
|
|
162
|
+
if (/^-?\d+\.\d+([eE][+-]?\d+)?$/.test(t)) return parseFloat(t);
|
|
163
|
+
throw new Error(
|
|
164
|
+
`yaml.parse: line ${ctx.lineNo}: scalar must be quoted, numeric, ` +
|
|
165
|
+
`boolean, or null (got: ${JSON.stringify(t)})`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseQuotedString(s, ctx) {
|
|
170
|
+
// Caller guarantees s starts and ends with ".
|
|
171
|
+
let out = '';
|
|
172
|
+
for (let i = 1; i < s.length - 1; i++) {
|
|
173
|
+
const c = s[i];
|
|
174
|
+
if (c === '\\') {
|
|
175
|
+
const next = s[i + 1];
|
|
176
|
+
if (next === undefined) {
|
|
177
|
+
throw new Error(`yaml.parse: line ${ctx.lineNo}: trailing backslash`);
|
|
178
|
+
}
|
|
179
|
+
if (next === 'n') { out += '\n'; i++; }
|
|
180
|
+
else if (next === 't') { out += '\t'; i++; }
|
|
181
|
+
else if (next === 'r') { out += '\r'; i++; }
|
|
182
|
+
else if (next === '\\') { out += '\\'; i++; }
|
|
183
|
+
else if (next === '"') { out += '"'; i++; }
|
|
184
|
+
else if (next === 'u') {
|
|
185
|
+
const hex = s.substr(i + 2, 4);
|
|
186
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
187
|
+
throw new Error(`yaml.parse: line ${ctx.lineNo}: invalid \\u escape`);
|
|
188
|
+
}
|
|
189
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
190
|
+
i += 5;
|
|
191
|
+
} else {
|
|
192
|
+
throw new Error(`yaml.parse: line ${ctx.lineNo}: bad escape \\${next}`);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
out += c;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function indentOf(line) {
|
|
202
|
+
let i = 0;
|
|
203
|
+
while (i < line.length && line[i] === ' ') i++;
|
|
204
|
+
if (i % 2 !== 0) {
|
|
205
|
+
// ANCI strict subset uses 2-space indent; any odd column is malformed.
|
|
206
|
+
// Exception: the ` ` continuation under a list dash is at indent+2,
|
|
207
|
+
// which is even — so pure 2-space indent always lands on even cols.
|
|
208
|
+
throw new Error(`yaml.parse: indent must be a multiple of 2 (got ${i})`);
|
|
209
|
+
}
|
|
210
|
+
return i / 2;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Tokenize: produce one record per non-empty, non-comment line:
|
|
215
|
+
* { lineNo, indent, kind: 'item' | 'pair' | 'item-with-pair', key?, value? }
|
|
216
|
+
*
|
|
217
|
+
* - 'item' : `- "x"` or `- 42` (plain scalar list item)
|
|
218
|
+
* - 'pair' : `key: value` (object pair; value may be empty)
|
|
219
|
+
* - 'item-with-pair' : `- key: value` (object item; first key)
|
|
220
|
+
*
|
|
221
|
+
* Empty lines and lines with only whitespace are dropped. We do not
|
|
222
|
+
* support YAML comments since ANCI emit never produces them and we
|
|
223
|
+
* do not want to be lenient enough to silently swallow malformed
|
|
224
|
+
* content as a "comment."
|
|
225
|
+
*/
|
|
226
|
+
function tokenize(text) {
|
|
227
|
+
const out = [];
|
|
228
|
+
const lines = text.split(/\r?\n/);
|
|
229
|
+
for (let i = 0; i < lines.length; i++) {
|
|
230
|
+
const raw = lines[i];
|
|
231
|
+
if (/^\s*$/.test(raw)) continue;
|
|
232
|
+
// Strip inline comments? The strict emit never produces them. We
|
|
233
|
+
// refuse silently-tolerated comments to keep the parser deterministic.
|
|
234
|
+
const indent = indentOf(raw);
|
|
235
|
+
const body = raw.slice(indent * 2);
|
|
236
|
+
const lineNo = i + 1;
|
|
237
|
+
|
|
238
|
+
if (body.startsWith('- ')) {
|
|
239
|
+
const after = body.slice(2);
|
|
240
|
+
// List item: either `- scalar` or `- key: value`
|
|
241
|
+
const firstColon = findUnquotedColon(after);
|
|
242
|
+
if (firstColon >= 0) {
|
|
243
|
+
const key = after.slice(0, firstColon).trim();
|
|
244
|
+
const value = after.slice(firstColon + 1).trim();
|
|
245
|
+
out.push({ lineNo, indent, kind: 'item-with-pair', key, value });
|
|
246
|
+
} else {
|
|
247
|
+
out.push({ lineNo, indent, kind: 'item', value: after.trim() });
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const firstColon = findUnquotedColon(body);
|
|
251
|
+
if (firstColon < 0) {
|
|
252
|
+
throw new Error(`yaml.parse: line ${lineNo}: expected 'key: value' or '- ...'`);
|
|
253
|
+
}
|
|
254
|
+
const key = body.slice(0, firstColon).trim();
|
|
255
|
+
const value = body.slice(firstColon + 1).trim();
|
|
256
|
+
out.push({ lineNo, indent, kind: 'pair', key, value });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* findUnquotedColon — return the index of the first `:` that is not
|
|
264
|
+
* inside a double-quoted string, or -1 if none.
|
|
265
|
+
*
|
|
266
|
+
* Required because key names are bare but values may be quoted strings
|
|
267
|
+
* that themselves contain colons (e.g. `path: "https://example.com:8080"`).
|
|
268
|
+
*/
|
|
269
|
+
function findUnquotedColon(s) {
|
|
270
|
+
let inQuote = false;
|
|
271
|
+
for (let i = 0; i < s.length; i++) {
|
|
272
|
+
const c = s[i];
|
|
273
|
+
if (c === '\\' && inQuote) { i++; continue; }
|
|
274
|
+
if (c === '"') { inQuote = !inQuote; continue; }
|
|
275
|
+
if (c === ':' && !inQuote) return i;
|
|
276
|
+
}
|
|
277
|
+
return -1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Parse a token stream starting at `start` at indent level `level`.
|
|
282
|
+
* Returns [value, nextIndex]. Used recursively by parseObject and
|
|
283
|
+
* parseList.
|
|
284
|
+
*
|
|
285
|
+
* Decision rule: peek at the first token at or after `start` whose
|
|
286
|
+
* indent === level. If it's a 'pair', we're parsing an object. If
|
|
287
|
+
* it's an 'item' or 'item-with-pair', we're parsing an array.
|
|
288
|
+
*/
|
|
289
|
+
function parseValueAtLevel(tokens, start, level) {
|
|
290
|
+
if (start >= tokens.length || tokens[start].indent !== level) {
|
|
291
|
+
// Empty container — caller should have handled this case.
|
|
292
|
+
return [null, start];
|
|
293
|
+
}
|
|
294
|
+
const first = tokens[start];
|
|
295
|
+
if (first.kind === 'pair') return parseObject(tokens, start, level);
|
|
296
|
+
return parseList(tokens, start, level);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function parseObject(tokens, start, level) {
|
|
300
|
+
const obj = {};
|
|
301
|
+
let i = start;
|
|
302
|
+
while (i < tokens.length && tokens[i].indent === level) {
|
|
303
|
+
const t = tokens[i];
|
|
304
|
+
if (t.kind !== 'pair') {
|
|
305
|
+
throw new Error(`yaml.parse: line ${t.lineNo}: expected key:value at indent ${level}`);
|
|
306
|
+
}
|
|
307
|
+
if (t.value === '') {
|
|
308
|
+
// Nested container under this key.
|
|
309
|
+
const childLevel = level + 1;
|
|
310
|
+
if (i + 1 >= tokens.length || tokens[i + 1].indent !== childLevel) {
|
|
311
|
+
// Empty nested container — represent as null.
|
|
312
|
+
obj[t.key] = null;
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const [val, next] = parseValueAtLevel(tokens, i + 1, childLevel);
|
|
317
|
+
obj[t.key] = val;
|
|
318
|
+
i = next;
|
|
319
|
+
} else {
|
|
320
|
+
obj[t.key] = parseScalar(t.value, t);
|
|
321
|
+
i++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return [obj, i];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseList(tokens, start, level) {
|
|
328
|
+
const list = [];
|
|
329
|
+
let i = start;
|
|
330
|
+
while (i < tokens.length && tokens[i].indent === level) {
|
|
331
|
+
const t = tokens[i];
|
|
332
|
+
if (t.kind === 'item') {
|
|
333
|
+
list.push(parseScalar(t.value, t));
|
|
334
|
+
i++;
|
|
335
|
+
} else if (t.kind === 'item-with-pair') {
|
|
336
|
+
// Object item. Collect the first pair, then keep absorbing pairs
|
|
337
|
+
// at indent === level + 1 (the `- ` plus 2-space continuation).
|
|
338
|
+
const item = {};
|
|
339
|
+
if (t.value === '') {
|
|
340
|
+
// `- key:` — value is a nested container at level + 2
|
|
341
|
+
const childLevel = level + 2;
|
|
342
|
+
if (i + 1 < tokens.length && tokens[i + 1].indent === childLevel) {
|
|
343
|
+
const [val, next] = parseValueAtLevel(tokens, i + 1, childLevel);
|
|
344
|
+
item[t.key] = val;
|
|
345
|
+
i = next;
|
|
346
|
+
} else {
|
|
347
|
+
item[t.key] = null;
|
|
348
|
+
i++;
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
item[t.key] = parseScalar(t.value, t);
|
|
352
|
+
i++;
|
|
353
|
+
}
|
|
354
|
+
// Absorb subsequent pair lines at level + 1 (continuation lines).
|
|
355
|
+
while (i < tokens.length && tokens[i].indent === level + 1 && tokens[i].kind === 'pair') {
|
|
356
|
+
const cp = tokens[i];
|
|
357
|
+
if (cp.value === '') {
|
|
358
|
+
const childLevel = level + 2;
|
|
359
|
+
if (i + 1 < tokens.length && tokens[i + 1].indent === childLevel) {
|
|
360
|
+
const [val, next] = parseValueAtLevel(tokens, i + 1, childLevel);
|
|
361
|
+
item[cp.key] = val;
|
|
362
|
+
i = next;
|
|
363
|
+
} else {
|
|
364
|
+
item[cp.key] = null;
|
|
365
|
+
i++;
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
item[cp.key] = parseScalar(cp.value, cp);
|
|
369
|
+
i++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
list.push(item);
|
|
373
|
+
} else {
|
|
374
|
+
// A 'pair' at this level inside what we thought was a list — bail.
|
|
375
|
+
throw new Error(`yaml.parse: line ${t.lineNo}: unexpected pair inside list at indent ${level}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return [list, i];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* parse(text) → object
|
|
383
|
+
*
|
|
384
|
+
* Parses a YAML document conforming to the ANCI v0.1 strict subset.
|
|
385
|
+
* Throws on any input outside the subset (no silent tolerance — see
|
|
386
|
+
* file header for rationale).
|
|
387
|
+
*/
|
|
388
|
+
function parse(text) {
|
|
389
|
+
const tokens = tokenize(text);
|
|
390
|
+
if (tokens.length === 0) return {};
|
|
391
|
+
if (tokens[0].indent !== 0) {
|
|
392
|
+
throw new Error(`yaml.parse: line ${tokens[0].lineNo}: first token must be at indent 0`);
|
|
393
|
+
}
|
|
394
|
+
const [val, next] = parseValueAtLevel(tokens, 0, 0);
|
|
395
|
+
if (next !== tokens.length) {
|
|
396
|
+
throw new Error(`yaml.parse: line ${tokens[next].lineNo}: unexpected trailing content`);
|
|
397
|
+
}
|
|
398
|
+
return val == null ? {} : val;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
module.exports = { emit, parse };
|