@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/atomic-write.d.ts +3 -0
- package/dist/atomic-write.js +90 -0
- package/dist/config-write.d.ts +49 -0
- package/dist/config-write.js +146 -0
- package/dist/document-fault.d.ts +59 -0
- package/dist/document-fault.js +78 -0
- package/dist/engine.d.ts +69 -0
- package/dist/engine.js +235 -0
- package/dist/formats.d.ts +132 -0
- package/dist/formats.js +1301 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/ledger.d.ts +193 -0
- package/dist/ledger.js +552 -0
- package/dist/materialise.d.ts +55 -0
- package/dist/materialise.js +620 -0
- package/dist/remediate.d.ts +65 -0
- package/dist/remediate.js +468 -0
- package/dist/targets/claude-mcp.d.ts +6 -0
- package/dist/targets/claude-mcp.js +36 -0
- package/dist/targets/codex-config.d.ts +6 -0
- package/dist/targets/codex-config.js +27 -0
- package/dist/targets/opencode-config.d.ts +6 -0
- package/dist/targets/opencode-config.js +58 -0
- package/dist/targets/skills.d.ts +22 -0
- package/dist/targets/skills.js +165 -0
- package/package.json +57 -0
package/dist/formats.js
ADDED
|
@@ -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
|
+
}
|