wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
- import { link, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
3
+ import { link, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
 
@@ -9,13 +9,13 @@ const GIT_ENVIRONMENT = Object.fromEntries(
9
9
  Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
10
10
  );
11
11
 
12
- export async function resolveGitCommonDir(startDir) {
12
+ export async function resolveGitCommonDir(startDir, options = {}) {
13
13
  let current = path.resolve(startDir);
14
14
  for (;;) {
15
15
  const candidate = path.join(current, '.git');
16
16
  let info = null;
17
17
  try {
18
- info = await stat(candidate);
18
+ info = await lstat(candidate);
19
19
  } catch (error) {
20
20
  if (error?.code !== 'ENOENT') throw error;
21
21
  }
@@ -23,7 +23,10 @@ export async function resolveGitCommonDir(startDir) {
23
23
  if (info?.isFile()) {
24
24
  const text = await readFile(candidate, 'utf8');
25
25
  const match = /^gitdir:\s*(.+)\s*$/m.exec(text);
26
- if (!match) return null;
26
+ if (!match) {
27
+ if (options.failClosed) throw gitVerificationFailed();
28
+ return null;
29
+ }
27
30
  const gitDir = path.resolve(current, match[1].trim());
28
31
  try {
29
32
  const commonText = await readFile(path.join(gitDir, 'commondir'), 'utf8');
@@ -33,14 +36,15 @@ export async function resolveGitCommonDir(startDir) {
33
36
  return gitDir;
34
37
  }
35
38
  }
39
+ if (info && options.failClosed) throw gitVerificationFailed();
36
40
  const parent = path.dirname(current);
37
41
  if (parent === current) return null;
38
42
  current = parent;
39
43
  }
40
44
  }
41
45
 
42
- export async function resolveVerifiedGitCommonDir(startDir) {
43
- const discovered = await resolveGitCommonDir(startDir);
46
+ export async function resolveVerifiedGitCommonDir(startDir, options = {}) {
47
+ const discovered = await resolveGitCommonDir(startDir, options);
44
48
  if (!discovered) return null;
45
49
  try {
46
50
  const { stdout } = await execFileAsync(
@@ -57,10 +61,18 @@ export async function resolveVerifiedGitCommonDir(startDir) {
57
61
  realpath(stdout.trim()),
58
62
  realpath(discovered),
59
63
  ]);
60
- return reported === expected ? discovered : null;
61
- } catch {
64
+ if (reported === expected) return discovered;
65
+ } catch (error) {
66
+ if (options.failClosed) throw error;
62
67
  return null;
63
68
  }
69
+ if (options.failClosed) throw gitVerificationFailed();
70
+ return null;
71
+ }
72
+ function gitVerificationFailed() {
73
+ const error = new Error('Git common directory verification failed.');
74
+ error.code = 'GIT_VERIFICATION_FAILED';
75
+ return error;
64
76
  }
65
77
 
66
78
  export function claimStorePath(commonDir, namespace) {
package/src/cli.js CHANGED
@@ -29,6 +29,14 @@ import {
29
29
  writeClaimState,
30
30
  } from './claim-store.js';
31
31
  import { loadLedger } from './ledger.js';
32
+ import {
33
+ assertReportOutputOutsideLedger,
34
+ buildReportModel,
35
+ loadReportConfig,
36
+ readLogoDataUrl,
37
+ writeReportFile,
38
+ } from './report.js';
39
+ import { renderReportHtml } from './report-html.js';
32
40
  import {
33
41
  createItem,
34
42
  inspectItem,
@@ -55,6 +63,7 @@ const DISTRIBUTION_VERSION = JSON.parse(
55
63
  const COMMAND_SUMMARIES = {
56
64
  validate: 'Validate a ledger and print the single JSON validation result.',
57
65
  ready: 'Validate a ledger and print the readiness queue for a date.',
66
+ report: 'Render one validated ledger as a self-contained HTML report.',
58
67
  capabilities: 'Describe the core contract and unbound default claim profile.',
59
68
  inspect: 'Inspect one ledger item as a lossless raw-byte snapshot.',
60
69
  create: 'Create one ledger item through atomic, no-clobber publication.',
@@ -70,6 +79,7 @@ const COMMAND_SUMMARIES = {
70
79
  const KNOWN_COMMANDS = new Set([
71
80
  'validate',
72
81
  'ready',
82
+ 'report',
73
83
  'capabilities',
74
84
  'inspect',
75
85
  'create',
@@ -124,6 +134,21 @@ export async function runCli(argumentsList, { scenario } = {}) {
124
134
  return;
125
135
  }
126
136
 
137
+ if (command === 'report') {
138
+ const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
139
+ if (parsedOptions.options.asOf !== undefined
140
+ && !isCalendarDate(parsedOptions.options.asOf)) {
141
+ parsedOptions.issues.push(argumentIssue(-1, 'invalid-value', 'Argument --as-of must be an ISO calendar date.'));
142
+ }
143
+ parsedOptions.issues = sortIssues(parsedOptions.issues);
144
+ if (parsedOptions.issues.length > 0) {
145
+ writeInvalidRequest(command, parsedOptions.issues);
146
+ return;
147
+ }
148
+ await runReportCommand(parsedOptions.options);
149
+ return;
150
+ }
151
+
127
152
  if (command === 'inspect') {
128
153
  const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
129
154
  if (parsedOptions.issues.length > 0) {
@@ -444,6 +469,69 @@ export async function runCli(argumentsList, { scenario } = {}) {
444
469
  process.stdout.write(`${JSON.stringify(result)}\n`);
445
470
  }
446
471
 
472
+ async function runReportCommand(options) {
473
+ const ledger = await loadLedger(options.ledger);
474
+ const validation = validateLedger(ledger);
475
+ if (!validation.valid) {
476
+ writeReportFailure('ledger-invalid', 'The configured ledger is invalid.', {
477
+ errors: validation.errors,
478
+ }, 1);
479
+ return;
480
+ }
481
+
482
+ try {
483
+ const config = await loadReportConfig(options.ledger, options.out);
484
+ const model = buildReportModel(ledger.items, config, options.asOf);
485
+ const logoDataUrl = await readLogoDataUrl(config.repository.logo);
486
+ const html = renderReportHtml(model, { logoDataUrl });
487
+ await assertReportOutputOutsideLedger(options.ledger, config.outputPath);
488
+ await writeReportFile(config.outputPath, html);
489
+ process.stdout.write(`${JSON.stringify({
490
+ ok: true,
491
+ command: 'report',
492
+ contract_version: MUTATION_CONTRACT_VERSION,
493
+ result: {
494
+ report_version: config.reportVersion,
495
+ as_of: options.asOf,
496
+ output: config.outputPath,
497
+ item_count: ledger.items.length,
498
+ ready_count: selectReady(ledger.items, options.asOf).length,
499
+ },
500
+ })}\n`);
501
+ } catch (error) {
502
+ const code = error?.code === 'report-config-invalid'
503
+ || error?.code === 'report-read-failed'
504
+ || error?.code === 'report-write-failed'
505
+ ? error.code
506
+ : 'report-write-failed';
507
+ const exit = code === 'report-config-invalid' ? 2 : 1;
508
+ const details = code === 'report-config-invalid'
509
+ ? { issues: [issue('/configuration', code, error?.message ?? 'Report configuration is invalid.')] }
510
+ : error?.details ?? {};
511
+ writeReportFailure(code, reportFailureMessage(code), details, exit);
512
+ }
513
+ }
514
+
515
+ function reportFailureMessage(code) {
516
+ if (code === 'report-config-invalid') {
517
+ return 'The report configuration is invalid.';
518
+ }
519
+ if (code === 'report-read-failed') {
520
+ return 'A report input could not be read.';
521
+ }
522
+ return 'The report could not be published.';
523
+ }
524
+
525
+ function writeReportFailure(code, message, details, exit) {
526
+ process.stdout.write(`${JSON.stringify({
527
+ ok: false,
528
+ command: 'report',
529
+ contract_version: MUTATION_CONTRACT_VERSION,
530
+ error: { code, message, details },
531
+ })}\n`);
532
+ process.exitCode = exit;
533
+ }
534
+
447
535
  async function capabilities(ledger) {
448
536
  const gitCommonDir = await resolveGitCommonDir(ledger ?? process.cwd());
449
537
  return {
@@ -557,17 +645,23 @@ function parseContractOptions(command, argumentsList) {
557
645
  const seen = new Set();
558
646
  const valueFlags = command === 'inspect'
559
647
  ? new Map([['--ledger', 'ledger'], ['--id', 'id']])
560
- : command === 'create' || command === 'transition' || command === 'patch'
561
- || command === 'publish-claimed' || command === 'publication-read'
562
- || command === 'claim-read' || command === 'claim-acquire' || command === 'claim-renew'
563
- || command === 'claim-release'
564
- ? new Map([['--ledger', 'ledger'], ['--input', 'input']])
565
- : command === 'provision' || command === 'claim-capabilities' || command === 'claim-verify'
566
- ? new Map([['--ledger', 'ledger']])
567
- : command === 'mint-id'
568
- ? new Map([['--date', 'date']])
569
- : new Map();
570
- const optionalFlags = command === 'mint-id' ? new Set(['--date']) : new Set();
648
+ : command === 'report'
649
+ ? new Map([['--ledger', 'ledger'], ['--as-of', 'asOf'], ['--out', 'out']])
650
+ : command === 'create' || command === 'transition' || command === 'patch'
651
+ || command === 'publish-claimed' || command === 'publication-read'
652
+ || command === 'claim-read' || command === 'claim-acquire' || command === 'claim-renew'
653
+ || command === 'claim-release'
654
+ ? new Map([['--ledger', 'ledger'], ['--input', 'input']])
655
+ : command === 'provision' || command === 'claim-capabilities' || command === 'claim-verify'
656
+ ? new Map([['--ledger', 'ledger']])
657
+ : command === 'mint-id'
658
+ ? new Map([['--date', 'date']])
659
+ : new Map();
660
+ const optionalFlags = command === 'mint-id'
661
+ ? new Set(['--date'])
662
+ : command === 'report'
663
+ ? new Set(['--out'])
664
+ : new Set();
571
665
  for (let index = 0; index < argumentsList.length; index += 1) {
572
666
  const argument = argumentsList[index];
573
667
  if (argument === '--json') {
@@ -800,6 +894,15 @@ async function runClaimCommand(claimCommand, argumentsList) {
800
894
  const storePath = claimStorePath(gitCommonDir, namespace);
801
895
  const journalPath = claimJournalPath(gitCommonDir, namespace);
802
896
  const operation = CLAIM_OPERATIONS[claimCommand];
897
+ if (claimCommand === 'read') {
898
+ try {
899
+ const replayed = await replayClaimJournal(journalPath, namespace);
900
+ writeClaimEnvelope(operation(replayed.state, request, new Date().toISOString()).envelope);
901
+ } catch {
902
+ writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'claim-store-unreadable'));
903
+ }
904
+ return;
905
+ }
803
906
  try {
804
907
  const envelope = await withClaimLock(storePath, async () => {
805
908
  let replayed;
@@ -976,6 +1079,10 @@ function usage(command) {
976
1079
  return 'Usage: wowbagger ready --ledger <dir> --as-of YYYY-MM-DD [--json]';
977
1080
  }
978
1081
 
1082
+ if (command === 'report') {
1083
+ return 'Usage: wowbagger report --ledger <dir> --as-of YYYY-MM-DD [--out <file>] --json';
1084
+ }
1085
+
979
1086
  if (command === 'capabilities') {
980
1087
  return 'Usage: wowbagger capabilities --json';
981
1088
  }
package/src/mutation.js CHANGED
@@ -4,7 +4,7 @@ import { withLegacyMutationFence } from './claim-coordinator.js';
4
4
  import { link, lstat, mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
- import { isAlias, isMap, isScalar, isSeq, parseDocument, Scalar } from 'yaml';
7
+ import { isAlias, isMap, isScalar, isSeq, parseDocument, Scalar, visit } from 'yaml';
8
8
  import { isDependencySatisfied } from './dependencies.js';
9
9
  import { loadLedger, parseLedgerItemSource } from './ledger.js';
10
10
  import { JsonNumber, parseJsonRequest, pointer, sortIssues } from './request.js';
@@ -51,6 +51,8 @@ const CORE_OWNED_FIELDS = new Set([
51
51
  const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
52
52
  const ULID_PATTERN = /^wb_([0-7][0-9A-HJKMNP-TV-Z]{25})$/;
53
53
  const MAX_LOCK_CLOSURE_RETRIES = 3;
54
+ const NUMBER_INDEX_LOCK_ID = '__number-index__';
55
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
54
56
 
55
57
  export async function inspectItem(ledgerDirectory, id) {
56
58
  const ledger = await loadLedger(ledgerDirectory);
@@ -72,12 +74,18 @@ export function revisionFor(bytes) {
72
74
  }
73
75
 
74
76
  function inspectedPublishedItem(id, displayPath, bytes) {
75
- const parsed = parseLedgerItemSource(bytes.toString('utf8'));
77
+ const parsed = parseLedgerItemSource(decodedItemSource(bytes));
76
78
  if (parsed.error) {
77
79
  throw new Error('Published bytes could not be parsed.');
78
80
  }
79
81
  return inspectedItem(id, displayPath, bytes, parsed.data, parsed.body);
80
82
  }
83
+ function decodedItemSource(bytes) {
84
+ return bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)
85
+ ? bytes.subarray(UTF8_BOM.length).toString('utf8')
86
+ : bytes.toString('utf8');
87
+ }
88
+
81
89
 
82
90
  function inspectedItem(id, displayPath, bytes, data, body) {
83
91
  return {
@@ -278,7 +286,13 @@ async function createItemUnfenced(ledgerDirectory, request, scenario) {
278
286
  }
279
287
 
280
288
  const schemaVersion = current.ledger.items[0]?.data.schema_version ?? 2;
281
- const bytes = createCandidateSource(request, schemaVersion);
289
+ let bytes;
290
+ try {
291
+ failCandidateSerializationForTest(scenario);
292
+ bytes = createCandidateSource(request, schemaVersion);
293
+ } catch {
294
+ return await finishUncommitted(operationFailed(id, 'serialize-candidate', 'serialization-failed'));
295
+ }
282
296
  const candidateValidation = validateSerializedCandidate(
283
297
  current.ledger,
284
298
  null,
@@ -303,7 +317,7 @@ async function createItemUnfenced(ledgerDirectory, request, scenario) {
303
317
  }
304
318
 
305
319
  temporaryPath = path.join(root, `.wowbagger-tmp-${id}-${randomSuffix()}`);
306
- const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
320
+ const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
307
321
  if (temporaryFailure) {
308
322
  const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
309
323
  temporaryPath = artifacts.length > 0 ? temporaryPath : null;
@@ -441,11 +455,12 @@ export function validateTransitionRequest(request, parseIssues = []) {
441
455
  }
442
456
 
443
457
  export async function transitionItem(ledgerDirectory, request, scenario) {
444
- return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', () => (
458
+ return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', (authorize) => (
445
459
  mutateExistingItem(ledgerDirectory, request, scenario, {
446
460
  name: 'transition',
447
461
  lockIds: lockIdsForTransition,
448
462
  build: buildTransition,
463
+ authorize,
449
464
  })
450
465
  ));
451
466
  }
@@ -485,7 +500,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
485
500
  if (!target) {
486
501
  return mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id });
487
502
  }
488
- const nextIds = operation.lockIds(target, initial.ledger);
503
+ const nextIds = operation.lockIds(target, initial.ledger, request);
489
504
  if (scenario === 'expand-lock-closure-through-bounded-retry-limit') {
490
505
  return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
491
506
  }
@@ -531,7 +546,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
531
546
  if (!lockedTarget) {
532
547
  return await finishUncommitted(mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id }));
533
548
  }
534
- const stableIds = operation.lockIds(lockedTarget, current.ledger);
549
+ const stableIds = operation.lockIds(lockedTarget, current.ledger, request);
535
550
  if (!sameIds(nextIds, stableIds)) {
536
551
  const cleanupFailure = await finishUncommitted(null);
537
552
  if (cleanupFailure) {
@@ -549,7 +564,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
549
564
  }));
550
565
  }
551
566
 
552
- const built = operation.build(lockedTarget, current.ledger, request);
567
+ const built = operation.build(lockedTarget, current.ledger, request, scenario);
553
568
  if (built.outcome) {
554
569
  return await finishUncommitted(built.outcome);
555
570
  }
@@ -569,8 +584,21 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
569
584
  validation_errors: candidateValidation.errors,
570
585
  }));
571
586
  }
587
+ if (operation.authorize) {
588
+ await operation.authorize(actualRevision, revisionFor(bytes));
589
+ }
572
590
 
573
591
  const targetDirectory = path.dirname(lockedTarget.file);
592
+ let targetMode;
593
+ try {
594
+ const targetStat = await lstat(lockedTarget.file);
595
+ if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
596
+ throw new Error('The target is not a regular file.');
597
+ }
598
+ targetMode = targetStat.mode & 0o7777;
599
+ } catch {
600
+ return await finishUncommitted(operationFailed(id, 'prepare-temporary', 'io-error'));
601
+ }
574
602
  temporaryPath = path.join(targetDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
575
603
  if (scenarioName(scenario) === 'transition-rename-applied-then-error') {
576
604
  await writeFile(path.join(root, '.wowbagger-test-transition-paths.json'), JSON.stringify({
@@ -579,7 +607,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
579
607
  synced_directory: null,
580
608
  }));
581
609
  }
582
- const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
610
+ const temporaryFailure = await prepareTemporary(temporaryPath, bytes, targetMode, scenario);
583
611
  if (temporaryFailure) {
584
612
  const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
585
613
  temporaryPath = artifacts.length > 0 ? temporaryPath : null;
@@ -732,16 +760,20 @@ function isPatchableInteger(value, minimum) {
732
760
  }
733
761
 
734
762
  export async function patchItem(ledgerDirectory, request, scenario) {
735
- return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', () => (
763
+ return withLegacyMutationFence(ledgerDirectory, request.id, 'patch-v1', (authorize) => (
736
764
  mutateExistingItem(ledgerDirectory, request, scenario, {
737
765
  name: 'patch',
738
- lockIds: (target) => [target.data.id],
766
+ lockIds: (target, _ledger, patchRequest) => [
767
+ target.data.id,
768
+ ...(hasOwn(patchRequest.set, 'number') ? [NUMBER_INDEX_LOCK_ID] : []),
769
+ ],
739
770
  build: buildPatch,
771
+ authorize,
740
772
  })
741
773
  ));
742
774
  }
743
775
 
744
- function buildPatch(lockedTarget, ledger, request) {
776
+ function buildPatch(lockedTarget, ledger, request, scenario) {
745
777
  const issues = [];
746
778
  if (request.date < lockedTarget.data.created) {
747
779
  issues.push(transitionIssue('date-before-created', 'date', 'Patch date must not be earlier than the current created date.', []));
@@ -756,8 +788,13 @@ function buildPatch(lockedTarget, ledger, request) {
756
788
  }) };
757
789
  }
758
790
  const successor = patchData(lockedTarget.data, request);
759
- const bytes = Buffer.from(serializePatch(lockedTarget.source, successor, request), 'utf8');
760
- return { successor, bytes };
791
+ try {
792
+ failCandidateSerializationForTest(scenario);
793
+ const bytes = serializedMutationBytes(lockedTarget, serializePatch(lockedTarget.source, successor, request));
794
+ return { successor, bytes };
795
+ } catch {
796
+ return { outcome: operationFailed(request.id, 'serialize-candidate', 'serialization-failed') };
797
+ }
761
798
  }
762
799
 
763
800
  function patchData(data, request) {
@@ -778,12 +815,27 @@ function patchData(data, request) {
778
815
  return successor;
779
816
  }
780
817
 
818
+ function failCandidateSerializationForTest(scenario) {
819
+ if (scenarioName(scenario) === 'candidate-serialization-fails') {
820
+ throw new Error('Fixture candidate serialization failure.');
821
+ }
822
+ }
823
+ function serializedMutationBytes(lockedTarget, source) {
824
+ const serialized = Buffer.from(source, 'utf8');
825
+ if (lockedTarget.bytes.length < UTF8_BOM.length
826
+ || !lockedTarget.bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)) {
827
+ return serialized;
828
+ }
829
+ return Buffer.concat([UTF8_BOM, serialized]);
830
+ }
831
+
832
+
781
833
  function serializePatch(source, successor, request) {
782
834
  return rewriteFrontmatter(source, (document) => {
783
835
  setRootScalar(document, 'updated', successor.updated);
784
836
  for (const field of Object.keys(request.set)) {
785
837
  if (!Object.hasOwn(successor, field)) {
786
- document.delete(field);
838
+ deleteRootFieldPreservingAliases(document, field);
787
839
  } else if (document.has(field)) {
788
840
  setRootScalar(document, field, successor[field]);
789
841
  } else {
@@ -817,7 +869,7 @@ function rewriteFrontmatter(source, edit) {
817
869
  return `${source.slice(0, bounds.start)}${serialized}${source.slice(bounds.end)}`;
818
870
  }
819
871
 
820
- function buildTransition(lockedTarget, ledger, request) {
872
+ function buildTransition(lockedTarget, ledger, request, scenario) {
821
873
  const id = lockedTarget.data.id;
822
874
  const edge = transitionEdge(lockedTarget.data.kind, lockedTarget.data.status, request.to_status);
823
875
  const issues = transitionPreconditions(lockedTarget, ledger, request, edge);
@@ -843,8 +895,13 @@ function buildTransition(lockedTarget, ledger, request) {
843
895
  }
844
896
 
845
897
  const successor = transitionData(lockedTarget.data, request, edge, ledger);
846
- const bytes = Buffer.from(serializeTransition(lockedTarget.source, successor, edge), 'utf8');
847
- return { successor, bytes };
898
+ try {
899
+ failCandidateSerializationForTest(scenario);
900
+ const bytes = serializedMutationBytes(lockedTarget, serializeTransition(lockedTarget.source, successor, edge));
901
+ return { successor, bytes };
902
+ } catch {
903
+ return { outcome: operationFailed(request.id, 'serialize-candidate', 'serialization-failed') };
904
+ }
848
905
  }
849
906
 
850
907
  function invalidTransitionDecision() {
@@ -895,7 +952,10 @@ function transitionEdge(kind, from, to) {
895
952
  'epic:archived:backlog': 'restore',
896
953
  }[`${kind}:${from}:${to}`] ?? null;
897
954
  const allowed = (from === 'triage' && (to === 'backlog' || to === 'killed'))
898
- || (from === 'backlog' && (to === 'in-progress' || to === 'archived' || to === 'killed' || to === 'deferred'))
955
+ || (from === 'backlog' && (
956
+ (kind === 'task' && to === 'in-progress')
957
+ || ['archived', 'killed', 'deferred'].includes(to)
958
+ ))
899
959
  || (from === 'deferred' && to === 'backlog')
900
960
  || (kind === 'task' && from === 'in-progress' && ['backlog', 'done', 'killed'].includes(to))
901
961
  || (kind === 'epic' && from === 'backlog' && ['done', 'archived', 'killed'].includes(to))
@@ -1017,10 +1077,10 @@ function serializeTransition(source, successor, edge) {
1017
1077
  return rewriteFrontmatter(source, (document) => {
1018
1078
  setRootScalar(document, 'status', successor.status);
1019
1079
  setRootScalar(document, 'updated', successor.updated);
1020
- document.delete('completed');
1021
- document.delete('killed');
1022
- document.delete('archived');
1023
- document.delete('deferred');
1080
+ deleteRootFieldPreservingAliases(document, 'completed');
1081
+ deleteRootFieldPreservingAliases(document, 'killed');
1082
+ deleteRootFieldPreservingAliases(document, 'archived');
1083
+ deleteRootFieldPreservingAliases(document, 'deferred');
1024
1084
  const terminal = terminalField(successor.status);
1025
1085
  if (terminal) {
1026
1086
  insertRootAfter(document, 'updated', terminal, successor[terminal]);
@@ -1040,6 +1100,21 @@ function setRootScalar(document, key, value) {
1040
1100
  document.set(key, value);
1041
1101
  }
1042
1102
 
1103
+ function deleteRootFieldPreservingAliases(document, key) {
1104
+ const node = document.get(key, true);
1105
+ if (node?.anchor) {
1106
+ visit(document, {
1107
+ Alias(_key, alias) {
1108
+ if (alias.resolve(document) !== node) return undefined;
1109
+ const replacement = node.clone(document.schema);
1110
+ replacement.anchor = undefined;
1111
+ return replacement;
1112
+ },
1113
+ });
1114
+ }
1115
+ document.delete(key);
1116
+ }
1117
+
1043
1118
  function insertRootAfter(document, afterKey, key, value) {
1044
1119
  const existing = document.contents.items.findIndex((pair) => pair.key?.value === key);
1045
1120
  if (existing >= 0) {
@@ -1118,13 +1193,16 @@ async function loadedValidLedger(root) {
1118
1193
  }
1119
1194
 
1120
1195
  function validateSerializedCandidate(ledger, replacementId, bytes, displayPath, file, expectedData, expectedSource) {
1121
- const source = bytes.toString('utf8');
1196
+ const source = decodedItemSource(bytes);
1122
1197
  const parsed = parseLedgerItemSource(source);
1123
1198
  const errors = parsed.error ? [{ path: displayPath, ...parsed.error }] : [];
1124
1199
  const coreMatches = parsed.error || !expectedData
1125
1200
  || isDeepStrictEqual(coreView(parsed.data), coreView(expectedData));
1126
1201
  const extensionsMatch = parsed.error || !expectedSource
1127
- || isDeepStrictEqual(extensionNodeIdentity(source), extensionNodeIdentity(expectedSource));
1202
+ || isDeepStrictEqual(
1203
+ extensionNodeIdentity(source),
1204
+ expectedExtensionNodeIdentity(expectedSource, expectedData),
1205
+ );
1128
1206
  if (!parsed.error && (!coreMatches || !extensionsMatch)) {
1129
1207
  errors.push({
1130
1208
  path: displayPath,
@@ -1147,6 +1225,25 @@ function validateSerializedCandidate(ledger, replacementId, bytes, displayPath,
1147
1225
  return validateLedger({ items: items.filter(Boolean), errors });
1148
1226
  }
1149
1227
 
1228
+ function expectedExtensionNodeIdentity(source, expectedData) {
1229
+ const bounds = frontmatterBounds(source);
1230
+ const document = parseDocument(source.slice(bounds.start, bounds.end), {
1231
+ schema: 'core',
1232
+ uniqueKeys: true,
1233
+ });
1234
+ const removedAnchors = [...CORE_OWNED_FIELDS].filter((field) => (
1235
+ !Object.hasOwn(expectedData, field)
1236
+ && document.get(field, true)?.anchor
1237
+ ));
1238
+ if (removedAnchors.length === 0) return extensionNodeIdentity(source);
1239
+ const normalized = rewriteFrontmatter(source, (normalizedDocument) => {
1240
+ for (const field of removedAnchors) {
1241
+ deleteRootFieldPreservingAliases(normalizedDocument, field);
1242
+ }
1243
+ });
1244
+ return extensionNodeIdentity(normalized);
1245
+ }
1246
+
1150
1247
  function extensionNodeIdentity(source) {
1151
1248
  const bounds = frontmatterBounds(source);
1152
1249
  const document = parseDocument(source.slice(bounds.start, bounds.end), {
@@ -1200,7 +1297,8 @@ function lockIdsForCreate(request, ledger) {
1200
1297
  const existingIds = new Set(ledger.items.map((item) => item.data.id));
1201
1298
  const references = [request.item.parent, ...(request.item.depends_on ?? [])]
1202
1299
  .filter((id) => existingIds.has(id));
1203
- return [request.id, ...references].sort(compareText);
1300
+ const numberLock = hasOwn(request.item, 'number') ? [NUMBER_INDEX_LOCK_ID] : [];
1301
+ return [request.id, ...references, ...numberLock].sort(compareText);
1204
1302
  }
1205
1303
 
1206
1304
  async function acquireLocks(root, ids, operation, scenario) {
@@ -1692,6 +1790,10 @@ async function testCheckpoint(root, scenario, point) {
1692
1790
  await writeFile(path.join(root, `.wowbagger-test-${suffix}-acquired`), 'acquired\n');
1693
1791
  await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-allow-successor`));
1694
1792
  }
1793
+ if (name === 'pause-after-temporary-open' && point === 'after-temporary-open') {
1794
+ await writeFile(path.join(root, `.wowbagger-test-${suffix}-temporary-open`), 'open\n');
1795
+ await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-continue`));
1796
+ }
1695
1797
  }
1696
1798
 
1697
1799
  async function waitForTestMarker(file) {
@@ -1867,19 +1969,36 @@ function randomSuffix() {
1867
1969
  return createHash('sha256').update(`${process.pid}:${Date.now()}:${Math.random()}`).digest('hex').slice(0, 24);
1868
1970
  }
1869
1971
 
1870
- async function prepareTemporary(file, bytes, scenario) {
1972
+ async function prepareTemporary(file, bytes, mode, scenario) {
1871
1973
  let handle;
1872
1974
  try {
1873
- handle = await open(file, 'wx');
1975
+ handle = await open(file, 'wx', (mode ?? 0o666) & 0o777);
1874
1976
  } catch {
1875
1977
  return 'prepare-temporary';
1876
1978
  }
1979
+ await testCheckpoint(path.dirname(file), scenario, 'after-temporary-open');
1877
1980
 
1878
1981
  let failure = null;
1879
- try {
1880
- await handle.writeFile(bytes);
1881
- } catch {
1882
- failure = 'prepare-temporary';
1982
+ if (mode !== null) {
1983
+ try {
1984
+ await handle.chmod(mode);
1985
+ } catch {
1986
+ failure = 'prepare-temporary';
1987
+ }
1988
+ }
1989
+ if (!failure) {
1990
+ try {
1991
+ await handle.writeFile(bytes);
1992
+ } catch {
1993
+ failure = 'prepare-temporary';
1994
+ }
1995
+ }
1996
+ if (!failure && mode !== null) {
1997
+ try {
1998
+ await handle.chmod(mode);
1999
+ } catch {
2000
+ failure = 'prepare-temporary';
2001
+ }
1883
2002
  }
1884
2003
  if (!failure) {
1885
2004
  try {