wowbagger 0.1.0-alpha.9 → 0.5.0

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 (46) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/README.md +272 -136
  3. package/docs/adapter-contract.md +1 -1
  4. package/docs/host-contract.md +7 -1
  5. package/docs/mutation-contract.md +354 -82
  6. package/docs/work-claim-contract.md +466 -88
  7. package/package.json +2 -2
  8. package/schemas/core-capabilities-response.json +1 -1
  9. package/schemas/core-envelope.json +4 -3
  10. package/schemas/index.json +18 -0
  11. package/schemas/ledger-repair-proposal.json +170 -0
  12. package/schemas/ledger-repair-request.json +61 -0
  13. package/schemas/ledger-repair-response.json +90 -0
  14. package/schemas/report-config-v1.json +4 -0
  15. package/schemas/report-config-v2.json +5 -0
  16. package/skills/wowbagger/SKILL.md +242 -59
  17. package/src/adapter/core-probe.js +3 -4
  18. package/src/adapter/process-outcome.js +8 -1
  19. package/src/claim-capabilities.js +3 -3
  20. package/src/claim-coordinator.js +61 -12
  21. package/src/claim-journal.js +212 -7
  22. package/src/claim-prospective.js +1 -28
  23. package/src/claim-publication.js +412 -78
  24. package/src/claim-request.js +9 -0
  25. package/src/claim-store.js +9 -4
  26. package/src/cli.js +302 -56
  27. package/src/extensions.js +1 -0
  28. package/src/git-autocommit.js +106 -43
  29. package/src/git-reconciliation.js +74 -19
  30. package/src/git-worktrees.js +73 -0
  31. package/src/instrumentation.js +1 -0
  32. package/src/launch.js +2 -2
  33. package/src/ledger-repair.js +1170 -0
  34. package/src/mutation.js +73 -15
  35. package/src/reconciliation-classifier.js +117 -0
  36. package/src/report-evidence.js +158 -41
  37. package/src/report-graph.js +201 -73
  38. package/src/report-html.js +358 -155
  39. package/src/report-impact.js +106 -0
  40. package/src/report-selection.js +97 -0
  41. package/src/report-sequencing.js +4 -4
  42. package/src/report-svg.js +74 -20
  43. package/src/report-view.js +14 -1
  44. package/src/report.js +109 -23
  45. package/src/version-drift.js +98 -0
  46. package/src/worktree-identity.js +165 -0
@@ -0,0 +1,1170 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { constants } from 'node:fs';
4
+ import {
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ } from 'node:fs/promises';
12
+ import { createHash, randomUUID } from 'node:crypto';
13
+ import path from 'node:path';
14
+
15
+ import {
16
+ appendClaimEntry,
17
+ claimJournalPath,
18
+ claimReconcileLogPath,
19
+ replayClaimJournal,
20
+ writeReconcileLog,
21
+ } from './claim-journal.js';
22
+ import { loadLedger, parseLedgerItemSource } from './ledger.js';
23
+ import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
24
+ import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
25
+ import { readNamespace } from './namespace.js';
26
+ import { pointer, sortIssues } from './request.js';
27
+
28
+ import { isCalendarDate, validateLedger } from './validate.js';
29
+ const GIT_ENVIRONMENT = Object.fromEntries(
30
+ Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
31
+ );
32
+ const execFileAsync = promisify(execFile);
33
+
34
+ // The repair domain's own version. It is not the core contract version and not
35
+ // the work-claim contract version; a future change to the repair request or the
36
+ // repaired source shape requires a new version here rather than a silent
37
+ // reinterpretation of version 1.
38
+ export const LEDGER_REPAIR_CONTRACT_VERSION = 1;
39
+
40
+ const REQUEST_MEMBERS = ['repair_id', 'ledger_snapshot_revision', 'date', 'changes'];
41
+ const CHANGE_MEMBERS = ['item_id', 'expected_revision', 'expected_number', 'replacement_number'];
42
+ const REQUEST_MEMBER_SET = new Set(REQUEST_MEMBERS);
43
+ const CHANGE_MEMBER_SET = new Set(CHANGE_MEMBERS);
44
+ // A repair ID is a bounded, sortable operator handle: `nr_`, the repair date,
45
+ // and a four-digit sequence within that date.
46
+ const REPAIR_ID = /^nr_\d{8}_\d{4}$/;
47
+ const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
48
+ const REVISION = /^sha256:[0-9a-f]{64}$/;
49
+
50
+
51
+ const STAGING_ID = /^[A-Za-z0-9_-]{1,80}$/;
52
+ const STAGING_RELATIVE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
53
+
54
+ export function repairStagingPath(gitCommonDir, namespace, repairId) {
55
+ assertStagingSegment(namespace, 'namespace');
56
+ assertStagingSegment(repairId, 'repair_id');
57
+ return path.join(
58
+ path.resolve(gitCommonDir),
59
+ 'wowbagger',
60
+ namespace,
61
+ 'repairs',
62
+ repairId,
63
+ );
64
+ }
65
+
66
+ export async function stageNumberRepairCandidates({
67
+ gitCommonDir,
68
+ namespace,
69
+ repairId,
70
+ ledgerSnapshotRevision,
71
+ candidates,
72
+ }) {
73
+ assertStagingSegment(namespace, 'namespace');
74
+ assertStagingSegment(repairId, 'repair_id');
75
+ if (!REVISION.test(ledgerSnapshotRevision) || !Array.isArray(candidates) || candidates.length === 0) {
76
+ throw stagingInvalid('manifest-shape');
77
+ }
78
+ const root = repairStagingPath(gitCommonDir, namespace, repairId);
79
+ const candidatesRoot = path.join(root, 'candidates');
80
+ await ensureStagingDirectory(candidatesRoot, path.resolve(gitCommonDir));
81
+ const manifestCandidates = [];
82
+ const seenPaths = new Set();
83
+ const seenItems = new Set();
84
+ for (const candidate of candidates) {
85
+ if (!isPlainObject(candidate)
86
+ || !ITEM_ID.test(candidate.item_id)
87
+ || seenItems.has(candidate.item_id)
88
+ || typeof candidate.path !== 'string'
89
+ || !STAGING_RELATIVE_PATH.test(candidate.path)
90
+ || seenPaths.has(candidate.path)
91
+ || !REVISION.test(candidate.candidate_revision)
92
+ || !Buffer.isBuffer(candidate.candidate_bytes)
93
+ || candidate.candidate_bytes.byteLength > MAX_ITEM_SOURCE_BYTES) {
94
+ throw stagingInvalid('candidate-shape');
95
+ }
96
+ const candidatePath = path.join(candidatesRoot, candidate.path);
97
+ await ensureStagingDirectory(path.dirname(candidatePath), path.resolve(gitCommonDir));
98
+ await writeExclusive(candidatePath, candidate.candidate_bytes);
99
+ manifestCandidates.push({
100
+ item_id: candidate.item_id,
101
+ path: candidate.path,
102
+ candidate_revision: candidate.candidate_revision,
103
+ sha256: revisionOf(candidate.candidate_bytes),
104
+ size: candidate.candidate_bytes.byteLength,
105
+ });
106
+ seenPaths.add(candidate.path);
107
+ seenItems.add(candidate.item_id);
108
+ }
109
+ const manifest = {
110
+ schema_version: 1,
111
+ repair_id: repairId,
112
+ ledger_snapshot_revision: ledgerSnapshotRevision,
113
+ candidates: manifestCandidates,
114
+ };
115
+ await writeExclusive(
116
+ path.join(root, 'manifest.json'),
117
+ Buffer.from(`${JSON.stringify(manifest)}\n`),
118
+ );
119
+ await syncDirectory(root);
120
+ return manifest;
121
+ }
122
+
123
+ export async function readStagedNumberRepair({
124
+ gitCommonDir,
125
+ namespace,
126
+ repairId,
127
+ }) {
128
+ const root = repairStagingPath(gitCommonDir, namespace, repairId);
129
+ const manifestPath = path.join(root, 'manifest.json');
130
+ let manifest;
131
+ try {
132
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
133
+ } catch (error) {
134
+ throw stagingInvalid(error?.code === 'ENOENT' ? 'staging-absent' : 'manifest-unreadable');
135
+ }
136
+ if (!isPlainObject(manifest)
137
+ || manifest.schema_version !== 1
138
+ || manifest.repair_id !== repairId
139
+ || !REVISION.test(manifest.ledger_snapshot_revision)
140
+ || !Array.isArray(manifest.candidates)
141
+ || manifest.candidates.length === 0) {
142
+ throw stagingInvalid('manifest-shape');
143
+ }
144
+ const candidates = [];
145
+ const seenPaths = new Set();
146
+ for (const entry of manifest.candidates) {
147
+ if (!isPlainObject(entry)
148
+ || !ITEM_ID.test(entry.item_id)
149
+ || typeof entry.path !== 'string'
150
+ || !STAGING_RELATIVE_PATH.test(entry.path)
151
+ || seenPaths.has(entry.path)
152
+ || !REVISION.test(entry.candidate_revision)
153
+ || entry.sha256 !== entry.candidate_revision
154
+ || !Number.isSafeInteger(entry.size)
155
+ || entry.size < 0) {
156
+ throw stagingInvalid('manifest-candidate');
157
+ }
158
+ const candidatePath = path.join(root, 'candidates', entry.path);
159
+ let bytes;
160
+ try {
161
+ const info = await lstat(candidatePath);
162
+ if (!info.isFile() || info.isSymbolicLink()) throw stagingInvalid('candidate-not-file');
163
+ bytes = await readFile(candidatePath);
164
+ } catch (error) {
165
+ if (error?.code === 'LEDGER_REPAIR_STAGING_INVALID') throw error;
166
+ throw stagingInvalid('candidate-absent');
167
+ }
168
+ if (bytes.byteLength !== entry.size || revisionOf(bytes) !== entry.candidate_revision) {
169
+ throw stagingInvalid('candidate-digest-mismatch');
170
+ }
171
+ candidates.push({ ...entry, candidate_bytes: bytes });
172
+ seenPaths.add(entry.path);
173
+ }
174
+ return { manifest, candidates };
175
+ }
176
+
177
+ function assertStagingSegment(value, field) {
178
+ if (typeof value !== 'string' || !STAGING_ID.test(value)) {
179
+ throw stagingInvalid(`invalid-${field}`);
180
+ }
181
+ }
182
+
183
+ async function ensureStagingDirectory(directory, boundary) {
184
+ const relative = path.relative(boundary, directory);
185
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw stagingInvalid('path-traversal');
186
+ let current = boundary;
187
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
188
+ current = path.join(current, segment);
189
+ await mkdir(current, { recursive: true });
190
+ const info = await lstat(current);
191
+ if (!info.isDirectory() || info.isSymbolicLink()) throw stagingInvalid('symlink-directory');
192
+ }
193
+ }
194
+
195
+ async function writeExclusive(file, bytes) {
196
+ let handle;
197
+ try {
198
+ handle = await openNoFollow(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
199
+ await handle.writeFile(bytes);
200
+ await handle.sync();
201
+ } catch (error) {
202
+ if (error?.code === 'EEXIST') throw stagingInvalid('staging-already-exists');
203
+ throw error;
204
+ } finally {
205
+ await handle?.close();
206
+ }
207
+ await syncDirectory(path.dirname(file));
208
+ }
209
+
210
+ async function openNoFollow(file, flags) {
211
+ if (constants.O_NOFOLLOW !== undefined) return open(file, flags | constants.O_NOFOLLOW);
212
+ const info = await lstat(file).catch((error) => {
213
+ if (error?.code === 'ENOENT') return null;
214
+ throw error;
215
+ });
216
+ if (info?.isSymbolicLink()) throw stagingInvalid('symlink-file');
217
+ return open(file, flags);
218
+ }
219
+
220
+ async function syncDirectory(directory) {
221
+ let handle;
222
+ try {
223
+ handle = await open(directory, 'r');
224
+ await handle.sync();
225
+ } catch (error) {
226
+ if (['EISDIR', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(error?.code)) return;
227
+ throw error;
228
+ } finally {
229
+ await handle?.close();
230
+ }
231
+ }
232
+
233
+ function stagingInvalid(reason) {
234
+ const error = new Error('ledger repair staging is invalid');
235
+ error.code = 'LEDGER_REPAIR_STAGING_INVALID';
236
+ error.reason = reason;
237
+ return error;
238
+ }
239
+ // Returns an array of {path, code, message} issues, sorted, and empty when the
240
+ // request is valid. The request must already be JSON-parsed and
241
+ // deep-normalized (plain objects/arrays, JsonNumber unwrapped) — this checks
242
+ // the request's own shape, never the ledger it names.
243
+ export function validateLedgerRepairRequest(request) {
244
+ if (!isPlainObject(request)) {
245
+ return [problem([], 'invalid-type', 'The number-repair request must be a JSON object.')];
246
+ }
247
+ const issues = [];
248
+ for (const member of REQUEST_MEMBERS) {
249
+ if (!Object.hasOwn(request, member)) {
250
+ issues.push(problem([member], 'missing-member', `Required member ${member} is missing.`));
251
+ }
252
+ }
253
+ for (const member of Object.keys(request)) {
254
+ if (!REQUEST_MEMBER_SET.has(member)) {
255
+ issues.push(problem([member], 'unknown-member', `Member ${member} is not allowed.`));
256
+ }
257
+ }
258
+ if (Object.hasOwn(request, 'repair_id') && !REPAIR_ID.test(request.repair_id)) {
259
+ issues.push(problem(['repair_id'], 'invalid-value', 'Member repair_id must match nr_YYYYMMDD_NNNN.'));
260
+ }
261
+ if (Object.hasOwn(request, 'ledger_snapshot_revision')
262
+ && !REVISION.test(request.ledger_snapshot_revision)) {
263
+ issues.push(problem(['ledger_snapshot_revision'], 'invalid-value', 'Member ledger_snapshot_revision must match sha256:[0-9a-f]{64}.'));
264
+ }
265
+ if (Object.hasOwn(request, 'date') && !isCalendarDate(request.date)) {
266
+ issues.push(problem(['date'], 'invalid-value', 'Member date must be an ISO calendar date.'));
267
+ }
268
+ // A repair with no change is a confused request rather than a no-op, so an
269
+ // empty mapping is refused before any entry is read.
270
+ if (Object.hasOwn(request, 'changes')
271
+ && (!Array.isArray(request.changes) || request.changes.length === 0)) {
272
+ issues.push(problem(['changes'], 'invalid-value', 'Member changes must be a non-empty array of number changes.'));
273
+ }
274
+ if (Array.isArray(request.changes)) {
275
+ issues.push(...changeIssues(request.changes));
276
+ }
277
+ return sortIssues(issues);
278
+ }
279
+
280
+ // The mutating apply entrypoint. The request contract is enforced here rather
281
+ // than in the CLI so the seam a host calls and the seam the command calls
282
+ // refuse identically.
283
+ export async function numberRepair(request, options = {}) {
284
+ const issues = validateLedgerRepairRequest(request);
285
+ if (issues.length > 0) {
286
+ return ledgerRepairInvalidRequest('number-repair', issues);
287
+ }
288
+ const pendingRecovery = await recoverPendingRepair(request, options);
289
+ if (pendingRecovery) return pendingRecovery;
290
+ const ledger = await loadLedger(options.ledgerDirectory);
291
+ const validation = validateLedger(ledger);
292
+ const duplicateErrors = validation.errors.filter((error) => error.code === 'duplicate-number');
293
+ if (duplicateErrors.length === 0
294
+ || validation.errors.some((error) => error.code !== 'duplicate-number')) {
295
+ return refusal(
296
+ 'number-repair',
297
+ 4,
298
+ 'ledger-repair-not-applicable',
299
+ 'The ledger is not blocked only by duplicate numbers.',
300
+ { validation_errors: validation.errors },
301
+ );
302
+ }
303
+ const actualSnapshot = ledgerSnapshotRevision(options.ledgerDirectory, ledger);
304
+ if (actualSnapshot !== request.ledger_snapshot_revision) {
305
+ return refusal(
306
+ 'number-repair',
307
+ 4,
308
+ 'ledger-repair-revision-conflict',
309
+ 'The ledger changed after the repair proposal was generated.',
310
+ {
311
+ expected_snapshot_revision: request.ledger_snapshot_revision,
312
+ actual_snapshot_revision: actualSnapshot,
313
+ },
314
+ );
315
+ }
316
+ const itemsById = new Map(ledger.items.map((item) => [item.data.id, item]));
317
+ for (const change of request.changes) {
318
+ const item = itemsById.get(change.item_id);
319
+ if (!item
320
+ || revisionOf(item.bytes) !== change.expected_revision
321
+ || item.data.number !== change.expected_number) {
322
+ return refusal(
323
+ 'number-repair',
324
+ 4,
325
+ 'ledger-repair-revision-conflict',
326
+ 'A repair item witness no longer matches the ledger.',
327
+ {
328
+ item_id: change.item_id,
329
+ expected_revision: change.expected_revision,
330
+ actual_revision: item ? revisionOf(item.bytes) : null,
331
+ expected_number: change.expected_number,
332
+ actual_number: item?.data.number ?? null,
333
+ },
334
+ );
335
+ }
336
+ }
337
+ const expectedChangedIds = new Set();
338
+ const groupedItems = new Map();
339
+ for (const item of ledger.items) {
340
+ const number = item.data.number;
341
+ if (!Number.isSafeInteger(number) || number < 1) continue;
342
+ const members = groupedItems.get(number) ?? [];
343
+ members.push(item);
344
+ groupedItems.set(number, members);
345
+ }
346
+ for (const members of groupedItems.values()) {
347
+ if (members.length < 2) continue;
348
+ for (const item of members.sort((left, right) => compareText(left.data.id, right.data.id)).slice(1)) {
349
+ expectedChangedIds.add(item.data.id);
350
+ }
351
+ }
352
+ const requestedChangedIds = new Set(request.changes.map((change) => change.item_id));
353
+ const missingItemIds = [...expectedChangedIds]
354
+ .filter((itemId) => !requestedChangedIds.has(itemId))
355
+ .sort(compareText);
356
+ const unexpectedItemIds = [...requestedChangedIds]
357
+ .filter((itemId) => !expectedChangedIds.has(itemId))
358
+ .sort(compareText);
359
+ if (missingItemIds.length > 0 || unexpectedItemIds.length > 0) {
360
+ return refusal(
361
+ 'number-repair',
362
+ 4,
363
+ 'ledger-repair-mapping-incomplete',
364
+ 'The repair mapping must include exactly the movable item from every duplicate group.',
365
+ { missing_item_ids: missingItemIds, unexpected_item_ids: unexpectedItemIds },
366
+ );
367
+ }
368
+ const changedIds = new Set(request.changes.map((change) => change.item_id));
369
+ const occupiedNumbers = new Map(
370
+ ledger.items.map((item) => [item.data.number, item.data.id]),
371
+ );
372
+ const replacementOwners = new Map();
373
+ for (const change of request.changes) {
374
+ const occupiedBy = occupiedNumbers.get(change.replacement_number);
375
+ if (occupiedBy && (occupiedBy === change.item_id || !changedIds.has(occupiedBy))) {
376
+ return refusal(
377
+ 'number-repair',
378
+ 4,
379
+ 'ledger-repair-number-collision',
380
+ 'A replacement number belongs to an unchanged ledger item.',
381
+ {
382
+ item_id: change.item_id,
383
+ replacement_number: change.replacement_number,
384
+ occupied_by: occupiedBy,
385
+ },
386
+ );
387
+ }
388
+ const previous = replacementOwners.get(change.replacement_number);
389
+ if (previous) {
390
+ return refusal(
391
+ 'number-repair',
392
+ 4,
393
+ 'ledger-repair-number-collision',
394
+ 'Multiple repair items request the same replacement number.',
395
+ {
396
+ replacement_number: change.replacement_number,
397
+ item_ids: [previous, change.item_id],
398
+ },
399
+ );
400
+ }
401
+ replacementOwners.set(change.replacement_number, change.item_id);
402
+ }
403
+ const gitCommonDir = await resolveVerifiedGitCommonDir(options.ledgerDirectory, { failClosed: true });
404
+ if (!gitCommonDir) return refusal(
405
+ 'number-repair',
406
+ 5,
407
+ 'capability-unavailable',
408
+ 'Duplicate repair requires a verified Git common directory.',
409
+ { reason: 'git-directory-not-found' },
410
+ );
411
+ const namespace = await readNamespace(options.ledgerDirectory);
412
+ if (!namespace) return refusal(
413
+ 'number-repair',
414
+ 5,
415
+ 'capability-unavailable',
416
+ 'Duplicate repair requires a provisioned ledger namespace.',
417
+ { reason: 'ledger-namespace-unbound' },
418
+ );
419
+ const storePath = claimStorePath(gitCommonDir, namespace);
420
+ try {
421
+ return await withClaimLock(storePath, async () => {
422
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
423
+ const replayed = await replayClaimJournal(journalPath, namespace);
424
+ const repairIntent = replayed.entries.find((entry) => (
425
+ entry.type === 'number-repair-intent' && entry.repair_id === request.repair_id
426
+ ));
427
+ const repairFinal = replayed.entries.find((entry) => (
428
+ entry.type === 'number-repair-final' && entry.repair_id === request.repair_id
429
+ ));
430
+ if (repairIntent && !repairFinal) {
431
+ return recoverNumberRepair({
432
+ gitCommonDir,
433
+ ledgerDirectory: options.ledgerDirectory,
434
+ namespace,
435
+ intent: repairIntent,
436
+ journalPath,
437
+ });
438
+ }
439
+ if (repairFinal) return committedRepairResponse(request.repair_id, repairFinal.items);
440
+ const current = await loadLedger(options.ledgerDirectory);
441
+ const currentSnapshot = ledgerSnapshotRevision(options.ledgerDirectory, current);
442
+ if (currentSnapshot !== request.ledger_snapshot_revision) {
443
+ return refusal(
444
+ 'number-repair',
445
+ 4,
446
+ 'ledger-repair-revision-conflict',
447
+ 'The ledger changed while the repair was waiting for the namespace fence.',
448
+ {
449
+ expected_snapshot_revision: request.ledger_snapshot_revision,
450
+ actual_snapshot_revision: currentSnapshot,
451
+ },
452
+ );
453
+ }
454
+ const candidates = buildRepairCandidates(current, request, options.ledgerDirectory);
455
+ if (candidates.error) return refusal(
456
+ 'number-repair',
457
+ 4,
458
+ candidates.error.code,
459
+ candidates.error.message,
460
+ candidates.error.details,
461
+ );
462
+ const staging = await stageNumberRepairCandidates({
463
+ gitCommonDir,
464
+ namespace,
465
+ repairId: request.repair_id,
466
+ ledgerSnapshotRevision: request.ledger_snapshot_revision,
467
+ candidates: candidates.items,
468
+ });
469
+ await appendClaimEntry(journalPath, {
470
+ type: 'number-repair-intent',
471
+ repair_id: request.repair_id,
472
+ ledger_namespace: namespace,
473
+ ledger_snapshot_revision: request.ledger_snapshot_revision,
474
+ date: request.date,
475
+ staging_id: request.repair_id,
476
+ items: candidates.items.map((candidate) => ({
477
+ item_id: candidate.item_id,
478
+ item_path: candidate.path,
479
+ expected_revision: candidate.expected_revision,
480
+ expected_number: candidate.expected_number,
481
+ replacement_number: candidate.replacement_number,
482
+ candidate_revision: candidate.candidate_revision,
483
+ })),
484
+ });
485
+ for (const candidate of candidates.items) {
486
+ await atomicReplace(
487
+ path.join(path.resolve(options.ledgerDirectory), candidate.path),
488
+ candidate.candidate_bytes,
489
+ );
490
+ }
491
+ const published = await loadLedger(options.ledgerDirectory);
492
+ const publishedValidation = validateLedger(published);
493
+ if (!publishedValidation.valid) {
494
+ return refusal(
495
+ 'number-repair',
496
+ 6,
497
+ 'ledger-repair-successor-invalid',
498
+ 'The repaired ledger failed validation after publication.',
499
+ { validation_errors: publishedValidation.errors },
500
+ );
501
+ }
502
+ const publishedById = new Map(published.items.map((item) => [item.data.id, item]));
503
+ const rereadMismatch = candidates.items.find((candidate) => (
504
+ revisionOf(publishedById.get(candidate.item_id)?.bytes ?? Buffer.alloc(0))
505
+ !== candidate.candidate_revision
506
+ ));
507
+ if (rereadMismatch) {
508
+ return refusal(
509
+ 'number-repair',
510
+ 6,
511
+ 'ledger-repair-successor-invalid',
512
+ 'A published candidate did not match its staged bytes.',
513
+ {
514
+ item_id: rereadMismatch.item_id,
515
+ expected_revision: rereadMismatch.candidate_revision,
516
+ actual_revision: revisionOf(publishedById.get(rereadMismatch.item_id)?.bytes ?? Buffer.alloc(0)),
517
+ },
518
+ );
519
+ }
520
+ const changedItems = candidates.items.map((candidate) => ({
521
+ item_id: candidate.item_id,
522
+ path: candidate.path,
523
+ revision: candidate.candidate_revision,
524
+ }));
525
+ await appendClaimEntry(journalPath, {
526
+ type: 'number-repair-final',
527
+ repair_id: request.repair_id,
528
+ ledger_namespace: namespace,
529
+ staging_id: staging.repair_id,
530
+ items: changedItems.map((item) => ({
531
+ item_id: item.item_id,
532
+ item_path: item.path,
533
+ candidate_revision: item.revision,
534
+ committed_revision: item.revision,
535
+ })),
536
+ observed_at: new Date().toISOString(),
537
+ });
538
+ let gitCommit = null;
539
+ if (options.autoCommit === true) {
540
+ const committed = await autoCommitRepair({
541
+ ledgerDirectory: options.ledgerDirectory,
542
+ gitCommonDir,
543
+ namespace,
544
+ journalPath,
545
+ repairId: request.repair_id,
546
+ changedItems,
547
+ });
548
+ if (!committed.ok) {
549
+ return refusal(
550
+ 'number-repair',
551
+ 6,
552
+ 'ledger-repair-commit-failed',
553
+ 'The repaired items were published, but Git commit failed.',
554
+ { recovery_token: committed.recovery_token },
555
+ );
556
+ }
557
+ gitCommit = committed.git_commit;
558
+ }
559
+ return {
560
+ exit: 0,
561
+ stdout: {
562
+ ok: true,
563
+ namespace: 'ledger-repair',
564
+ command: 'number-repair',
565
+ contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
566
+ state: 'committed',
567
+ result: {
568
+ repair_id: request.repair_id,
569
+ ledger_snapshot_revision: request.ledger_snapshot_revision,
570
+ changed_items: changedItems,
571
+ git_commit: gitCommit,
572
+ },
573
+ },
574
+ };
575
+ });
576
+ } catch (error) {
577
+ if (error?.code === 'CLAIM_LOCK_HELD') {
578
+ return refusal(
579
+ 'number-repair',
580
+ 4,
581
+ 'lock-held',
582
+ 'The ledger namespace is locked by another writer.',
583
+ { reason: 'claim-store-locked' },
584
+ );
585
+ }
586
+ throw error;
587
+ }
588
+ }
589
+
590
+ // The read-only proposal takes no request: the ledger it reads is the whole
591
+ // input, and it changes nothing.
592
+ export async function numberRepairProposal(ledgerDirectory) {
593
+ const ledger = await loadLedger(ledgerDirectory);
594
+ const validation = validateLedger(ledger);
595
+ const duplicateErrors = validation.errors.filter((error) => error.code === 'duplicate-number');
596
+ if (duplicateErrors.length === 0
597
+ || validation.errors.some((error) => error.code !== 'duplicate-number')) {
598
+ return refusal(
599
+ 'number-repair-proposal',
600
+ 4,
601
+ 'ledger-repair-not-applicable',
602
+ 'The ledger is not blocked only by duplicate numbers.',
603
+ { validation_errors: validation.errors },
604
+ );
605
+ }
606
+
607
+ const groups = new Map();
608
+ for (const item of ledger.items) {
609
+ const number = item.data.number;
610
+ if (!Number.isSafeInteger(number) || number < 1) continue;
611
+ const members = groups.get(number) ?? [];
612
+ members.push(item);
613
+ groups.set(number, members);
614
+ }
615
+ const duplicateGroups = [...groups.entries()]
616
+ .filter(([, members]) => members.length > 1)
617
+ .sort(([left], [right]) => left - right)
618
+ .map(([number, members]) => ({
619
+ number,
620
+ item_ids: members.map((item) => item.data.id).sort(compareText),
621
+ }));
622
+ const affectedIds = new Set(duplicateGroups.flatMap((group) => group.item_ids));
623
+ const affectedItems = ledger.items
624
+ .filter((item) => affectedIds.has(item.data.id))
625
+ .sort((left, right) => compareText(left.data.id, right.data.id));
626
+ const highest = ledger.items.reduce(
627
+ (current, item) => Number.isSafeInteger(item.data.number)
628
+ ? Math.max(current, item.data.number)
629
+ : current,
630
+ 0,
631
+ );
632
+ const preserved = new Set(
633
+ duplicateGroups.map((group) => group.item_ids.slice().sort(compareText)[0]),
634
+ );
635
+ const moved = affectedItems
636
+ .filter((item) => !preserved.has(item.data.id))
637
+ .sort((left, right) => compareText(left.data.id, right.data.id));
638
+ const suggestedChanges = moved.map((item, index) => ({
639
+ item_id: item.data.id,
640
+ expected_revision: revisionOf(item.bytes),
641
+ expected_number: item.data.number,
642
+ replacement_number: highest + index + 1,
643
+ }));
644
+
645
+ return {
646
+ exit: 0,
647
+ stdout: {
648
+ ok: true,
649
+ namespace: 'ledger-repair',
650
+ command: 'number-repair-proposal',
651
+ contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
652
+ state: 'unchanged',
653
+ result: {
654
+ ledger_snapshot_revision: ledgerSnapshotRevision(ledgerDirectory, ledger),
655
+ duplicate_groups: duplicateGroups,
656
+ items: affectedItems.map((item) => ({
657
+ item_id: item.data.id,
658
+ path: path.relative(path.resolve(ledgerDirectory), item.file),
659
+ revision: revisionOf(item.bytes),
660
+
661
+ number: item.data.number,
662
+ })),
663
+ suggested_changes: suggestedChanges,
664
+ preserved_items: [...preserved].sort(compareText),
665
+ references: affectedItems.map((item) => ({
666
+ item_id: item.data.id,
667
+ depends_on: item.data.depends_on ?? [],
668
+ related: item.data.related ?? [],
669
+ parent: item.data.parent ?? null,
670
+ })),
671
+ validation_errors: validation.errors,
672
+ },
673
+
674
+ },
675
+ };
676
+ }
677
+ export function isNumberRepairRecoveryToken(token) {
678
+ const payload = decodeNumberRepairToken(token);
679
+ return payload?.command === 'number-repair';
680
+ }
681
+
682
+ export async function finalizeNumberRepairCommit({ ledgerDirectory, token }) {
683
+ const payload = decodeNumberRepairToken(token);
684
+ if (!payload || payload.command !== 'number-repair') {
685
+ return refusal(
686
+ 'number-repair',
687
+ 2,
688
+ 'invalid-request',
689
+ 'The number-repair recovery token is invalid.',
690
+ { reason: 'recovery-token-invalid' },
691
+ );
692
+ }
693
+ const gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
694
+ const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
695
+ if (!gitCommonDir || namespace !== payload.ledger_namespace) {
696
+ return refusal(
697
+ 'number-repair',
698
+ 4,
699
+ 'ledger-repair-commit-failed',
700
+ 'The repair recovery token does not name this ledger namespace.',
701
+ { reason: 'ledger-namespace-unbound' },
702
+ );
703
+ }
704
+ const repoRoot = path.dirname(path.resolve(gitCommonDir));
705
+ const paths = payload.commit_set.map((entry) => entry.path).sort(compareText);
706
+ try {
707
+ await execFileAsync('git', ['add', '--', ...paths], { cwd: repoRoot, env: GIT_ENVIRONMENT });
708
+ const staged = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
709
+ cwd: repoRoot,
710
+ env: GIT_ENVIRONMENT,
711
+ });
712
+ const stagedPaths = staged.stdout.split('\n').filter(Boolean).sort(compareText);
713
+ if (stagedPaths.length !== paths.length
714
+ || stagedPaths.some((entry, index) => entry !== paths[index])) {
715
+ return refusal(
716
+ 'number-repair',
717
+ 4,
718
+ 'ledger-repair-commit-failed',
719
+ 'The repair recovery commit set is not exact.',
720
+ { reason: 'commit-set-mismatch', expected_paths: paths, actual_paths: stagedPaths },
721
+ );
722
+ }
723
+ await execFileAsync(
724
+ 'git',
725
+ ['commit', '-m', 'wowbagger: repair duplicate numbers'],
726
+ { cwd: repoRoot, env: GIT_ENVIRONMENT },
727
+ );
728
+ const head = await execFileAsync(
729
+ 'git',
730
+ ['rev-parse', 'HEAD'],
731
+ { cwd: repoRoot, env: GIT_ENVIRONMENT },
732
+ );
733
+ return committedRepairResponse(payload.repair_id, [], head.stdout.trim());
734
+ } catch {
735
+ return refusal(
736
+ 'number-repair',
737
+ 6,
738
+ 'ledger-repair-commit-failed',
739
+ 'The repair recovery commit failed.',
740
+ { recovery_token: token },
741
+ );
742
+ }
743
+ }
744
+
745
+ function decodeNumberRepairToken(token) {
746
+ if (typeof token !== 'string' || token.length === 0) return null;
747
+ try {
748
+ const payload = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
749
+ if (payload?.v !== 1
750
+ || payload.command !== 'number-repair'
751
+ || !REPAIR_ID.test(payload.repair_id)
752
+ || !REPAIR_STAGING_ID.test(payload.ledger_namespace)
753
+ || !Array.isArray(payload.commit_set)
754
+ || payload.commit_set.length === 0
755
+ || typeof payload.message !== 'string') return null;
756
+ if (payload.commit_set.some((entry) => (
757
+ !isPlainObject(entry)
758
+ || typeof entry.path !== 'string'
759
+ || !STAGING_RELATIVE_PATH.test(entry.path)
760
+ || entry.sha256 !== null
761
+ ))) return null;
762
+ return payload;
763
+ } catch {
764
+ return null;
765
+ }
766
+ }
767
+ async function autoCommitRepair({
768
+ ledgerDirectory,
769
+ gitCommonDir,
770
+ namespace,
771
+ journalPath,
772
+ repairId,
773
+ changedItems,
774
+ }) {
775
+ const replayed = await replayClaimJournal(journalPath, namespace);
776
+ const logPath = claimReconcileLogPath(ledgerDirectory, namespace);
777
+ await writeReconcileLog(logPath, namespace, replayed.entries);
778
+ const ledgerRoot = path.resolve(ledgerDirectory);
779
+ const repoRoot = path.dirname(path.resolve(gitCommonDir));
780
+ const paths = [
781
+ ...changedItems.map((item) => path.relative(repoRoot, path.join(ledgerRoot, item.path)).split(path.sep).join('/')),
782
+ path.relative(repoRoot, logPath).split(path.sep).join('/'),
783
+ ].sort(compareText);
784
+ const token = Buffer.from(JSON.stringify({
785
+ v: 1,
786
+ command: 'number-repair',
787
+ ledger_namespace: namespace,
788
+ repair_id: repairId,
789
+ commit_set: paths.map((entry) => ({ path: entry, sha256: null })),
790
+ message: 'wowbagger: repair duplicate numbers',
791
+ }), 'utf8').toString('base64url');
792
+ try {
793
+ await execFileAsync('git', ['add', '--', ...paths], { cwd: repoRoot, env: GIT_ENVIRONMENT });
794
+ const staged = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
795
+ cwd: repoRoot,
796
+ env: GIT_ENVIRONMENT,
797
+ });
798
+ const stagedPaths = staged.stdout.split('\n').filter(Boolean).sort(compareText);
799
+ if (stagedPaths.length !== paths.length
800
+ || stagedPaths.some((entry, index) => entry !== paths[index])) {
801
+ return { ok: false, recovery_token: token };
802
+ }
803
+ const commit = await execFileAsync(
804
+ 'git',
805
+ ['commit', '-m', 'wowbagger: repair duplicate numbers'],
806
+ { cwd: repoRoot, env: GIT_ENVIRONMENT },
807
+ );
808
+ const head = await execFileAsync(
809
+ 'git',
810
+ ['rev-parse', 'HEAD'],
811
+ { cwd: repoRoot, env: GIT_ENVIRONMENT },
812
+ );
813
+ return { ok: true, git_commit: head.stdout.trim(), output: commit.stdout };
814
+ } catch {
815
+ return { ok: false, recovery_token: token };
816
+ }
817
+ }
818
+
819
+ async function recoverPendingRepair(request, options) {
820
+ const gitCommonDir = await resolveVerifiedGitCommonDir(options.ledgerDirectory, { failClosed: true });
821
+ if (!gitCommonDir) return null;
822
+ const namespace = await readNamespace(options.ledgerDirectory);
823
+ if (!namespace) return null;
824
+ const journalPath = claimJournalPath(gitCommonDir, namespace);
825
+ const replayed = await replayClaimJournal(journalPath, namespace);
826
+ const intent = replayed.entries.find((entry) => (
827
+ entry.type === 'number-repair-intent' && entry.repair_id === request.repair_id
828
+ ));
829
+ if (!intent) return null;
830
+ const final = replayed.entries.find((entry) => (
831
+ entry.type === 'number-repair-final' && entry.repair_id === request.repair_id
832
+ ));
833
+ if (final) return committedRepairResponse(request.repair_id, final.items);
834
+ try {
835
+ return await withClaimLock(
836
+ claimStorePath(gitCommonDir, namespace),
837
+ () => recoverNumberRepair({
838
+ gitCommonDir,
839
+ ledgerDirectory: options.ledgerDirectory,
840
+ namespace,
841
+ intent,
842
+ journalPath,
843
+ }),
844
+ );
845
+ } catch (error) {
846
+ if (error?.code === 'CLAIM_LOCK_HELD') {
847
+ return refusal(
848
+ 'number-repair',
849
+ 4,
850
+ 'lock-held',
851
+ 'The ledger namespace is locked by another writer.',
852
+ { reason: 'claim-store-locked' },
853
+ );
854
+ }
855
+ throw error;
856
+ }
857
+ }
858
+
859
+ async function recoverNumberRepair({
860
+ gitCommonDir,
861
+ ledgerDirectory,
862
+ namespace,
863
+ intent,
864
+ journalPath,
865
+ }) {
866
+ let staged;
867
+ try {
868
+ staged = await readStagedNumberRepair({
869
+ gitCommonDir,
870
+ namespace,
871
+ repairId: intent.repair_id,
872
+ });
873
+ } catch (error) {
874
+ return outcomeUnknown(
875
+ intent.repair_id,
876
+ 'Repair intent exists but its staged candidates cannot be read.',
877
+ { reason: error.reason ?? 'staging-unavailable' },
878
+ );
879
+ }
880
+ if (staged.manifest.ledger_snapshot_revision !== intent.ledger_snapshot_revision) {
881
+ return outcomeUnknown(
882
+ intent.repair_id,
883
+ 'Repair intent and staged manifest disagree about the ledger snapshot.',
884
+ { reason: 'staging-snapshot-mismatch' },
885
+ );
886
+ }
887
+ const current = await loadLedger(ledgerDirectory);
888
+ const currentById = new Map(current.items.map((item) => [item.data.id, item]));
889
+ const stagedById = new Map(staged.candidates.map((candidate) => [candidate.item_id, candidate]));
890
+ for (const entry of intent.items) {
891
+ const candidate = stagedById.get(entry.item_id);
892
+ const item = currentById.get(entry.item_id);
893
+ const currentRevision = item ? revisionOf(item.bytes) : null;
894
+ if (!candidate || currentRevision === null) {
895
+ return outcomeUnknown(intent.repair_id, 'A staged repair item is not present in the ledger.', {
896
+ item_id: entry.item_id,
897
+ reason: 'candidate-item-missing',
898
+ });
899
+ }
900
+ if (currentRevision === candidate.candidate_revision) continue;
901
+ if (currentRevision !== entry.expected_revision) {
902
+ return outcomeUnknown(intent.repair_id, 'A repair target contains a third revision.', {
903
+ item_id: entry.item_id,
904
+ reason: 'third-revision',
905
+ expected_revision: entry.expected_revision,
906
+ actual_revision: currentRevision,
907
+ });
908
+ }
909
+ await atomicReplace(item.file, candidate.candidate_bytes);
910
+ }
911
+ const repaired = await loadLedger(ledgerDirectory);
912
+ const validation = validateLedger(repaired);
913
+ if (!validation.valid) {
914
+ return outcomeUnknown(intent.repair_id, 'Recovered repair candidates do not form a valid ledger.', {
915
+ reason: 'successor-invalid',
916
+ validation_errors: validation.errors,
917
+ });
918
+ }
919
+ const items = intent.items.map((entry) => ({
920
+ item_id: entry.item_id,
921
+ item_path: entry.item_path,
922
+ candidate_revision: entry.candidate_revision,
923
+ committed_revision: entry.candidate_revision,
924
+ }));
925
+ await appendClaimEntry(journalPath, {
926
+ type: 'number-repair-final',
927
+ repair_id: intent.repair_id,
928
+ ledger_namespace: namespace,
929
+ staging_id: intent.staging_id,
930
+ items,
931
+ observed_at: new Date().toISOString(),
932
+ });
933
+ return committedRepairResponse(intent.repair_id, items);
934
+ }
935
+
936
+ function committedRepairResponse(repairId, items, gitCommit = null) {
937
+ return {
938
+ exit: 0,
939
+ stdout: {
940
+ ok: true,
941
+ namespace: 'ledger-repair',
942
+ command: 'number-repair',
943
+ contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
944
+ state: 'committed',
945
+ result: {
946
+ repair_id: repairId,
947
+ changed_items: items.map((item) => ({
948
+ item_id: item.item_id,
949
+ path: item.item_path,
950
+ revision: item.committed_revision,
951
+ })),
952
+ git_commit: gitCommit,
953
+ },
954
+ },
955
+ };
956
+ }
957
+
958
+ function outcomeUnknown(repairId, message, details) {
959
+ return {
960
+ exit: 6,
961
+ stdout: {
962
+ ok: false,
963
+ namespace: 'ledger-repair',
964
+ command: 'number-repair',
965
+ contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
966
+ state: 'unknown',
967
+ error: {
968
+ code: 'ledger-repair-outcome-unknown',
969
+ message,
970
+ details: { repair_id: repairId, ...details },
971
+ },
972
+ },
973
+ };
974
+ }
975
+
976
+ function buildRepairCandidates(ledger, request, ledgerDirectory) {
977
+ const itemsById = new Map(ledger.items.map((item) => [item.data.id, item]));
978
+ const candidates = [];
979
+ for (const change of request.changes) {
980
+ const item = itemsById.get(change.item_id);
981
+ const source = item?.bytes?.toString('utf8');
982
+ const numberLines = source?.match(/^number:[ \t]*[0-9]+[ \t]*$/gm) ?? [];
983
+ if (!item || numberLines.length !== 1) {
984
+ return {
985
+ error: {
986
+ code: 'ledger-repair-successor-invalid',
987
+ message: 'An affected item does not contain one canonical number scalar.',
988
+ details: { item_id: change.item_id },
989
+ },
990
+ };
991
+ }
992
+ const candidateSource = source.replace(
993
+ numberLines[0],
994
+ `number: ${change.replacement_number}`,
995
+ );
996
+ const parsed = parseLedgerItemSource(candidateSource);
997
+ if (parsed.error) {
998
+ return {
999
+ error: {
1000
+ code: 'ledger-repair-successor-invalid',
1001
+ message: 'A candidate item could not be parsed.',
1002
+ details: { item_id: change.item_id, validation_errors: [parsed.error] },
1003
+ },
1004
+ };
1005
+ }
1006
+ candidates.push({
1007
+ item_id: change.item_id,
1008
+ path: path.relative(path.resolve(ledgerDirectory), item.file)
1009
+ .split(path.sep).join('/'),
1010
+ expected_revision: change.expected_revision,
1011
+ expected_number: change.expected_number,
1012
+ replacement_number: change.replacement_number,
1013
+ candidate_revision: revisionOf(Buffer.from(candidateSource)),
1014
+ candidate_bytes: Buffer.from(candidateSource),
1015
+ });
1016
+ }
1017
+ const candidateById = new Map(candidates.map((candidate) => [candidate.item_id, candidate]));
1018
+ const candidateItems = ledger.items.map((item) => {
1019
+ const candidate = candidateById.get(item.data.id);
1020
+ if (!candidate) return item;
1021
+ const parsed = parseLedgerItemSource(candidate.candidate_bytes.toString('utf8'));
1022
+ return { ...item, bytes: candidate.candidate_bytes, data: parsed.data, body: parsed.body };
1023
+ });
1024
+ const successor = validateLedger({ ...ledger, items: candidateItems });
1025
+ if (!successor.valid) {
1026
+ return {
1027
+ error: {
1028
+ code: 'ledger-repair-successor-invalid',
1029
+ message: 'The proposed number changes do not produce a valid ledger.',
1030
+ details: { validation_errors: successor.errors },
1031
+ },
1032
+ };
1033
+ }
1034
+ return { items: candidates };
1035
+ }
1036
+
1037
+ async function atomicReplace(target, bytes) {
1038
+ const info = await lstat(target);
1039
+ if (!info.isFile() || info.isSymbolicLink()) {
1040
+ const error = new Error('repair target is not a regular file');
1041
+ error.code = 'LEDGER_REPAIR_TARGET_INVALID';
1042
+ throw error;
1043
+ }
1044
+ const temporary = `${target}.${process.pid}.${randomUUID()}.candidate`;
1045
+ await writeExclusive(temporary, bytes);
1046
+ try {
1047
+ await rename(temporary, target);
1048
+ await syncDirectory(path.dirname(target));
1049
+ } finally {
1050
+ await rmIfExists(temporary);
1051
+ }
1052
+ }
1053
+ async function rmIfExists(file) {
1054
+ try {
1055
+ await rm(file, { force: true });
1056
+ } catch (error) {
1057
+ if (error?.code !== 'ENOENT') throw error;
1058
+ }
1059
+ }
1060
+
1061
+ function compareText(left, right) {
1062
+ return left < right ? -1 : left > right ? 1 : 0;
1063
+ }
1064
+
1065
+ function revisionOf(bytes) {
1066
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
1067
+ }
1068
+
1069
+ function ledgerSnapshotRevision(ledgerDirectory, ledger) {
1070
+ const root = path.resolve(ledgerDirectory);
1071
+ const hash = createHash('sha256');
1072
+ for (const item of ledger.items.slice().sort((left, right) => (
1073
+ compareText(left.file, right.file)
1074
+ ))) {
1075
+ const relative = path.relative(root, item.file).split(path.sep).join('/');
1076
+ hash.update(relative);
1077
+ hash.update('\0');
1078
+ hash.update(item.bytes);
1079
+ hash.update('\0');
1080
+ }
1081
+ return `sha256:${hash.digest('hex')}`;
1082
+ }
1083
+
1084
+ // The repair domain's own refusal envelope. A consumer dispatches on
1085
+ // `namespace` before it reads any version member, so a repair refusal is never
1086
+ // read as a core version 5 or work-claim version 1 response.
1087
+ export function ledgerRepairInvalidRequest(command, issues) {
1088
+ return refusal(command, 2, 'invalid-request', `The ${command} request is invalid.`, { issues });
1089
+ }
1090
+
1091
+
1092
+ function refusal(command, exit, code, message, details) {
1093
+ return {
1094
+ exit,
1095
+ stdout: {
1096
+ ok: false,
1097
+ namespace: 'ledger-repair',
1098
+ command,
1099
+ contract_version: LEDGER_REPAIR_CONTRACT_VERSION,
1100
+ state: 'unchanged',
1101
+ error: { code, message, details },
1102
+ },
1103
+ };
1104
+ }
1105
+
1106
+ // Every change is one item's number move. The entry carries its own witnesses,
1107
+ // so a malformed entry is named by its index rather than collapsing the whole
1108
+ // mapping into one issue. The repeat checks are request-internal: an item that
1109
+ // moves twice, or two items that land on one number, make the applied result
1110
+ // depend on entry order.
1111
+ function changeIssues(changes) {
1112
+ const issues = [];
1113
+ const seenItemIds = new Set();
1114
+ const seenReplacements = new Set();
1115
+ for (let index = 0; index < changes.length; index += 1) {
1116
+ const change = changes[index];
1117
+ const location = ['changes', String(index)];
1118
+ if (!isPlainObject(change)) {
1119
+ issues.push(problem(location, 'invalid-type', 'Member changes entries must be JSON objects.'));
1120
+ continue;
1121
+ }
1122
+ for (const member of CHANGE_MEMBERS) {
1123
+ if (!Object.hasOwn(change, member)) {
1124
+ issues.push(problem([...location, member], 'missing-member', `Required member ${member} is missing.`));
1125
+ }
1126
+ }
1127
+ for (const member of Object.keys(change)) {
1128
+ if (!CHANGE_MEMBER_SET.has(member)) {
1129
+ issues.push(problem([...location, member], 'unknown-member', `Member ${member} is not allowed.`));
1130
+ }
1131
+ }
1132
+ if (Object.hasOwn(change, 'item_id')) {
1133
+ if (!ITEM_ID.test(change.item_id)) {
1134
+ issues.push(problem([...location, 'item_id'], 'invalid-value', 'Member item_id must be a canonical Wowbagger item ID.'));
1135
+ } else if (seenItemIds.has(change.item_id)) {
1136
+ issues.push(problem([...location, 'item_id'], 'invalid-value', 'Member item_id must not repeat within changes.'));
1137
+ }
1138
+ seenItemIds.add(change.item_id);
1139
+ }
1140
+ if (Object.hasOwn(change, 'expected_revision') && !REVISION.test(change.expected_revision)) {
1141
+ issues.push(problem([...location, 'expected_revision'], 'invalid-value', 'Member expected_revision must match sha256:[0-9a-f]{64}.'));
1142
+ }
1143
+ // The old number is a witness the apply path compares against the item's
1144
+ // own number, so a string is a mismatch rather than a spelling.
1145
+ if (Object.hasOwn(change, 'expected_number') && !isPositiveInteger(change.expected_number)) {
1146
+ issues.push(problem([...location, 'expected_number'], 'invalid-value', 'Member expected_number must be a positive integer.'));
1147
+ }
1148
+ if (Object.hasOwn(change, 'replacement_number')) {
1149
+ if (!isPositiveInteger(change.replacement_number)) {
1150
+ issues.push(problem([...location, 'replacement_number'], 'invalid-value', 'Member replacement_number must be a positive integer.'));
1151
+ } else if (seenReplacements.has(change.replacement_number)) {
1152
+ issues.push(problem([...location, 'replacement_number'], 'invalid-value', 'Member replacement_number must not repeat within changes.'));
1153
+ }
1154
+ seenReplacements.add(change.replacement_number);
1155
+ }
1156
+ }
1157
+ return issues;
1158
+ }
1159
+
1160
+ function isPositiveInteger(value) {
1161
+ return Number.isSafeInteger(value) && value >= 1;
1162
+ }
1163
+
1164
+ function isPlainObject(value) {
1165
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1166
+ }
1167
+
1168
+ function problem(location, code, message) {
1169
+ return { path: pointer(location), code, message };
1170
+ }