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,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
+ };
@@ -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 };