@ybotok/loam 0.0.0 → 0.1.0-beta.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,783 @@
1
+ /**
2
+ * The commit machinery `archive` and `unarchive` share: stage every new file
3
+ * version beside its target, swap the staged versions in, roll back what
4
+ * already went in when a later step fails — plus the undo snapshot `archive`
5
+ * writes and `unarchive` honours. Moved here verbatim from archive.ts once
6
+ * unarchive became its second consumer; the MERGE computations stay with
7
+ * archive, which is still their only caller.
8
+ */
9
+ import { isUtf8 } from "node:buffer";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync } from "node:fs";
12
+ import { link, mkdir, open, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
13
+ import { hostname } from "node:os";
14
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { repoPath } from "./json.js";
16
+ /** The one place a planned write becomes bytes. */
17
+ function toBytes(content) {
18
+ return typeof content === "string" ? Buffer.from(content, "utf8") : content;
19
+ }
20
+ /**
21
+ * The one place that decides whether a planned write is a CREATE.
22
+ *
23
+ * `exclusive` is a no-clobber create (link(2)), so it can only be asked of a
24
+ * path that does not exist yet: asking it of an overwrite would fail EEXIST on
25
+ * every single run. Deciding here — rather than at each of the dozen
26
+ * `writes.push` sites in archive and unarchive — is what keeps the two commands
27
+ * from disagreeing about which of their writes may safely lose a race. An
28
+ * OVERWRITE is not left unguarded: `swapStaged` compares the target against the
29
+ * pre-image immediately before the rename, so a file another writer changed
30
+ * under us stops the commit instead of silently burying their bytes.
31
+ */
32
+ export function planWrite(path, content) {
33
+ if (content !== null && !existsSync(path))
34
+ return { path, content, exclusive: true };
35
+ return { path, content };
36
+ }
37
+ /* ------------------------------------------------------------------ */
38
+ /* Bytes in, text only where a parser needs it */
39
+ /* ------------------------------------------------------------------ */
40
+ /**
41
+ * A file loam must PARSE, whose bytes are not UTF-8.
42
+ *
43
+ * Commands map this to `merge-failed`: nothing was written, the file is named,
44
+ * and re-saving it as UTF-8 makes the command work. The read-only migration
45
+ * ingest already refuses non-UTF-8 input outright (openspec-inventory.ts) —
46
+ * the destructive path has strictly more to lose, so it refuses the same way
47
+ * rather than substituting U+FFFD and rewriting the file over the author.
48
+ */
49
+ export class NotUtf8Error extends Error {
50
+ path;
51
+ name = "NotUtf8Error";
52
+ constructor(path) {
53
+ super(`${path} is not valid UTF-8 — loam parses this file as text and writes it back, which would replace every ` +
54
+ `undecodable byte with U+FFFD, in the living document AND in the undo snapshot. Nothing was written. ` +
55
+ `Re-save it as UTF-8, then re-run.`);
56
+ this.path = path;
57
+ }
58
+ }
59
+ /** Decode bytes a parser is about to be handed, or refuse naming the file. */
60
+ export function decodeUtf8(bytes, path) {
61
+ if (!isUtf8(bytes))
62
+ throw new NotUtf8Error(path);
63
+ return bytes.toString("utf8");
64
+ }
65
+ /** Read a file loam is going to parse. The only supported way to turn a docs file into a string. */
66
+ export async function readUtf8(path) {
67
+ return decodeUtf8(await readFile(path), path);
68
+ }
69
+ /** Same bytes? Absence is its own value: a file that is gone is not a file that is empty. */
70
+ function sameBytes(a, b) {
71
+ return a === null || b === null ? a === b : a.equals(b);
72
+ }
73
+ /* ------------------------------------------------------------------ */
74
+ /* The docs-repo advisory lock */
75
+ /* ------------------------------------------------------------------ */
76
+ /** The lock file, in the docs repo root — one writer at a time across the whole fleet. */
77
+ export const DOCS_LOCK = ".loam-lock";
78
+ /** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */
79
+ export class DocsBusyError extends Error {
80
+ lockPath;
81
+ name = "DocsBusyError";
82
+ constructor(lockPath, holder) {
83
+ super(`another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +
84
+ `Wait for it to finish, or remove ${lockPath} if no such command is running.`);
85
+ this.lockPath = lockPath;
86
+ }
87
+ }
88
+ /**
89
+ * The target changed between the pre-image staging read and the swap.
90
+ *
91
+ * The pre-image is what a rollback restores and what `unarchive` is handed, so
92
+ * swapping over somebody else's newer bytes would both lose their work and
93
+ * arm a rollback that writes a third version nobody wrote. Commands map this to
94
+ * `merge-failed`: nothing of theirs went in, the docs are as the other writer
95
+ * left them, and re-running can work.
96
+ */
97
+ export class StagingRaceError extends Error {
98
+ path;
99
+ name = "StagingRaceError";
100
+ constructor(path) {
101
+ super(`${path} changed while this command was planning its merge — nothing was written; re-run`);
102
+ this.path = path;
103
+ }
104
+ }
105
+ /**
106
+ * Take the docs repo's advisory lock for the whole plan+commit window, and
107
+ * return the release.
108
+ *
109
+ * Advisory, and deliberately coarse: the destructive commands write the SAME
110
+ * three shared files (a service spec, a service contract, the one landscape),
111
+ * so two of them overlapping is not a merge to be resolved but a merge one of
112
+ * them computed against a snapshot that no longer exists. Two archives used to
113
+ * plan against the same landscape bytes and the second's splice silently
114
+ * dropped the first's additions — exit 0 on both, and a `validate` that stayed
115
+ * green because the surviving document was perfectly coherent.
116
+ *
117
+ * O_EXCL create is the primitive: it is atomic on every filesystem loam runs
118
+ * on, needs no daemon, and leaves a file a human can read and delete. A holder
119
+ * that died without releasing would otherwise wedge the repo forever, so a lock
120
+ * naming a pid on THIS host that no longer exists is broken once and retried —
121
+ * two racers could both break the same stale lock, which is why the pre-image
122
+ * check in `swapStaged` remains the second line of defence rather than an
123
+ * optimisation.
124
+ */
125
+ export async function acquireDocsLock(docsDir) {
126
+ const path = join(docsDir, DOCS_LOCK);
127
+ const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\n`;
128
+ for (let attempt = 0;; attempt += 1) {
129
+ try {
130
+ await writeFile(path, payload, { flag: "wx" });
131
+ }
132
+ catch (err) {
133
+ if (err.code !== "EEXIST")
134
+ throw err;
135
+ if (attempt === 0 && (await breakStaleLock(path)))
136
+ continue;
137
+ throw new DocsBusyError(path, await describeLock(path));
138
+ }
139
+ let released = false;
140
+ return async () => {
141
+ if (released)
142
+ return;
143
+ released = true;
144
+ await quietRm(path);
145
+ };
146
+ }
147
+ }
148
+ /** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */
149
+ async function breakStaleLock(path) {
150
+ let holder;
151
+ try {
152
+ holder = JSON.parse(await readFile(path, "utf8"));
153
+ }
154
+ catch {
155
+ // Unreadable or not ours to interpret — treat it as held. A lock file we
156
+ // cannot understand is the one case where guessing is worst.
157
+ return false;
158
+ }
159
+ if (holder.host !== hostname() || typeof holder.pid !== "number")
160
+ return false;
161
+ try {
162
+ // Signal 0 asks "does this pid exist" without delivering anything. EPERM
163
+ // means it exists and belongs to somebody else — alive either way.
164
+ process.kill(holder.pid, 0);
165
+ return false;
166
+ }
167
+ catch (err) {
168
+ if (err.code !== "ESRCH")
169
+ return false;
170
+ }
171
+ try {
172
+ await unlink(path);
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ async function describeLock(path) {
180
+ try {
181
+ const raw = JSON.parse(await readFile(path, "utf8"));
182
+ return `pid ${String(raw.pid)} on ${String(raw.host)} since ${String(raw.at)}`;
183
+ }
184
+ catch {
185
+ return `see ${path}`;
186
+ }
187
+ }
188
+ /**
189
+ * Write every new version to a temp file beside its target, remembering what the
190
+ * target said first. Nothing observable changes here: on failure the temp files
191
+ * are removed and the docs are exactly as they were.
192
+ *
193
+ * The pre-image is read eagerly and deliberately: it is what a rollback restores
194
+ * and what `unarchive` is given, so a file we cannot read is a file we cannot
195
+ * safely rewrite, and the merge must stop before it starts.
196
+ */
197
+ export async function stageWrites(writes) {
198
+ const staged = [];
199
+ try {
200
+ for (const [i, write] of writes.entries()) {
201
+ // mkdir(recursive) reports the topmost directory it had to create — the
202
+ // handle a rollback needs to put the tree back the way it found it.
203
+ const createdDir = (await mkdir(dirname(write.path), { recursive: true })) ?? null;
204
+ const before = existsSync(write.path) ? await readFile(write.path) : null;
205
+ const content = toBytes(write.content);
206
+ let tmp = null;
207
+ if (content !== null) {
208
+ tmp = tempPath(write.path, i);
209
+ await writeFile(tmp, content);
210
+ }
211
+ staged.push({ write, content, tmp, before, createdDir, swapped: false });
212
+ }
213
+ }
214
+ catch (err) {
215
+ await discardStaged(staged);
216
+ throw err;
217
+ }
218
+ return staged;
219
+ }
220
+ /**
221
+ * Swap the staged versions in, one file at a time. Each swap is a rename(2)
222
+ * within a single directory, so a concurrent reader sees either the old bytes or
223
+ * the new ones — never a half-written file. Across files it is not atomic: the
224
+ * caller rolls back what has already gone in.
225
+ *
226
+ * Every swap re-reads its target first and compares it with the pre-image the
227
+ * plan was computed from — a compare-and-set on the file's whole content. It is
228
+ * half a CAS on purpose: read and rename cannot be one syscall, so a writer
229
+ * landing inside that window still wins. What it does buy is the window that
230
+ * actually matters — the seconds a plan spends parsing LikeC4 and merging YAML
231
+ * between the pre-image read and this swap — and it costs one read per file.
232
+ */
233
+ export async function swapStaged(staged) {
234
+ for (const s of staged) {
235
+ const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;
236
+ if (!sameBytes(now, s.before))
237
+ throw new StagingRaceError(s.write.path);
238
+ if (s.tmp === null) {
239
+ if (s.before !== null)
240
+ await unlink(s.write.path);
241
+ }
242
+ else if (s.write.exclusive === true) {
243
+ // link(2) is the same-filesystem, atomic no-clobber counterpart to rename:
244
+ // it fails with EEXIST instead of replacing a file created after planning.
245
+ await link(s.tmp, s.write.path);
246
+ s.swapped = true;
247
+ await unlink(s.tmp);
248
+ s.tmp = null;
249
+ continue;
250
+ }
251
+ else {
252
+ await rename(s.tmp, s.write.path);
253
+ }
254
+ s.swapped = true;
255
+ }
256
+ }
257
+ /**
258
+ * Put back every file that was already swapped, newest first, from the bytes read
259
+ * before the swap. Returns the paths it could NOT restore — the caller must say so
260
+ * out loud rather than report a clean failure over a half-merged repo.
261
+ *
262
+ * A rollback only ever un-does ITS OWN write: if the file no longer holds the
263
+ * bytes this run put there, somebody else has written it since, and restoring
264
+ * the pre-image would destroy their work while reporting a clean failure. That
265
+ * file is left exactly as found and named in the failures, which is what turns
266
+ * the caller's answer from `merge-failed` ("trust the repo") into
267
+ * `rollback-incomplete` ("look at it by hand") — the honest answer, because the
268
+ * repo now holds one run's writes half-reverted next to another's.
269
+ */
270
+ export async function rollbackStaged(staged) {
271
+ const failures = [];
272
+ for (const s of [...staged].reverse()) {
273
+ if (!s.swapped)
274
+ continue;
275
+ try {
276
+ const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;
277
+ if (!sameBytes(now, s.content)) {
278
+ failures.push(`${s.write.path} (changed by another writer after this run wrote it — left as found)`);
279
+ continue;
280
+ }
281
+ if (s.before === null)
282
+ await rm(s.write.path, { force: true });
283
+ else
284
+ await atomicWrite(s.write.path, s.before);
285
+ s.swapped = false;
286
+ }
287
+ catch (err) {
288
+ failures.push(`${s.write.path} (${message(err)})`);
289
+ }
290
+ }
291
+ await discardStaged(staged);
292
+ return failures;
293
+ }
294
+ /** Write `content` to `path` through a temp file in the same directory. */
295
+ async function atomicWrite(path, content) {
296
+ const tmp = tempPath(path, 0);
297
+ await writeFile(tmp, content);
298
+ await rename(tmp, path);
299
+ }
300
+ /** A hidden sibling of `path`: same directory, so the rename never crosses a filesystem. */
301
+ function tempPath(path, n) {
302
+ return join(dirname(path), `.${basename(path)}.loam-${process.pid}-${n}-${Date.now()}.tmp`);
303
+ }
304
+ /** The shape `tempPath` writes — what a scan for leftovers of a killed run matches on. */
305
+ const TEMP_FILE_RE = /^\..*\.loam-\d+-\d+-\d+\.tmp$/;
306
+ /**
307
+ * Everything staging left on disk: the temp files, and the directories it made
308
+ * for them.
309
+ *
310
+ * Directories go by rmdir, never by recursive remove, and the walk stops the
311
+ * moment one is not empty. The recursive form used to be reached for because
312
+ * "we created this directory, so it is ours" — but a directory created at plan
313
+ * time is shared by the time the rollback runs: a second archive can have put a
314
+ * whole feature inside `features/archive/` in between, and `rm -r` on our own
315
+ * created root took their work with it, silently, on OUR failure path.
316
+ */
317
+ async function discardStaged(staged) {
318
+ for (const s of staged)
319
+ if (s.tmp !== null)
320
+ await quietRm(s.tmp);
321
+ const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir));
322
+ for (const s of [...staged].reverse()) {
323
+ const root = roots.find((candidate) => contains(candidate, s.write.path));
324
+ if (root !== undefined)
325
+ await quietPruneEmptyParents(dirname(s.write.path), root);
326
+ }
327
+ }
328
+ function contains(parent, candidate) {
329
+ const rel = relative(parent, resolve(candidate));
330
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
331
+ }
332
+ /**
333
+ * Undo the directories a write had to create, from `start` up to and including
334
+ * `boundary`. Remove only directories that are still EMPTY: anything else in
335
+ * there arrived after we made it, and belongs to whoever put it there.
336
+ */
337
+ export async function quietPruneEmptyParents(start, boundary) {
338
+ let cursor = resolve(start);
339
+ const root = resolve(boundary);
340
+ while (contains(root, cursor)) {
341
+ try {
342
+ await rmdir(cursor);
343
+ }
344
+ catch {
345
+ return;
346
+ }
347
+ if (cursor === root)
348
+ return;
349
+ const parent = dirname(cursor);
350
+ if (parent === cursor)
351
+ return;
352
+ cursor = parent;
353
+ }
354
+ }
355
+ export async function quietRm(path) {
356
+ try {
357
+ await rm(path, { recursive: true, force: true });
358
+ }
359
+ catch {
360
+ // Best effort — a leftover temp file is noise, not corruption.
361
+ }
362
+ }
363
+ /** Say what the failure cost: nothing, or a repo that needs looking at by hand. */
364
+ export function rollbackError(err, failures) {
365
+ if (failures.length === 0) {
366
+ return new Error(`${message(err)} — the living docs were rolled back, nothing was merged`);
367
+ }
368
+ return new Error(`${message(err)} — ROLLBACK INCOMPLETE, these files may be half-merged and need checking by hand: ${failures.join(", ")}`);
369
+ }
370
+ export function message(err) {
371
+ return err instanceof Error ? err.message : String(err);
372
+ }
373
+ /* ------------------------------------------------------------------ */
374
+ /* The undo snapshot */
375
+ /* ------------------------------------------------------------------ */
376
+ /**
377
+ * Where archive parks the bytes it is about to overwrite, inside the feature
378
+ * directory so it travels with it into `features/archive/`.
379
+ *
380
+ * It exists because the merge is not invertible: a MODIFIED requirement's
381
+ * previous text appears nowhere in the delta, and a landscape rewritten by hand
382
+ * since cannot be un-rewritten by re-reading the delta either. `unarchive` puts
383
+ * bytes back; it does not recompute them.
384
+ */
385
+ export const SNAPSHOT_DIR = ".loam-before";
386
+ export const SNAPSHOT_MANIFEST = "manifest.json";
387
+ /**
388
+ * Bumped only when the layout changes in a way `unarchive` must refuse to guess
389
+ * at. 2 added `before` — a version-1 snapshot records nothing about the bytes it
390
+ * would RESTORE, so nothing can tell an intact pre-image from an edited one, and
391
+ * "I put your docs back" over a silently altered pre-image is the one sentence
392
+ * this command must never be able to say by accident.
393
+ */
394
+ export const SNAPSHOT_VERSION = 2;
395
+ /** A snapshot directory that must not be replaced, because it is the only copy of the true pre-images. */
396
+ export class SnapshotClobberError extends Error {
397
+ dir;
398
+ name = "SnapshotClobberError";
399
+ constructor(dir, detail) {
400
+ super(`${dir} already holds an undo snapshot that does NOT match the living docs (${detail}) — a previous archive of ` +
401
+ `this feature wrote to the living docs and never took them back, and these are the only copies of what they ` +
402
+ `said before. Overwriting them would make that merge permanent and unreportable. Run \`loam archive\` again ` +
403
+ `after restoring the living docs from version control, or delete ${SNAPSHOT_DIR}/ if you know the merge never landed.`);
404
+ this.dir = dir;
405
+ }
406
+ }
407
+ export function snapshotDir(featureDir) {
408
+ return join(featureDir, SNAPSHOT_DIR);
409
+ }
410
+ export function sha256(content) {
411
+ return typeof content === "string"
412
+ ? createHash("sha256").update(content, "utf8").digest("hex")
413
+ : createHash("sha256").update(content).digest("hex");
414
+ }
415
+ export async function writeSnapshot(featureDir, docsDir, featureId, dirName, staged) {
416
+ const dir = snapshotDir(featureDir);
417
+ await assertSnapshotReplaceable(dir, docsDir);
418
+ // A leftover from a rolled-back run would describe a merge that never happened.
419
+ await quietRm(dir);
420
+ const files = [];
421
+ for (const s of staged) {
422
+ const rel = repoPath(docsDir, s.write.path);
423
+ files.push({
424
+ path: rel,
425
+ existed: s.before !== null,
426
+ after: sha256(s.content ?? Buffer.alloc(0)),
427
+ before: s.before === null ? null : sha256(s.before),
428
+ });
429
+ if (s.before !== null) {
430
+ const dest = join(dir, "files", rel);
431
+ await mkdir(dirname(dest), { recursive: true });
432
+ await writeFile(dest, s.before);
433
+ }
434
+ }
435
+ const manifest = {
436
+ version: SNAPSHOT_VERSION,
437
+ feature: featureId,
438
+ dirName,
439
+ archivedAt: new Date().toISOString(),
440
+ files,
441
+ };
442
+ // Manifest last: its presence is what says the pre-images beside it are complete.
443
+ await mkdir(dir, { recursive: true });
444
+ await writeFile(join(dir, SNAPSHOT_MANIFEST), JSON.stringify(manifest, null, 2) + "\n", "utf8");
445
+ }
446
+ /**
447
+ * Refuse to replace a snapshot whose pre-images are still the only record of
448
+ * what the living docs said.
449
+ *
450
+ * A snapshot in an ACTIVE feature directory means a previous archive of this
451
+ * same feature wrote it — and since a clean rollback deletes it, finding one
452
+ * means that run either died mid-commit or reported `rollback-incomplete`. If
453
+ * its pre-images still match the living docs, nothing of that run landed and
454
+ * the snapshot is stale bookkeeping. If they do not, the living docs carry
455
+ * that run's writes, these files are the only way back, and the unconditional
456
+ * clear turned a repairable half-merge into a permanent one — after which
457
+ * `unarchive` restored the half-merged text and said the docs were back.
458
+ */
459
+ async function assertSnapshotReplaceable(dir, docsDir) {
460
+ const manifestPath = join(dir, SNAPSHOT_MANIFEST);
461
+ // The manifest is written LAST, so a `files/` tree without one is a snapshot
462
+ // whose run died before it could swap anything: it describes no merge.
463
+ if (!existsSync(manifestPath))
464
+ return;
465
+ let manifest;
466
+ try {
467
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
468
+ }
469
+ catch {
470
+ throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} cannot be read, so nothing can say whether it is stale`);
471
+ }
472
+ const parsed = manifest;
473
+ if (parsed.version !== SNAPSHOT_VERSION || !Array.isArray(parsed.files)) {
474
+ throw new SnapshotClobberError(dir, `it was written by a different loam and cannot be checked against the living docs`);
475
+ }
476
+ const landed = [];
477
+ for (const entry of parsed.files) {
478
+ if (typeof entry?.path !== "string") {
479
+ throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} is malformed, so nothing can say whether it is stale`);
480
+ }
481
+ const target = join(docsDir, ...entry.path.split("/"));
482
+ const now = existsSync(target) ? sha256(await readFile(target)) : null;
483
+ // `existed: false` means archive created the file, so "nothing landed"
484
+ // means it is still absent.
485
+ const untouched = entry.existed ? now === entry.before : now === null;
486
+ if (!untouched)
487
+ landed.push(entry.path);
488
+ }
489
+ if (landed.length > 0)
490
+ throw new SnapshotClobberError(dir, `${landed.length} file(s) already carry that merge: ${landed.join(", ")}`);
491
+ }
492
+ /* ------------------------------------------------------------------ */
493
+ /* The commit intent record */
494
+ /* ------------------------------------------------------------------ */
495
+ /**
496
+ * The journal that makes an interrupted commit detectable.
497
+ *
498
+ * `swapStaged` is N renames, and only each ONE of them is atomic. A kill
499
+ * between two of them leaves the living docs half-merged — and nothing could
500
+ * see it: `doctor` and `status` called the repo healthy, `validate --all`
501
+ * blamed the delta, and the next archive cleared the one snapshot that could
502
+ * have repaired it. So the swap loop now runs inside a record written and
503
+ * fsynced BEFORE the first rename and removed after the last step of the
504
+ * commit: its mere presence says a commit was in flight, and its per-path
505
+ * digests say, file by file, whether that file's rename landed.
506
+ *
507
+ * It is deliberately NOT a source of truth and never disagrees with the files:
508
+ * every judgement it supports is made by re-reading the living bytes and
509
+ * comparing digests, and every byte a repair writes comes from the snapshot
510
+ * and is verified against the digest recorded before the crash. Outside the
511
+ * commit window it does not exist.
512
+ */
513
+ export const COMMIT_INTENT = ".loam-commit";
514
+ export const COMMIT_INTENT_VERSION = 1;
515
+ /** An interrupted commit this loam must not silently write over. */
516
+ export class InterruptedCommitError extends Error {
517
+ name = "InterruptedCommitError";
518
+ }
519
+ /**
520
+ * Write the intent record and get it onto the disk before the first swap.
521
+ *
522
+ * fsync, not just write: the point of the record is to survive the crash that
523
+ * interrupts the commit, and a record still sitting in the page cache when the
524
+ * machine dies describes nothing. The directory entry is fsynced too where the
525
+ * platform allows opening a directory — without that the file's bytes can be
526
+ * durable while its name is not.
527
+ */
528
+ export async function writeCommitIntent(docsDir, spec, staged) {
529
+ const intent = {
530
+ version: COMMIT_INTENT_VERSION,
531
+ command: spec.command,
532
+ restore: spec.restore,
533
+ pid: process.pid,
534
+ host: hostname(),
535
+ at: new Date().toISOString(),
536
+ feature: spec.feature,
537
+ moveFrom: repoPath(docsDir, spec.moveFrom),
538
+ moveTo: repoPath(docsDir, spec.moveTo),
539
+ files: staged.map((s) => ({
540
+ path: repoPath(docsDir, s.write.path),
541
+ before: s.before === null ? null : sha256(s.before),
542
+ after: s.content === null ? null : sha256(s.content),
543
+ })),
544
+ };
545
+ const path = join(docsDir, COMMIT_INTENT);
546
+ const handle = await open(path, "w");
547
+ try {
548
+ await handle.writeFile(JSON.stringify(intent, null, 2) + "\n", "utf8");
549
+ await handle.sync();
550
+ }
551
+ finally {
552
+ await handle.close();
553
+ }
554
+ try {
555
+ const dir = await open(docsDir, "r");
556
+ try {
557
+ await dir.sync();
558
+ }
559
+ finally {
560
+ await dir.close();
561
+ }
562
+ }
563
+ catch {
564
+ // Not every platform lets a directory be opened; the record's own bytes are
565
+ // flushed either way, which is the part that carries the digests.
566
+ }
567
+ }
568
+ /** The commit finished (or was rolled back): there is nothing left to recover from. */
569
+ export async function clearCommitIntent(docsDir) {
570
+ await quietRm(join(docsDir, COMMIT_INTENT));
571
+ }
572
+ /** The intent record as written, or null when it is absent or unreadable. */
573
+ export async function readCommitIntent(docsDir) {
574
+ const path = join(docsDir, COMMIT_INTENT);
575
+ if (!existsSync(path))
576
+ return null;
577
+ let parsed;
578
+ try {
579
+ parsed = JSON.parse(await readFile(path, "utf8"));
580
+ }
581
+ catch {
582
+ return null;
583
+ }
584
+ const i = parsed;
585
+ if (i === null || typeof i !== "object")
586
+ return null;
587
+ if (i.version !== COMMIT_INTENT_VERSION)
588
+ return null;
589
+ if (i.command !== "archive" && i.command !== "unarchive")
590
+ return null;
591
+ if (i.restore !== "before" && i.restore !== "after")
592
+ return null;
593
+ if (typeof i.feature !== "string" || typeof i.moveFrom !== "string" || typeof i.moveTo !== "string")
594
+ return null;
595
+ if (!Array.isArray(i.files))
596
+ return null;
597
+ for (const f of i.files) {
598
+ if (typeof f?.path !== "string")
599
+ return null;
600
+ if (!isDigestOrNull(f.before) || !isDigestOrNull(f.after))
601
+ return null;
602
+ }
603
+ return i;
604
+ }
605
+ function isDigestOrNull(value) {
606
+ return value === null || (typeof value === "string" && /^[0-9a-f]{64}$/.test(value));
607
+ }
608
+ /**
609
+ * Refuse or repair before this run reads anything. Returns null when there is
610
+ * nothing to recover.
611
+ *
612
+ * Repair is only ever the bytes the interrupted run already had on disk: each
613
+ * file still in the wrong state is rewritten from the snapshot pre-image and
614
+ * only after that pre-image's own sha256 matches the digest the record captured
615
+ * before the crash. A file in NEITHER state was written by somebody else since,
616
+ * so this refuses and names it rather than choosing which of two truths to
617
+ * destroy — the same doctrine `rollbackStaged` follows when it leaves a file as
618
+ * found.
619
+ */
620
+ export async function recoverInterruptedCommit(docsDir) {
621
+ const path = join(docsDir, COMMIT_INTENT);
622
+ if (!existsSync(path))
623
+ return null;
624
+ const intent = await readCommitIntent(docsDir);
625
+ if (intent === null) {
626
+ throw new InterruptedCommitError(`${repoPath(docsDir, path)} records a commit this loam cannot read, so it cannot tell a half-merged docs repo ` +
627
+ `from a healthy one. Check the living docs against version control, delete ${COMMIT_INTENT}, then re-run.`);
628
+ }
629
+ const moveFrom = join(docsDir, ...intent.moveFrom.split("/"));
630
+ const moveTo = join(docsDir, ...intent.moveTo.split("/"));
631
+ // The move is the commit's LAST step. If it landed, every swap before it did
632
+ // too, and only the record's own removal was lost.
633
+ if (existsSync(moveTo) && !existsSync(moveFrom)) {
634
+ await finishRecovery(docsDir, intent);
635
+ return { command: intent.command, feature: intent.feature, outcome: "completed", repaired: [] };
636
+ }
637
+ const target = (rel) => join(docsDir, ...rel.split("/"));
638
+ const wanted = (f) => (intent.restore === "before" ? f.before : f.after);
639
+ const other = (f) => (intent.restore === "before" ? f.after : f.before);
640
+ const pending = [];
641
+ const unknown = [];
642
+ for (const f of intent.files) {
643
+ const file = target(f.path);
644
+ const now = existsSync(file) ? sha256(await readFile(file)) : null;
645
+ if (now === wanted(f))
646
+ continue;
647
+ if (now === other(f))
648
+ pending.push(f);
649
+ else
650
+ unknown.push(f.path);
651
+ }
652
+ if (unknown.length > 0) {
653
+ throw new InterruptedCommitError(`a \`loam ${intent.command} ${intent.feature}\` was interrupted mid-commit (pid ${intent.pid} on ${intent.host}, ` +
654
+ `${intent.at}) and ${unknown.length} of the file(s) it was writing now hold bytes that are neither what it ` +
655
+ `found nor what it wrote — somebody edited them since: ${unknown.join(", ")}. loam will not choose between ` +
656
+ `those two truths. Reconcile them against version control, delete ${COMMIT_INTENT}, then re-run.`);
657
+ }
658
+ if (pending.length === 0) {
659
+ await finishRecovery(docsDir, intent);
660
+ return { command: intent.command, feature: intent.feature, outcome: "consistent", repaired: [] };
661
+ }
662
+ // The pre-images travel inside the feature directory, so they are at one of
663
+ // the two ends of the move depending on how far the interrupted run got.
664
+ const roots = [join(moveFrom, SNAPSHOT_DIR, "files"), join(moveTo, SNAPSHOT_DIR, "files")];
665
+ const repaired = [];
666
+ for (const f of pending) {
667
+ const file = target(f.path);
668
+ const want = wanted(f);
669
+ if (want === null) {
670
+ await rm(file, { force: true });
671
+ await quietRmdir(dirname(file));
672
+ repaired.push(f.path);
673
+ continue;
674
+ }
675
+ const source = roots.map((r) => join(r, ...f.path.split("/"))).find((p) => existsSync(p));
676
+ if (source === undefined) {
677
+ throw new InterruptedCommitError(`a \`loam ${intent.command} ${intent.feature}\` was interrupted mid-commit and ${f.path} is half-written, but ` +
678
+ `the pre-image that would repair it is gone. Restore ${f.path} from version control, delete ` +
679
+ `${COMMIT_INTENT}, then re-run.`);
680
+ }
681
+ const bytes = await readFile(source);
682
+ if (sha256(bytes) !== want) {
683
+ throw new InterruptedCommitError(`a \`loam ${intent.command} ${intent.feature}\` was interrupted mid-commit and ${f.path} is half-written, but ` +
684
+ `the pre-image that would repair it no longer matches the digest recorded before the commit — it has been ` +
685
+ `edited since. Restore ${f.path} from version control, delete ${COMMIT_INTENT}, then re-run.`);
686
+ }
687
+ await atomicWrite(file, bytes);
688
+ repaired.push(f.path);
689
+ }
690
+ await finishRecovery(docsDir, intent);
691
+ return { command: intent.command, feature: intent.feature, outcome: "repaired", repaired };
692
+ }
693
+ /**
694
+ * Drop the record, and the staged temp files the killed run could not remove.
695
+ *
696
+ * Scoped to that run's own pid and its own targets — the temp name carries both
697
+ * — so a leftover belonging to anything else is left for `doctor` to name.
698
+ * Without this a repair that put every byte back still left the docs repo
699
+ * dirty, which is the one thing a caller checks to decide whether it worked.
700
+ */
701
+ async function finishRecovery(docsDir, intent) {
702
+ const dirs = new Set(intent.files.map((f) => dirname(join(docsDir, ...f.path.split("/")))));
703
+ for (const dir of dirs) {
704
+ let entries;
705
+ try {
706
+ entries = await readdir(dir);
707
+ }
708
+ catch {
709
+ continue;
710
+ }
711
+ for (const name of entries) {
712
+ if (!TEMP_FILE_RE.test(name) || !name.includes(`.loam-${intent.pid}-`))
713
+ continue;
714
+ await quietRm(join(dir, name));
715
+ }
716
+ }
717
+ await clearCommitIntent(docsDir);
718
+ }
719
+ /** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */
720
+ async function quietRmdir(dir) {
721
+ try {
722
+ await rmdir(dir);
723
+ }
724
+ catch {
725
+ // Not empty, or not ours.
726
+ }
727
+ }
728
+ export async function scanWritePathResidue(docsDir) {
729
+ const lockPath = join(docsDir, DOCS_LOCK);
730
+ let lock = null;
731
+ if (existsSync(lockPath)) {
732
+ lock = { path: repoPath(docsDir, lockPath), holder: await describeLock(lockPath), stale: await lockIsStale(lockPath) };
733
+ }
734
+ const intent = await readCommitIntent(docsDir);
735
+ return {
736
+ lock,
737
+ intent,
738
+ intentUnreadable: intent === null && existsSync(join(docsDir, COMMIT_INTENT)),
739
+ temps: (await tempLeftovers(docsDir)).map((p) => repoPath(docsDir, p)).sort(),
740
+ };
741
+ }
742
+ /** Does the lock name a pid on THIS host that no longer exists? Read-only twin of breakStaleLock. */
743
+ async function lockIsStale(path) {
744
+ let holder;
745
+ try {
746
+ holder = JSON.parse(await readFile(path, "utf8"));
747
+ }
748
+ catch {
749
+ return false;
750
+ }
751
+ if (holder.host !== hostname() || typeof holder.pid !== "number")
752
+ return false;
753
+ try {
754
+ process.kill(holder.pid, 0);
755
+ return false;
756
+ }
757
+ catch (err) {
758
+ return err.code === "ESRCH";
759
+ }
760
+ }
761
+ async function tempLeftovers(dir) {
762
+ const out = [];
763
+ let entries;
764
+ try {
765
+ entries = await readdir(dir, { withFileTypes: true });
766
+ }
767
+ catch {
768
+ return out;
769
+ }
770
+ for (const entry of entries) {
771
+ // `.git` alone can hold more files than the whole docs repo, and nothing
772
+ // loam writes ever lands in it.
773
+ if (entry.name === ".git" || entry.name === "node_modules")
774
+ continue;
775
+ const path = join(dir, entry.name);
776
+ if (entry.isDirectory())
777
+ out.push(...(await tempLeftovers(path)));
778
+ else if (entry.isFile() && TEMP_FILE_RE.test(entry.name))
779
+ out.push(path);
780
+ }
781
+ return out;
782
+ }
783
+ //# sourceMappingURL=staging.js.map