bmad-plus 0.17.1 → 0.18.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.
@@ -472,8 +472,144 @@ function languageCode(value, strings = STRINGS) {
472
472
  return Object.keys(strings).find((code) => code.toLowerCase().split('-')[0] === base) || null;
473
473
  }
474
474
 
475
+ /**
476
+ * What every built page guarantees a tester, by construction. Each entry names a
477
+ * behaviour the DOM suite exercises (tests/unit/uat-page.test.js) and the literal
478
+ * the template must carry for it. A template that lost one does not build: the
479
+ * FormaPro incident of 2026-09-25 (answers typed before any run existed, then lost
480
+ * on reload, with a "saved" status nobody had verified) came from exactly such a loss.
481
+ */
482
+ const PAGE_GUARANTEES = [
483
+ {
484
+ id: 'hidden-before-start',
485
+ what: 'no answer field is shown before a run exists, whatever a class says about display',
486
+ // The rule must also be the LAST rule of the sheet: a later !important display rule would beat it.
487
+ markers: [
488
+ '[hidden] { display: none !important; }\n</style>',
489
+ '<main class="steps" id="steps" hidden>',
490
+ ],
491
+ },
492
+ {
493
+ id: 'no-answer-before-run',
494
+ what: 'a tick or a note before the run starts is refused and explained, never discarded in silence',
495
+ markers: [
496
+ 'function requireRun(',
497
+ 'T.startFirst',
498
+ '!requireRun(event)',
499
+ 'if (!requireRun()) return;',
500
+ ],
501
+ },
502
+ {
503
+ id: 'verified-local-write',
504
+ what: 'a write to this browser counts only once it reads back; a refused write returns false',
505
+ markers: ['return localStorage.getItem(k) === v;', 'function saveLocal()'],
506
+ },
507
+ {
508
+ id: 'honest-save-status',
509
+ what: 'the status line reports the verified outcome, and names a failed save',
510
+ markers: ['function showSaveState(', 'T.saveFailed', 'T.remoteFailed', 'T.saving'],
511
+ },
512
+ {
513
+ id: 'save-on-every-change',
514
+ what: 'every tick and every keystroke in a note is saved at once, without waiting for blur, and a save never moves the cursor',
515
+ markers: [
516
+ 'addEventListener("change"',
517
+ 'addEventListener("input"',
518
+ 'document.activeElement !== field',
519
+ ],
520
+ },
521
+ {
522
+ id: 'restore-before-capabilities',
523
+ what: 'the browser copy is restored synchronously, before any optional capability answers',
524
+ markers: ['function restoreLocal()'],
525
+ order: ['restoreLocal();', 'probe()'],
526
+ },
527
+ {
528
+ id: 'newer-copy-wins',
529
+ what: 'an older remote copy never overwrites a newer local one; a copy without a date is the oldest of all',
530
+ markers: ['function connectDb()', 'remoteNewer', 'function at(', 'function adopt('],
531
+ },
532
+ {
533
+ id: 'revision-guard',
534
+ what: 'a run that answered another revision is offered, explained and carried by an explicit rule, never poured in blindly',
535
+ markers: ['function sameRevision(', 'function carry(', 'T.otherRevision', 'T.continueHere'],
536
+ },
537
+ {
538
+ id: 'other-tab-notice',
539
+ what: 'two tabs on one run converge on the latest change, and the page says so',
540
+ markers: ['addEventListener("storage"', 'T.otherTab'],
541
+ },
542
+ {
543
+ id: 'unreadable-draft-kept',
544
+ what: 'a saved run that cannot be read is reported and exportable, never deleted',
545
+ markers: ['id="draft-problem"', 'T.draftUnreadable', 'id="btn-save-raw"'],
546
+ },
547
+ {
548
+ id: 'progress-accessible',
549
+ what: 'the share of lines answered is exposed with a progressbar role and value; answered is not passed',
550
+ markers: ['role="progressbar"', 'aria-valuenow', 'T.percentAnswered'],
551
+ },
552
+ {
553
+ id: 'storage-explained',
554
+ what: 'the page says where answers live and what can make them disappear',
555
+ markers: ['id="storage-note"', 'id="mode-note"'],
556
+ },
557
+ {
558
+ id: 'finished-is-not-accepted',
559
+ what: 'finishing keeps the date and says it is not an acceptance',
560
+ markers: ['T.finishedNote', 'run.finishedAt = new Date().toISOString();'],
561
+ },
562
+ {
563
+ id: 'unload-guard',
564
+ what: 'leaving with an unsaved run is questioned; pending remote writes are flushed',
565
+ markers: ['addEventListener("beforeunload"', 'addEventListener("pagehide"'],
566
+ },
567
+ {
568
+ id: 'utf8-and-escaped-diacritics',
569
+ what: 'the page declares UTF-8 and writes the combining-mark range as escapes',
570
+ markers: ['<meta charset="utf-8">', '\\u0300-\\u036f'],
571
+ },
572
+ {
573
+ id: 'export-is-the-run',
574
+ what: 'the exported JSON is the run as answered, and nothing is exported before a run exists',
575
+ markers: ['download(runId + ".json", JSON.stringify(run, null, 2))'],
576
+ },
577
+ ];
578
+
579
+ /**
580
+ * Which guarantees a template carries: every marker present, in the required order where one
581
+ * is set. Checked on the TEMPLATE, never on the built page: a recipe's own text must not be
582
+ * able to satisfy or defeat a marker.
583
+ */
584
+ function pageGuarantees(template) {
585
+ const missing = [];
586
+ for (const guarantee of PAGE_GUARANTEES) {
587
+ const lost = guarantee.markers.filter((marker) => !template.includes(marker));
588
+ if (!lost.length && guarantee.order) {
589
+ const positions = guarantee.order.map((marker) => template.indexOf(marker));
590
+ if (positions.some((at) => at < 0) || positions.some((at, i) => i && at < positions[i - 1]))
591
+ lost.push(`order ${guarantee.order.join(' → ')}`);
592
+ }
593
+ if (lost.length) missing.push({ id: guarantee.id, what: guarantee.what, lost });
594
+ }
595
+ const carried = PAGE_GUARANTEES.map((g) => g.id).filter(
596
+ (id) => !missing.some((entry) => entry.id === id)
597
+ );
598
+ return { ok: missing.length === 0, carried, missing };
599
+ }
600
+
475
601
  function buildPage(spec, options = {}) {
476
602
  const template = options.template || fs.readFileSync(TEMPLATE, 'utf8');
603
+ // A page missing one guarantee is not a page: refusing here is what keeps a tester's
604
+ // answers safe in an installation that has no test suite of its own.
605
+ const guarantees = pageGuarantees(template);
606
+ if (!guarantees.ok) {
607
+ throw new Error(
608
+ `the page template (${options.template ? 'the template given' : TEMPLATE}) lost a guarantee — not built: ${guarantees.missing
609
+ .map((entry) => `${entry.id}: ${entry.what} — missing ${entry.lost.join(', ')}`)
610
+ .join('; ')}`
611
+ );
612
+ }
477
613
  const hash = specHash(spec);
478
614
  const json = canonical(spec).replace(/<\//g, '<\\/');
479
615
  const strings = options.strings || STRINGS;
@@ -502,7 +638,7 @@ function buildPage(spec, options = {}) {
502
638
  cause: error,
503
639
  });
504
640
  }
505
- return { html, sha256: hash, language };
641
+ return { html, sha256: hash, language, guarantees: guarantees.carried };
506
642
  }
507
643
 
508
644
  // ── Runs: legacy adapter, normalisation, reading ──────────────────────────────
@@ -873,6 +1009,8 @@ module.exports = {
873
1009
  lintSpec,
874
1010
  screenLabels,
875
1011
  buildPage,
1012
+ PAGE_GUARANTEES,
1013
+ pageGuarantees,
876
1014
  languageCode,
877
1015
  normalizeRun,
878
1016
  readRuns,