@skanl/brambo-projection 0.1.1

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.
@@ -0,0 +1,1301 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { BramboError, BRAMBO_ERROR_CODES, REGISTRY_ENTRY_TYPES, UNPROJECTABLE_ENTRY_IDS, isRecord, } from '@skanl/brambo-contracts';
3
+ import { parse, parseTree } from 'jsonc-parser';
4
+ import { FAULT_UNLOCATED, faultDetail, positionOf, strictFaultLocation } from './document-fault.js';
5
+ import { hashOwnedText, resolveOwnedPath, sameOwnedPath } from './ledger.js';
6
+ /**
7
+ * The keys THIS vendor's renderer emits, which are exactly the keys its reader
8
+ * consumes — asked of the renderer rather than written down beside it.
9
+ *
10
+ * A hand-written list here was a THIRD spelling of the same fact, and a key
11
+ * added to a renderer and forgotten in that list would be reported to the user
12
+ * as `dropped` while brambo was writing it. The sample is arbitrary: every
13
+ * renderer emits a fixed key set, which `vendor-conformance.test.ts` pins.
14
+ */
15
+ export function renderedKeys(traits) {
16
+ return Object.keys(traits.renderMcpEntry({ id: 'sample', command: 'sample', args: ['sample'] }));
17
+ }
18
+ /**
19
+ * The native keys the renderer does not emit, sorted so two runs report the same
20
+ * list. `REGISTRY_PATH_FIELDS` gives an `mcp-server` a command and its arguments
21
+ * and nothing else, so a vendor's `env` table or a `url` has no root field to
22
+ * land in — and D10 says those are REPORTED, never silently lost.
23
+ */
24
+ function droppedNativeKeys(native, rendered) {
25
+ return Object.keys(native)
26
+ .filter((key) => !rendered.includes(key))
27
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
28
+ }
29
+ /**
30
+ * The reading Claude Code and Codex SHARE: a `command` string beside an optional
31
+ * `args` array of strings.
32
+ *
33
+ * OpenCode deliberately does NOT use it. Its `command` is the whole argv, and
34
+ * D1 keeps that un-join in `opencode-config.ts` alone; what is shared here is
35
+ * only the vocabulary the other two already spell identically, so the sentence a
36
+ * user reads for a missing command cannot differ between two vendors that failed
37
+ * the same way.
38
+ */
39
+ export function readNativeCommand(native) {
40
+ const command = native['command'];
41
+ if (typeof command !== 'string' || command === '') {
42
+ return {
43
+ ok: false,
44
+ detail: command === undefined
45
+ ? "it declares no 'command', so there is nothing for brambo to run"
46
+ : "'command' is not a non-empty string, so there is nothing for brambo to run",
47
+ };
48
+ }
49
+ const args = native['args'];
50
+ if (args !== undefined && typeof args === 'string') {
51
+ return { ok: false, detail: "'args' is a string rather than an array, and brambo will not guess how to split it" };
52
+ }
53
+ return { ok: true, command, args: args ?? [] };
54
+ }
55
+ // --- shared text helpers ----------------------------------------------------
56
+ const BYTE_ORDER_MARK = '\uFEFF';
57
+ const FALLBACK_INDENT_UNIT = ' ';
58
+ function isSpace(character) {
59
+ return character === ' ' || character === '\t' || character === '\r' || character === '\n';
60
+ }
61
+ function lineEndingOf(bodyText) {
62
+ return bodyText.includes('\r\n') ? '\r\n' : '\n';
63
+ }
64
+ /** Leading whitespace of the FIRST indented content line; fallback two spaces. */
65
+ function indentationUnitOf(bodyText) {
66
+ for (const line of bodyText.split('\n')) {
67
+ const indented = /^[ \t]+(?=\S)/.exec(line);
68
+ if (indented)
69
+ return indented[0];
70
+ }
71
+ return FALLBACK_INDENT_UNIT;
72
+ }
73
+ function leadingWhitespaceBefore(text, offset) {
74
+ let start = offset;
75
+ while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
76
+ start -= 1;
77
+ return text.slice(start, offset);
78
+ }
79
+ function lineIndentAt(text, offset) {
80
+ const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
81
+ return /^[ \t]*/.exec(text.slice(lineStart, offset))[0];
82
+ }
83
+ function indentLevelOf(whitespace, unit) {
84
+ let level = 0;
85
+ let rest = whitespace;
86
+ while (rest.startsWith(unit)) {
87
+ rest = rest.slice(unit.length);
88
+ level += 1;
89
+ }
90
+ return level;
91
+ }
92
+ /**
93
+ * `detail` is a string brambo AUTHORS, never a parser's message and never a
94
+ * `cause`. `document-fault.ts` holds the rule and why it exists; the `string`
95
+ * parameter is what enforces it, because an `Error` can no longer be handed in
96
+ * at all.
97
+ */
98
+ function nativeMalformed(filePath, detail) {
99
+ return new BramboError(BRAMBO_ERROR_CODES.projectionNativeMalformed, `native config file '${filePath}' is malformed: ${detail}`);
100
+ }
101
+ function nativeUnclaimable(filePath, detail) {
102
+ return new BramboError(BRAMBO_ERROR_CODES.projectionNativeUnclaimable, `native config file '${filePath}' is intact but brambo cannot place entries there: ${detail}`);
103
+ }
104
+ /** Sorted-key JSON: two renderings of the same entry hash the same. */
105
+ function stableJson(value) {
106
+ if (Array.isArray(value))
107
+ return `[${value.map(stableJson).join(',')}]`;
108
+ if (isRecord(value)) {
109
+ return `{${Object.keys(value)
110
+ .sort()
111
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
112
+ .join(',')}}`;
113
+ }
114
+ return JSON.stringify(value) ?? 'null';
115
+ }
116
+ // --- JSON family: native key-path splice ------------------------------------
117
+ function memberProperties(objectNode, key) {
118
+ return (objectNode.children ?? []).filter((property) => property.children?.[0]?.value === key);
119
+ }
120
+ function memberValue(objectNode, key) {
121
+ return memberProperties(objectNode, key)[0]?.children?.[1];
122
+ }
123
+ function objectRootOf(body, filePath, strictJson) {
124
+ if (strictJson) {
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(body);
128
+ }
129
+ catch {
130
+ // The error is DISCARDED unread. See `nativeMalformed`: for the shapes
131
+ // that quote the document there is no position in the message to keep.
132
+ throw nativeMalformed(filePath, strictFaultLocation(body));
133
+ }
134
+ if (!isRecord(parsed))
135
+ throw nativeMalformed(filePath, 'document root is not an object');
136
+ // Strict JSON already validated above; the tree exists by construction.
137
+ return parseTree(body);
138
+ }
139
+ // Errors are COLLECTED, and trailing commas are allowed while collecting.
140
+ // `parseTree` recovers: handed a broken document it returns a tree built from
141
+ // a guess, and brambo splices by OFFSET into whatever it returns. Without this
142
+ // out-param a file whose only fault is an unquoted key parsed as an object and
143
+ // brambo wrote its own block INSIDE one of the user's own server definitions.
144
+ //
145
+ // `allowTrailingComma` is not decoration. A trailing comma is legitimate JSONC
146
+ // that every JSONC-tolerant vendor accepts, and WITHOUT the option it reports
147
+ // the same `PropertyNameExpected` a genuinely doubled comma does — so
148
+ // refusing on any error would reject working files. With it, every legitimate
149
+ // spelling (comments, trailing commas, nested and in arrays) collects zero and
150
+ // every real fault collects at least one. `canonical()` below already parses
151
+ // this way; this is the same answer given at both doors.
152
+ const errors = [];
153
+ let root;
154
+ try {
155
+ root = parseTree(body, errors, { allowTrailingComma: true });
156
+ }
157
+ catch {
158
+ // `parseTree` RECURSES and throws `RangeError` past ~5000 nesting levels
159
+ // (Spec M17.A, Change Log 2). Refusing coded without a location is the
160
+ // documented outcome; propagating an uncoded throw is not.
161
+ throw nativeMalformed(filePath, FAULT_UNLOCATED);
162
+ }
163
+ const first = errors[0];
164
+ if (first !== undefined) {
165
+ // The FIRST only. A recovering parser cascades — an unquoted key reports
166
+ // four — and the rest are that one's shadow.
167
+ //
168
+ // The parser's OWN code (`InvalidSymbol`, `PropertyNameExpected`), not prose
169
+ // brambo invents for it. It is terser than a sentence and it is stable,
170
+ // greppable, and the same word the user's editor and every other
171
+ // jsonc-parser consumer already shows them for that fault.
172
+ throw nativeMalformed(filePath, faultDetail(body, first));
173
+ }
174
+ if (!root || root.type !== 'object') {
175
+ throw nativeMalformed(filePath, 'document root is not an object');
176
+ }
177
+ return root;
178
+ }
179
+ /**
180
+ * Deterministic serializer for the VENDOR-shaped entry only — fixed key order
181
+ * (the trait renderer's insertion order), arrays one element per line. This is
182
+ * not a general JSON writer: it exists so the same entry always renders to the
183
+ * same bytes.
184
+ */
185
+ function serializeJsonValue(value, level, unit, eol) {
186
+ const at = (depth) => unit.repeat(depth);
187
+ if (typeof value === 'string')
188
+ return JSON.stringify(value);
189
+ if (Array.isArray(value)) {
190
+ if (value.length === 0)
191
+ return '[]';
192
+ const inner = value
193
+ .map((item) => `${at(level + 1)}${serializeJsonValue(item, level + 1, unit, eol)}`)
194
+ .join(`,${eol}`);
195
+ return `[${eol}${inner}${eol}${at(level)}]`;
196
+ }
197
+ if (!isRecord(value)) {
198
+ // Unreachable for a NativeEntryShape; a trait renderer returning anything
199
+ // else must fail coded instead of emitting invalid JSON.
200
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionTraitsInvalid, `native entry shape contains a value that cannot be serialized: ${JSON.stringify(value)}`);
201
+ }
202
+ const entries = Object.entries(value);
203
+ if (entries.length === 0)
204
+ return '{}';
205
+ const inner = entries
206
+ .map(([key, child]) => `${at(level + 1)}${JSON.stringify(key)}: ${serializeJsonValue(child, level + 1, unit, eol)}`)
207
+ .join(`,${eol}`);
208
+ return `{${eol}${inner}${eol}${at(level)}}`;
209
+ }
210
+ /** The member text WITHOUT its leading indent, matching what parseTree spans. */
211
+ function renderJsonMember(id, shape, level, style) {
212
+ return `${JSON.stringify(id)}: ${serializeJsonValue(shape, level, style.unit, style.eol)}`;
213
+ }
214
+ /**
215
+ * Brambo's entry lines up with the container's EXISTING members; only an empty
216
+ * container falls back to one unit in from its own line. Rendering root members
217
+ * at column 0 inside a two-space document is how the previous build produced
218
+ * output no formatter would leave alone.
219
+ */
220
+ function memberLevelOf(body, objectNode, unit) {
221
+ const first = objectNode.children?.[0];
222
+ if (first !== undefined)
223
+ return indentLevelOf(leadingWhitespaceBefore(body, first.offset), unit);
224
+ if (objectNode.parent === undefined)
225
+ return 0;
226
+ return indentLevelOf(lineIndentAt(body, objectNode.offset), unit) + 1;
227
+ }
228
+ /**
229
+ * Insertion is purely ADDITIVE: characters are added at exactly one point and
230
+ * no existing byte is rewritten. With a trailing comma after the last property
231
+ * we insert right after it; otherwise we contribute the separator comma
232
+ * ourselves. An empty object owns its interior wholesale.
233
+ */
234
+ function insertIntoObject(body, objectNode, memberText, style, level) {
235
+ const indent = style.unit.repeat(level);
236
+ const properties = objectNode.children ?? [];
237
+ if (properties.length === 0) {
238
+ // An empty object owns its interior wholesale, and closes on the
239
+ // container's own line so nothing reformats it on the next save.
240
+ const at = objectNode.offset + 1;
241
+ const closingIndent = style.unit.repeat(Math.max(0, level - 1));
242
+ return {
243
+ start: at,
244
+ end: at,
245
+ replacement: `${style.eol}${indent}${memberText}${style.eol}${closingIndent}`,
246
+ };
247
+ }
248
+ const last = properties[properties.length - 1];
249
+ const lastEnd = last.offset + last.length;
250
+ for (let index = lastEnd; index < body.length; index += 1) {
251
+ const character = body[index];
252
+ if (character === ',') {
253
+ return { start: index + 1, end: index + 1, replacement: `${style.eol}${indent}${memberText}` };
254
+ }
255
+ if (!isSpace(character))
256
+ break;
257
+ }
258
+ return { start: lastEnd, end: lastEnd, replacement: `,${style.eol}${indent}${memberText}` };
259
+ }
260
+ /**
261
+ * Symmetric with insertion: takes back the separator comma and the whitespace
262
+ * brambo's own line introduced, so removal cannot leave a dangling comma that
263
+ * strict JSON would reject.
264
+ */
265
+ function jsonRemovalSpan(body, existing) {
266
+ let end = existing.end;
267
+ let scan = end;
268
+ while (isSpace(body[scan]))
269
+ scan += 1;
270
+ const hasTrailingComma = body[scan] === ',';
271
+ if (hasTrailingComma)
272
+ end = scan + 1;
273
+ let start = existing.start;
274
+ while (start > 0 && isSpace(body[start - 1]))
275
+ start -= 1;
276
+ if (!hasTrailingComma && body[start - 1] === ',')
277
+ start -= 1;
278
+ if (body[start - 1] === '{') {
279
+ // We were the object's only member. Inserting into an empty object adds
280
+ // whitespace on BOTH sides, so emptying one has to take both back —
281
+ // otherwise every rename leaves another blank line behind.
282
+ let closing = end;
283
+ while (isSpace(body[closing]))
284
+ closing += 1;
285
+ if (body[closing] === '}')
286
+ end = closing;
287
+ }
288
+ return { start, end };
289
+ }
290
+ /**
291
+ * A JSON node as a {@link NativeEntryShape} value, or `undefined` when it is
292
+ * neither a string nor an array of them.
293
+ *
294
+ * `undefined` is NOT "absent": it is "brambo has no way to carry this", which the
295
+ * caller turns into a reported foreign key. A number, a boolean, a nested object
296
+ * and a mixed array all land here.
297
+ */
298
+ function nativeValueOf(node) {
299
+ if (node === undefined)
300
+ return undefined;
301
+ if (node.type === 'string')
302
+ return node.value;
303
+ if (node.type !== 'array')
304
+ return undefined;
305
+ const items = node.children ?? [];
306
+ if (!items.every((item) => item.type === 'string'))
307
+ return undefined;
308
+ return items.map((item) => item.value);
309
+ }
310
+ const JSONC_STRATEGY = {
311
+ validate(body, filePath, traits) {
312
+ const root = objectRootOf(body, filePath, traits.strictJson ?? false);
313
+ if (memberProperties(root, traits.mcpContainerKey).length > 1) {
314
+ throw nativeMalformed(filePath, `document declares more than one '${traits.mcpContainerKey}' key`);
315
+ }
316
+ const container = memberValue(root, traits.mcpContainerKey);
317
+ if (container !== undefined && container.type !== 'object') {
318
+ // The vendor's own key holding something brambo cannot place entries in.
319
+ // The FILE is fine — telling the user it is malformed would be a lie.
320
+ throw nativeUnclaimable(filePath, `'${traits.mcpContainerKey}' holds a ${container.type}, not an object of servers`);
321
+ }
322
+ },
323
+ containerConflict() {
324
+ // JSON has no ambiguous container spelling: validate() already rejected the
325
+ // two shapes that exist (duplicate key, non-object value).
326
+ return undefined;
327
+ },
328
+ entryConflict(body, traits, id) {
329
+ const root = parseTree(body);
330
+ const container = root === undefined ? undefined : memberValue(root, traits.mcpContainerKey);
331
+ if (container === undefined || container.type !== 'object')
332
+ return undefined;
333
+ if (memberProperties(container, id).length > 1) {
334
+ return `'${traits.mcpContainerKey}.${id}' is declared more than once; brambo would edit the first while every vendor reads the last`;
335
+ }
336
+ return undefined;
337
+ },
338
+ locate(body, traits, id) {
339
+ const root = parseTree(body);
340
+ if (!root || root.type !== 'object')
341
+ return undefined;
342
+ const container = memberValue(root, traits.mcpContainerKey);
343
+ if (container === undefined || container.type !== 'object')
344
+ return undefined;
345
+ const member = memberProperties(container, id)[0];
346
+ return member === undefined ? undefined : { start: member.offset, end: member.offset + member.length };
347
+ },
348
+ listEntries(body, traits) {
349
+ const root = parseTree(body);
350
+ const container = root === undefined || root.type !== 'object' ? undefined : memberValue(root, traits.mcpContainerKey);
351
+ // An absent container is E2 — a config brambo writes into that holds no
352
+ // servers yet — and is no more an error on the way in than on the way out.
353
+ if (container === undefined || container.type !== 'object')
354
+ return { entries: [], unreadable: [] };
355
+ const entries = [];
356
+ const unreadable = [];
357
+ for (const property of container.children ?? []) {
358
+ const id = property.children?.[0]?.value;
359
+ if (typeof id !== 'string')
360
+ continue;
361
+ const value = property.children?.[1];
362
+ if (value === undefined || value.type !== 'object') {
363
+ unreadable.push({
364
+ id,
365
+ detail: `'${traits.mcpContainerKey}.${id}' holds a ${value?.type ?? 'nothing'} rather than an object, so brambo cannot read a command out of it`,
366
+ });
367
+ continue;
368
+ }
369
+ const native = {};
370
+ const foreignKeys = [];
371
+ for (const member of value.children ?? []) {
372
+ const key = member.children?.[0]?.value;
373
+ if (typeof key !== 'string')
374
+ continue;
375
+ const read = nativeValueOf(member.children?.[1]);
376
+ if (read === undefined)
377
+ foreignKeys.push(key);
378
+ else
379
+ native[key] = read;
380
+ }
381
+ entries.push({ id, native, foreignKeys });
382
+ }
383
+ return { entries, unreadable };
384
+ },
385
+ canonical(ownedText) {
386
+ // The owned text is one object MEMBER; wrapping it makes a document jsonc
387
+ // can parse. A member that no longer parses has been edited beyond
388
+ // recognition, and its raw text is the honest canonical form.
389
+ const parsed = parse(`{${ownedText}}`, [], { allowTrailingComma: true });
390
+ return isRecord(parsed) ? stableJson(parsed) : ownedText.trim();
391
+ },
392
+ upsert(body, style, traits, entry, existing) {
393
+ // validate() ran first, so the root is an object and the container is
394
+ // either absent or an object.
395
+ const root = parseTree(body);
396
+ if (existing !== undefined) {
397
+ const level = indentLevelOf(leadingWhitespaceBefore(body, existing.start), style.unit);
398
+ const owned = renderJsonMember(entry.id, traits.renderMcpEntry(entry), level, style);
399
+ return { start: existing.start, end: existing.end, replacement: owned, owned };
400
+ }
401
+ const container = memberValue(root, traits.mcpContainerKey);
402
+ if (container !== undefined) {
403
+ const level = memberLevelOf(body, container, style.unit);
404
+ const owned = renderJsonMember(entry.id, traits.renderMcpEntry(entry), level, style);
405
+ return { ...insertIntoObject(body, container, owned, style, level), owned };
406
+ }
407
+ // The vendor's container does not exist yet: brambo creates it holding this
408
+ // one entry, indented like the document's own root members so nothing
409
+ // reformats it on the next save.
410
+ const containerLevel = memberLevelOf(body, root, style.unit);
411
+ const owned = renderJsonMember(entry.id, traits.renderMcpEntry(entry), containerLevel + 1, style);
412
+ const containerMember = `${JSON.stringify(traits.mcpContainerKey)}: {${style.eol}${style.unit.repeat(containerLevel + 1)}${owned}${style.eol}${style.unit.repeat(containerLevel)}}`;
413
+ return { ...insertIntoObject(body, root, containerMember, style, containerLevel), owned };
414
+ },
415
+ remove(body, _style, existing) {
416
+ return jsonRemovalSpan(body, existing);
417
+ },
418
+ reclaimContainer(body, traits) {
419
+ const root = parseTree(body);
420
+ if (!root || root.type !== 'object')
421
+ return undefined;
422
+ const property = memberProperties(root, traits.mcpContainerKey)[0];
423
+ const container = property?.children?.[1];
424
+ if (property === undefined || container === undefined || container.type !== 'object')
425
+ return undefined;
426
+ if ((container.children ?? []).length > 0)
427
+ return undefined;
428
+ // ponytail: brambo cannot tell a container it created from an empty one the
429
+ // user left behind — but for all three vendors an empty container and an
430
+ // absent one are the same configuration, so reclaiming it is semantically
431
+ // free and stops renames from accreting dead scaffolding.
432
+ return jsonRemovalSpan(body, { start: property.offset, end: property.offset + property.length });
433
+ },
434
+ };
435
+ // --- TOML family: one native table per entry --------------------------------
436
+ function tomlBareKey(key) {
437
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
438
+ }
439
+ function tomlValue(value) {
440
+ if (typeof value === 'string')
441
+ return JSON.stringify(value);
442
+ return `[${value.map((item) => JSON.stringify(item)).join(', ')}]`;
443
+ }
444
+ /**
445
+ * Splits a TOML key path into its decoded segments, tolerating the spellings
446
+ * that mean the same key: bare, basic-quoted, literal-quoted, and whitespace
447
+ * around the dots. This is key canonicalisation, NOT a TOML parser — anything
448
+ * it cannot decode returns undefined and is treated as unrecognised.
449
+ */
450
+ function splitTomlKeyPath(raw) {
451
+ const segments = [];
452
+ let rest = raw.trim();
453
+ if (rest === '')
454
+ return undefined;
455
+ for (;;) {
456
+ const basic = /^"((?:[^"\\]|\\.)*)"/.exec(rest);
457
+ const literal = /^'([^']*)'/.exec(rest);
458
+ const bare = /^[A-Za-z0-9_-]+/.exec(rest);
459
+ if (basic) {
460
+ const decoded = JSON.parse(basic[0]);
461
+ segments.push(String(decoded));
462
+ rest = rest.slice(basic[0].length);
463
+ }
464
+ else if (literal) {
465
+ segments.push(literal[1]);
466
+ rest = rest.slice(literal[0].length);
467
+ }
468
+ else if (bare) {
469
+ segments.push(bare[0]);
470
+ rest = rest.slice(bare[0].length);
471
+ }
472
+ else {
473
+ return undefined;
474
+ }
475
+ rest = rest.trimStart();
476
+ if (rest === '')
477
+ return segments;
478
+ if (!rest.startsWith('.'))
479
+ return undefined;
480
+ rest = rest.slice(1).trimStart();
481
+ }
482
+ }
483
+ /** The decoded path of a `[a.b]` header line, tolerating spacing and comments. */
484
+ function tomlHeaderPath(line) {
485
+ const match = /^\s*\[([^[\]]*)\]\s*(?:#.*)?$/.exec(line);
486
+ return match === undefined || match === null ? undefined : splitTomlKeyPath(match[1]);
487
+ }
488
+ /** The decoded path on the left of a `key = value` line, if it is one. */
489
+ function tomlAssignmentPath(line) {
490
+ const match = /^\s*([^=#]+?)\s*=/.exec(line);
491
+ return match === null ? undefined : splitTomlKeyPath(match[1]);
492
+ }
493
+ function startsTable(lineText) {
494
+ return lineText.trimStart().startsWith('[');
495
+ }
496
+ function tomlHeader(traits, id) {
497
+ return `[${traits.mcpContainerKey}.${tomlBareKey(id)}]`;
498
+ }
499
+ function renderTomlTable(traits, entry, style) {
500
+ const lines = [tomlHeader(traits, entry.id)];
501
+ for (const [key, value] of Object.entries(traits.renderMcpEntry(entry))) {
502
+ lines.push(`${key} = ${tomlValue(value)}`);
503
+ }
504
+ return lines.join(style.eol) + style.eol;
505
+ }
506
+ function splitLines(body) {
507
+ const lines = [];
508
+ let start = 0;
509
+ while (start <= body.length) {
510
+ const newline = body.indexOf('\n', start);
511
+ if (newline < 0) {
512
+ if (start < body.length)
513
+ lines.push({ start, end: body.length, text: body.slice(start) });
514
+ break;
515
+ }
516
+ lines.push({ start, end: newline + 1, text: body.slice(start, newline) });
517
+ start = newline + 1;
518
+ }
519
+ return lines;
520
+ }
521
+ /** Indices of the header lines that define `[<container>.<id>]`, any spelling. */
522
+ function tomlEntryHeaders(lines, container, id) {
523
+ const found = [];
524
+ lines.forEach((line, index) => {
525
+ const path = tomlHeaderPath(line.text);
526
+ if (path?.length === 2 && path[0] === container && path[1] === id)
527
+ found.push(index);
528
+ });
529
+ return found;
530
+ }
531
+ const TOML_STRATEGY = {
532
+ validate() {
533
+ // Foreign TOML is never parsed, so malformed foreign TOML is undetectable
534
+ // here by design. Every shape brambo MUST notice is a container conflict,
535
+ // which fails closed below instead of relying on a parse.
536
+ },
537
+ /**
538
+ * The load-bearing half of "not found is not free". Every one of these
539
+ * spellings defines servers brambo cannot address; appending its own table
540
+ * anyway would define the same table twice, which stops the user's entire
541
+ * config.toml from loading in DEFAULT mode — the exact catastrophe
542
+ * correction-01 exists to eliminate.
543
+ */
544
+ containerConflict(body, traits) {
545
+ let inRootTable = true;
546
+ for (const line of splitLines(body)) {
547
+ const header = tomlHeaderPath(line.text);
548
+ if (header !== undefined) {
549
+ inRootTable = false;
550
+ if (header.length === 1 && header[0] === traits.mcpContainerKey) {
551
+ return `'[${traits.mcpContainerKey}]' is defined as a table, so its servers are keys brambo cannot address individually`;
552
+ }
553
+ continue;
554
+ }
555
+ if (startsTable(line.text)) {
556
+ // An array-of-tables header or anything else bracketed: no longer root.
557
+ inRootTable = false;
558
+ continue;
559
+ }
560
+ if (!inRootTable)
561
+ continue;
562
+ const assignment = tomlAssignmentPath(line.text);
563
+ if (assignment !== undefined && assignment[0] === traits.mcpContainerKey) {
564
+ return `'${assignment.join('.')}' is assigned directly, so '${traits.mcpContainerKey}' is not a set of tables brambo can add to`;
565
+ }
566
+ }
567
+ return undefined;
568
+ },
569
+ entryConflict(body, traits, id) {
570
+ const headers = tomlEntryHeaders(splitLines(body), traits.mcpContainerKey, id);
571
+ return headers.length > 1
572
+ ? `'${traits.mcpContainerKey}.${id}' is defined ${headers.length} times; the document does not load as it is and brambo will not add to it`
573
+ : undefined;
574
+ },
575
+ /**
576
+ * ponytail: line-oriented table locator, not a TOML parser. Brambo renders
577
+ * every value on one line, so no line inside a table brambo wrote can start
578
+ * with '[' — a user edit that introduces one ends the region early, the hash
579
+ * stops matching, and the entry lands as 'edited' drift. That is the safe
580
+ * direction: brambo reports instead of rewriting.
581
+ */
582
+ locate(body, traits, id) {
583
+ const lines = splitLines(body);
584
+ const headerIndex = tomlEntryHeaders(lines, traits.mcpContainerKey, id)[0];
585
+ if (headerIndex === undefined)
586
+ return undefined;
587
+ let end = lines[headerIndex].end;
588
+ for (let index = headerIndex + 1; index < lines.length; index += 1) {
589
+ const line = lines[index];
590
+ if (startsTable(line.text))
591
+ break;
592
+ // Trailing blank lines after the table are foreign spacing, not ours.
593
+ if (line.text.trim() !== '')
594
+ end = line.end;
595
+ }
596
+ return { start: lines[headerIndex].start, end };
597
+ },
598
+ /**
599
+ * ponytail: LINE-ORIENTED, exactly like `locate` above and for the same
600
+ * reason — this is not a TOML parser and does not become one to read. Brambo
601
+ * renders every value on one line with `JSON.stringify`, so `JSON.parse` on
602
+ * the value text is that renderer's exact inverse, and anything it cannot
603
+ * parse is REPORTED as unreadable rather than guessed at: a TOML literal
604
+ * string (`command = 'uvx'`), a multi-line array, a trailing comment. Ceiling:
605
+ * brambo ingests only entries spelled the way brambo writes them. Upgrade path:
606
+ * a real TOML parser, worth it the first time a user reports a legitimate
607
+ * server brambo declined to read.
608
+ */
609
+ listEntries(body, traits) {
610
+ const lines = splitLines(body);
611
+ const byId = new Map();
612
+ const unreadable = [];
613
+ const order = [];
614
+ lines.forEach((line, index) => {
615
+ const path = tomlHeaderPath(line.text);
616
+ if (path === undefined || path.length !== 2 || path[0] !== traits.mcpContainerKey)
617
+ return;
618
+ const id = path[1];
619
+ // A second `[<container>.<id>]` is the document's own ambiguity, and
620
+ // `entryConflict` is what names it; reading either copy would be picking.
621
+ if (byId.has(id))
622
+ return;
623
+ const entry = { native: {}, foreignKeys: [] };
624
+ byId.set(id, entry);
625
+ order.push(id);
626
+ for (let scan = index + 1; scan < lines.length; scan += 1) {
627
+ const text = lines[scan].text;
628
+ if (startsTable(text))
629
+ break;
630
+ const trimmed = text.trim();
631
+ if (trimmed === '' || trimmed.startsWith('#'))
632
+ continue;
633
+ const key = tomlAssignmentPath(text);
634
+ const equals = text.indexOf('=');
635
+ if (key === undefined || key.length !== 1 || equals < 0) {
636
+ // LOCATED, never quoted (`document-fault.ts`). This echoed the line
637
+ // itself until M17.A, and a line of a vendor config is a line that can
638
+ // hold an API token — the same hazard as a parser message, reached
639
+ // through brambo's OWN prose rather than through V8's.
640
+ unreadable.push({
641
+ id,
642
+ detail: `'${traits.mcpContainerKey}.${id}' holds a line brambo cannot read as one 'key = value' assignment, at ${positionOf(body, lines[scan].start)}`,
643
+ });
644
+ byId.delete(id);
645
+ order.splice(order.indexOf(id), 1);
646
+ break;
647
+ }
648
+ const raw = text.slice(equals + 1).trim();
649
+ let parsed;
650
+ try {
651
+ parsed = JSON.parse(raw);
652
+ }
653
+ catch {
654
+ // The value is NAMED by its key, never reproduced — the shape the
655
+ // JSONC reporter above already uses ("holds a value brambo cannot
656
+ // read…"), and the reason it never leaked while this one did. Measured
657
+ // at 4232e9c: `brambo ingest --dry-run` printed a planted credential
658
+ // verbatim out of `config.toml` through this interpolation.
659
+ unreadable.push({
660
+ id,
661
+ detail: `'${traits.mcpContainerKey}.${id}.${key[0]}' holds a value brambo cannot read, because it is not spelled the way brambo renders one; brambo reports it rather than guessing what it means, at ${positionOf(body, lines[scan].start + equals + 1)}`,
662
+ });
663
+ byId.delete(id);
664
+ order.splice(order.indexOf(id), 1);
665
+ break;
666
+ }
667
+ if (typeof parsed === 'string')
668
+ entry.native[key[0]] = parsed;
669
+ else if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
670
+ entry.native[key[0]] = parsed;
671
+ }
672
+ else
673
+ entry.foreignKeys.push(key[0]);
674
+ }
675
+ });
676
+ // A `[<container>.<id>.<sub>]` table ends the region scan above, so its keys
677
+ // would otherwise disappear in silence. It is one of the keys brambo cannot
678
+ // represent, and D10 says those are reported.
679
+ for (const line of lines) {
680
+ const path = tomlHeaderPath(line.text);
681
+ if (path === undefined || path.length < 3 || path[0] !== traits.mcpContainerKey)
682
+ continue;
683
+ byId.get(path[1])?.foreignKeys.push(path.slice(2).join('.'));
684
+ }
685
+ return {
686
+ entries: order.map((id) => ({ id, native: byId.get(id).native, foreignKeys: byId.get(id).foreignKeys })),
687
+ unreadable,
688
+ };
689
+ },
690
+ canonical(ownedText) {
691
+ return ownedText
692
+ .split('\n')
693
+ .map((line) => {
694
+ const trimmed = line.trim();
695
+ const header = tomlHeaderPath(trimmed);
696
+ // Re-spell the header from its decoded path so `[ a."b" ]` and `[a.b]`
697
+ // hash the same.
698
+ return header === undefined ? trimmed : `[${header.map(tomlBareKey).join('.')}]`;
699
+ })
700
+ .filter((line) => line !== '')
701
+ .join('\n');
702
+ },
703
+ upsert(body, style, traits, entry, existing) {
704
+ const owned = renderTomlTable(traits, entry, style);
705
+ if (existing !== undefined) {
706
+ return { start: existing.start, end: existing.end, replacement: owned, owned };
707
+ }
708
+ // Appended at EOF, after ensuring the foreign tail keeps its own trailing
709
+ // newline and exactly one blank line separates brambo's table from it.
710
+ const separator = body === '' ? '' : body.endsWith(style.eol) ? style.eol : `${style.eol}${style.eol}`;
711
+ return { start: body.length, end: body.length, replacement: `${separator}${owned}`, owned };
712
+ },
713
+ remove(body, style, existing) {
714
+ const separator = style.eol + style.eol;
715
+ const hasSeparator = existing.start >= separator.length &&
716
+ body.slice(existing.start - separator.length, existing.start) === separator;
717
+ return { start: hasSeparator ? existing.start - style.eol.length : existing.start, end: existing.end };
718
+ },
719
+ reclaimContainer() {
720
+ // TOML tables are independent definitions; removing one leaves no container.
721
+ return undefined;
722
+ },
723
+ };
724
+ const FORMAT_STRATEGIES = {
725
+ jsonc: JSONC_STRATEGY,
726
+ toml: TOML_STRATEGY,
727
+ };
728
+ /**
729
+ * Every MCP server one vendor's own config file declares, or `undefined` when
730
+ * there is no such file.
731
+ *
732
+ * ABSENCE IS NOT FAILURE (AD-5): an executor is allowed not to be installed, so
733
+ * a missing `~/.codex/config.toml` contributes nothing and is not an error.
734
+ * Everything else is coded and names the path — a malformed document, a
735
+ * container holding something brambo cannot address, a file brambo may not read.
736
+ */
737
+ export async function readNativeMcpEntries(traits, options = {}) {
738
+ const filePath = options.filePath ?? traits.defaultPath;
739
+ let nativeText;
740
+ try {
741
+ nativeText = await readFile(filePath, 'utf8');
742
+ }
743
+ catch (error) {
744
+ const code = error?.code;
745
+ if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR')
746
+ return undefined;
747
+ // Reported, not thrown. D2/AD-5 let an executor be unusable, and a file
748
+ // brambo may not open is closer to absent than to malformed — but it is not
749
+ // absent either, and silence would tell a user their servers were considered
750
+ // when they never were.
751
+ return { filePath, entries: [], unreadable: [], unreadableFile: code ?? String(error) };
752
+ }
753
+ const strategy = FORMAT_STRATEGIES[traits.fileFormat];
754
+ const body = nativeText.startsWith(BYTE_ORDER_MARK) ? nativeText.slice(1) : nativeText;
755
+ // The merge SEEDS a whitespace-only JSON document to `{}` so entries can be
756
+ // added to it. There is nothing in one to read, and validating it would call
757
+ // an empty file malformed — which it is not.
758
+ if (traits.fileFormat === 'jsonc' && body.trim() === '') {
759
+ return { filePath, entries: [], unreadable: [] };
760
+ }
761
+ strategy.validate(body, filePath, traits);
762
+ const conflict = strategy.containerConflict(body, traits);
763
+ if (conflict !== undefined)
764
+ throw nativeUnclaimable(filePath, conflict);
765
+ const rendered = renderedKeys(traits);
766
+ const listing = strategy.listEntries(body, traits);
767
+ const entries = [];
768
+ const unreadable = [...listing.unreadable];
769
+ for (const candidate of listing.entries) {
770
+ const ambiguous = strategy.entryConflict(body, traits, candidate.id);
771
+ if (ambiguous !== undefined) {
772
+ unreadable.push({ id: candidate.id, detail: ambiguous });
773
+ continue;
774
+ }
775
+ // A key the renderer DOES emit, holding a value no `NativeEntryShape` can
776
+ // carry (`args: ['ok', 7]`), is a value brambo cannot READ — not a key brambo
777
+ // cannot hold. Reporting it as dropped would silently lose the arguments of
778
+ // a server brambo then went on to project, and would blame the wrong thing.
779
+ const unreadableValues = candidate.foreignKeys.filter((key) => rendered.includes(key));
780
+ if (unreadableValues.length > 0) {
781
+ unreadable.push({
782
+ id: candidate.id,
783
+ detail: `'${unreadableValues.join("', '")}' holds a value brambo cannot read as a string or a list of strings, and brambo will not project a server it read only half of`,
784
+ });
785
+ continue;
786
+ }
787
+ const read = traits.readMcpEntry(candidate.native);
788
+ if (!read.ok) {
789
+ unreadable.push({ id: candidate.id, detail: read.detail });
790
+ continue;
791
+ }
792
+ entries.push({
793
+ id: candidate.id,
794
+ command: read.command,
795
+ args: read.args,
796
+ // Everything the renderer does not emit, whatever its value type: a user
797
+ // reading this wants what did not travel, not which layer noticed.
798
+ dropped: [...new Set([...droppedNativeKeys(candidate.native, rendered), ...candidate.foreignKeys])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
799
+ });
800
+ }
801
+ return { filePath, entries, unreadable };
802
+ }
803
+ // --- correction-01 C6: brambo's own prior output ------------------------------
804
+ //
805
+ // Stories 2.2 and 2.3 wrote brambo's OWN vocabulary into vendor files: a reserved
806
+ // `$.brambo` root key in the JSON family, and a `# BEGIN brambo-managed` block in
807
+ // Codex's TOML. Not one byte of it is read by any executor, and the Codex form
808
+ // is actively harmful — foreign sub-keys inside `[tools]` and `[skills]` make
809
+ // the user's whole `config.toml` fail to load under the documented
810
+ // `--strict-config`. correction-01 C6 makes removing it part of the correction.
811
+ //
812
+ // The corrected build cannot reach that state itself, so this is a LOCATOR, not
813
+ // a drift verdict: it finds brambo's own litter on a machine that ran a previous
814
+ // build, and the `discard` remediation removes exactly the region it names. One
815
+ // function, used by the report and by the act — a second locator could describe
816
+ // a region other than the one removed, which is the whole reason `brambo doctor`
817
+ // is an inspection mode rather than a copy.
818
+ /** The legacy marker the TOML form opens with; also how a reader recognises it. */
819
+ const LEGACY_TOML_BEGIN = '# BEGIN brambo-managed';
820
+ const LEGACY_TOML_END = '# END brambo-managed';
821
+ /** The JSON-family root key a previous build reserved for itself. */
822
+ const LEGACY_JSON_KEY = 'brambo';
823
+ /**
824
+ * The sub-keys the invalidated builds wrote under the reserved root key, from
825
+ * correction-01's own evidence table (`$.brambo.{tools,mcpServers,skills,hooks}`,
826
+ * plus the grammar version those builds stamped).
827
+ *
828
+ * This list is what makes the JSON side EVIDENCE rather than a name match. A key
829
+ * called `brambo` proves nothing — it is a name a user may have chosen, and AD-6
830
+ * is explicit that ownership is never inferred, with a bare key name weaker than
831
+ * the path AD-6 already rules out. So brambo claims the key only when its VALUE
832
+ * is an object whose every member is vocabulary a brambo build wrote. Anything
833
+ * else is somebody's own configuration: not reported, not removed.
834
+ */
835
+ const LEGACY_JSON_MEMBERS = new Set(['version', 'tools', 'mcpServers', 'skills', 'hooks']);
836
+ /** Whether this `brambo` value is one a previous brambo build wrote. */
837
+ function isLegacyBramboValue(node) {
838
+ if (node === undefined || node.type !== 'object')
839
+ return false;
840
+ const members = (node.children ?? []).map((property) => property.children?.[0]?.value);
841
+ if (members.length === 0)
842
+ return false;
843
+ return members.every((name) => typeof name === 'string' && LEGACY_JSON_MEMBERS.has(name));
844
+ }
845
+ function scanLegacyJson(body, shift) {
846
+ const root = parseTree(body);
847
+ if (root === undefined || root.type !== 'object') {
848
+ // Not a refusal to report loudly: a file with no object root never held the
849
+ // reserved key, because the build that wrote it spliced into an object.
850
+ return {};
851
+ }
852
+ const properties = memberProperties(root, LEGACY_JSON_KEY);
853
+ const property = properties[0];
854
+ if (property === undefined)
855
+ return {};
856
+ // Somebody's own `brambo` key. Silence is the only correct answer: reporting it
857
+ // would put a `problem` on the exit code for a state brambo invented, and the
858
+ // detail would assert a provenance brambo cannot know.
859
+ if (!properties.some((candidate) => isLegacyBramboValue(candidate.children?.[1])))
860
+ return {};
861
+ if (properties.length > 1) {
862
+ return {
863
+ refusal: `'${LEGACY_JSON_KEY}' is declared ${properties.length} times; brambo will not guess which of them it wrote. Leave one and re-run, or remove the block by hand`,
864
+ };
865
+ }
866
+ const span = jsonRemovalSpan(body, { start: property.offset, end: property.offset + property.length });
867
+ return {
868
+ block: {
869
+ start: span.start + shift,
870
+ end: span.end + shift,
871
+ detail: `the reserved '$.${LEGACY_JSON_KEY}' key a previous brambo build wrote — every member of it is brambo's own vocabulary and no executor reads any of it`,
872
+ },
873
+ };
874
+ }
875
+ /** The two TOML multi-line string fences, spelled without a literal triple quote. */
876
+ const TOML_FENCES = ['"'.repeat(3), "'".repeat(3)];
877
+ /**
878
+ * The lines that are real TOML lines rather than the interior of a multi-line
879
+ * string.
880
+ *
881
+ * A `# BEGIN brambo-managed` inside a multi-line value is three of the USER's own
882
+ * bytes, and matching it deleted them — measured. Brambo never PARSES foreign
883
+ * TOML and this does not start: it tracks only the two multi-line fences, which
884
+ * is the whole of what can hide a line-initial `#`. A line inside an open fence
885
+ * is invisible to the marker scan.
886
+ *
887
+ * ponytail: fence counting, not a lexer. A fence sequence inside a single-line
888
+ * basic string would desynchronise it, and the failure direction is that brambo
889
+ * stops recognising its OWN block and reports nothing — the safe one. Upgrade
890
+ * path: a real TOML lexer, worth it only if a legacy block is ever found in a
891
+ * file shaped like that.
892
+ */
893
+ function tomlCodeLines(lines) {
894
+ const code = [];
895
+ let open;
896
+ for (const line of lines) {
897
+ if (open === undefined)
898
+ code.push(line);
899
+ let rest = line.text;
900
+ for (;;) {
901
+ if (open === undefined) {
902
+ const found = TOML_FENCES.map((fence) => ({ fence, at: rest.indexOf(fence) }))
903
+ .filter((candidate) => candidate.at >= 0)
904
+ .sort((a, b) => a.at - b.at)[0];
905
+ if (found === undefined)
906
+ break;
907
+ open = found.fence;
908
+ rest = rest.slice(found.at + found.fence.length);
909
+ continue;
910
+ }
911
+ const closes = rest.indexOf(open);
912
+ if (closes < 0)
913
+ break;
914
+ rest = rest.slice(closes + open.length);
915
+ open = undefined;
916
+ }
917
+ }
918
+ return code;
919
+ }
920
+ function scanLegacyToml(body, shift) {
921
+ const lines = tomlCodeLines(splitLines(body));
922
+ const begins = lines.filter((line) => line.text.trimStart().startsWith(LEGACY_TOML_BEGIN));
923
+ if (begins.length === 0) {
924
+ // An END with no BEGIN is a hand-edited remnant brambo cannot bound.
925
+ return lines.some((line) => line.text.trimStart().startsWith(LEGACY_TOML_END))
926
+ ? { refusal: `'${LEGACY_TOML_END}' appears with no '${LEGACY_TOML_BEGIN}' before it; brambo cannot tell where the block starts. Remove what is left of it by hand` }
927
+ : {};
928
+ }
929
+ if (begins.length > 1) {
930
+ return { refusal: `'${LEGACY_TOML_BEGIN}' appears ${begins.length} times; brambo will not guess which block is which. Leave one and re-run, or remove them by hand` };
931
+ }
932
+ const begin = begins[0];
933
+ const end = lines.find((line) => line.start >= begin.start && line.text.trimStart().startsWith(LEGACY_TOML_END));
934
+ if (end === undefined) {
935
+ return { refusal: `'${LEGACY_TOML_BEGIN}' has no matching '${LEGACY_TOML_END}'; brambo cannot tell where the block ends. Remove what is left of it by hand` };
936
+ }
937
+ // Symmetric with how a TOML region brambo owns is taken back today
938
+ // (`TOML_STRATEGY.remove`): one blank line separated the appended block from
939
+ // the foreign tail, and removing the block without it leaves that blank behind
940
+ // in every file a previous build wrote one into.
941
+ const eol = lineEndingOf(body);
942
+ const separator = eol + eol;
943
+ const separated = begin.start >= separator.length &&
944
+ body.slice(begin.start - separator.length, begin.start) === separator;
945
+ const start = separated ? begin.start - eol.length : begin.start;
946
+ return {
947
+ block: {
948
+ start: start + shift,
949
+ end: end.end + shift,
950
+ detail: `the '${LEGACY_TOML_BEGIN}' block a previous brambo build wrote, whose sub-keys under '[tools]' and '[skills]' make this file fail to load under --strict-config`,
951
+ },
952
+ };
953
+ }
954
+ /**
955
+ * Brambo's own prior output in one vendor file, if any is there.
956
+ *
957
+ * READS ONLY. `brambo doctor` calls it to report the state and the `discard`
958
+ * remediation calls it to remove exactly the region it returns.
959
+ */
960
+ export function scanLegacyBramboBlock(nativeText, fileFormat) {
961
+ const hasBom = nativeText.startsWith(BYTE_ORDER_MARK);
962
+ const body = hasBom ? nativeText.slice(1) : nativeText;
963
+ const shift = hasBom ? 1 : 0;
964
+ return fileFormat === 'toml' ? scanLegacyToml(body, shift) : scanLegacyJson(body, shift);
965
+ }
966
+ // --- registry -> native entries ---------------------------------------------
967
+ function byId(a, b) {
968
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
969
+ }
970
+ /**
971
+ * Reduces the registry to the MCP servers every target can express, in stable
972
+ * id order. Kinds this story does not project (skills) and MCP
973
+ * entries with no command are REPORTED through skippedEntryIds rather than
974
+ * approximated into something no executor reads — Stories 2.9 and 2.10 own
975
+ * those concepts. `present` is EVERY mcp-server id the registry holds,
976
+ * including the ones brambo cannot render: removal authority comes from absence
977
+ * in the registry, never from unprojectability.
978
+ */
979
+ function collectMcpEntries(entries) {
980
+ const skipped = [];
981
+ // Every DECLARED kind this format has no native location for — derived, so a
982
+ // word added to or removed from `REGISTRY_ENTRY_TYPES` cannot leave a stale
983
+ // literal here. (A RETIRED kind never arrives: `groupByKind` has no bucket for
984
+ // one, so it is dropped before any target is asked about it.)
985
+ for (const kind of REGISTRY_ENTRY_TYPES.filter((candidate) => candidate !== 'mcp-server')) {
986
+ for (const entry of entries[kind])
987
+ skipped.push(entry.id);
988
+ }
989
+ const mcp = [];
990
+ const present = new Set();
991
+ let previousId;
992
+ for (const entry of [...entries['mcp-server']].sort(byId)) {
993
+ // Defense in depth: the Registry already rejects duplicate type+id pairs
994
+ // and unprojectable ids, so reaching either branch means a hand-edited or
995
+ // corrupted store — which must fail coded, never silently collapse two
996
+ // entries into one native location or address one through a prototype key.
997
+ if (UNPROJECTABLE_ENTRY_IDS.has(entry.id)) {
998
+ throw new BramboError(BRAMBO_ERROR_CODES.registryInvalidEntry, `registry mcp-server entry '${entry.id}' cannot be used as a native config key`);
999
+ }
1000
+ if (previousId === entry.id) {
1001
+ throw new BramboError(BRAMBO_ERROR_CODES.registryContention, `duplicate registry mcp-server entries '${entry.id}': two entries with the same id cannot both be projected`);
1002
+ }
1003
+ previousId = entry.id;
1004
+ present.add(entry.id);
1005
+ if (entry.command === undefined) {
1006
+ // Reported, and LEFT ALONE on disk. Treating "brambo cannot render this"
1007
+ // as "the user deleted it" would delete a registered server from every
1008
+ // config the moment its command went missing.
1009
+ skipped.push(entry.id);
1010
+ continue;
1011
+ }
1012
+ mcp.push({ id: entry.id, command: entry.command, args: [...(entry.args ?? [])] });
1013
+ }
1014
+ return { mcp, present, skippedEntryIds: skipped.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) };
1015
+ }
1016
+ // --- the merge --------------------------------------------------------------
1017
+ function drifted(kind, traits, entryId, detail) {
1018
+ return { kind, entryId, location: `${traits.mcpContainerKey}.${entryId}`, detail };
1019
+ }
1020
+ function nativeLocationOf(traits, entryId) {
1021
+ return `${traits.mcpContainerKey}.${entryId}`;
1022
+ }
1023
+ function mergeNative(request, filePath, traits) {
1024
+ const strategy = FORMAT_STRATEGIES[traits.fileFormat];
1025
+ // A leading BOM is foreign state: stripped for parsing, re-prepended to the
1026
+ // output so it survives every projection byte-intact.
1027
+ const hasBom = request.nativeText.startsWith(BYTE_ORDER_MARK);
1028
+ const nativeBody = hasBom ? request.nativeText.slice(1) : request.nativeText;
1029
+ // A missing or whitespace-only JSON file has no document to splice into, so
1030
+ // the projection IS the file; TOML needs no seed because a table appends to
1031
+ // anything.
1032
+ const seeded = traits.fileFormat === 'jsonc' && nativeBody.trim() === '';
1033
+ let body = seeded ? '{}' : nativeBody;
1034
+ strategy.validate(body, filePath, traits);
1035
+ const style = { eol: lineEndingOf(nativeBody), unit: indentationUnitOf(nativeBody) };
1036
+ const { mcp, present, skippedEntryIds } = collectMcpEntries(request.entries);
1037
+ // A record is authority ONLY for its own key. A stale record from a build
1038
+ // that used a different container key, another target, or another file must
1039
+ // never authorise an overwrite or a deletion here.
1040
+ const ownedPath = resolveOwnedPath(filePath);
1041
+ const authoritative = request.records.filter((record) => record.targetId === traits.targetId &&
1042
+ sameOwnedPath(resolveOwnedPath(record.filePath), ownedPath) &&
1043
+ record.nativeLocation === nativeLocationOf(traits, record.entryId));
1044
+ const claimed = new Map(authoritative.map((record) => [record.entryId, record]));
1045
+ const unchanged = (drift) => ({
1046
+ text: request.nativeText,
1047
+ drift,
1048
+ skippedEntryIds,
1049
+ records: authoritative,
1050
+ ownedSpans: [],
1051
+ });
1052
+ const conflict = strategy.containerConflict(body, traits);
1053
+ if (conflict !== undefined) {
1054
+ const affected = [...new Set([...mcp.map((entry) => entry.id), ...claimed.keys()])].sort();
1055
+ return unchanged(affected.map((entryId) => drifted('foreign-collision', traits, entryId, `${conflict}; brambo will not touch '${entryId}' in '${filePath}'`)));
1056
+ }
1057
+ const drift = [];
1058
+ const records = [];
1059
+ const spans = [];
1060
+ let removed = 0;
1061
+ const applySplice = (start, end, replacement) => {
1062
+ body = body.slice(0, start) + replacement + body.slice(end);
1063
+ const delta = replacement.length - (end - start);
1064
+ // A later entry can land INSIDE a span brambo already owns — the second
1065
+ // entry going into a container the first one created. Such a splice grows
1066
+ // the enclosing span instead of adding an overlapping one. Only a NON-EMPTY
1067
+ // span can enclose: a removal's zero-width span must never swallow a later
1068
+ // insertion's own span and leave the verification surface empty.
1069
+ const enclosing = spans.find((span) => span[1] > span[0] && span[0] <= start && end <= span[1]);
1070
+ for (const span of spans) {
1071
+ if (span[0] >= end) {
1072
+ span[0] += delta;
1073
+ span[1] += delta;
1074
+ }
1075
+ else if (span === enclosing) {
1076
+ span[1] += delta;
1077
+ }
1078
+ }
1079
+ if (enclosing === undefined && replacement.length > 0) {
1080
+ spans.push([start, start + replacement.length]);
1081
+ }
1082
+ };
1083
+ const stillBrambos = (record, region) => hashOwnedText(strategy.canonical(body.slice(region.start, region.end))) === record.contentHash;
1084
+ // 1. Entries absent from the REGISTRY: remove exactly the ledger-recorded
1085
+ // region, and only while it still hashes to what brambo wrote.
1086
+ const renderable = new Set(mcp.map((entry) => entry.id));
1087
+ for (const record of [...authoritative].sort((a, b) => (a.entryId < b.entryId ? -1 : 1))) {
1088
+ if (present.has(record.entryId)) {
1089
+ // Still registered but not renderable this run: keep the claim, or the
1090
+ // next run would see its own entry as foreign and never manage it again.
1091
+ if (!renderable.has(record.entryId))
1092
+ records.push(record);
1093
+ continue;
1094
+ }
1095
+ const entryBlocked = strategy.entryConflict(body, traits, record.entryId);
1096
+ if (entryBlocked !== undefined) {
1097
+ drift.push(drifted('foreign-collision', traits, record.entryId, `${entryBlocked} in '${filePath}'`));
1098
+ records.push(record);
1099
+ continue;
1100
+ }
1101
+ const existing = strategy.locate(body, traits, record.entryId);
1102
+ if (existing === undefined)
1103
+ continue;
1104
+ if (!stillBrambos(record, existing)) {
1105
+ drift.push(drifted('edited', traits, record.entryId, `'${record.entryId}' in '${filePath}' has been edited since brambo wrote it; brambo will not remove it`));
1106
+ records.push(record);
1107
+ continue;
1108
+ }
1109
+ const removal = strategy.remove(body, style, existing);
1110
+ applySplice(removal.start, removal.end, '');
1111
+ removed += 1;
1112
+ }
1113
+ // 2. Entries the registry holds. Brambo writes only where it already owns the
1114
+ // location or where the location is provably free; everything else is
1115
+ // reported and left alone.
1116
+ for (const entry of mcp) {
1117
+ const record = claimed.get(entry.id);
1118
+ const entryBlocked = strategy.entryConflict(body, traits, entry.id);
1119
+ if (entryBlocked !== undefined) {
1120
+ drift.push(drifted('foreign-collision', traits, entry.id, `${entryBlocked} in '${filePath}'`));
1121
+ if (record !== undefined)
1122
+ records.push(record);
1123
+ continue;
1124
+ }
1125
+ const existing = strategy.locate(body, traits, entry.id);
1126
+ if (record === undefined) {
1127
+ if (existing !== undefined) {
1128
+ // M11.A D4 case (ii): the CONFIG half of the SOURCE-IS-THE-DESTINATION
1129
+ // verdict `materialise.ts` already reaches for a tree.
1130
+ //
1131
+ // Deciding `foreign-collision` from EXISTENCE alone made brambo report a
1132
+ // conflict against a server that already does exactly what the registry
1133
+ // asks, and M4.C's "every state has a way out" then offered two bad
1134
+ // exits: adopt bytes brambo did not write, or delete the user's entry.
1135
+ //
1136
+ // THE QUESTION HERE IS ABOUT MEANING, NOT BYTES, and that is why this
1137
+ // does NOT reuse `stillBrambos`. `stillBrambos` asks "are these the bytes
1138
+ // brambo WROTE?", where a hash is the right instrument. This asks "does
1139
+ // this FOREIGN entry already deliver what the registry says?", and a
1140
+ // hash answers that only when the user happened to spell brambo's exact
1141
+ // key set — measured by driving the binary: `{"command":"npx"}`, a
1142
+ // missing `type`, and an entry carrying `env` each reported a collision
1143
+ // while running precisely the right server. So the comparison is the
1144
+ // trait's own INVERSE: read the native entry back and compare what runs.
1145
+ // Keys brambo cannot represent are ignored rather than counted against
1146
+ // it, which is the same answer the reader gives when it ingests such an
1147
+ // entry and reports the key dropped — the two halves of the story now
1148
+ // agree. Being value-based it is also format-independent by
1149
+ // construction, so key order, spacing and comments stop mattering
1150
+ // without a second canonicaliser to keep in step.
1151
+ //
1152
+ // ponytail: `listEntries` walks the whole container to answer about one
1153
+ // id, so a merge is O(entries^2) in the container size. Vendor configs
1154
+ // hold a handful of servers; upgrade path is a `readEntry(body, id)` on
1155
+ // the strategy if a container ever grows big enough to measure.
1156
+ const native = strategy.listEntries(body, traits).entries.find((item) => item.id === entry.id);
1157
+ const read = native === undefined ? undefined : traits.readMcpEntry(native.native);
1158
+ if (read?.ok === true &&
1159
+ read.command === entry.command &&
1160
+ read.args.length === entry.args.length &&
1161
+ read.args.every((argument, index) => argument === entry.args[index])) {
1162
+ // ALREADY SATISFIED: nothing written, nothing claimed, no drift.
1163
+ //
1164
+ // NOT ADOPTED, and that is the load-bearing half — the reason
1165
+ // `materialise.ts` gives for its twin holds verbatim here: brambo did
1166
+ // not write these bytes, so claiming them would hand the release
1167
+ // remediation an authority to delete a server the user owns. An
1168
+ // unclaimed entry is also never in the removal path above, which
1169
+ // needs a ledger record to reach.
1170
+ //
1171
+ // Degrades correctly in both directions: change what the entry RUNS
1172
+ // and it is a foreign collision again, which is true.
1173
+ continue;
1174
+ }
1175
+ drift.push(drifted('foreign-collision', traits, entry.id, `'${entry.id}' already exists in '${filePath}' and does not run what the registry says it should, and brambo's ledger does not claim it; brambo will not resolve the collision`));
1176
+ continue;
1177
+ }
1178
+ }
1179
+ else if (existing === undefined) {
1180
+ drift.push(drifted('removed-by-user', traits, entry.id, `brambo wrote '${entry.id}' to '${filePath}' and it is gone; brambo will not re-add it`));
1181
+ // The claim is KEPT: dropping it would make the next run treat the entry
1182
+ // as never written and silently re-add what the user deleted.
1183
+ records.push(record);
1184
+ continue;
1185
+ }
1186
+ else if (!stillBrambos(record, existing)) {
1187
+ drift.push(drifted('edited', traits, entry.id, `'${entry.id}' in '${filePath}' has been edited since brambo wrote it; brambo will not overwrite it`));
1188
+ records.push(record);
1189
+ continue;
1190
+ }
1191
+ const splice = strategy.upsert(body, style, traits, entry, existing);
1192
+ applySplice(splice.start, splice.end, splice.replacement);
1193
+ records.push({
1194
+ targetId: traits.targetId,
1195
+ filePath: ownedPath,
1196
+ nativeLocation: nativeLocationOf(traits, entry.id),
1197
+ entryId: entry.id,
1198
+ contentHash: hashOwnedText(strategy.canonical(splice.owned)),
1199
+ });
1200
+ }
1201
+ if (removed > 0) {
1202
+ const reclaimable = strategy.reclaimContainer(body, traits);
1203
+ if (reclaimable !== undefined)
1204
+ applySplice(reclaimable.start, reclaimable.end, '');
1205
+ }
1206
+ // Nothing to write means nothing to create: a run with an empty registry must
1207
+ // not conjure a config file the user never had.
1208
+ if (seeded && spans.length === 0)
1209
+ return unchanged(drift);
1210
+ const text = hasBom ? BYTE_ORDER_MARK + body : body;
1211
+ const bomShift = hasBom ? 1 : 0;
1212
+ const ownedSpans = seeded
1213
+ ? [[0, text.length]]
1214
+ : spans
1215
+ .filter((span) => span[1] > span[0])
1216
+ .sort((a, b) => a[0] - b[0])
1217
+ .map((span) => [span[0] + bomShift, span[1] + bomShift]);
1218
+ return { text, drift, skippedEntryIds, records, ownedSpans };
1219
+ }
1220
+ /**
1221
+ * What currently occupies ONE entry's native location, expressed as the ledger
1222
+ * record that would claim it — the target's half of `adopt`.
1223
+ *
1224
+ * Every predicate below is the merge's own, called on the same text through the
1225
+ * same strategy: `entryConflict` for a location the document spells twice,
1226
+ * `locate` for the region, and `hashOwnedText(strategy.canonical(...))` for the
1227
+ * hash. That is not tidiness. An adopted record whose hash were computed any
1228
+ * other way would fail `stillBrambos` on the very next run and the entry would
1229
+ * report `edited` forever — the exact state adoption exists to leave.
1230
+ *
1231
+ * Every failure is a REFUSAL rather than a throw. A malformed vendor file, an
1232
+ * unclaimable container, a duplicated key: each is a reason brambo will not take
1233
+ * ownership, and the caller has to be able to print it beside the entry.
1234
+ */
1235
+ function claimNative(request, filePath, traits) {
1236
+ const strategy = FORMAT_STRATEGIES[traits.fileFormat];
1237
+ const location = nativeLocationOf(traits, request.entryId);
1238
+ const refuse = (refusal) => ({ location, byteLength: 0, refusal });
1239
+ if (UNPROJECTABLE_ENTRY_IDS.has(request.entryId)) {
1240
+ return refuse(`'${request.entryId}' cannot be used as a native config key`);
1241
+ }
1242
+ const hasBom = request.nativeText.startsWith(BYTE_ORDER_MARK);
1243
+ // NOT seeded to '{}' the way the merge seeds an empty document: seeding
1244
+ // invents a document so entries can be ADDED to it, and there is nothing to
1245
+ // adopt in a file that does not exist.
1246
+ const body = hasBom ? request.nativeText.slice(1) : request.nativeText;
1247
+ try {
1248
+ strategy.validate(body, filePath, traits);
1249
+ }
1250
+ catch (error) {
1251
+ return refuse(error instanceof Error ? error.message : String(error));
1252
+ }
1253
+ const conflict = strategy.containerConflict(body, traits) ?? strategy.entryConflict(body, traits, request.entryId);
1254
+ // The refusal has to be ACTIONABLE. This is the shape where every brambo verb
1255
+ // declines — `adopt` on the ambiguity, `release` because a foreign collision
1256
+ // holds no claim to drop — so the sentence has to name the thing that does
1257
+ // leave it, which is the user's own edit of their own file. Brambo's ledger is
1258
+ // not involved and saying so is half the answer.
1259
+ if (conflict !== undefined) {
1260
+ return refuse(`${conflict}. Brambo holds no claim here, so no remediation applies: resolve the ambiguity in '${filePath}' itself and re-run`);
1261
+ }
1262
+ const existing = strategy.locate(body, traits, request.entryId);
1263
+ if (existing === undefined)
1264
+ return { location, byteLength: 0 };
1265
+ const owned = body.slice(existing.start, existing.end);
1266
+ return {
1267
+ location,
1268
+ byteLength: Buffer.byteLength(owned, 'utf8'),
1269
+ // `ownedPaths` deliberately omitted: a config claim authorises replacing one
1270
+ // REGION inside this file and can never remove the file, so there is no path
1271
+ // a later run gains delete authority over.
1272
+ record: {
1273
+ targetId: traits.targetId,
1274
+ filePath: resolveOwnedPath(filePath),
1275
+ nativeLocation: location,
1276
+ entryId: request.entryId,
1277
+ contentHash: hashOwnedText(strategy.canonical(owned)),
1278
+ },
1279
+ };
1280
+ }
1281
+ /**
1282
+ * The ONE factory every target flows through: adding a target means writing a
1283
+ * trait record — no engine or strategy code changes. An unknown file format is
1284
+ * a coded configuration error, not a crash at splice time.
1285
+ */
1286
+ export function createProjectionTargetFromTraits(traits, options = {}) {
1287
+ if (FORMAT_STRATEGIES[traits.fileFormat] === undefined) {
1288
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionTraitsInvalid, `projection target '${traits.targetId}' declares unknown fileFormat '${traits.fileFormat}'`);
1289
+ }
1290
+ const filePath = options.filePath ?? traits.defaultPath;
1291
+ return {
1292
+ targetId: traits.targetId,
1293
+ filePath,
1294
+ merge(request) {
1295
+ return mergeNative(request, filePath, traits);
1296
+ },
1297
+ claim(request) {
1298
+ return claimNative(request, filePath, traits);
1299
+ },
1300
+ };
1301
+ }