@bongos/core 1.19.972 → 1.19.974

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 (59) hide show
  1. package/.bongos-core.json +74 -64
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/architecture.md +6 -6
  8. package/docs/branding-contract.md +1 -1
  9. package/docs/canonical-permissions.md +24 -24
  10. package/docs/copy-inventory.md +45 -38
  11. package/docs/copy-registry.json +130 -67
  12. package/docs/design/projects-hub-direction-v2.md +1 -1
  13. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  14. package/docs/file-map.md +7 -7
  15. package/docs/handoff-template.md +1 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/modules-contract.md +1 -1
  18. package/docs/onboarding/drachmae-and-karma.md +2 -2
  19. package/docs/packs/engineer.md +5 -5
  20. package/docs/packs/ideator.md +1 -1
  21. package/docs/recipes/README.md +1 -1
  22. package/docs/recipes/audit-replay.md +2 -2
  23. package/docs/recipes/gds-db-backup.md +3 -3
  24. package/docs/recipes/managed-settings-remote-control.md +9 -9
  25. package/docs/recipes/memory-sync-conflicts.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +24 -5
  27. package/docs/recipes/overnight-builder.md +3 -3
  28. package/docs/recipes/parallel-goal-waves.md +3 -3
  29. package/docs/recipes/reranking-a-goals-board.md +2 -2
  30. package/docs/recipes/search-index-ingestion.md +1 -1
  31. package/docs/recipes/ship-and-regrade.md +2 -2
  32. package/docs/recipes/staging-environment.md +1 -1
  33. package/docs/recipes/windows-builders.md +1 -1
  34. package/modules/hall-ui/public/deploy.html +10 -5
  35. package/modules/hall-ui/public/deploy.js +199 -110
  36. package/modules/hall-ui/public/project-settings.css +36 -0
  37. package/modules/provisioning/core-upgrade.js +61 -2
  38. package/modules/public-landing/public/contact.html +1 -1
  39. package/modules/public-landing/public/index.html +1 -1
  40. package/modules/public-landing/public/privacy.html +1 -1
  41. package/modules/public-landing/public/projects.html +1 -1
  42. package/modules/public-landing/public/terms.html +1 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/scripts/gds/api-alias-caller-scan.js +53 -9
  46. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  47. package/scripts/gds/provision-core-upgrade.js +132 -1
  48. package/scripts/gds/run-unit-tests.js +14 -9
  49. package/scripts/hall-preview/server.js +5 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/api_alias_caller_ratchet.mjs +74 -2
  52. package/tests/core_upgrade_door.mjs +81 -0
  53. package/tests/core_upgrade_runner.mjs +132 -0
  54. package/tests/deploy_page_projects.mjs +234 -79
  55. package/tests/idea_spark_hall.mjs +101 -12
  56. package/tests/known_failing_ratchet.mjs +19 -9
  57. package/tests/landing_legal_pages.mjs +5 -1
  58. package/tests/projects_hub_pre_uat.mjs +12 -6
  59. package/tests/served_paths_shipped.mjs +36 -2
@@ -65,6 +65,15 @@ function boot({
65
65
  // `{ outcome: null }`, which is the server saying "nothing to state yet"; the
66
66
  // line must go quiet for both, and there is a case below for each.
67
67
  landing = undefined,
68
+ // OUT-OF-ORDER READS (task 1004108). Names of fakes whose answers are PARKED
69
+ // instead of returned: the call is recorded and its response computed at once,
70
+ // but the caller's `await` does not resolve until the test releases it. That is
71
+ // the only way two reads of the same endpoint are ever in flight together here
72
+ // — every other fake is an `async` function that settles before the next call
73
+ // is made, so a monotonic-sequence guard sits on an unreachable branch and
74
+ // deleting it reds nothing. Pass e.g. `defer: ['preview']` and drive
75
+ // `r.deferred` in whatever order the race being pinned requires.
76
+ defer = [],
68
77
  } = {}) {
69
78
  const els = new Map();
70
79
  const makeEl = () => ({
@@ -88,6 +97,21 @@ function boot({
88
97
  };
89
98
 
90
99
  const calls = [];
100
+
101
+ // Parked calls, in REQUEST order. Each carries the name it was made under, the
102
+ // arguments it was made with, and `release()` — which hands the caller the
103
+ // answer the fake had already computed. Releasing out of order is the point.
104
+ const deferred = [];
105
+ const deferSet = new Set(defer);
106
+ const gate = (name, fn) => (...args) => {
107
+ const answer = fn(...args); // record the call and compute the reply NOW...
108
+ if (!deferSet.has(name)) return answer;
109
+ let release;
110
+ const held = new Promise((resolve) => { release = resolve; });
111
+ deferred.push({ name, args, release: () => release(answer) }); // ...hand it over LATER
112
+ return held;
113
+ };
114
+
91
115
  const client = {
92
116
  inbox: {
93
117
  getInboxTemplate: async () => ({ status: 200, ok: true, data: { template: TEMPLATE } }),
@@ -117,7 +141,7 @@ function boot({
117
141
  // real matrix for the fixtures this file already files with: a goal named
118
142
  // ⇒ it lands there, no goal ⇒ it floats. A test that cares about a
119
143
  // particular verdict passes `landing` and overrides the lot.
120
- postInboxPreviewLanding: async (a) => {
144
+ postInboxPreviewLanding: gate('preview', async (a) => {
121
145
  calls.push(['preview', a]);
122
146
  if (landing === null) return { status: 500, ok: false, data: null };
123
147
  if (landing !== undefined) return { status: 200, ok: true, data: landing };
@@ -127,7 +151,7 @@ function boot({
127
151
  return { status: 200, ok: true, data: { outcome: 'routed', reason: 'bug_trusted_filer', status: 'ready', goal: { id: String(gid), title: g ? g.title : `goal ${gid}` }, version_id: 'BV1', refusal: null } };
128
152
  }
129
153
  return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } };
130
- },
154
+ }),
131
155
  },
132
156
  // task 1003069 (R08) — the quick-filing goal picker's reads: the building
133
157
  // versions (to scope the suggester) and the open goal list (the picker's
@@ -137,7 +161,7 @@ function boot({
137
161
  },
138
162
  goals: {
139
163
  getGoals: async () => { calls.push(['goals', null]); return { status: 200, ok: true, data: { goals } }; },
140
- getGoalsSuggestions: async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; },
164
+ getGoalsSuggestions: gate('suggest', async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; }),
141
165
  },
142
166
  };
143
167
 
@@ -173,7 +197,7 @@ function boot({
173
197
 
174
198
  const el = (id) => documentObj.getElementById(id);
175
199
  return {
176
- calls, toasts, store, el,
200
+ calls, toasts, store, el, deferred,
177
201
  queueHtml: () => el('fi-queue').innerHTML,
178
202
  fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
179
203
  submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
@@ -678,11 +702,67 @@ await test('filing clears the line — gravity describes a draft, and the draft
678
702
  assert.equal(r.el('fq-receipts').hidden, false, 'the RECEIPT is what survives instead (task 1004082)');
679
703
  });
680
704
 
705
+ // --- out-of-order reads (task 1004108) -----------------------------------
706
+ //
707
+ // Everything above this line drives fakes that settle before the next call is
708
+ // made, so `refreshGravity`'s monotonic `gravitySeq` guard sat on a branch no
709
+ // case could reach: deleting it redded NOTHING, which the closing note below
710
+ // used to record as an honest gap. `boot({ defer: [...] })` parks a named fake's
711
+ // answer until the test hands it back, so two reads are genuinely in flight at
712
+ // once and the slower one can land last.
713
+
714
+ await test('two previews are genuinely in flight at once — the harness can interleave them', async () => {
715
+ // The non-vacuity guard for the case beneath it. If `defer` ever stopped
716
+ // parking, the race case below would still pass — it would just be testing two
717
+ // sequential reads again, which is the exact hole this pair exists to close.
718
+ const r = boot({ defer: ['preview'] });
719
+ await settle(); await settle();
720
+ r.el('fq-title').value = 'a loose thought';
721
+ r.el('fq-goal')._handlers.change();
722
+ await settle(); await settle();
723
+ assert.equal(r.deferred.length, 1, 'the first read is parked, not answered');
724
+ assert.equal(r.el('fq-gravity').textContent, '', 'and nothing is painted while it is in flight');
725
+ r.el('fq-goal').value = '77';
726
+ r.el('fq-goal')._handlers.change();
727
+ await settle(); await settle();
728
+ assert.equal(r.deferred.length, 2, 'the second read starts while the first is still unanswered');
729
+ assert.deepEqual(r.deferred.map((d) => d.name), ['preview', 'preview']);
730
+ });
731
+
732
+ await test('a slow preview answering after a faster one never repaints the stale verdict', async () => {
733
+ // The race a builder hits by picking a goal and then changing their mind
734
+ // faster than the server answers. The FIRST request (no goal ⇒ "floats") comes
735
+ // back LAST; the line must keep the SECOND request's verdict.
736
+ const r = boot({ defer: ['preview'] });
737
+ await settle(); await settle();
738
+ r.el('fq-title').value = 'the widget is broken';
739
+ r.el('fq-goal').value = '';
740
+ r.el('fq-goal')._handlers.change();
741
+ await settle();
742
+ r.el('fq-goal').value = '77';
743
+ r.el('fq-goal')._handlers.change();
744
+ await settle();
745
+ assert.equal(r.deferred.length, 2, 'both reads are in flight');
746
+ assert.equal(r.deferred[0].args[0].body.goal_id, undefined, 'the first asked with no goal');
747
+ assert.equal(r.deferred[1].args[0].body.goal_id, 77, 'the second asked with the goal picked');
748
+
749
+ r.deferred[1].release(); // the NEWER answer lands first
750
+ await settle(); await settle();
751
+ assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the current verdict paints');
752
+
753
+ r.deferred[0].release(); // and now the older, slower one arrives
754
+ await settle(); await settle();
755
+ assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the stale answer does not repaint the line');
756
+ assert.doesNotMatch(r.el('fq-gravity').textContent, /floats in the inbox/, 'the superseded verdict never reaches the builder');
757
+ assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
758
+ });
759
+
681
760
  // Every guard above was reverted and each redded its named case (RUN, and these
682
761
  // are the measured counts, not the predicted ones):
683
762
  // drop renderGravity from the goal-change handler -> 2 red (named-goal + filing-clears)
684
763
  // infer from the select instead of the verdict -> 4 red (both lies, + backlog + refusal)
685
- // drop the seq guard and paint every answer -> 0 red <-- see below
764
+ // drop the seq guard and paint every answer -> 1 red (the race case; task 1004108)
765
+ // make boot's `defer` never park (gate returns at once) -> 2 red (both race cases)
686
766
  // rename the id in ideas.html -> 1 red (the markup pin ONLY)
687
767
  //
688
768
  // THE FIRST LINE COST A TEST FIX, and it is the reason to run these rather than
@@ -692,13 +772,22 @@ await test('filing clears the line — gravity describes a draft, and the draft
692
772
  // rendered at all. It asserts on the TEXT now. A stub's defaults can agree with
693
773
  // a broken page, which is the same lesson the markup pin below teaches twice.
694
774
  //
695
- // THE THIRD LINE IS AN HONEST GAP, recorded rather than papered over. The
696
- // out-of-order guard in refreshGravity is not covered: the fake resolves
697
- // synchronously, so two previews cannot interleave here and a test that removed
698
- // the guard would still pass. Pinning it needs a fake that delays per call,
699
- // which is a different harness from this one. Filed as task 1004108 rather than
700
- // claimed as tested — a green test over an unreachable branch would be a false
701
- // claim of coverage, which is the failure this file's own note above warns about.
775
+ // THE THIRD LINE WAS AN HONEST GAP UNTIL TASK 1004108, and the shape of the fix
776
+ // is worth keeping: the guard was not wrong, it was UNREACHABLE. Every fake here
777
+ // was an `async` function that settled before the next call was made, so two
778
+ // previews could never overlap and deleting `if (seq !== gravitySeq) return;`
779
+ // redded nothing — a green suite over a branch no case could enter. `boot({
780
+ // defer: ['preview'] })` parks a named fake's answer until the test hands it
781
+ // back, which makes the overlap real and the guard a tested property. The FOURTH
782
+ // line above is the guard on the guard: if `defer` ever stops parking, the race
783
+ // case would quietly go back to testing two sequential reads, so a case asserts
784
+ // that both calls are in flight before the ordering is asserted at all.
785
+ //
786
+ // `getGoalsSuggestions` is gated the same way and for the same reason, but has
787
+ // NO case yet: `refreshGoalSuggestions` carries no sequence guard — only
788
+ // `goalTouchedByUser` — so a test of its ordering would red against the current
789
+ // page rather than pin it. That is a production bug to file, not a test to
790
+ // write, and the harness is now able to reach it the day it is fixed.
702
791
 
703
792
  console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
704
793
  if (failed > 0) process.exit(1);
@@ -38,16 +38,26 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
38
38
  // right all along. The apex flip removed the redirect, so the test passes and
39
39
  // the entry had to go in the same change — this file fails the suite the moment
40
40
  // a parked test starts passing.
41
- const MAX_KNOWN_FAILING = 1;
41
+ //
42
+ // Ratcheted down 1 → 0 by task 1002617, which unparked served_paths_shipped — the last
43
+ // entry. Its own note here had PREDICTED the resolution ("whoever lands that claim should
44
+ // re-check this assertion and remove this entry"); the claim landed (task 1002200, the
45
+ // self-healing /docs/openapi.json endpoint) and the entry outlived it by months. That is
46
+ // the graveyard this file exists to prevent, and it took this ratchet going red to close
47
+ // the loop — which is the ratchet working, just later than it should have.
48
+ //
49
+ // AT ZERO, this file now asserts something stronger than a cap: that NOTHING is parked.
50
+ // Parking the next failure means raising this number in the same diff, which is exactly
51
+ // the deliberate, reviewable act it is meant to be.
52
+ const MAX_KNOWN_FAILING = 0;
42
53
 
43
- const KNOWN_FAILING = {
44
- served_paths_shipped: {
45
- owner: 'task 1002617; collides with the ACTIVE claim "make the /docs/openapi.json endpoint self-healing"',
46
- why: 'Asserts serve-internal.js reads docs/api/openapi.json. That exact path is what '
47
- + 'the in-flight claim is changing, so touching it here would collide. Whoever lands '
48
- + 'that claim should re-check this assertion and remove this entry.',
49
- },
50
- };
54
+ // EMPTY — nothing is parked, and that is the intended steady state.
55
+ //
56
+ // An entry here is invisible coverage: the test still runs, but its failure is expected,
57
+ // so nobody looks at it. The last one sat for months while the very change it was waiting
58
+ // on had already landed. If you add one, give it an owner and a concrete condition for
59
+ // removal, and raise MAX_KNOWN_FAILING in the same diff.
60
+ const KNOWN_FAILING = {};
51
61
 
52
62
  function runsRed(name) {
53
63
  try {
@@ -103,7 +103,11 @@ test('the landing page and the hub footers link privacy, terms and contact', ()
103
103
  for (const href of ['/privacy', '/terms', '/contact']) {
104
104
  assert.match(foot, new RegExp(`<a href="${href}">`), `${file}: footer links ${href}`);
105
105
  }
106
- assert.match(foot, /Open source \(AGPL-3\.0\)/, `${file}: the licence link stays`);
106
+ // task 1003920 split one link into two: the WORDS now open the published code and the licence
107
+ // sits behind its own link beside it, so the claim can be checked by whoever clicks it. Both
108
+ // halves are asserted — a bare /Open source/ would pass again the moment the licence link went.
109
+ assert.match(foot, /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core"[^>]*>Open source<\/a>/, `${file}: the words open the actual code`);
110
+ assert.match(foot, /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html"[^>]*>AGPL-3\.0<\/a>/, `${file}: the licence link stays`);
107
111
  assert.match(foot, /Built by agents/, `${file}: the attribution half stays`);
108
112
  }
109
113
  });
@@ -474,16 +474,22 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
474
474
  });
475
475
 
476
476
  test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
477
- const AGPL = /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">Open source \(AGPL-3\.0\)<\/a>\./;
477
+ // task 1003920 split this into TWO links: the words "Open source" open the published package and
478
+ // the licence sits behind its own link beside it. Both are asserted, and CODE is anchored first
479
+ // because it is the half 1003920 added and the half nothing else in the repo pins.
480
+ const CODE = /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core" target="_blank" rel="noopener">Open source<\/a>/;
481
+ const AGPL = /\(<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">AGPL-3\.0<\/a>\)\./;
478
482
  const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
479
483
  // task 1003298: the chrome-world landing's footer is a real <footer>; the
480
484
  // pre-world page used <div class="foot">. Either element is the footer.
481
485
  const indexFoot = (INDEX.match(/<footer(?:\s[^>]*)?>[\s\S]*?<\/footer>|<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
482
- assert.match(hubFoot, AGPL, 'projects.html');
483
- assert.match(indexFoot, AGPL, 'index.html');
484
- // the link sits OUTSIDE the apex-only span, so a child instance keeps it too
485
- assert.ok(hubFoot.indexOf('<!--apex-only:end-->') < hubFoot.search(AGPL), 'projects.html: after the apex-only block');
486
- assert.ok(indexFoot.indexOf('<!--apex-only:end-->') < indexFoot.search(AGPL), 'index.html: after the apex-only block');
486
+ assert.match(hubFoot, CODE, 'projects.html: the words open the actual code');
487
+ assert.match(indexFoot, CODE, 'index.html: the words open the actual code');
488
+ assert.match(hubFoot, AGPL, 'projects.html: the licence sits beside it');
489
+ assert.match(indexFoot, AGPL, 'index.html: the licence sits beside it');
490
+ // both links sit OUTSIDE the apex-only span, so a child instance keeps them too
491
+ assert.ok(hubFoot.indexOf('<!--apex-only:end-->') < hubFoot.search(CODE), 'projects.html: after the apex-only block');
492
+ assert.ok(indexFoot.indexOf('<!--apex-only:end-->') < indexFoot.search(CODE), 'index.html: after the apex-only block');
487
493
  assert.match(COSMOS, /\.foot a\{color:var\(--link-ink\);font-weight:700;text-decoration:none;\}/, 'never the browser’s blue');
488
494
  });
489
495
 
@@ -47,14 +47,39 @@ const INTENTIONALLY_UNSHIPPED = {
47
47
  // module is individually allowlisted), so the base alone is not a shippable-asset claim.
48
48
  const NOT_SERVED_BASE = new Set([
49
49
  'modules', // path.join(ROOT,'modules') → MODULES_ROOT; real serves are path.join(MODULES_ROOT, key, 'public', rel)
50
+ // Surfaced by widening the scan to root ladders (task 1002617). tryLoadInstanceConfig
51
+ // does path.join(root, 'config', name) with a DYNAMIC name, and the result is READ as
52
+ // instance configuration — never sendFile()d to a client, so it is not a served asset
53
+ // and this test's premise (a served path must ship or the box 404s) does not apply.
54
+ // Its exclusion is also deliberate rather than an oversight: serve-internal.js:59-62
55
+ // records that config/ is host content under publish-manifest INSTANCE_EXCLUDE, which
56
+ // is exactly WHY that function walks an instance-root-first ladder. Shipping it would
57
+ // be the bug.
58
+ 'config',
50
59
  ]);
51
60
 
52
61
  // Extract the leading contiguous string-literal prefix of every path.join(ROOT, ...)
53
62
  // call. A trailing non-literal arg (a variable) means "some file under this dir", so the
54
63
  // literal prefix is the directory that must ship.
64
+ // ROOT-LADDER FORMS (task 1002617). The scan originally matched `path.join(ROOT, …)`
65
+ // only. Task 1002200 made the OpenAPI spec self-healing and multi-rooted:
66
+ //
67
+ // const roots = [...new Set([resolveDocsRoot(), ROOT])];
68
+ // candidates: roots.map((r) => path.join(r, 'docs/api/openapi.json'))
69
+ //
70
+ // so the served path stopped matching and the scan silently stopped seeing it. The
71
+ // existence guard below is what caught that — it is the only reason this test was parked
72
+ // rather than quietly asserting nothing, which is precisely what a self-guard is for.
73
+ //
74
+ // The fix is to match any single-identifier first argument, not to special-case `r`.
75
+ // A root ladder is still a root: whichever entry wins, the file must be IN the package or
76
+ // the standalone box 404s. Accepting any identifier does pull in non-asset bases
77
+ // (`root + 'config'`, `ROOT + 'infra'`, `ROOT + 'modules'`); those are handled by the
78
+ // existing NOT_SERVED_BASE / INTENTIONALLY_UNSHIPPED lists, which is what they are for,
79
+ // and each is individually asserted by the stale-exception guard.
55
80
  function extractRootJoinPrefixes(src) {
56
81
  const out = new Set();
57
- const re = /path\.join\(\s*ROOT\s*,/g;
82
+ const re = /path\.join\(\s*[A-Za-z_$][\w$]*\s*,/g;
58
83
  let m;
59
84
  while ((m = re.exec(src))) {
60
85
  // Arg span up to the matching close paren. These calls contain no nested parens
@@ -84,10 +109,19 @@ function probe(prefix) {
84
109
 
85
110
  const prefixes = extractRootJoinPrefixes(fs.readFileSync(SERVE_INTERNAL, 'utf8'));
86
111
 
112
+ // THE SELF-GUARD, and it has now earned its keep once (task 1002617). A static source
113
+ // scan degrades to silence when the code it scans changes shape: the regex stops
114
+ // matching, `prefixes` empties, and every downstream assertion passes over nothing. That
115
+ // is strictly worse than a red test, because it reads as coverage. This test failed for
116
+ // months for exactly the right reason — the OpenAPI serve moved to a root ladder — and
117
+ // it was parked in KNOWN_FAILING rather than diagnosed. Keep it, and keep it specific:
118
+ // a bare `size > 0` would have stayed green through the very change that blinded it.
87
119
  test('extraction found the known served assets (guards against a silent no-match)', () => {
88
120
  assert.ok(prefixes.size >= 5, `expected several ROOT-served paths, found ${prefixes.size}`);
89
121
  assert.ok(prefixes.has('clients/bongos-client/bongos-client.global.js'), 'must see the client bundle read');
90
- assert.ok(prefixes.has('docs/api/openapi.json'), 'must see the openapi spec read');
122
+ assert.ok(prefixes.has('docs/api/openapi.json'),
123
+ 'must see the openapi spec read — it is served from a ROOT LADDER (roots.map(r => path.join(r, …))), '
124
+ + 'so a scan matching only `path.join(ROOT, …)` goes blind here');
91
125
  });
92
126
 
93
127
  test('every ROOT-served asset ships in the core package (isPublishable)', () => {