@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
|
@@ -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 {};
|