@bongos/core 1.19.509 → 1.19.511

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.
@@ -52,9 +52,21 @@ function mkExec(scn = {}) {
52
52
  if (cmd === 'checkout') return ret(scn.checkout ?? 0);
53
53
  if (cmd === 'rev-parse') {
54
54
  revCount += 1;
55
- const sha = revCount === 1 ? (scn.before ?? 'aaaaaaa') : (scn.after ?? 'bbbbbbb');
56
- return { code: 0, stdout: `${sha}\n`, stderr: '' };
55
+ // 1st = the pre-merge tip, 2nd = the merge commit, 3rd = the post-merge
56
+ // regeneration commit (task 1003546 step 7b), when there was drift to commit.
57
+ if (revCount === 1) return { code: 0, stdout: `${scn.before ?? 'aaaaaaa'}\n`, stderr: '' };
58
+ if (revCount === 2) return { code: 0, stdout: `${scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
59
+ return { code: 0, stdout: `${scn.regenSha ?? scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
57
60
  }
61
+ if (cmd === 'status') {
62
+ // --porcelain after step 7b's regeneration: a non-empty body = the merged tree
63
+ // disagreed with the generated artifacts the merge produced. Default OFF so the
64
+ // pre-1003546 cases keep asserting the plain merge-and-push path.
65
+ const drift = scn.drift ?? false;
66
+ return { code: 0, stdout: drift ? ' M docs/copy-registry.json\n' : '', stderr: '' };
67
+ }
68
+ if (cmd === 'add') return ret(0);
69
+ if (cmd === 'commit') return ret(scn.commit ?? 0);
58
70
  if (cmd === 'merge' && a[1] === '--abort') return ret(0);
59
71
  if (cmd === 'merge') {
60
72
  if (scn.merge === 'conflict') { mergeOutcome = 'conflict'; return { code: 1, stdout: 'CONFLICT (content)', stderr: '' }; }
@@ -79,8 +91,13 @@ function mkExec(scn = {}) {
79
91
  function mkDeps(scn = {}, over = {}) {
80
92
  const reg = [];
81
93
  const exec = mkExec(scn);
94
+ const genCalls = [];
82
95
  return {
83
96
  exec,
97
+ // task 1003546: resolveBranchConflicts regenerates on the MERGED tree before it
98
+ // pushes, so it takes the same runGenerators seam refreshStaleGenerated does.
99
+ // Stubbed for every case — the real one would spawn node against a fake clone.
100
+ runGenerators: over.runGenerators || (async (opts) => { genCalls.push(opts); return { code: scn.genCode ?? 0 }; }),
84
101
  githubPush: {
85
102
  isConfigured: () => (over.configured === undefined ? true : over.configured),
86
103
  isSafeBranch: (b) => (over.safeBranch === undefined ? (typeof b === 'string' && b.length > 0 && !b.startsWith('-')) : over.safeBranch),
@@ -94,6 +111,7 @@ function mkDeps(scn = {}, over = {}) {
94
111
  rmtmp: () => {},
95
112
  _exec: exec,
96
113
  _reg: reg,
114
+ _gen: genCalls,
97
115
  };
98
116
  }
99
117
 
@@ -377,7 +395,11 @@ test('1915 defaultRunGenerators strips *_INSTANCE_ROOT from the child env and pi
377
395
  const leaked = Object.keys(call.opts.env || {}).filter((k) => /_INSTANCE_ROOT$/.test(k));
378
396
  assert.deepEqual(leaked, [], 'no *_INSTANCE_ROOT key survives into the generator child env');
379
397
  assert.ok(call.opts.env.PATH, 'benign env (PATH) is preserved so node still resolves');
380
- assert.ok(String(call.argv[0]).startsWith('/tmp/clone-xyz'), 'the generator script path is under the clone');
398
+ // Separator-normalised: defaultRunGenerators builds this with path.join, which
399
+ // emits backslashes on win32 — the bare startsWith made this assertion fail on
400
+ // every Windows builder for a reason unrelated to what it tests (task 1003546).
401
+ assert.ok(String(call.argv[0]).replace(/\\/g, '/').startsWith('/tmp/clone-xyz'),
402
+ 'the generator script path is under the clone');
381
403
  }
382
404
  } finally {
383
405
  if (prev.g === undefined) delete process.env.GDS_INSTANCE_ROOT; else process.env.GDS_INSTANCE_ROOT = prev.g;
@@ -455,8 +477,100 @@ test('1915 defaultRunGenerators reports the worst child exit code (0 all-clean;
455
477
  assert.equal(badGen.code, 2, 'a single failing generator surfaces its non-zero code');
456
478
  });
457
479
 
480
+ // ── task 1003546: the merged tree is regenerated BEFORE the push ────────────────
481
+ // The `otb-regen` driver runs DURING the merge and rebuilds by scanning the working
482
+ // tree, which at that moment is still the PRE-merge tree. So a merge can come out
483
+ // clean — exit 0, no unmerged paths, no markers — carrying a generated artifact that
484
+ // describes neither side nor the merged tree, and nothing downstream can see it.
485
+ // resolveBranchConflicts must therefore regenerate on the FINAL tree and commit the
486
+ // drift before pushing (PR #536 / task 1003544: the registry kept the branch's line
487
+ // numbers and `unit` stayed red through the heal).
488
+
489
+ test('1003546 clean merge → generators run on the merged tree, drift committed, head_sha is the regen commit', async () => {
490
+ const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'mergesha', drift: true, regenSha: 'regensha' });
491
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr1' }, deps);
492
+ assert.equal(r.resolved, true);
493
+ assert.equal(deps._gen.length, 1, 'generators ran exactly once against the merged tree');
494
+ assert.ok(ran(deps._exec, 'commit'), 'post-merge generator drift is committed');
495
+ assert.equal(r.regenerated, true, 'the result reports that a regeneration was needed');
496
+ assert.equal(r.head_sha, 'regensha', 'head_sha is the regen commit, not the bare merge commit');
497
+ });
498
+
499
+ test('1003546 the regeneration happens AFTER the merge and its commit BEFORE the push (ordering is the whole point)', async () => {
500
+ const order = [];
501
+ const deps = mkDeps({ merge: 'clean', drift: true });
502
+ const baseExec = deps.exec;
503
+ deps.exec = async (args) => {
504
+ const a = args[0] === '-C' ? args.slice(2) : args;
505
+ if (['merge', 'commit', 'push'].includes(a[0])) order.push(a[0]);
506
+ return baseExec(args);
507
+ };
508
+ deps.runGenerators = async () => { order.push('__generators__'); return { code: 0 }; };
509
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr2' }, deps);
510
+ assert.equal(r.resolved, true);
511
+ assert.deepEqual(order, ['merge', '__generators__', 'commit', 'push'],
512
+ 'a pre-merge regeneration (or a push before the regen commit) is exactly the bug');
513
+ });
514
+
515
+ test('1003546 no post-merge drift → the merge alone is pushed, nothing extra committed', async () => {
516
+ const deps = mkDeps({ merge: 'clean', after: 'mergesha', drift: false });
517
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr3' }, deps);
518
+ assert.equal(r.resolved, true);
519
+ assert.equal(r.regenerated, false);
520
+ assert.equal(r.head_sha, 'mergesha', 'the merge commit is the head when regen is a no-op');
521
+ assert.ok(!ran(deps._exec, 'commit'), 'nothing is committed when the merge was already correct');
522
+ assert.ok(ran(deps._exec, 'push'), 'the resolved branch still lands');
523
+ });
524
+
525
+ test('1003546 a failing generator never pushes an uncertifiable merged tree', async () => {
526
+ const deps = mkDeps({ merge: 'clean', drift: true, genCode: 2 });
527
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr4' }, deps);
528
+ assert.equal(r.resolved, false);
529
+ assert.equal(r.skipped, 'generator_failed');
530
+ assert.ok(!ran(deps._exec, 'push'), 'a tree we cannot certify is left for a human, never pushed');
531
+ });
532
+
533
+ test('1003546 a failing post-merge commit is reported, not pushed', async () => {
534
+ const deps = mkDeps({ merge: 'clean', drift: true, commit: 1 });
535
+ const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr5' }, deps);
536
+ assert.equal(r.resolved, false);
537
+ assert.equal(r.skipped, 'commit_failed');
538
+ assert.ok(!ran(deps._exec, 'push'), 'no push without the regeneration commit');
539
+ });
540
+
541
+ // THE INVARIANT THAT WOULD HAVE CAUGHT #536 ON DAY ONE. Two hand-maintained lists
542
+ // name the same artifacts: REGENERABLE (what the merge driver claims to heal) and
543
+ // GENERATOR_SCRIPTS (what the server actually re-runs). copy-inventory.js was added
544
+ // to the first and not the second, so the copy registry was "covered" by the driver
545
+ // and regenerated by nobody. Pin them as one set, via each artifact's PRODUCER.
546
+ test('1003546 every REGENERABLE artifact has a producer, and every producer is in GENERATOR_SCRIPTS', async () => {
547
+ const regen = require('../scripts/gds/git-merge-regen.js');
548
+ const G = require('../scripts/gds/gen-repo-map.js');
549
+ const CI = require('../scripts/gds/copy-inventory.js');
550
+
551
+ // Derived from each generator's own exports where it publishes them, so this map
552
+ // cannot silently drift from the generator it names.
553
+ const PRODUCED_BY = new Map([
554
+ ['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
555
+ ['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
556
+ ['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
557
+ [CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
558
+ [CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
559
+ ...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
560
+ ]);
561
+
562
+ for (const p of regen.REGENERABLE) {
563
+ const producer = PRODUCED_BY.get(p);
564
+ assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
565
+ assert.ok(
566
+ cr.GENERATOR_SCRIPTS.includes(producer),
567
+ `${p} is healed by the merge driver but ${producer} is not in GENERATOR_SCRIPTS, so the server never re-runs it on the merged tree (the task 1003544 strand)`
568
+ );
569
+ }
570
+ });
571
+
458
572
  // Drain the microtask queue, then report + exit with the right code.
459
573
  setTimeout(() => {
460
- console.log(`\nconflict-resolve (task 1385 + 1915) tests: ${passed} passed, ${failed} failed`);
574
+ console.log(`\nconflict-resolve (task 1385 + 1915 + 1003546) tests: ${passed} passed, ${failed} failed`);
461
575
  process.exit(failed > 0 ? 1 : 0);
462
576
  }, 100);
@@ -22,6 +22,12 @@
22
22
  // 9. END-TO-END for docs/copy-registry.json: two branches that each regenerate
23
23
  // it differently merge without conflict markers via the real .gitattributes +
24
24
  // git-config wiring, and the result equals a fresh regeneration.
25
+ // 10. REGRESSION (task 1003546) — the driver runs MID-merge, so a tree-scanning
26
+ // artifact heals to the PRE-merge tree: two branches shifting line numbers in
27
+ // the same scanned UI file merge CLEAN and leave the registry STALE. Pins why
28
+ // the merge's OWNER must regenerate afterwards (conflict-resolve.js step 7b).
29
+ // Needs a real clone (the driver's __dirname must BE the merging tree), so it
30
+ // skips where the environment cannot host that.
25
31
  //
26
32
  // Run: node tests/git_merge_regen.mjs
27
33
 
@@ -135,7 +141,15 @@ function withBlockBody(src, body) {
135
141
  try {
136
142
  g('init', '-q');
137
143
  g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
138
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
144
+ // The fixture repo carries none of the repo's eol rules, so on a Windows box
145
+ // with the usual global core.autocrlf=true git would check the merge result out
146
+ // as CRLF while the generators render LF — a byte comparison below would fail on
147
+ // line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
148
+ g('config', 'core.autocrlf', 'false');
149
+ // Forward slashes: `git config` strips backslashes from the value, so a win32
150
+ // path.join() driver path reaches node with its separators gone and the driver
151
+ // never loads — this fixture was silently dead on Windows (task 1003546).
152
+ g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
139
153
  fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/repo-map.md merge=otb-regen\n');
140
154
  fs.mkdirSync(path.join(tmp, 'docs'));
141
155
  writeMap('base\n'); g('add', '-A'); g('commit', '-qm', 'base');
@@ -213,7 +227,15 @@ function withBlockBody(src, body) {
213
227
  try {
214
228
  g('init', '-q');
215
229
  g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
216
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
230
+ // The fixture repo carries none of the repo's eol rules, so on a Windows box
231
+ // with the usual global core.autocrlf=true git would check the merge result out
232
+ // as CRLF while the generators render LF — a byte comparison below would fail on
233
+ // line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
234
+ g('config', 'core.autocrlf', 'false');
235
+ // Forward slashes: `git config` strips backslashes from the value, so a win32
236
+ // path.join() driver path reaches node with its separators gone and the driver
237
+ // never loads — this fixture was silently dead on Windows (task 1003546).
238
+ g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
217
239
  fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/copy-registry.json merge=otb-regen\n');
218
240
  fs.mkdirSync(path.join(tmp, 'docs'));
219
241
  writeReg('{"schema":0}\n'); g('add', '-A'); g('commit', '-qm', 'base');
@@ -235,4 +257,108 @@ function withBlockBody(src, body) {
235
257
  }
236
258
  }
237
259
 
260
+ // ── 10) REGRESSION (task 1003546): the driver runs MID-merge, so a tree-scanning ──
261
+ // artifact heals to the PRE-merge tree and the merge lands clean but STALE.
262
+ //
263
+ // Cases 8 + 9 prove the copy-registry rule regenerates and that a real merge comes
264
+ // out marker-free — but their fixture repos contain no scanned SOURCE, so the scan
265
+ // returns the same bytes whichever tree it reads and they are blind to WHEN it runs.
266
+ // This case supplies the missing half: two branches that each shift line numbers in
267
+ // the SAME scanned UI file, in a real clone where the driver's own `__dirname` is
268
+ // the merging tree. The merge exits 0 with no unmerged paths and no markers — and
269
+ // keeps OUR registry verbatim, silently discarding the other side's line numbers.
270
+ // Measured before the fix: `"line": 464` where the merged tree says 466; that is the
271
+ // PR #536 / task 1003544 strand, and the reason conflict-resolve.js regenerates
272
+ // AFTER the merge (step 7b) instead of trusting the driver.
273
+ //
274
+ // It SKIPS rather than fails where the environment cannot host the fixture (no local
275
+ // clone, the scanned file moved, or a base whose registry is already stale) — a
276
+ // characterization test must never red for a reason it cannot attribute.
277
+ {
278
+ const SCANNED = 'modules/hall-ui/public/task-detail.js';
279
+ const REGEN_PATHS = ['docs/copy-registry.json', 'docs/copy-inventory.md'];
280
+ // Short clone path: a deep temp dir blows Windows' MAX_PATH on the clone's
281
+ // .git/objects/info/commit-graphs entries.
282
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-regen-e2e-'));
283
+ const work = path.join(root, 'w');
284
+ const g = (...a) => spawnSync('git', a, { cwd: work, encoding: 'utf8' });
285
+ const gen = (...a) => spawnSync('node', [path.join(work, 'scripts/gds/copy-inventory.js'), ...a],
286
+ { cwd: work, encoding: 'utf8' });
287
+ // "Does regeneration change tracked CONTENT?" — `git diff` applies .gitattributes
288
+ // (`*.json text eol=lf`), so it ignores the worktree line endings that make a raw
289
+ // byte comparison stale on a Windows checkout.
290
+ const regenDirty = () => { gen(); return g('diff', '--quiet', '--', ...REGEN_PATHS).status !== 0; };
291
+ const scannedFile = () => path.join(work, SCANNED);
292
+
293
+ let skip = null;
294
+ try {
295
+ if (spawnSync('git', ['clone', '--local', '-q', repo, work], { encoding: 'utf8' }).status !== 0) {
296
+ skip = 'the repo could not be locally cloned';
297
+ }
298
+ if (!skip) {
299
+ g('config', 'user.email', 't@t');
300
+ g('config', 'user.name', 't');
301
+ g('config', 'core.autocrlf', 'false');
302
+ g('config', 'merge.otb-regen.name', 'regen');
303
+ g('config', 'merge.otb-regen.driver',
304
+ `node ${path.join(work, 'scripts/gds/git-merge-regen.js').replace(/\\/g, '/')} %O %A %B %P`);
305
+
306
+ if (!fs.existsSync(scannedFile())) skip = `${SCANNED} is gone — repoint this fixture at another scanned surface`;
307
+ else if (fs.readFileSync(scannedFile(), 'utf8').split('\n').length < 420) skip = `${SCANNED} is too short for two distant edits`;
308
+ }
309
+ if (!skip && regenDirty()) {
310
+ skip = 'the committed copy registry is already stale at this base — nothing could be attributed to the merge';
311
+ }
312
+
313
+ if (!skip) {
314
+ g('checkout', '--', ...REGEN_PATHS);
315
+ const pad = (atLine, tag) => {
316
+ const lines = fs.readFileSync(scannedFile(), 'utf8').split('\n');
317
+ lines.splice(atLine, 0, `// pad ${tag}`, `// pad ${tag}`);
318
+ fs.writeFileSync(scannedFile(), lines.join('\n'));
319
+ };
320
+
321
+ // Base is HEAD itself — never a branch NAME, which does not exist in a CI
322
+ // detached checkout.
323
+ const base = g('rev-parse', 'HEAD').stdout.trim();
324
+ g('checkout', '-q', '-B', 'fixture-ours', base);
325
+ pad(10, 'OURS');
326
+ gen();
327
+ g('add', '-A'); g('commit', '-qm', 'ours: shift copy lines near the top + regen');
328
+
329
+ g('checkout', '-q', '-B', 'fixture-theirs', base);
330
+ pad(400, 'THEIRS');
331
+ gen();
332
+ g('add', '-A'); g('commit', '-qm', 'theirs: shift copy lines far below + regen');
333
+
334
+ g('checkout', '-q', 'fixture-ours');
335
+ const m = g('merge', '--no-edit', 'fixture-theirs');
336
+
337
+ // 1. The merge lands CLEAN — the driver did clear the textual conflict.
338
+ assert.equal(m.status, 0, `the driver must resolve the registry conflict; got ${m.status} :: ${m.stdout}${m.stderr}`);
339
+ assert.equal(g('diff', '--name-only', '--diff-filter=U').stdout.trim(), '', 'no path may be left unmerged');
340
+ const healed = fs.readFileSync(path.join(work, 'docs', 'copy-registry.json'), 'utf8');
341
+ assert.ok(!MARKERS.test(healed), 'the merged registry must carry no conflict markers');
342
+
343
+ // 2. THE HAZARD: it kept OURS verbatim, because the scan ran before the merge.
344
+ assert.equal(
345
+ g('diff', '--quiet', 'HEAD^1', 'HEAD', '--', 'docs/copy-registry.json').status, 0,
346
+ 'expected the merge to keep ours byte-for-byte (the mid-merge scan)'
347
+ );
348
+ assert.equal(regenDirty(), true,
349
+ 'expected the merged tree to be STALE — the driver cannot see the merged tree, which is WHY '
350
+ + 'conflict-resolve.js regenerates after the merge (step 7b). If this assertion now fails, git has '
351
+ + 'begun running merge drivers against the merged tree: re-read step 7b, it may be simplifiable.');
352
+
353
+ // 3. THE CURE step 7b relies on: a post-merge regeneration is sufficient.
354
+ g('add', '-A'); g('commit', '-qm', 'regenerate from the merged tree');
355
+ assert.equal(regenDirty(), false, 'a post-merge regeneration must leave the registry fresh');
356
+ } else {
357
+ console.log(`git_merge_regen: case 10 SKIPPED — ${skip}`);
358
+ }
359
+ } finally {
360
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* best effort */ }
361
+ }
362
+ }
363
+
238
364
  console.log('git_merge_regen: all assertions passed');
@@ -1995,6 +1995,19 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
1995
1995
  assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
1996
1996
  });
1997
1997
 
1998
+ /* ── one fold for the whole wizard (task 1003538, a task 1003346 follow-up). The rail folded
1999
+ at the sheet's 900 while the wizard's fields, choice bands and review ledger folded at the
2000
+ page's 840 (cosmos's createSplit collapse), so between the two the rail was already three-up
2001
+ beside full-width bands. Both sheets now name the same width. ── */
2002
+ test('the wizard folds at one width: the rail\'s media rule sits at 840px with the bands, and no 900px rule is left in the #view-new block (task 1003538)', () => {
2003
+ const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
2004
+ assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
2005
+ const block = COSMOS.slice(at);
2006
+ assert.match(block, /@media \(max-width:840px\)\{\s*#view-new \.steps\{grid-template-columns:repeat\(3,1fr\);/, 'the rail folds to three columns at 840, the width the bands fold at');
2007
+ assert.doesNotMatch(block, /@media \(max-width:900px\)/, 'no 900px rule is left in the wizard block');
2008
+ assert.match(STYLE, /@media \(max-width:840px\)\{[\s\S]*?\.review\{grid-template-columns:1fr;/, 'the page\'s own wizard block still folds its bands and ledger at that same 840');
2009
+ });
2010
+
1998
2011
  test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
1999
2012
  const at = STYLE.indexOf('the create wizard (task 1002763');
2000
2013
  assert.ok(at > -1, 'the wizard block is still the page\'s own');
@@ -0,0 +1,268 @@
1
+ // tests/provisioning_starter_bundles.mjs — the curated starter bundles (BV1.R19,
2
+ // task 1002338; goal 1000046 criterion `proj-module-select`).
3
+ //
4
+ // Three things are pinned here, and the middle one is the reason this file exists.
5
+ //
6
+ // 1. The resolver — bundleForProjectType(type) for each declared type, and the
7
+ // core-only fallback for a type nobody declared. A creation form must never
8
+ // be handed a guess.
9
+ // 2. Bundle <-> catalog integrity — a bundle is a list of module KEYS, declared
10
+ // in a pure file that may not import the live catalog (the one-way rule, ADR
11
+ // 0083). This test IS the link: it reads every modules/*/module.json off disk
12
+ // and fails the build the moment a bundle names a module that does not exist,
13
+ // names an always-on one (a preselect that toggles nothing), or the declared
14
+ // always-on list drifts from the manifests' `default: true` set.
15
+ // 3. The read surface — GET /provisioning/starter-bundles is public and returns
16
+ // exactly what the resolver returns, so R20's picker and R17's rule engine
17
+ // cannot read a different answer than a script would.
18
+ //
19
+ // DB-free: the route is a pure read, so nothing here touches Postgres or the
20
+ // network beyond a loopback listener on port 0.
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/provisioning_starter_bundles.mjs
23
+
24
+ import { test, before, after } from 'node:test';
25
+ import { strict as assert } from 'node:assert';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import http from 'node:http';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createRequire } from 'node:module';
31
+
32
+ // ESM named imports off the CJS module on purpose: knip cannot trace a
33
+ // createRequire() import, so a test-only export would read as dead code and move
34
+ // the knip ratchet (see the repo's knip learning).
35
+ import {
36
+ ALWAYS_ON_CORE,
37
+ STARTER_BUNDLES,
38
+ HUB_ONLY_MODULES,
39
+ bundleForProjectType,
40
+ allStarterBundles,
41
+ } from '../modules/provisioning/starter-bundles.js';
42
+
43
+ const require = createRequire(import.meta.url);
44
+
45
+ // Stub the doorway in require.cache BEFORE the route file loads (the
46
+ // access_requests_invite pattern): the route destructures `pool` at require time,
47
+ // and a real pg pool would hold the event loop open long after the assertions
48
+ // pass. Nothing here needs a database — the route under test is a pure read.
49
+ const apiPath = require.resolve('../src/module-api.js');
50
+ const realApi = require(apiPath);
51
+ require.cache[apiPath].exports = {
52
+ ...realApi,
53
+ pool: { async query() { throw new Error('the starter-bundles route must not touch the database'); } },
54
+ };
55
+
56
+ const provisioning = require('../modules/provisioning/provisioning.js');
57
+
58
+ const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
59
+
60
+ // Every module key on disk, with the manifest's `default` — the live catalog as
61
+ // the loader discovers it (src/module-loader/loader.js scans exactly this).
62
+ function manifestsOnDisk() {
63
+ const out = new Map();
64
+ for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
65
+ if (!ent.isDirectory()) continue;
66
+ const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
67
+ if (!fs.existsSync(manifestPath)) continue;
68
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
69
+ out.set(manifest.key, manifest);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // ── the resolver ────────────────────────────────────────────────────────────
75
+
76
+ test('bundleForProjectType returns the always-on core plus the type\'s opt-in modules', () => {
77
+ // Arrange
78
+ const type = 'game';
79
+
80
+ // Act
81
+ const resolved = bundleForProjectType(type);
82
+
83
+ // Assert
84
+ assert.equal(resolved.type, 'game');
85
+ assert.deepEqual(resolved.core, [...ALWAYS_ON_CORE]);
86
+ assert.deepEqual(resolved.bundle, [...STARTER_BUNDLES.game.modules]);
87
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE, ...STARTER_BUNDLES.game.modules]);
88
+ assert.ok(resolved.label && resolved.summary, 'carries owner-facing copy for the picker to render');
89
+ });
90
+
91
+ test('every declared project type resolves to a bundle', () => {
92
+ // Arrange — the shipped vocabulary (task 1002772, owner decision 2026-08-13).
93
+ const declared = provisioning.PROJECT_TYPES;
94
+
95
+ // Act + Assert
96
+ for (const type of declared) {
97
+ const resolved = bundleForProjectType(type);
98
+ assert.equal(resolved.type, type, `${type} resolves to itself, not the fallback`);
99
+ }
100
+ assert.deepEqual(
101
+ Object.keys(STARTER_BUNDLES).sort(),
102
+ [...declared].sort(),
103
+ 'the bundle table covers the declared type vocabulary exactly — no missing type, no invented one',
104
+ );
105
+ });
106
+
107
+ test('an undeclared or unknown project type falls back to the core, and never guesses a type', () => {
108
+ // A project may have no type at all: the column is nullable because every row
109
+ // predates the field and must not be back-filled with a guess (migration
110
+ // provisioning_007). The fallback must be honest about that.
111
+ for (const type of [null, undefined, '', 'saas-tool', 'GAME', 42, {}]) {
112
+ // Act
113
+ const resolved = bundleForProjectType(type);
114
+
115
+ // Assert
116
+ assert.equal(resolved.type, null, `${JSON.stringify(type)} → no declared type`);
117
+ assert.deepEqual(resolved.bundle, [], 'nothing is preselected on a guess');
118
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE]);
119
+ }
120
+ });
121
+
122
+ test('"not-sure" is a real answer that resolves to the core only, distinct from an undeclared type', () => {
123
+ // Arrange + Act
124
+ const notSure = bundleForProjectType('not-sure');
125
+ const undeclared = bundleForProjectType(null);
126
+
127
+ // Assert — same modules, different provenance: one owner said "not sure", the
128
+ // other said nothing at all, and the picker should be able to tell them apart.
129
+ assert.deepEqual(notSure.modules, undeclared.modules);
130
+ assert.equal(notSure.type, 'not-sure');
131
+ assert.equal(undeclared.type, null);
132
+ assert.deepEqual(notSure.bundle, [], 'the escape hatch adds nothing — nothing depends on it');
133
+ });
134
+
135
+ test('a caller mutating a resolved bundle cannot corrupt the declaration', () => {
136
+ // Arrange
137
+ const first = bundleForProjectType('game');
138
+
139
+ // Act
140
+ first.modules.push('not-a-module');
141
+ first.bundle.length = 0;
142
+ first.core.length = 0;
143
+
144
+ // Assert
145
+ const second = bundleForProjectType('game');
146
+ assert.deepEqual(second.core, [...ALWAYS_ON_CORE]);
147
+ assert.deepEqual(second.bundle, [...STARTER_BUNDLES.game.modules]);
148
+ assert.ok(!second.modules.includes('not-a-module'));
149
+ });
150
+
151
+ test('allStarterBundles returns one resolved bundle per declared type', () => {
152
+ // Act
153
+ const all = allStarterBundles();
154
+
155
+ // Assert
156
+ assert.deepEqual(all.map((b) => b.type), Object.keys(STARTER_BUNDLES));
157
+ for (const row of all) assert.deepEqual(row, bundleForProjectType(row.type));
158
+ });
159
+
160
+ // ── bundle <-> catalog integrity ────────────────────────────────────────────
161
+
162
+ test('every module a bundle names exists in the module catalog', () => {
163
+ // Arrange
164
+ const onDisk = manifestsOnDisk();
165
+ assert.ok(onDisk.size > 0, 'read the manifests off disk');
166
+
167
+ // Act + Assert
168
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
169
+ for (const key of bundle.modules) {
170
+ assert.ok(onDisk.has(key), `bundle "${type}" names "${key}", which is not a module on disk`);
171
+ }
172
+ }
173
+ });
174
+
175
+ test('every always-on module is a real module whose manifest says default: true', () => {
176
+ // Arrange
177
+ const onDisk = manifestsOnDisk();
178
+
179
+ // Act + Assert
180
+ for (const key of ALWAYS_ON_CORE) {
181
+ assert.ok(onDisk.has(key), `ALWAYS_ON_CORE names "${key}", which is not a module on disk`);
182
+ assert.equal(onDisk.get(key).default, true, `"${key}" is listed as always-on but its manifest is not default: true`);
183
+ }
184
+ const defaultsOnDisk = [...onDisk.values()].filter((m) => m.default === true).map((m) => m.key).sort();
185
+ assert.deepEqual(
186
+ [...ALWAYS_ON_CORE].sort(),
187
+ defaultsOnDisk,
188
+ 'the declared always-on list has drifted from the manifests — a module was added, removed, or flipped its default',
189
+ );
190
+ });
191
+
192
+ test('a bundle may only add opt-in modules — never one that is already always-on', () => {
193
+ // A preselect that names an always-on module toggles nothing and reads to the
194
+ // owner as a choice they made; that is a lie the picker would render.
195
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
196
+ for (const key of bundle.modules) {
197
+ assert.ok(!ALWAYS_ON_CORE.includes(key), `bundle "${type}" names always-on module "${key}"`);
198
+ }
199
+ }
200
+ });
201
+
202
+ test('no bundle names a hub-only module', () => {
203
+ // platform-identity and provisioning are the control plane's own modules — the
204
+ // hub runs them, a project created THROUGH the hub never should.
205
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
206
+ for (const key of bundle.modules) {
207
+ assert.ok(!HUB_ONLY_MODULES.includes(key), `bundle "${type}" names hub-only module "${key}"`);
208
+ }
209
+ }
210
+ });
211
+
212
+ test('a bundle lists each module once', () => {
213
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
214
+ assert.equal(new Set(bundle.modules).size, bundle.modules.length, `bundle "${type}" repeats a module`);
215
+ }
216
+ });
217
+
218
+ // ── the read surface ────────────────────────────────────────────────────────
219
+
220
+ // Mount the real router. The route under test is a pure read, so it needs no
221
+ // session gate and no res.fail helper — which is itself the point of the
222
+ // assertion below.
223
+ const express = require('express');
224
+ const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
225
+ const app = express();
226
+ app.use('/api/bongos', buildProvisioningRouter());
227
+
228
+ let server;
229
+ let port;
230
+ before(async () => {
231
+ server = http.createServer(app);
232
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
233
+ port = server.address().port;
234
+ });
235
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
236
+
237
+ function get(pathname) {
238
+ return new Promise((resolve, reject) => {
239
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
240
+ let body = '';
241
+ res.on('data', (c) => { body += c; });
242
+ res.on('end', () => {
243
+ let parsed = null;
244
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
245
+ resolve({ status: res.statusCode, body: parsed, raw: body });
246
+ });
247
+ }).on('error', reject);
248
+ });
249
+ }
250
+
251
+ test('GET /provisioning/starter-bundles is public and serves the resolved bundles', async () => {
252
+ // Act — no session: `rank: public`, the same gate (none) the signed-out create
253
+ // wizard reads GET /provisioning/onboard-plan with.
254
+ const res = await get('/api/bongos/provisioning/starter-bundles');
255
+
256
+ // Assert
257
+ assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
258
+ assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
259
+ assert.deepEqual(res.body.bundles, allStarterBundles());
260
+ assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
261
+ });
262
+
263
+ test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
264
+ // The stubbed pool throws on any query, so a 200 here IS the assertion: an
265
+ // owner picking a type in the wizard must not cost a round trip to Postgres.
266
+ const res = await get('/api/bongos/provisioning/starter-bundles');
267
+ assert.equal(res.status, 200);
268
+ });