@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,620 @@
1
+ import { lstat, mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
4
+ import { atomicWriteBytes } from './atomic-write.js';
5
+ import { canonicalBytesHash, hashOwnedBytes, hashOwnedText, isUnderRoot, resolveOwnedPath, sameOwnedPath, } from './ledger.js';
6
+ function driftEntry(kind, entryId, location, detail) {
7
+ return { kind, entryId, location, detail };
8
+ }
9
+ /**
10
+ * `undefined` — nothing there. `'unreadable'` — something IS there and brambo
11
+ * cannot read it (a directory where a file was, a mode the user changed).
12
+ *
13
+ * The second case is deliberately not a throw: it means "present and not what
14
+ * brambo wrote", which is drift on one entry, and throwing would fail the whole
15
+ * target and unmaterialise every OTHER skill for that executor.
16
+ */
17
+ async function readIfPresent(path) {
18
+ try {
19
+ return await readFile(path);
20
+ }
21
+ catch (error) {
22
+ const code = error?.code;
23
+ return code === 'ENOENT' || code === 'ENOTDIR' ? undefined : 'unreadable';
24
+ }
25
+ }
26
+ // `isUnderRoot` is the shared boundary check in `ledger.ts`, beside the path
27
+ // canonicalisation it is inseparable from. The destination of every write is
28
+ // built from a root-relative path a target supplied, and a target is ordinary
29
+ // code: it is what stops `../..` in a registry id from turning a projection into
30
+ // an arbitrary write. The REMOVAL path applies the same check to every path a
31
+ // ledger record names, because a record is parsed from a file and is therefore
32
+ // input, not fact — and so does the ADOPT path, because the record it writes is
33
+ // an authority to delete on some later run.
34
+ function absolutePathOf(root, relativePath, entryId) {
35
+ const resolved = resolveOwnedPath(join(root, ...relativePath.split('/')));
36
+ if (!isUnderRoot(resolved, root)) {
37
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionTraitsInvalid, `materialisation target planned '${relativePath}' for entry '${entryId}', which resolves outside its own root '${root}'`);
38
+ }
39
+ return resolved;
40
+ }
41
+ async function isLink(path) {
42
+ try {
43
+ return (await lstat(path)).isSymbolicLink();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * Whether reaching `path` from `root` passes through any link.
51
+ *
52
+ * `lstat` refuses to follow only the FINAL component, so checking the file
53
+ * alone would miss the case that matters most: a junction at `<root>/<id>`
54
+ * whose files resolve perfectly through it.
55
+ */
56
+ async function traversesLink(path, root) {
57
+ let current = path;
58
+ while (isUnderRoot(current, root)) {
59
+ if (await isLink(current))
60
+ return true;
61
+ current = dirname(current);
62
+ }
63
+ return false;
64
+ }
65
+ /** Machine-independent identity of a whole tree: its paths and their bytes. */
66
+ function treeHash(root, owned) {
67
+ return hashOwnedText(JSON.stringify(owned.map((entry) => [relative(root, entry.path).split(sep).join('/'), entry.contentHash])));
68
+ }
69
+ /**
70
+ * `rmdir` upward from a directory brambo emptied, stopping at the first one that
71
+ * is not empty and never reaching the root.
72
+ *
73
+ * `rmdir` rather than a recursive delete on purpose: it REFUSES a directory
74
+ * that still holds anything, so a foreign file the user put inside brambo's tree
75
+ * keeps its directory alive without brambo having to notice it. It does NOT
76
+ * refuse a junction to a non-empty directory — measured — so a link stops the
77
+ * walk before `rmdir` is ever reached.
78
+ */
79
+ async function pruneEmptyDirectories(from, root) {
80
+ let directory = from;
81
+ while (isUnderRoot(directory, root)) {
82
+ if (await isLink(directory))
83
+ return;
84
+ try {
85
+ await rmdir(directory);
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ directory = dirname(directory);
91
+ }
92
+ }
93
+ /**
94
+ * Whether anything at all occupies `path`. An error brambo cannot classify is
95
+ * reported as OCCUPIED: the caller uses this to prove a location is free, and an
96
+ * unreadable answer is not a proof.
97
+ */
98
+ async function occupied(path) {
99
+ try {
100
+ await stat(path);
101
+ return { taken: true, detail: 'it already exists' };
102
+ }
103
+ catch (error) {
104
+ const code = error?.code;
105
+ if (code === 'ENOENT' || code === 'ENOTDIR')
106
+ return { taken: false, detail: '' };
107
+ return {
108
+ taken: true,
109
+ detail: `brambo could not determine whether it is free (${code ?? 'unknown error'})`,
110
+ };
111
+ }
112
+ }
113
+ /**
114
+ * Whether an entry's own DIRECTORY holds content brambo must not resolve.
115
+ *
116
+ * AN EMPTY DIRECTORY IS NOBODY'S CONTENT, and treating one as a foreign
117
+ * collision built a state with no exit that brambo's own instructions walked the
118
+ * user into: delete a materialised `SKILL.md` and its directory survives; doctor
119
+ * reports `removed-by-user` and says `release` frees the location so the next
120
+ * run writes it back; `release` drops the claim; the next run then finds the
121
+ * EMPTY directory, calls it foreign and refuses; `adopt` has nothing to claim
122
+ * and refuses; `release` has no claim left and refuses. Exit 1 forever, escapable
123
+ * only with `rmdir` by hand. Brambo was refusing to write in order to protect
124
+ * nothing.
125
+ *
126
+ * The protection that stays exactly as it was: a directory holding ANY entry is
127
+ * foreign, and so is one brambo cannot list — an unreadable answer is not a proof
128
+ * that a location is free. A LINK is occupation whatever it points at, because
129
+ * writing through it lands outside the root brambo owns.
130
+ */
131
+ async function occupiedByContent(directory) {
132
+ const state = await occupied(directory);
133
+ if (!state.taken)
134
+ return state;
135
+ if (await isLink(directory))
136
+ return state;
137
+ try {
138
+ if ((await readdir(directory)).length > 0)
139
+ return state;
140
+ }
141
+ catch {
142
+ return state;
143
+ }
144
+ return { taken: false, detail: '' };
145
+ }
146
+ /** One spelling of a path for set membership, matching `sameOwnedPath`. */
147
+ function pathKey(path) {
148
+ const resolved = resolveOwnedPath(path);
149
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
150
+ }
151
+ /**
152
+ * The ONE place a materialisation plan is obtained, and the one place the
153
+ * SOURCE-IS-THE-DESTINATION verdict is reached (spec M9.A amendment 3).
154
+ *
155
+ * `brambo ingest` reads the same roots the projection writes into, so an ingested
156
+ * skill arrives already sitting at one of its own destinations. Brambo's ledger
157
+ * does not claim it — brambo did not write it — and the plain reading of that was
158
+ * `foreign-collision`, so using the feature immediately reported a broken
159
+ * environment. The verdict was also factually wrong: the bytes that should be
160
+ * there ARE there, byte for byte, because the file brambo would copy FROM and the
161
+ * file brambo would copy TO are the same file.
162
+ *
163
+ * So the entry is ALREADY SATISFIED: nothing to write, nothing to claim, no
164
+ * drift. It stays in `presentEntryIds`, which is what keeps it out of the
165
+ * removal path, and it is deliberately NOT reported as `skipped` — a skipped
166
+ * entry is one a target could not express (C5), and this one is expressed
167
+ * perfectly. Reporting it would be reporting a problem that is not there.
168
+ *
169
+ * NOT ADOPTED, and that is the load-bearing half: brambo did not write these
170
+ * bytes, so claiming them would make `brambo remediate release` an authority to
171
+ * delete a skill the user owns. The ledger keeps telling the truth.
172
+ *
173
+ * Both consumers of a plan route through here — the engine below and
174
+ * {@link claimMaterialised} — so the rule is stated once rather than guarded at
175
+ * each decision that would otherwise have to re-derive it.
176
+ */
177
+ async function planFor(target, entries, claimed, root) {
178
+ const plan = await target.plan({ entries, records: claimed, rootPath: root });
179
+ const satisfied = new Set();
180
+ for (const entry of plan.entries) {
181
+ // CANONICAL comparison, never string equality: a target's `sourcePath` and
182
+ // the destination brambo builds from the root are two spellings arrived at by
183
+ // different routes, and on win32 they can differ in drive-letter and
184
+ // directory casing while naming one file. `pathKey` is the same spelling the
185
+ // removal path keys ownership on.
186
+ const same = entry.files.every((file) => {
187
+ let destination;
188
+ try {
189
+ destination = absolutePathOf(root, file.relativePath, entry.entryId);
190
+ }
191
+ catch {
192
+ // Outside the root: not satisfied, and the ordinary path reports it.
193
+ return false;
194
+ }
195
+ return pathKey(destination) === pathKey(file.sourcePath);
196
+ });
197
+ if (same && entry.files.length > 0)
198
+ satisfied.add(entry.entryId);
199
+ }
200
+ if (satisfied.size === 0)
201
+ return { plan, satisfied };
202
+ return {
203
+ plan: { ...plan, entries: plan.entries.filter((entry) => !satisfied.has(entry.entryId)) },
204
+ satisfied,
205
+ };
206
+ }
207
+ /**
208
+ * The ledger record that would claim the tree currently at one entry's location
209
+ * — the materialisation half of `adopt`, and the exit from every reported state
210
+ * a skills root can be in: a `foreign-collision` (brambo's own tree left
211
+ * unclaimed by a crash included), an `edited` tree, and a tree that is only
212
+ * PARTLY there.
213
+ *
214
+ * IT LIVES HERE, BESIDE THE REMOVAL RULE, ON PURPOSE. A record this returns is
215
+ * an authority to DELETE on some later run, so every clause of that rule is
216
+ * applied while the record is built rather than trusted afterwards: the paths
217
+ * come from the target's own plan (never from a directory listing, so a file the
218
+ * user put beside brambo's is not swept into the claim and cannot later be
219
+ * removed); each is resolved and containment-checked against the root; a link
220
+ * anywhere between the root and the file disqualifies it; and a path any OTHER
221
+ * record already claims is refused rather than duplicated.
222
+ *
223
+ * A PARTIALLY PRESENT TREE IS CLAIMED AS THE SUBSET THAT IS THERE, and that is
224
+ * the correction that gives that state an exit at all. Claiming the whole
225
+ * planned set would write a record reading `edited` on the very next run — the
226
+ * state adoption exists to leave — so brambo claims exactly the files that exist,
227
+ * the record reads `intact`, and the ordinary run writes the missing ones back.
228
+ * Refusing instead (the first shipped shape) left three separate routes into a
229
+ * tree with no exit but `rm -rf`: `release` on an edited tree, a crash inside
230
+ * `land()` between two file writes, and a user deleting one file from a skill.
231
+ *
232
+ * ponytail: when a record claims MORE paths than the plan wants — a file that
233
+ * left the source — adoption claims the planned subset and lets that path go
234
+ * unclaimed, so the ordinary run stops being authorised to take it back and it
235
+ * stays inside brambo's tree. Under-claiming, which is the safe direction and the
236
+ * one this whole file errs towards; the alternative is claiming the union, which
237
+ * widens what an explicit user action makes deletable. Upgrade path: claim the
238
+ * union once a case exists where the leftover file matters.
239
+ *
240
+ * WHEN THE ENTRY HAS LEFT THE REGISTRY the plan holds nothing for it, and the
241
+ * ledger record is the fallback authority — that is the shape reported as
242
+ * `edited` on the removal path, whose only other exit was `release`, which drops
243
+ * the claim and leaves the tree on disk forever. Adoption there means the next
244
+ * ordinary run REMOVES the tree, which is why the claim carries `removedNext`
245
+ * and the caller has to say so before writing it.
246
+ */
247
+ export async function claimMaterialised(target, entries, claimed, entryId) {
248
+ const root = resolveOwnedPath(target.rootPath);
249
+ const { plan, satisfied } = await planFor(target, entries, claimed, root);
250
+ const planned = plan.entries.find((candidate) => candidate.entryId === entryId);
251
+ const held = claimed.find((record) => record.entryId === entryId &&
252
+ record.targetId === target.targetId &&
253
+ sameOwnedPath(resolveOwnedPath(record.filePath), root) &&
254
+ (record.ownedPaths ?? []).length > 0);
255
+ const location = planned?.location ?? held?.nativeLocation ?? entryId;
256
+ const refuse = (refusal) => ({ location, byteLength: 0, refusal });
257
+ if (planned === undefined && held === undefined) {
258
+ if (satisfied.has(entryId)) {
259
+ // Already satisfied, so there is nothing to adopt — and adopting is the
260
+ // one thing that must not happen here: brambo did not write these bytes,
261
+ // and a claim over them is an authority to DELETE them on a later run.
262
+ return refuse(`'${entryId}' under '${root}' is the very source brambo would copy from, so it is already exactly what brambo would write; brambo did not put it there and will not claim a file it did not write`);
263
+ }
264
+ const skipped = (plan.skipped ?? []).find((candidate) => candidate.entryId === entryId);
265
+ return refuse(skipped?.reason ??
266
+ `brambo would not materialise '${entryId}' under '${root}' and holds no record of ever having done so, so there is nothing there for brambo to claim`);
267
+ }
268
+ // Every path a SURVIVING claim holds, so two registry ids that land on one
269
+ // path cannot both own it — the same clause 6 the removal path applies, and
270
+ // for the same reason: a record that claims another entry's file is an
271
+ // authority to delete it.
272
+ const otherClaims = new Set(claimed
273
+ .filter((record) => record.entryId !== entryId)
274
+ .flatMap((record) => (record.ownedPaths ?? []).map((item) => pathKey(item.path))));
275
+ // The plan's paths where brambo would write, the RECORD's where brambo no longer
276
+ // would. Never a directory listing: a file the user added beside brambo's must
277
+ // stay outside every claim brambo writes.
278
+ let candidates;
279
+ if (planned !== undefined) {
280
+ try {
281
+ candidates = planned.files.map((file) => absolutePathOf(root, file.relativePath, entryId));
282
+ }
283
+ catch (error) {
284
+ return refuse(error instanceof Error ? error.message : String(error));
285
+ }
286
+ }
287
+ else {
288
+ candidates = (held?.ownedPaths ?? []).map((item) => resolveOwnedPath(item.path));
289
+ }
290
+ const owned = [];
291
+ let byteLength = 0;
292
+ let absent = 0;
293
+ for (const path of candidates) {
294
+ // Re-checked for the record-derived list too: a record is a file brambo
295
+ // PARSED, so its paths are input rather than fact.
296
+ if (!isUnderRoot(path, root)) {
297
+ return refuse(`'${path}' is outside '${root}'; brambo will not claim a path it cannot prove it owns`);
298
+ }
299
+ if (await traversesLink(path, root)) {
300
+ return refuse(`'${path}' is reached through a link, so it is not a path brambo can prove it owns; brambo will not claim it`);
301
+ }
302
+ if (otherClaims.has(pathKey(path))) {
303
+ return refuse(`'${path}' is already claimed by another registry entry at this root; brambo will not claim one file twice`);
304
+ }
305
+ const bytes = await readIfPresent(path);
306
+ if (bytes === undefined) {
307
+ // The subset rule. Not a refusal: claiming what IS there is what makes a
308
+ // partially materialised tree leavable, and the ordinary run writes the
309
+ // rest back because the record it reads is `intact`.
310
+ absent += 1;
311
+ continue;
312
+ }
313
+ if (bytes === 'unreadable') {
314
+ return refuse(`'${path}' cannot be read, so brambo cannot hash what it would be claiming`);
315
+ }
316
+ byteLength += bytes.byteLength;
317
+ owned.push({
318
+ path,
319
+ contentHash: hashOwnedBytes(bytes),
320
+ canonicalHash: canonicalBytesHash(bytes),
321
+ });
322
+ }
323
+ if (owned.length === 0) {
324
+ return refuse(`nothing of '${entryId}' is on disk under '${root}'${absent === 0 ? '' : ` (${absent} path(s) brambo would claim are absent)`}; there is nothing to claim`);
325
+ }
326
+ return {
327
+ location,
328
+ byteLength,
329
+ ownedPaths: owned.map((item) => item.path),
330
+ removedNext: !plan.presentEntryIds.includes(entryId),
331
+ record: {
332
+ targetId: target.targetId,
333
+ filePath: root,
334
+ nativeLocation: location,
335
+ entryId,
336
+ contentHash: treeHash(root, owned),
337
+ ownedPaths: owned,
338
+ },
339
+ };
340
+ }
341
+ /**
342
+ * Runs one materialisation target: plan, classify against the ledger, then —
343
+ * under `apply` — land exactly the difference.
344
+ *
345
+ * Under inspection NOTHING touches the filesystem except reads, and
346
+ * `result.written` reads as "these paths WOULD change", which is the one field
347
+ * whose sentence the mode alters.
348
+ */
349
+ export async function materialiseTarget(target, entries, claimed, apply) {
350
+ const root = resolveOwnedPath(target.rootPath);
351
+ const { plan } = await planFor(target, entries, claimed, root);
352
+ const drift = [];
353
+ const records = [];
354
+ const skipped = [...(plan.skipped ?? [])];
355
+ const writes = [];
356
+ const candidateRemovals = [];
357
+ const keep = (record) => {
358
+ records.push(record);
359
+ };
360
+ // A record is authority ONLY if it claims PATHS, and only paths inside this
361
+ // root. Everything else is CARRIED THROUGH UNTOUCHED rather than dropped:
362
+ // persisting a reduced record set is how a one-run under-claim becomes a
363
+ // permanent orphan, which Story 2.8's review already declared terminal for
364
+ // the whole-ledger case. The same rule has to hold per record.
365
+ const authoritative = [];
366
+ for (const record of claimed) {
367
+ const owned = record.ownedPaths ?? [];
368
+ if (record.targetId !== target.targetId ||
369
+ !sameOwnedPath(resolveOwnedPath(record.filePath), root) ||
370
+ owned.length === 0) {
371
+ keep(record);
372
+ continue;
373
+ }
374
+ // THE ENTRY'S OWN DIRECTORY, not the root. Every path brambo materialises for
375
+ // an entry lives under `<root>/<nativeLocation>/` — driven on a real run:
376
+ // `mysk` owns `skills\mysk\SKILL.md` and `skills\mysk\nested\more.md`,
377
+ // never anything beside them. Containing to the ROOT made one entry's record
378
+ // authority over every sibling directory in it, and `ownedPaths` is a DELETE
379
+ // authority: a record for `mysk` claiming `usersk/NOTES.md` deleted a file
380
+ // brambo never wrote and pruned its directory, exit 0, EMPTY STDERR, zero
381
+ // drift. Present and hash-matching, so every verdict voted `intact` and the
382
+ // path reached `candidateRemovals` unopposed.
383
+ //
384
+ // `home` is resolved against the root FIRST, so a `nativeLocation` of `..`
385
+ // or an absolute path is caught by the same comparison rather than widening
386
+ // the boundary it is supposed to narrow.
387
+ const home = resolveOwnedPath(join(root, record.nativeLocation));
388
+ const escaping = isUnderRoot(home, root)
389
+ ? owned.find((item) => !isUnderRoot(resolveOwnedPath(item.path), home))
390
+ : owned[0];
391
+ if (escaping !== undefined) {
392
+ drift.push(driftEntry('foreign-collision', record.entryId, record.nativeLocation, `brambo's ledger claims '${escaping.path}' for '${record.entryId}', which is outside '${join(root, record.nativeLocation)}'; brambo will not touch a path it cannot prove it owns`));
393
+ keep(record);
394
+ continue;
395
+ }
396
+ authoritative.push(record);
397
+ }
398
+ const sizes = new Map();
399
+ const states = new Map();
400
+ for (const record of authoritative) {
401
+ const owned = record.ownedPaths ?? [];
402
+ let present = 0;
403
+ let exact = 0;
404
+ let canonical = 0;
405
+ let linked = false;
406
+ for (const item of owned) {
407
+ if (await traversesLink(item.path, root)) {
408
+ linked = true;
409
+ present += 1;
410
+ continue;
411
+ }
412
+ const bytes = await readIfPresent(item.path);
413
+ if (bytes === undefined)
414
+ continue;
415
+ present += 1;
416
+ if (bytes === 'unreadable')
417
+ continue;
418
+ sizes.set(item.path, bytes.byteLength);
419
+ if (hashOwnedBytes(bytes) === item.contentHash)
420
+ exact += 1;
421
+ // No `canonicalHash` means a record an older build wrote: fall back to the
422
+ // exact hash, which is the conservative direction for both predicates.
423
+ if (canonicalBytesHash(bytes) === (item.canonicalHash ?? item.contentHash))
424
+ canonical += 1;
425
+ }
426
+ const total = owned.length;
427
+ const verdict = (matches) => present === 0 ? 'gone' : !linked && present === total && matches === total ? 'intact' : 'edited';
428
+ states.set(record.entryId, { remove: verdict(exact), write: verdict(canonical) });
429
+ }
430
+ const wanted = new Map(plan.entries.map((entry) => [entry.entryId, entry]));
431
+ const registered = new Set(plan.presentEntryIds);
432
+ const byEntry = new Map(authoritative.map((record) => [record.entryId, record]));
433
+ // 1. Trees whose entry left the REGISTRY. The only removals brambo performs.
434
+ for (const record of [...authoritative].sort((a, b) => (a.entryId < b.entryId ? -1 : 1))) {
435
+ if (wanted.has(record.entryId))
436
+ continue;
437
+ if (registered.has(record.entryId)) {
438
+ // Registered, and this run could not render it (an unreadable source, an
439
+ // id brambo cannot use as a directory). The claim survives untouched, or
440
+ // the next run would treat brambo's own tree as foreign forever.
441
+ keep(record);
442
+ continue;
443
+ }
444
+ const state = states.get(record.entryId)?.remove;
445
+ if (state === 'gone')
446
+ continue;
447
+ if (state === 'edited') {
448
+ drift.push(driftEntry('edited', record.entryId, record.nativeLocation, `'${record.entryId}' under '${root}' is no longer byte-for-byte what brambo wrote, or is reached through a link; brambo will not remove a tree it no longer recognises`));
449
+ keep(record);
450
+ continue;
451
+ }
452
+ for (const owned of record.ownedPaths ?? [])
453
+ candidateRemovals.push(owned.path);
454
+ }
455
+ // 2. Trees the registry holds. Brambo writes only where it already owns the
456
+ // location or where the location is provably free.
457
+ for (const entry of plan.entries) {
458
+ const record = byEntry.get(entry.entryId);
459
+ const directory = absolutePathOf(root, entry.location, entry.entryId);
460
+ const planned = entry.files.map((file) => ({
461
+ file,
462
+ path: absolutePathOf(root, file.relativePath, entry.entryId),
463
+ }));
464
+ if (record === undefined) {
465
+ const state = await occupiedByContent(directory);
466
+ if (state.taken) {
467
+ drift.push(driftEntry('foreign-collision', entry.entryId, entry.location, `'${directory}' is not claimed by brambo's ledger and ${state.detail}; brambo will not resolve the collision`));
468
+ continue;
469
+ }
470
+ }
471
+ else {
472
+ const state = states.get(entry.entryId)?.write;
473
+ if (state === 'edited') {
474
+ drift.push(driftEntry('edited', entry.entryId, entry.location, `'${entry.entryId}' under '${root}' has been edited since brambo wrote it; brambo will not overwrite it`));
475
+ keep(record);
476
+ continue;
477
+ }
478
+ if (state === 'gone') {
479
+ drift.push(driftEntry('removed-by-user', entry.entryId, entry.location, `brambo wrote '${entry.entryId}' under '${root}' and it is gone; brambo will not re-add it`));
480
+ keep(record);
481
+ continue;
482
+ }
483
+ // Intact, but a file the plan wants may still be someone else's: a path
484
+ // inside brambo's directory that no record claims was put there by hand.
485
+ const owned = (record.ownedPaths ?? []).map((item) => pathKey(item.path));
486
+ const foreign = planned.find((item) => !owned.includes(pathKey(item.path)));
487
+ if (foreign !== undefined && (await occupied(foreign.path)).taken) {
488
+ drift.push(driftEntry('foreign-collision', entry.entryId, entry.location, `'${foreign.path}' exists and brambo's ledger does not claim it; brambo will not resolve the collision`));
489
+ keep(record);
490
+ continue;
491
+ }
492
+ }
493
+ // Every source read BEFORE the first byte lands, so a skill whose source
494
+ // cannot be read is reported with nothing of it partially materialised.
495
+ let sources;
496
+ try {
497
+ sources = await Promise.all(planned.map(async (item) => await readFile(item.file.sourcePath)));
498
+ }
499
+ catch (error) {
500
+ const code = error?.code ?? 'unknown error';
501
+ skipped.push({
502
+ entryId: entry.entryId,
503
+ reason: `'${entry.entryId}' names a source brambo cannot read (${code}); nothing was materialised for it`,
504
+ });
505
+ if (record !== undefined)
506
+ keep(record);
507
+ continue;
508
+ }
509
+ const newOwned = [];
510
+ for (const [index, item] of planned.entries()) {
511
+ const bytes = sources[index];
512
+ const contentHash = hashOwnedBytes(bytes);
513
+ const previous = await readIfPresent(item.path);
514
+ const disk = previous === 'unreadable' ? undefined : previous;
515
+ // Byte-exact, not canonical: this is the idempotence predicate, and it is
516
+ // also what quietly repairs a materialised file whose line endings were
517
+ // rewritten under brambo.
518
+ if (disk === undefined || hashOwnedBytes(disk) !== contentHash) {
519
+ writes.push({ path: item.path, bytes, previous: disk });
520
+ }
521
+ newOwned.push({ path: item.path, contentHash, canonicalHash: canonicalBytesHash(bytes) });
522
+ }
523
+ // A file that left the source is a file brambo still claims: taking it back
524
+ // is part of keeping the tree equal to the registry, and it is safe because
525
+ // the whole tree is `intact`.
526
+ if (record !== undefined) {
527
+ const keptPaths = newOwned.map((item) => pathKey(item.path));
528
+ for (const owned of record.ownedPaths ?? []) {
529
+ if (!keptPaths.includes(pathKey(owned.path)))
530
+ candidateRemovals.push(owned.path);
531
+ }
532
+ }
533
+ records.push({
534
+ targetId: target.targetId,
535
+ filePath: root,
536
+ nativeLocation: entry.location,
537
+ entryId: entry.entryId,
538
+ contentHash: treeHash(root, newOwned),
539
+ ownedPaths: newOwned,
540
+ });
541
+ }
542
+ // Clause 6, applied last because it needs the FINAL record set: a path some
543
+ // surviving record still claims is never removed, whichever entry scheduled
544
+ // it. Without this the survivor's file disappears with no drift and is then
545
+ // locked out as `removed-by-user` on the next run.
546
+ const stillClaimed = new Set(records.flatMap((record) => (record.ownedPaths ?? []).map((item) => pathKey(item.path))));
547
+ const removals = [];
548
+ for (const path of candidateRemovals) {
549
+ if (!stillClaimed.has(pathKey(path))) {
550
+ removals.push(path);
551
+ continue;
552
+ }
553
+ const location = relative(root, resolveOwnedPath(path)).split(sep).join('/');
554
+ drift.push(driftEntry('foreign-collision', location, location, `'${path}' is claimed by more than one registry entry at this root; brambo kept it rather than removing a file another entry still owns`));
555
+ }
556
+ const written = writes.length > 0 || removals.length > 0;
557
+ const byteDelta = writes.reduce((total, write) => total + Math.abs(write.bytes.byteLength - (write.previous?.byteLength ?? 0)), 0) + removals.reduce((total, path) => total + (sizes.get(path) ?? 0), 0);
558
+ if (apply && written)
559
+ await land(writes, removals, root);
560
+ return {
561
+ result: {
562
+ targetId: target.targetId,
563
+ written,
564
+ byteDelta,
565
+ drift,
566
+ skippedEntryIds: [...skipped.map((item) => item.entryId)].sort((a, b) => a < b ? -1 : a > b ? 1 : 0),
567
+ skipped,
568
+ },
569
+ records,
570
+ };
571
+ }
572
+ /**
573
+ * Lands the plan, or leaves the filesystem as it found it.
574
+ *
575
+ * Writes come first and every one of them is reversible — a created file is
576
+ * deleted again, an overwritten one is restored from the bytes read before the
577
+ * write — so a failure halfway through a tree leaves no partial tree behind.
578
+ *
579
+ * ponytail: the removals that follow are not reversible, and they are last for
580
+ * that reason. A removal that fails after earlier ones succeeded leaves those
581
+ * entries still claimed in the ledger (the target failed, so no ledger write
582
+ * happens) and the next run finishes the job. Upgrade path: a staging directory
583
+ * per target, which buys real transactionality at the price of copying every
584
+ * tree twice.
585
+ */
586
+ async function land(writes, removals, root) {
587
+ const applied = [];
588
+ try {
589
+ for (const write of writes) {
590
+ await mkdir(dirname(write.path), { recursive: true });
591
+ await atomicWriteBytes(write.path, write.bytes);
592
+ applied.push(write);
593
+ }
594
+ }
595
+ catch (error) {
596
+ for (const write of [...applied].reverse()) {
597
+ if (write.previous === undefined) {
598
+ await rm(write.path, { force: true }).catch(() => { });
599
+ await pruneEmptyDirectories(dirname(write.path), root);
600
+ }
601
+ else {
602
+ await atomicWriteBytes(write.path, write.previous).catch(() => { });
603
+ }
604
+ }
605
+ throw error;
606
+ }
607
+ const pruneFrom = new Set();
608
+ for (const path of removals) {
609
+ // Last line of defence, and cheap: nothing reaches `rm` without being
610
+ // inside the root brambo owns. The list was filtered on the same predicate
611
+ // when it was built; this is the copy that runs next to the syscall.
612
+ const resolved = resolveOwnedPath(path);
613
+ if (!isUnderRoot(resolved, root))
614
+ continue;
615
+ await rm(resolved, { force: true });
616
+ pruneFrom.add(dirname(resolved));
617
+ }
618
+ for (const directory of pruneFrom)
619
+ await pruneEmptyDirectories(directory, root);
620
+ }
@@ -0,0 +1,65 @@
1
+ import type { ProjectionTarget, RegistryEntriesByKind, RemediationOutcome } from '@skanl/brambo-contracts';
2
+ import type { ProjectionMode } from './engine.ts';
3
+ import type { FileFormat } from './formats.ts';
4
+ import type { ProjectionLedger } from './ledger.ts';
5
+ /** A vendor file that may still hold brambo's own prior output (correction-01 C6). */
6
+ export interface LegacyBlockLocation {
7
+ /** The executor's target id, carried through so a caller can attribute the row. */
8
+ readonly targetId: string;
9
+ readonly filePath: string;
10
+ readonly fileFormat: FileFormat;
11
+ /**
12
+ * The scope directory this file must lie inside — the home directory for the
13
+ * machine scope, the project root for a project. Brambo derives `filePath` from
14
+ * its own catalogue, and this is the check that says so out loud rather than
15
+ * trusting it: a caller reaching this API from outside the CLI supplies both.
16
+ */
17
+ readonly rootPath: string;
18
+ }
19
+ interface RemediationBase {
20
+ /**
21
+ * Defaults to `'inspect'` — the OPPOSITE default from `runProjection`, and the
22
+ * same one `remediate` in `@skanl/brambo-environment` uses.
23
+ *
24
+ * Two exported layers of one operation with opposite defaults is how the
25
+ * describe-before-act guarantee becomes true of the command and false of the
26
+ * SDK surface underneath it, and this is on the FR-29 surface where untyped
27
+ * callers reach it. Under `'inspect'` NOTHING is written — not the ledger, not
28
+ * a vendor file — and `changes` reads as "these are the changes this would
29
+ * make", which is the one field whose sentence the mode alters.
30
+ */
31
+ readonly mode?: ProjectionMode;
32
+ }
33
+ export interface AdoptRemediationOptions extends RemediationBase {
34
+ readonly remediation: 'adopt';
35
+ readonly target: ProjectionTarget;
36
+ readonly entryId: string;
37
+ /** What the registry wants; a materialisation target plans its files from it. */
38
+ readonly entries: RegistryEntriesByKind;
39
+ readonly ledger: ProjectionLedger;
40
+ }
41
+ export interface ReleaseRemediationOptions extends RemediationBase {
42
+ readonly remediation: 'release';
43
+ readonly target: ProjectionTarget;
44
+ readonly entryId: string;
45
+ readonly ledger: ProjectionLedger;
46
+ }
47
+ export interface RepairRemediationOptions extends RemediationBase {
48
+ readonly remediation: 'repair';
49
+ readonly ledger: ProjectionLedger;
50
+ }
51
+ export interface DiscardRemediationOptions extends RemediationBase {
52
+ readonly remediation: 'discard';
53
+ readonly legacy: LegacyBlockLocation;
54
+ }
55
+ export type RunRemediationOptions = AdoptRemediationOptions | ReleaseRemediationOptions | RepairRemediationOptions | DiscardRemediationOptions;
56
+ /**
57
+ * Performs — or, under `'inspect'`, describes — exactly one remediation.
58
+ *
59
+ * The caller names the verb and its subject; nothing else is touched, and
60
+ * nothing runs by default. A state brambo will not leave is reported as a coded
61
+ * refusal in the result rather than thrown, because under inspection "brambo will
62
+ * not do this, and here is why" is part of the description.
63
+ */
64
+ export declare function runRemediation(options: RunRemediationOptions): Promise<RemediationOutcome>;
65
+ export {};