@bongos/core 1.19.512 → 1.19.514

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 (42) hide show
  1. package/.bongos-core.json +77 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0182-project-visibility-two-axes.md +2 -0
  8. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +41 -4
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +36 -35
  13. package/docs/copy-registry.json +93 -74
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/hall-ui/public/hall-kit.js +309 -0
  17. package/modules/hall-ui/public/hall-render.js +10 -38
  18. package/modules/hall-ui/public/profile-charts.js +58 -65
  19. package/modules/hall-ui/public/profile.css +9 -18
  20. package/modules/hall-ui/public/style.css +101 -15
  21. package/modules/hall-ui/public/watch.css +6 -14
  22. package/modules/hall-ui/public/watch.html +1 -1
  23. package/modules/hall-ui/public/watch.js +139 -54
  24. package/modules/platform-identity/platform-identity.js +24 -20
  25. package/modules/platform-identity/projects-feed.js +142 -0
  26. package/modules/platform-identity/routes/projects.js +27 -8
  27. package/modules/platform-identity/tests/platform-identity.mjs +9 -5
  28. package/modules/provisioning/recommendations.js +260 -0
  29. package/modules/provisioning/routes/provisioning.js +29 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/hall_audit.mjs +84 -0
  34. package/tests/hall_kit_charts.mjs +328 -0
  35. package/tests/hall_profile_world.mjs +27 -6
  36. package/tests/helpers.mjs +161 -0
  37. package/tests/joinability_invite_only.mjs +10 -2
  38. package/tests/projects_hub.mjs +5 -1
  39. package/tests/provisioning_recommendations.mjs +448 -0
  40. package/tests/task_detail_ui.mjs +1 -153
  41. package/tests/visible_projects_feed.mjs +320 -0
  42. package/tests/watch_applications_queue.mjs +381 -0
@@ -0,0 +1,448 @@
1
+ // tests/provisioning_recommendations.mjs — the rule-based recommendation engine
2
+ // (BV1.R17, task 1002336; goal 1000046 criteria `proj-module-select`,
3
+ // `proj-governance-reco`, `proj-invite-builders`). Decision record: ADR 0239.
4
+ //
5
+ // Four things are pinned here:
6
+ //
7
+ // 1. Each rule fires on its condition and NOT otherwise — the whole claim of a
8
+ // rule engine, and the thing a table quietly breaks when a fourth row lands.
9
+ // 2. Skipping the interview yields the stated defaults, and an off-vocabulary
10
+ // answer is read as no answer. Criterion `proj-detail-interview` splits on
11
+ // exactly this ("completing it produces tailored recommendations; skipping
12
+ // produces sensible defaults"), and R18 (task 1002337) proves it live.
13
+ // 3. Table integrity — every module a rule names is a real OPT-IN module on
14
+ // disk, every governance rule names a preset that exists, and every team
15
+ // shape in the interview's own vocabulary has a rule. The ADR 0237 idiom: a
16
+ // pure file declares, and this test is the link back to the live catalog.
17
+ // 4. The read surface — GET /provisioning/recommendations is public, needs no
18
+ // database, and hands back the same bundle shape the starter-bundles route
19
+ // serves, so a recommendation and a preselect can never name different
20
+ // modules.
21
+ //
22
+ // DB-free: the route is a pure read, so nothing here touches Postgres or the
23
+ // network beyond a loopback listener on port 0.
24
+ //
25
+ // Run: node --test --test-reporter=tap tests/provisioning_recommendations.mjs
26
+
27
+ import { test, before, after } from 'node:test';
28
+ import { strict as assert } from 'node:assert';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import http from 'node:http';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { createRequire } from 'node:module';
34
+
35
+ // ESM named imports off the CJS module on purpose: knip cannot trace a
36
+ // createRequire() import, so a test-only export would read as dead code and move
37
+ // the knip ratchet (see the repo's knip learning).
38
+ import {
39
+ TEAM_SHAPE_RULES,
40
+ GOVERNANCE_PRESETS,
41
+ GOVERNANCE_RULES,
42
+ GOVERNANCE_DEFAULT,
43
+ INVITE_RULES,
44
+ INVITE_DEFAULT,
45
+ recommendationsForDetails,
46
+ } from '../modules/provisioning/recommendations.js';
47
+ import {
48
+ ALWAYS_ON_CORE,
49
+ HUB_ONLY_MODULES,
50
+ bundleForProjectType,
51
+ } from '../modules/provisioning/starter-bundles.js';
52
+
53
+ const require = createRequire(import.meta.url);
54
+
55
+ // Stub the doorway in require.cache BEFORE the route file loads (the
56
+ // access_requests_invite pattern): the route destructures `pool` at require time,
57
+ // and a real pg pool would hold the event loop open long after the assertions
58
+ // pass. Nothing here needs a database — the route under test is a pure read, and
59
+ // this stub is also the assertion that proves it.
60
+ const apiPath = require.resolve('../src/module-api.js');
61
+ const realApi = require(apiPath);
62
+ require.cache[apiPath].exports = {
63
+ ...realApi,
64
+ pool: { async query() { throw new Error('the recommendations route must not touch the database'); } },
65
+ };
66
+
67
+ const provisioning = require('../modules/provisioning/provisioning.js');
68
+
69
+ const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
70
+
71
+ // Every module key on disk, with the manifest's `default` — the live catalog as
72
+ // the loader discovers it (src/module-loader/loader.js scans exactly this).
73
+ function manifestsOnDisk() {
74
+ const out = new Map();
75
+ for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
76
+ if (!ent.isDirectory()) continue;
77
+ const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
78
+ if (!fs.existsSync(manifestPath)) continue;
79
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
80
+ out.set(manifest.key, manifest);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // ── the rules fire on their condition, and not otherwise ────────────────────
86
+
87
+ test('an open community adds somewhere to gather to a bundle that lacks one', () => {
88
+ // Arrange — the research bundle is `agents` alone (ADR 0237 §4), so the rule
89
+ // has something to actually change.
90
+ const answers = { type: 'research', team_shape: 'community' };
91
+
92
+ // Act
93
+ const recs = recommendationsForDetails(answers);
94
+
95
+ // Assert
96
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
97
+ assert.equal(recs.bundle.adjustments.length, 1);
98
+ assert.equal(recs.bundle.adjustments[0].module, 'discord');
99
+ assert.equal(recs.bundle.adjustments[0].effect, 'add');
100
+ assert.equal(recs.bundle.adjustments[0].rule, 'a-community-needs-somewhere-to-gather');
101
+ assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
102
+ });
103
+
104
+ test('a small team adds the shared build environment', () => {
105
+ // Arrange
106
+ const answers = { type: 'research', team_shape: 'small-team' };
107
+
108
+ // Act
109
+ const recs = recommendationsForDetails(answers);
110
+
111
+ // Assert
112
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
113
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
114
+ });
115
+
116
+ test('a solo builder drops the shared build environment from the bundle', () => {
117
+ // Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
118
+ const answers = { type: 'business', team_shape: 'solo' };
119
+
120
+ // Act
121
+ const recs = recommendationsForDetails(answers);
122
+
123
+ // Assert
124
+ assert.deepEqual(recs.bundle.bundle, ['agents']);
125
+ assert.equal(recs.bundle.adjustments.length, 1);
126
+ assert.equal(recs.bundle.adjustments[0].effect, 'remove');
127
+ assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
128
+ });
129
+
130
+ test('a rule does not fire for a team shape it does not name', () => {
131
+ // Arrange — `solo` removes dev-box; `community` must leave it exactly alone.
132
+ const answers = { type: 'business', team_shape: 'community' };
133
+
134
+ // Act
135
+ const recs = recommendationsForDetails(answers);
136
+
137
+ // Assert
138
+ assert.ok(recs.bundle.bundle.includes('dev-box'), 'the solo removal did not leak onto another shape');
139
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.rule), ['a-community-needs-somewhere-to-gather']);
140
+ });
141
+
142
+ test('a rule whose module the bundle already carries records no adjustment', () => {
143
+ // The game bundle already contains `discord`, so the community rule has nothing
144
+ // to change. An adjustment list is a DELTA — claiming a change that did not
145
+ // happen is a reason the owner cannot check against the picker in front of them.
146
+ // Arrange
147
+ const answers = { type: 'game', team_shape: 'community' };
148
+
149
+ // Act
150
+ const recs = recommendationsForDetails(answers);
151
+
152
+ // Assert
153
+ assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle], 'the bundle is untouched');
154
+ assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
155
+ });
156
+
157
+ test('a removal rule whose module is absent records no adjustment', () => {
158
+ // Arrange — `non-profit` is `discord` alone, so solo\'s dev-box removal is a no-op.
159
+ const answers = { type: 'non-profit', team_shape: 'solo' };
160
+
161
+ // Act
162
+ const recs = recommendationsForDetails(answers);
163
+
164
+ // Assert
165
+ assert.deepEqual(recs.bundle.bundle, ['discord']);
166
+ assert.deepEqual(recs.bundle.adjustments, []);
167
+ });
168
+
169
+ test('each team shape maps to its own governance preset and invite seek', () => {
170
+ // Arrange
171
+ const expected = {
172
+ solo: { preset: 'owner-only', seeking: null },
173
+ 'small-team': { preset: 'trusted-team', seeking: { kind: 'top-builders', limit: 3 } },
174
+ community: { preset: 'open-contribution', seeking: { kind: 'top-builders', limit: 5 } },
175
+ };
176
+
177
+ // Act + Assert
178
+ for (const [shape, want] of Object.entries(expected)) {
179
+ const recs = recommendationsForDetails({ type: 'not-sure', team_shape: shape });
180
+ assert.equal(recs.governance.preset, want.preset, `${shape} → ${want.preset}`);
181
+ assert.deepEqual(recs.invites.seeking, want.seeking, `${shape} → its own invite seek`);
182
+ assert.ok(recs.governance.reason.length > 0, 'the preset always says why it was picked');
183
+ }
184
+ });
185
+
186
+ // ── skipping the interview, and answers that are not answers ────────────────
187
+
188
+ test('skipping the interview returns the type bundle unchanged, the narrowest governance and no invites', () => {
189
+ // Arrange — a declared type but no interview answer at all: the supported path
190
+ // (ADR 0182 D4), not a gap.
191
+ const answers = { type: 'game' };
192
+
193
+ // Act
194
+ const recs = recommendationsForDetails(answers);
195
+
196
+ // Assert
197
+ assert.equal(recs.teamShape, null);
198
+ assert.equal(recs.tailored, false, 'nothing here came from an answer the owner gave');
199
+ assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle]);
200
+ assert.deepEqual(recs.bundle.adjustments, []);
201
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
202
+ assert.equal(recs.governance.reason, GOVERNANCE_DEFAULT.reason);
203
+ assert.deepEqual(recs.invites.suggested, []);
204
+ assert.equal(recs.invites.seeking, null);
205
+ assert.equal(recs.invites.reason, INVITE_DEFAULT.reason);
206
+ });
207
+
208
+ test('answering the interview marks the recommendation tailored', () => {
209
+ // Arrange + Act
210
+ const answered = recommendationsForDetails({ type: 'game', team_shape: 'solo' });
211
+ const skipped = recommendationsForDetails({ type: 'game' });
212
+
213
+ // Assert — the machine-readable half of criterion `proj-detail-interview`.
214
+ assert.equal(answered.tailored, true);
215
+ assert.equal(skipped.tailored, false);
216
+ });
217
+
218
+ test('an off-vocabulary team shape is read as no answer, not as a new rule', () => {
219
+ // A value that was never in the vocabulary — or has since left it — must land
220
+ // on the defaults, exactly as normalizeProjectDetail drops it on the way in.
221
+ // Inventing an answer nobody gave is the failure this file exists to prevent.
222
+ for (const shape of [null, undefined, '', 'guild', 'SOLO', 'small team', 42, {}, ['solo']]) {
223
+ // Act
224
+ const recs = recommendationsForDetails({ type: 'business', team_shape: shape });
225
+
226
+ // Assert
227
+ assert.equal(recs.teamShape, null, `${JSON.stringify(shape)} → no answer`);
228
+ assert.equal(recs.tailored, false);
229
+ assert.deepEqual(recs.bundle.adjustments, [], 'no rule fires on a value nobody chose');
230
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
231
+ }
232
+ });
233
+
234
+ test('a call with no answers at all recommends the always-on core and nothing else', () => {
235
+ // Act — the signed-out wizard's very first ask, before any question is answered.
236
+ for (const input of [undefined, null, {}, 'nonsense', []]) {
237
+ const recs = recommendationsForDetails(input);
238
+
239
+ // Assert
240
+ assert.equal(recs.type, null, 'never guesses a type');
241
+ assert.deepEqual(recs.bundle.bundle, [], 'nothing is preselected on a guess');
242
+ assert.deepEqual(recs.bundle.modules, [...ALWAYS_ON_CORE]);
243
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
244
+ assert.equal(recs.invites.seeking, null);
245
+ }
246
+ });
247
+
248
+ test('an unknown project type recommends the core, even when the interview was answered', () => {
249
+ // Arrange — the stale seeded vocabulary (ADR 0237 §2) is the realistic caller.
250
+ const answers = { type: 'saas-tool', team_shape: 'community' };
251
+
252
+ // Act
253
+ const recs = recommendationsForDetails(answers);
254
+
255
+ // Assert — the type is refused, but the ANSWER the owner actually gave still counts.
256
+ assert.equal(recs.type, null);
257
+ assert.equal(recs.teamShape, 'community');
258
+ assert.deepEqual(recs.bundle.bundle, ['discord'], 'the community rule applies to an empty bundle');
259
+ assert.equal(recs.governance.preset, 'open-contribution');
260
+ });
261
+
262
+ // ── the recommendation is a preview, and the invites are a seam ─────────────
263
+
264
+ test('governance is a named preview and is never applied', () => {
265
+ // The governance module is planned and unbuilt (goal 1000030), which criterion
266
+ // `proj-governance-reco` states outright. Flip this pin when R25 (task 1002344)
267
+ // fits the presets to the real module.
268
+ for (const shape of [...provisioning.PROJECT_DETAIL_VOCAB.team_shape, null]) {
269
+ // Act
270
+ const recs = recommendationsForDetails({ type: 'game', team_shape: shape });
271
+
272
+ // Assert
273
+ assert.equal(recs.governance.applied, false, 'a recommendation, never a setting');
274
+ assert.ok(recs.governance.label, 'carries owner-facing copy');
275
+ assert.ok(recs.governance.preview, 'and one sentence of what it feels like');
276
+ }
277
+ });
278
+
279
+ test('invites are a seam: the suggested list is empty and the seek says what to look for', () => {
280
+ // Ranking builders means querying the leaderboard, which lives in the `economy`
281
+ // module this pure file may not import (the one-way rule, ADR 0083). R28 (task
282
+ // 1002347) owns the lookup; the engine only states what it should look for.
283
+ // Arrange
284
+ const answers = { type: 'business', team_shape: 'small-team' };
285
+
286
+ // Act
287
+ const recs = recommendationsForDetails(answers);
288
+
289
+ // Assert
290
+ assert.deepEqual(recs.invites.suggested, []);
291
+ assert.equal(recs.invites.seeking.kind, 'top-builders');
292
+ assert.equal(typeof recs.invites.seeking.limit, 'number');
293
+ assert.ok(recs.invites.reason.length > 0);
294
+ });
295
+
296
+ test('a caller may mutate the result without corrupting the declaration', () => {
297
+ // Arrange
298
+ const first = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
299
+
300
+ // Act — the picker sorts and splices what it is handed.
301
+ first.bundle.bundle.push('not-a-module');
302
+ first.bundle.modules.length = 0;
303
+ first.invites.seeking.limit = 999;
304
+
305
+ // Assert
306
+ const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
307
+ assert.deepEqual(second.bundle.bundle, ['dev-box', 'discord']);
308
+ assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'dev-box', 'discord']);
309
+ assert.equal(second.invites.seeking.limit, 3);
310
+ });
311
+
312
+ // ── table integrity ─────────────────────────────────────────────────────────
313
+
314
+ test('every module a rule names is a real opt-in module in the catalog', () => {
315
+ // Arrange
316
+ const manifests = manifestsOnDisk();
317
+
318
+ // Act + Assert
319
+ for (const rule of TEAM_SHAPE_RULES) {
320
+ const manifest = manifests.get(rule.module);
321
+ assert.ok(manifest, `rule ${rule.id} names '${rule.module}', which is not a module on disk`);
322
+ assert.notEqual(manifest.default, true, `rule ${rule.id} names always-on '${rule.module}' — a toggle that toggles nothing`);
323
+ assert.ok(!ALWAYS_ON_CORE.includes(rule.module), `rule ${rule.id} names a core module`);
324
+ assert.ok(!HUB_ONLY_MODULES.includes(rule.module), `rule ${rule.id} names hub-only '${rule.module}'`);
325
+ }
326
+ });
327
+
328
+ test('every team shape the interview can return has a governance rule and an invite rule', () => {
329
+ // Arrange — the interview's own vocabulary is the source of truth. A fourth
330
+ // answer added there must not be silently unhandled here.
331
+ const vocab = provisioning.PROJECT_DETAIL_VOCAB.team_shape;
332
+
333
+ // Act + Assert
334
+ for (const shape of vocab) {
335
+ assert.ok(GOVERNANCE_RULES.some((r) => r.teamShape === shape), `no governance rule for '${shape}'`);
336
+ assert.ok(INVITE_RULES.some((r) => r.teamShape === shape), `no invite rule for '${shape}'`);
337
+ }
338
+ for (const rule of [...TEAM_SHAPE_RULES, ...GOVERNANCE_RULES, ...INVITE_RULES]) {
339
+ assert.ok(vocab.includes(rule.teamShape), `a rule names '${rule.teamShape}', which the interview cannot return`);
340
+ }
341
+ });
342
+
343
+ test('every governance rule names a preset that exists, and the default does too', () => {
344
+ // Act + Assert
345
+ for (const rule of [...GOVERNANCE_RULES, GOVERNANCE_DEFAULT]) {
346
+ const preset = GOVERNANCE_PRESETS[rule.preset];
347
+ assert.ok(preset, `no preset '${rule.preset}'`);
348
+ assert.ok(preset.label && preset.preview, `preset '${rule.preset}' is missing owner-facing copy`);
349
+ }
350
+ });
351
+
352
+ test('the rule tables carry exactly one row per shape, so no rule can shadow another', () => {
353
+ // Act + Assert — GOVERNANCE_RULES and INVITE_RULES are resolved by `find`, so a
354
+ // duplicate row would be dead code that reads as live.
355
+ for (const [name, table] of [['governance', GOVERNANCE_RULES], ['invite', INVITE_RULES]]) {
356
+ const shapes = table.map((r) => r.teamShape);
357
+ assert.equal(new Set(shapes).size, shapes.length, `duplicate team shape in the ${name} table`);
358
+ }
359
+ const ruleIds = TEAM_SHAPE_RULES.map((r) => r.id);
360
+ assert.equal(new Set(ruleIds).size, ruleIds.length, 'duplicate bundle rule id');
361
+ });
362
+
363
+ // ── the read surface ────────────────────────────────────────────────────────
364
+
365
+ // Mount the real router. The route under test is a pure read, so it needs no
366
+ // session gate and no res.fail helper — which is itself the point of the
367
+ // assertions below.
368
+ const express = require('express');
369
+ const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
370
+ const app = express();
371
+ app.use('/api/bongos', buildProvisioningRouter());
372
+
373
+ let server;
374
+ let port;
375
+ before(async () => {
376
+ server = http.createServer(app);
377
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
378
+ port = server.address().port;
379
+ });
380
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
381
+
382
+ function get(pathname) {
383
+ return new Promise((resolve, reject) => {
384
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
385
+ let body = '';
386
+ res.on('data', (c) => { body += c; });
387
+ res.on('end', () => {
388
+ let parsed = null;
389
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
390
+ resolve({ status: res.statusCode, body: parsed, raw: body });
391
+ });
392
+ }).on('error', reject);
393
+ });
394
+ }
395
+
396
+ test('GET /provisioning/recommendations is public and answers with no session', async () => {
397
+ // Act
398
+ const res = await get('/api/bongos/provisioning/recommendations?type=game&team_shape=community');
399
+
400
+ // Assert
401
+ assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
402
+ const recs = res.body.recommendations;
403
+ assert.equal(recs.type, 'game');
404
+ assert.equal(recs.teamShape, 'community');
405
+ assert.equal(recs.tailored, true);
406
+ assert.ok(Array.isArray(recs.bundle.modules));
407
+ assert.ok(recs.governance.preset);
408
+ assert.ok(Array.isArray(recs.invites.suggested));
409
+ });
410
+
411
+ test('the route serves exactly what the engine returns', async () => {
412
+ // Arrange
413
+ const expected = recommendationsForDetails({ type: 'business', team_shape: 'solo' });
414
+
415
+ // Act
416
+ const res = await get('/api/bongos/provisioning/recommendations?type=business&team_shape=solo');
417
+
418
+ // Assert
419
+ assert.deepEqual(res.body.recommendations, expected, 'a script and the wizard read one answer');
420
+ });
421
+
422
+ test('the route reads the same bundle shape the starter-bundles route serves', async () => {
423
+ // ADR 0237 §5's promise: a recommendation and a preselect can never name
424
+ // different modules, because both resolve through bundleForProjectType.
425
+ // Act
426
+ const [bundles, recs] = await Promise.all([
427
+ get('/api/bongos/provisioning/starter-bundles'),
428
+ get('/api/bongos/provisioning/recommendations?type=research'),
429
+ ]);
430
+
431
+ // Assert — no interview answer, so the recommendation IS the preselect.
432
+ const preset = bundles.body.bundles.find((b) => b.type === 'research');
433
+ const recommended = recs.body.recommendations.bundle;
434
+ assert.deepEqual(recommended.core, preset.core);
435
+ assert.deepEqual(recommended.bundle, preset.bundle);
436
+ assert.deepEqual(recommended.modules, preset.modules);
437
+ assert.equal(recommended.label, preset.label);
438
+ });
439
+
440
+ test('the route answers the defaults for missing or unusable query values', async () => {
441
+ // Act + Assert — an owner who skipped the step must get a 200 with the
442
+ // defaults, never a 400: skipping is a supported answer (ADR 0182 D4).
443
+ for (const query of ['', '?type=', '?team_shape=guild', '?type=saas-tool', '?type[]=game&team_shape[]=solo']) {
444
+ const res = await get(`/api/bongos/provisioning/recommendations${query}`);
445
+ assert.equal(res.status, 200, `${query || '(no query)'} → 200, got ${res.status} ${res.raw}`);
446
+ assert.ok(res.body.recommendations, 'and a recommendation, not an error envelope');
447
+ }
448
+ });
@@ -37,7 +37,7 @@ import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import vm from 'node:vm';
39
39
  import { fileURLToPath } from 'node:url';
40
- import { makeRunner } from './helpers.mjs';
40
+ import { makeRunner, makeEl, parseInto, walk, matches } from './helpers.mjs';
41
41
 
42
42
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
43
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-detail.js'), 'utf8');
@@ -48,158 +48,6 @@ const { test, summary } = makeRunner();
48
48
  // A minimal DOM that actually parses what the file writes.
49
49
  // ===========================================================================
50
50
 
51
- const VOID_TAGS = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']);
52
-
53
- function decodeEntities(s) {
54
- return String(s)
55
- .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
56
- .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
57
- .replace(/&amp;/g, '&');
58
- }
59
-
60
- function parseAttrs(text) {
61
- const out = {};
62
- const re = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
63
- let m;
64
- while ((m = re.exec(text))) {
65
- const raw = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : '';
66
- out[m[1].toLowerCase()] = decodeEntities(raw);
67
- }
68
- return out;
69
- }
70
-
71
- // Compound selectors only (tag / .class / #id / [attr] / [attr="v"], comma lists)
72
- // — that is every selector task-detail.js uses, and refusing anything richer
73
- // keeps the stub honest about what it can prove.
74
- function matchesOne(el, sel) {
75
- let ok = true;
76
- const re = /([a-zA-Z][\w-]*)|\.([\w-]+)|#([\w-]+)|\[([\w:-]+)(?:=["']([^"']*)["'])?\]/g;
77
- let m;
78
- while ((m = re.exec(sel))) {
79
- if (m[1]) ok = ok && el.tagName === m[1].toLowerCase();
80
- else if (m[2]) ok = ok && String(el.className || '').split(/\s+/).includes(m[2]);
81
- else if (m[3]) ok = ok && el.id === m[3];
82
- else if (m[4]) {
83
- const have = el.attrs[m[4].toLowerCase()];
84
- ok = ok && (m[5] === undefined ? have !== undefined : have === m[5]);
85
- }
86
- }
87
- return ok;
88
- }
89
- const matches = (el, sel) => String(sel).split(',').map((s) => s.trim()).filter(Boolean).some((s) => matchesOne(el, s));
90
-
91
- function walk(el, fn) {
92
- for (const child of el.children) {
93
- const hit = fn(child);
94
- if (hit) return hit;
95
- const deeper = walk(child, fn);
96
- if (deeper) return deeper;
97
- }
98
- return null;
99
- }
100
-
101
- function makeEl(tagName, attrs = {}) {
102
- const el = {
103
- tagName: String(tagName).toLowerCase(),
104
- attrs,
105
- children: [],
106
- parentNode: null,
107
- className: attrs.class || '',
108
- id: attrs.id || '',
109
- name: attrs.name,
110
- dataset: {},
111
- disabled: 'disabled' in attrs,
112
- style: {},
113
- listeners: {},
114
- _text: '',
115
- _html: null,
116
- _value: null,
117
-
118
- get value() {
119
- if (this._value !== null) return this._value;
120
- if (this.tagName === 'select') {
121
- const opts = this.querySelectorAll('option');
122
- const chosen = opts.find((o) => 'selected' in o.attrs) || opts[0];
123
- return chosen ? (chosen.attrs.value !== undefined ? chosen.attrs.value : chosen.textContent) : '';
124
- }
125
- return this.attrs.value !== undefined ? this.attrs.value : '';
126
- },
127
- set value(v) { this._value = String(v); },
128
-
129
- get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
130
- set textContent(v) { this._text = String(v == null ? '' : v); this.children = []; this._html = null; },
131
-
132
- get innerHTML() { return this._html === null ? '' : this._html; },
133
- set innerHTML(v) { this._html = String(v); this._value = null; parseInto(this, this._html); },
134
-
135
- setAttribute(k, v) { this.attrs[String(k).toLowerCase()] = String(v); },
136
- getAttribute(k) { const v = this.attrs[String(k).toLowerCase()]; return v === undefined ? null : v; },
137
- removeAttribute(k) { delete this.attrs[String(k).toLowerCase()]; },
138
- appendChild(child) { child.parentNode = this; this.children.push(child); return child; },
139
- remove() {
140
- const p = this.parentNode;
141
- if (!p) return;
142
- p.children = p.children.filter((c) => c !== this);
143
- this.parentNode = null;
144
- },
145
- querySelector(sel) { return walk(this, (c) => (matches(c, sel) ? c : null)); },
146
- querySelectorAll(sel) {
147
- const out = [];
148
- walk(this, (c) => { if (matches(c, sel)) out.push(c); return null; });
149
- return out;
150
- },
151
- closest(sel) {
152
- let node = this;
153
- while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
154
- return null;
155
- },
156
- addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
157
- focus() {},
158
- fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
159
- };
160
- for (const [k, v] of Object.entries(attrs)) {
161
- if (!k.startsWith('data-')) continue;
162
- el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
163
- }
164
- return el;
165
- }
166
-
167
- // The reserved names a <form>'s named-field shorthand (form.title.value) must
168
- // never shadow — everything else on the element is fair game, same as the DOM.
169
- const FORM_FIELD_RESERVED = new Set(['tagName', 'attrs', 'children', 'parentNode', 'className', 'id', 'dataset',
170
- 'disabled', 'style', 'listeners', 'value', 'textContent', 'innerHTML', 'name']);
171
-
172
- function parseInto(host, html) {
173
- host.children = [];
174
- host._text = '';
175
- const stack = [host];
176
- const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
177
- let last = 0;
178
- let m;
179
- const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
180
- while ((m = re.exec(html))) {
181
- addText(html.slice(last, m.index));
182
- last = re.lastIndex;
183
- const tag = m[2].toLowerCase();
184
- if (m[1]) {
185
- for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; }
186
- continue;
187
- }
188
- const el = makeEl(tag, parseAttrs(m[3]));
189
- stack[stack.length - 1].appendChild(el);
190
- if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
191
- }
192
- addText(html.slice(last));
193
- // Named-field access on every form the markup just produced.
194
- walk(host, (el) => {
195
- if (el.tagName !== 'form') return null;
196
- for (const field of el.querySelectorAll('input, select, textarea')) {
197
- if (!field.name || FORM_FIELD_RESERVED.has(field.name) || field.name in el) continue;
198
- el[field.name] = field;
199
- }
200
- return null;
201
- });
202
- }
203
51
 
204
52
  // ===========================================================================
205
53
  // The harness: run the real file against a scripted API and hand back handles.