@bongos/core 1.19.512 → 1.19.513
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.
- package/.bongos-core.json +62 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/watch_applications_queue.mjs +381 -0
package/tests/helpers.mjs
CHANGED
|
@@ -188,3 +188,164 @@ export function taskRoutesSource(modulesDir = path.join(ROOT, 'modules')) {
|
|
|
188
188
|
return familySource(path.join(modulesDir, 'lifecycle', 'routes'),
|
|
189
189
|
(f) => f === 'tasks.js' || (f.startsWith('task-') && f.endsWith('.js')));
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
// ===========================================================================
|
|
193
|
+
// A minimal DOM that actually PARSES what a page script writes into innerHTML
|
|
194
|
+
// (task 1003516, extracted here by task 1002332 when the access-requests queue
|
|
195
|
+
// needed the same harness). querySelector resolves only if the element is
|
|
196
|
+
// genuinely there, form fields carry the values the file rendered (escaping
|
|
197
|
+
// included), and handlers captured by addEventListener run for real — which is
|
|
198
|
+
// what makes a hall page testable at all: there is no jsdom in this repo.
|
|
199
|
+
// ===========================================================================
|
|
200
|
+
const VOID_TAGS = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']);
|
|
201
|
+
|
|
202
|
+
function decodeEntities(s) {
|
|
203
|
+
return String(s)
|
|
204
|
+
.replace(/</g, '<').replace(/>/g, '>')
|
|
205
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
206
|
+
.replace(/&/g, '&');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseAttrs(text) {
|
|
210
|
+
const out = {};
|
|
211
|
+
const re = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
212
|
+
let m;
|
|
213
|
+
while ((m = re.exec(text))) {
|
|
214
|
+
const raw = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : '';
|
|
215
|
+
out[m[1].toLowerCase()] = decodeEntities(raw);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Compound selectors only (tag / .class / #id / [attr] / [attr="v"], comma lists)
|
|
221
|
+
// — that is every selector task-detail.js uses, and refusing anything richer
|
|
222
|
+
// keeps the stub honest about what it can prove.
|
|
223
|
+
function matchesOne(el, sel) {
|
|
224
|
+
let ok = true;
|
|
225
|
+
const re = /([a-zA-Z][\w-]*)|\.([\w-]+)|#([\w-]+)|\[([\w:-]+)(?:=["']([^"']*)["'])?\]/g;
|
|
226
|
+
let m;
|
|
227
|
+
while ((m = re.exec(sel))) {
|
|
228
|
+
if (m[1]) ok = ok && el.tagName === m[1].toLowerCase();
|
|
229
|
+
else if (m[2]) ok = ok && String(el.className || '').split(/\s+/).includes(m[2]);
|
|
230
|
+
else if (m[3]) ok = ok && el.id === m[3];
|
|
231
|
+
else if (m[4]) {
|
|
232
|
+
const have = el.attrs[m[4].toLowerCase()];
|
|
233
|
+
ok = ok && (m[5] === undefined ? have !== undefined : have === m[5]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return ok;
|
|
237
|
+
}
|
|
238
|
+
export const matches = (el, sel) => String(sel).split(',').map((s) => s.trim()).filter(Boolean).some((s) => matchesOne(el, s));
|
|
239
|
+
|
|
240
|
+
export function walk(el, fn) {
|
|
241
|
+
for (const child of el.children) {
|
|
242
|
+
const hit = fn(child);
|
|
243
|
+
if (hit) return hit;
|
|
244
|
+
const deeper = walk(child, fn);
|
|
245
|
+
if (deeper) return deeper;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function makeEl(tagName, attrs = {}) {
|
|
251
|
+
const el = {
|
|
252
|
+
tagName: String(tagName).toLowerCase(),
|
|
253
|
+
attrs,
|
|
254
|
+
children: [],
|
|
255
|
+
parentNode: null,
|
|
256
|
+
className: attrs.class || '',
|
|
257
|
+
id: attrs.id || '',
|
|
258
|
+
name: attrs.name,
|
|
259
|
+
dataset: {},
|
|
260
|
+
disabled: 'disabled' in attrs,
|
|
261
|
+
style: {},
|
|
262
|
+
listeners: {},
|
|
263
|
+
_text: '',
|
|
264
|
+
_html: null,
|
|
265
|
+
_value: null,
|
|
266
|
+
|
|
267
|
+
get value() {
|
|
268
|
+
if (this._value !== null) return this._value;
|
|
269
|
+
if (this.tagName === 'select') {
|
|
270
|
+
const opts = this.querySelectorAll('option');
|
|
271
|
+
const chosen = opts.find((o) => 'selected' in o.attrs) || opts[0];
|
|
272
|
+
return chosen ? (chosen.attrs.value !== undefined ? chosen.attrs.value : chosen.textContent) : '';
|
|
273
|
+
}
|
|
274
|
+
return this.attrs.value !== undefined ? this.attrs.value : '';
|
|
275
|
+
},
|
|
276
|
+
set value(v) { this._value = String(v); },
|
|
277
|
+
|
|
278
|
+
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
|
|
279
|
+
set textContent(v) { this._text = String(v == null ? '' : v); this.children = []; this._html = null; },
|
|
280
|
+
|
|
281
|
+
get innerHTML() { return this._html === null ? '' : this._html; },
|
|
282
|
+
set innerHTML(v) { this._html = String(v); this._value = null; parseInto(this, this._html); },
|
|
283
|
+
|
|
284
|
+
setAttribute(k, v) { this.attrs[String(k).toLowerCase()] = String(v); },
|
|
285
|
+
getAttribute(k) { const v = this.attrs[String(k).toLowerCase()]; return v === undefined ? null : v; },
|
|
286
|
+
removeAttribute(k) { delete this.attrs[String(k).toLowerCase()]; },
|
|
287
|
+
appendChild(child) { child.parentNode = this; this.children.push(child); return child; },
|
|
288
|
+
remove() {
|
|
289
|
+
const p = this.parentNode;
|
|
290
|
+
if (!p) return;
|
|
291
|
+
p.children = p.children.filter((c) => c !== this);
|
|
292
|
+
this.parentNode = null;
|
|
293
|
+
},
|
|
294
|
+
querySelector(sel) { return walk(this, (c) => (matches(c, sel) ? c : null)); },
|
|
295
|
+
querySelectorAll(sel) {
|
|
296
|
+
const out = [];
|
|
297
|
+
walk(this, (c) => { if (matches(c, sel)) out.push(c); return null; });
|
|
298
|
+
return out;
|
|
299
|
+
},
|
|
300
|
+
closest(sel) {
|
|
301
|
+
let node = this;
|
|
302
|
+
while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
|
|
303
|
+
return null;
|
|
304
|
+
},
|
|
305
|
+
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
|
|
306
|
+
focus() {},
|
|
307
|
+
fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
|
|
308
|
+
};
|
|
309
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
310
|
+
if (!k.startsWith('data-')) continue;
|
|
311
|
+
el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
312
|
+
}
|
|
313
|
+
return el;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The reserved names a <form>'s named-field shorthand (form.title.value) must
|
|
317
|
+
// never shadow — everything else on the element is fair game, same as the DOM.
|
|
318
|
+
const FORM_FIELD_RESERVED = new Set(['tagName', 'attrs', 'children', 'parentNode', 'className', 'id', 'dataset',
|
|
319
|
+
'disabled', 'style', 'listeners', 'value', 'textContent', 'innerHTML', 'name']);
|
|
320
|
+
|
|
321
|
+
export function parseInto(host, html) {
|
|
322
|
+
host.children = [];
|
|
323
|
+
host._text = '';
|
|
324
|
+
const stack = [host];
|
|
325
|
+
const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
|
|
326
|
+
let last = 0;
|
|
327
|
+
let m;
|
|
328
|
+
const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
|
|
329
|
+
while ((m = re.exec(html))) {
|
|
330
|
+
addText(html.slice(last, m.index));
|
|
331
|
+
last = re.lastIndex;
|
|
332
|
+
const tag = m[2].toLowerCase();
|
|
333
|
+
if (m[1]) {
|
|
334
|
+
for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; }
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const el = makeEl(tag, parseAttrs(m[3]));
|
|
338
|
+
stack[stack.length - 1].appendChild(el);
|
|
339
|
+
if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
|
|
340
|
+
}
|
|
341
|
+
addText(html.slice(last));
|
|
342
|
+
// Named-field access on every form the markup just produced.
|
|
343
|
+
walk(host, (el) => {
|
|
344
|
+
if (el.tagName !== 'form') return null;
|
|
345
|
+
for (const field of el.querySelectorAll('input, select, textarea')) {
|
|
346
|
+
if (!field.name || FORM_FIELD_RESERVED.has(field.name) || field.name in el) continue;
|
|
347
|
+
el[field.name] = field;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
@@ -187,6 +187,14 @@ test('the Watch page offers the Archon an invite by username — the way in when
|
|
|
187
187
|
assert.match(watch, /nobody can apply, so this is how people get in/);
|
|
188
188
|
const gate = (watch.match(/function renderGate\(data, target\) \{[\s\S]*?\n \}\n/) || [''])[0];
|
|
189
189
|
assert.ok(gate, 'renderGate() must exist');
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// The form used to be rendered and wired once per branch, and this counted the
|
|
191
|
+
// pair. Task 1002332 unified the empty and listed branches onto one `head`, so
|
|
192
|
+
// the count is now 1 and the invariant it stood for is STRONGER: the form is
|
|
193
|
+
// rendered and wired UNCONDITIONALLY, outside the split, so neither branch can
|
|
194
|
+
// drop it. tests/watch_applications_queue.mjs EXECUTES both cases ("an empty
|
|
195
|
+
// view still offers the invite form and the switch out of it") — the proof a
|
|
196
|
+
// source read like this one can only ever approximate.
|
|
197
|
+
assert.equal((gate.match(/inviteFormHtml\(\)/g) || []).length, 1, 'rendered once');
|
|
198
|
+
assert.match(gate, /const head = inviteFormHtml\(\)/, 'into the head BOTH branches are built from');
|
|
199
|
+
assert.match(gate, /\n {4}wireInviteForm\(target\);\n/, 'and wired at renderGate top level, never inside a branch');
|
|
192
200
|
});
|
|
@@ -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
|
+
});
|