@bongos/core 1.19.527 → 1.19.529
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 +37 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0243-a-bundle-adjustment-belongs-to-the-bundle-not-the-advice-about-it.md +159 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/docs/copy-inventory.md +219 -219
- package/docs/copy-registry.json +282 -282
- package/docs/module-api-changelog.md +4 -0
- package/modules/grading/grader-subagent.js +20 -1
- package/modules/grading/grader.js +24 -1
- package/modules/lifecycle/cascade.js +86 -4
- package/modules/lifecycle/tests/cascade.mjs +63 -6
- package/modules/provisioning/recommendations.js +18 -57
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/starter-bundles.js +143 -4
- package/modules/public-landing/public/projects.html +55 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/grader_root_outage.mjs +109 -0
- package/tests/projects_hub_module_picker.mjs +217 -4
- package/tests/provisioning_starter_bundles.mjs +90 -1
|
@@ -328,6 +328,14 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
328
328
|
.tpill:hover::before{border-color:var(--ink-faint);}
|
|
329
329
|
.tpill[aria-pressed=true]{background:none;border-color:var(--rule-soft);color:var(--ink);}
|
|
330
330
|
.tpill[aria-pressed=true]::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
|
|
331
|
+
/* Why the picker preselected what it did (task 1002341) — one line per rule that
|
|
332
|
+
moved something, between the band and the always-on clause. Wears .reassure's
|
|
333
|
+
weight on purpose: this is the panel explaining itself, never a thing to act on,
|
|
334
|
+
so it must not compete with the toggles it is explaining. */
|
|
335
|
+
.modWhy{display:none;}
|
|
336
|
+
.modWhy.on{display:grid;gap:6px;margin:0 0 14px;max-width:62ch;}
|
|
337
|
+
.modWhy span{font-size:13.5px;font-weight:400;color:var(--ink-soft);line-height:1.5;}
|
|
338
|
+
|
|
331
339
|
/* the module picker (task 1002339) wears the SAME band as the type and team
|
|
332
340
|
questions — one hairline cell per choice, the dot on the chosen ones. The only
|
|
333
341
|
difference is that a module carries a one-line summary under its name, so its
|
|
@@ -1017,11 +1025,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1017
1025
|
to make, when in fact they were never choices. -->
|
|
1018
1026
|
<section class="panel" id="panel-modules" aria-labelledby="mod-h">
|
|
1019
1027
|
<h1 id="mod-h">What it comes with</h1>
|
|
1020
|
-
<p class="lede">Optional — skip it and you get the suggested set below. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
|
|
1028
|
+
<p class="lede">Optional — skip it and you get the suggested set below, shaped by what you just told us. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
|
|
1021
1029
|
<form data-next="5">
|
|
1022
1030
|
<div class="wizField"><span id="modLbl">Extras for this project</span>
|
|
1023
1031
|
<div class="typeCards" id="modCards" role="group" aria-labelledby="modLbl"></div>
|
|
1024
1032
|
</div>
|
|
1033
|
+
<div class="modWhy" id="modWhy" aria-live="polite"></div>
|
|
1025
1034
|
<div class="reassure" id="modCoreNote"></div>
|
|
1026
1035
|
<div class="hint" id="h-modules"> </div>
|
|
1027
1036
|
<div class="nav">
|
|
@@ -3204,10 +3213,39 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3204
3213
|
if (state.step === 4) renderModules();
|
|
3205
3214
|
else if (state.step === 7) renderReview();
|
|
3206
3215
|
}
|
|
3216
|
+
/* The row for the declared type, or null before the read lands / for a type
|
|
3217
|
+
the table has no row for (including no type at all). */
|
|
3218
|
+
function bundleRow() {
|
|
3219
|
+
if (!starterBundles) return null;
|
|
3220
|
+
return (starterBundles.bundles || []).filter(function (b) { return b.type === state.projType; })[0] || null;
|
|
3221
|
+
}
|
|
3222
|
+
/* The type's preselect AS THE ABOUT STEP'S ANSWER SHAPES IT (task 1002341,
|
|
3223
|
+
BV1.R22). The server resolved every combination into byTeamShape, so
|
|
3224
|
+
answering "just me" three panels back re-preselects here with no round trip
|
|
3225
|
+
and no rule applied twice in two languages — the wizard reads the same
|
|
3226
|
+
table `effectiveModules` falls back to (ADR 0243).
|
|
3227
|
+
|
|
3228
|
+
An older payload without byTeamShape, or a team shape no rule fires on,
|
|
3229
|
+
falls through to the unadjusted bundle: the answer this panel showed before
|
|
3230
|
+
R22, which is never wrong, only untailored. */
|
|
3231
|
+
function tailoredRow() {
|
|
3232
|
+
var row = bundleRow();
|
|
3233
|
+
if (!row) return null;
|
|
3234
|
+
var forShape = row.byTeamShape && state.teamShape ? row.byTeamShape[state.teamShape] : null;
|
|
3235
|
+
return forShape || { bundle: row.bundle || [], adjustments: [] };
|
|
3236
|
+
}
|
|
3207
3237
|
function preselectedModules() {
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3238
|
+
var row = tailoredRow();
|
|
3239
|
+
return row ? row.bundle.slice() : [];
|
|
3240
|
+
}
|
|
3241
|
+
/* Why the preselect is what it is, in the owner's own words — one sentence per
|
|
3242
|
+
rule that ACTUALLY moved something. Empty when nothing did, and empty once
|
|
3243
|
+
the owner has answered for themselves: a reason given back for a toggle they
|
|
3244
|
+
flipped reads as the panel arguing with them. */
|
|
3245
|
+
function preselectReasons() {
|
|
3246
|
+
if (state.modules !== null) return [];
|
|
3247
|
+
var row = tailoredRow();
|
|
3248
|
+
return row ? (row.adjustments || []) : [];
|
|
3211
3249
|
}
|
|
3212
3250
|
/* What the panel paints and the review names: the owner's answer if they
|
|
3213
3251
|
gave one, otherwise the type's preselect. */
|
|
@@ -3233,6 +3271,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3233
3271
|
a moment; only a read that ANSWERED badly gets the clause, so a slow
|
|
3234
3272
|
network never tells the owner something is broken when it isn't. */
|
|
3235
3273
|
band.innerHTML = '';
|
|
3274
|
+
$('modWhy').innerHTML = '';
|
|
3275
|
+
$('modWhy').classList.remove('on');
|
|
3236
3276
|
note.textContent = bundlesFailed
|
|
3237
3277
|
? 'We couldn’t load the list of extras just now. Your project is created with everything it needs either way, and you can add extras from its settings.'
|
|
3238
3278
|
: '';
|
|
@@ -3245,6 +3285,17 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3245
3285
|
+ (on.indexOf(m.key) === -1 ? 'false' : 'true') + '"><span><b>' + esc(m.label)
|
|
3246
3286
|
+ '</b><small>' + esc(m.summary) + '</small></span></button>';
|
|
3247
3287
|
}).join('');
|
|
3288
|
+
/* The tailoring, said out loud (task 1002341). A preselect an owner cannot
|
|
3289
|
+
see the reason for is one they have to audit, which is the thing ADR 0237
|
|
3290
|
+
says is worse than no preselect at all. Rendered as its own block above the
|
|
3291
|
+
core clause, because it is about THIS project's answers while the clause
|
|
3292
|
+
below is about every project. */
|
|
3293
|
+
var why = $('modWhy');
|
|
3294
|
+
var reasons = preselectReasons();
|
|
3295
|
+
why.innerHTML = reasons.length
|
|
3296
|
+
? reasons.map(function (a) { return '<span>' + esc(a.reason) + '</span>'; }).join('')
|
|
3297
|
+
: '';
|
|
3298
|
+
why.classList.toggle('on', reasons.length > 0);
|
|
3248
3299
|
var core = (starterBundles.core || []).length;
|
|
3249
3300
|
note.textContent = core
|
|
3250
3301
|
? 'Every project also starts with the same ' + core + ' core parts — the platform itself, the way it tracks work, and the builders’ hall. They are always on, so they are not part of this choice.'
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.529",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.529",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.529",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.529'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// tests/grader_root_outage.mjs — a grader that could not START must say so, and it
|
|
2
|
+
// must be able to start as root (task 1003574, goal 1000024).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. #943 added a 'unavailable' panel outcome for a total grader
|
|
5
|
+
// outage, so a builder sees "grader unavailable" instead of a FAIL nobody produced.
|
|
6
|
+
// It worked when every gating worker was an LLM subagent. Then the DETERMINISTIC
|
|
7
|
+
// pseudo-workers (route-rank, permission-path) were appended to the panel — they
|
|
8
|
+
// never spawn a subprocess, so an outage cannot touch them, and one of them passing
|
|
9
|
+
// made `gating.every(isMissingSignal)` permanently false. The outage branch became
|
|
10
|
+
// unreachable: a total subagent outage fell through to the securityLensErrored branch
|
|
11
|
+
// and was recorded as `FAIL · 0/10` with zero issues and every rubric axis null.
|
|
12
|
+
//
|
|
13
|
+
// That shape is indistinguishable from a real rejection in a terminal, and it routes
|
|
14
|
+
// a builder to /grade-recover Branch A — whose prescribed re-grade cannot clear an
|
|
15
|
+
// outage that is deterministic. Observed end-to-end on task 1003280, whose ship was
|
|
16
|
+
// blocked twice by a panel that never ran.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test tests/grader_root_outage.mjs
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const grader = require(path.join(ROOT, 'modules', 'grading', 'grader.js'));
|
|
29
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
30
|
+
|
|
31
|
+
// An LLM worker whose subprocess died — no signal at all.
|
|
32
|
+
const errored = (kind) => ({
|
|
33
|
+
kind, name: kind, specialty: 'x', verdict: 'error', scores: {}, issues: [],
|
|
34
|
+
notes_md: '', confidence: 0, model: 'claude-sonnet-5', run_ms: 10,
|
|
35
|
+
error: 'SUBAGENT_NONZERO_EXIT',
|
|
36
|
+
});
|
|
37
|
+
// The deterministic pseudo-worker, exactly as route-rank-check.js stamps it.
|
|
38
|
+
const deterministicPass = (kind = 'route-rank') => ({
|
|
39
|
+
kind, name: kind, specialty: 'x', verdict: 'pass', scores: {}, issues: [],
|
|
40
|
+
notes_md: `${kind} pass`, confidence: 1, model: 'deterministic', run_ms: 0, error: null,
|
|
41
|
+
});
|
|
42
|
+
const llmPass = (kind) => ({
|
|
43
|
+
kind, name: kind, specialty: 'x', verdict: 'pass', scores: { code_quality: 8 }, issues: [],
|
|
44
|
+
notes_md: '', confidence: 0.8, model: 'claude-sonnet-5', run_ms: 10, error: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the exact task-1003280 shape: every subagent dead, route-rank passing → unavailable, not fail', () => {
|
|
48
|
+
const r = grader.aggregatePanel([
|
|
49
|
+
errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'),
|
|
50
|
+
deterministicPass('route-rank'),
|
|
51
|
+
]);
|
|
52
|
+
assert.equal(r.signals.panel_outcome, 'unavailable',
|
|
53
|
+
'a passing deterministic check is not evidence the panel ran');
|
|
54
|
+
assert.equal(r.passed, false, 'unavailable never auto-passes — it routes to manual-confirm');
|
|
55
|
+
assert.match(r.notes_md, /GRADER UNAVAILABLE/);
|
|
56
|
+
// The tell that made this so hard to read in a terminal: no findings at all.
|
|
57
|
+
assert.equal((r.issues || []).length, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('two deterministic pseudo-workers do not resurrect the fail either', () => {
|
|
61
|
+
const r = grader.aggregatePanel([
|
|
62
|
+
errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'),
|
|
63
|
+
deterministicPass('route-rank'), deterministicPass('permission-path'),
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(r.signals.panel_outcome, 'unavailable');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a DETERMINISTIC FAIL still hard-blocks — the fix does not soften the gate', () => {
|
|
69
|
+
const detFail = { ...deterministicPass('route-rank'), verdict: 'fail',
|
|
70
|
+
issues: [{ severity: 'blocker', file: 'r.js', summary: 'ungated write route' }] };
|
|
71
|
+
const r = grader.aggregatePanel([
|
|
72
|
+
errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'), detFail,
|
|
73
|
+
]);
|
|
74
|
+
assert.equal(r.signals.panel_outcome, 'fail',
|
|
75
|
+
'gatingFail is the first branch and must still win over the outage branch');
|
|
76
|
+
assert.equal(r.passed, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a real security-lens outage with the rest of the panel alive still hard-blocks (#574 intact)', () => {
|
|
80
|
+
const r = grader.aggregatePanel([
|
|
81
|
+
llmPass('narc'), llmPass('quality'), errored('hacker'), llmPass('efficiency'),
|
|
82
|
+
deterministicPass('route-rank'),
|
|
83
|
+
]);
|
|
84
|
+
assert.equal(r.signals.panel_outcome, 'fail', 'a missing Hacker among live workers is still a block');
|
|
85
|
+
assert.match(r.notes_md, /SECURITY LENS MISSING/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a healthy panel is unaffected', () => {
|
|
89
|
+
const r = grader.aggregatePanel([
|
|
90
|
+
llmPass('narc'), llmPass('quality'), llmPass('hacker'), llmPass('efficiency'),
|
|
91
|
+
deterministicPass('route-rank'),
|
|
92
|
+
]);
|
|
93
|
+
assert.equal(r.signals.panel_outcome, 'pass');
|
|
94
|
+
assert.equal(r.passed, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- the spawn half -------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test('the worker spawn picks a permission mode the CLI accepts as root', () => {
|
|
100
|
+
const src = read('modules', 'grading', 'grader-subagent.js');
|
|
101
|
+
// The refusal is euid-based, so the choice must be too.
|
|
102
|
+
assert.match(src, /process\.getuid\(\) === 0/);
|
|
103
|
+
assert.match(src, /isRoot \? 'acceptEdits' : 'bypassPermissions'/);
|
|
104
|
+
// The flag must be passed as the computed value, never the hardcoded wide one.
|
|
105
|
+
assert.match(src, /'--permission-mode', permissionMode/);
|
|
106
|
+
assert.doesNotMatch(src, /'--permission-mode', 'bypassPermissions'/);
|
|
107
|
+
// The tool list is what makes acceptEdits cost nothing: there are no edits.
|
|
108
|
+
assert.match(src, /'--tools', 'Read'/);
|
|
109
|
+
});
|
|
@@ -25,6 +25,17 @@ import path from 'node:path';
|
|
|
25
25
|
import vm from 'node:vm';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
|
|
28
|
+
// ESM named imports off the CJS module on purpose (the knip learning): the
|
|
29
|
+
// cross-check test at the bottom runs the REAL declaration the route serves, so a
|
|
30
|
+
// rule change cannot pass this file while breaking the panel.
|
|
31
|
+
import {
|
|
32
|
+
ALWAYS_ON_CORE,
|
|
33
|
+
RULED_TEAM_SHAPES,
|
|
34
|
+
starterBundleAnswerTable,
|
|
35
|
+
optionalModules,
|
|
36
|
+
effectiveModules,
|
|
37
|
+
} from '../modules/provisioning/starter-bundles.js';
|
|
38
|
+
|
|
28
39
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
40
|
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
30
41
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
@@ -59,6 +70,39 @@ const PAYLOAD = {
|
|
|
59
70
|
],
|
|
60
71
|
};
|
|
61
72
|
|
|
73
|
+
// The `byTeamShape` half of the same payload (BV1.R22, task 1002341): the bundle
|
|
74
|
+
// re-resolved for each About-step answer, with the adjustments that got it there.
|
|
75
|
+
//
|
|
76
|
+
// Deliberately only TWO types carry it. The rest are left without so the
|
|
77
|
+
// degradation path is exercised by the same fixture — a payload from a server that
|
|
78
|
+
// predates R22, or a team shape no rule fires on, must fall through to the
|
|
79
|
+
// unadjusted bundle rather than preselecting nothing. The real table for every
|
|
80
|
+
// type is pinned against the real module further down ("the picker and the server
|
|
81
|
+
// resolve the same set").
|
|
82
|
+
const TAILORED = {
|
|
83
|
+
...PAYLOAD,
|
|
84
|
+
bundles: PAYLOAD.bundles.map((b) => {
|
|
85
|
+
if (b.type === 'game') {
|
|
86
|
+
return { ...b, byTeamShape: {
|
|
87
|
+
// solo: the shared environment comes off
|
|
88
|
+
solo: { bundle: ['discord'], adjustments: [{ rule: 'building-alone-needs-no-shared-environment', effect: 'remove', module: 'dev-box', reason: 'You are building this alone, so the shared cloud environment is off.' }] },
|
|
89
|
+
// small-team: the rule wants dev-box, the bundle already has it — a rule
|
|
90
|
+
// that changed nothing claims nothing
|
|
91
|
+
'small-team': { bundle: ['dev-box', 'discord'], adjustments: [] },
|
|
92
|
+
community: { bundle: ['dev-box', 'discord'], adjustments: [] },
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
95
|
+
if (b.type === 'research') {
|
|
96
|
+
return { ...b, byTeamShape: {
|
|
97
|
+
solo: { bundle: ['agents'], adjustments: [] },
|
|
98
|
+
'small-team': { bundle: ['agents', 'dev-box'], adjustments: [{ rule: 'a-team-shares-one-environment', effect: 'add', module: 'dev-box', reason: 'A small team gets one shared environment to build in.' }] },
|
|
99
|
+
community: { bundle: ['agents', 'discord'], adjustments: [{ rule: 'a-community-needs-somewhere-to-gather', effect: 'add', module: 'discord', reason: 'You said an open community is building this, so somewhere for it to gather is switched on.' }] },
|
|
100
|
+
} };
|
|
101
|
+
}
|
|
102
|
+
return b;
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
|
|
62
106
|
function makeEl(id) {
|
|
63
107
|
const classes = new Set();
|
|
64
108
|
const attrs = new Map();
|
|
@@ -88,13 +132,26 @@ function rowsOf(html) {
|
|
|
88
132
|
return out;
|
|
89
133
|
}
|
|
90
134
|
|
|
135
|
+
// The reason lines the panel painted, in order (task 1002341). Parsed out of the
|
|
136
|
+
// markup for the same reason rowsOf is: a selector that resolves against markup
|
|
137
|
+
// nobody wrote proves nothing.
|
|
138
|
+
function whyOf(html) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const re = /<span>([^<]*)<\/span>/g;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = re.exec(html))) out.push(m[1]);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
// Boot the picker with a given draft state. `fetchOk: false` drives the
|
|
92
|
-
// couldn't-load branch
|
|
93
|
-
|
|
147
|
+
// couldn't-load branch; `payload` swaps in a different server answer (TAILORED
|
|
148
|
+
// carries the R22 byTeamShape half). Returns the handles the assertions drive it
|
|
149
|
+
// through.
|
|
150
|
+
function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
|
|
94
151
|
const els = new Map();
|
|
95
152
|
const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
|
96
153
|
const form = makeEl('panel-modules-form');
|
|
97
|
-
const state = { step: 4, projType: null, modules: null, ...seed };
|
|
154
|
+
const state = { step: 4, projType: null, teamShape: null, modules: null, ...seed };
|
|
98
155
|
const shown = [];
|
|
99
156
|
let saves = 0;
|
|
100
157
|
let fetches = 0;
|
|
@@ -112,7 +169,7 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
|
|
|
112
169
|
renderReview: () => { reviews++; },
|
|
113
170
|
API: '/api/bongos',
|
|
114
171
|
fetch: () => { fetches++; return Promise.resolve(fetchOk
|
|
115
|
-
? { ok: true, json: () => Promise.resolve(
|
|
172
|
+
? { ok: true, json: () => Promise.resolve(payload) }
|
|
116
173
|
: { ok: false, json: () => Promise.resolve(null) }); },
|
|
117
174
|
Promise, JSON, Math, Object, Array, String, Number, console,
|
|
118
175
|
};
|
|
@@ -134,6 +191,8 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
|
|
|
134
191
|
render: () => sandbox.renderModules(),
|
|
135
192
|
open: async function () { sandbox.renderModules(); for (let i = 0; i < 20; i++) await Promise.resolve(); },
|
|
136
193
|
rows: () => rowsOf(band.innerHTML),
|
|
194
|
+
why: () => whyOf($('modWhy').innerHTML),
|
|
195
|
+
whyShown: () => $('modWhy').classList.contains('on'),
|
|
137
196
|
coreNote: () => $('modCoreNote').textContent,
|
|
138
197
|
coreShown: () => $('modCoreNote').classList.contains('on'),
|
|
139
198
|
click: (key) => {
|
|
@@ -367,3 +426,157 @@ test('the review reads an answered picker back, empty included', async () => {
|
|
|
367
426
|
await none.settle();
|
|
368
427
|
assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
|
|
369
428
|
});
|
|
429
|
+
|
|
430
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
// BV1.R22 (task 1002341) — the About step's answer reaches the preselect, and
|
|
432
|
+
// the panel says why. ADR 0243.
|
|
433
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
test('the team shape adjusts the type bundle, and the panel says why in the owner\'s words', async () => {
|
|
436
|
+
// Arrange — "a game", then "just me" one panel later
|
|
437
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
|
|
438
|
+
|
|
439
|
+
// Act
|
|
440
|
+
await h.open();
|
|
441
|
+
|
|
442
|
+
// Assert — the shared environment came off, and the reason is on screen
|
|
443
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['discord'],
|
|
444
|
+
'solo drops the dev box the game bundle would have preselected');
|
|
445
|
+
assert.equal(h.whyShown(), true);
|
|
446
|
+
assert.deepEqual(h.why(), ['You are building this alone, so the shared cloud environment is off.']);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('a rule that changed nothing claims nothing', async () => {
|
|
450
|
+
// small-team ADDS dev-box, and the game bundle already carries it. A reason
|
|
451
|
+
// shown for a change that did not happen is a claim the owner cannot check.
|
|
452
|
+
const h = boot({ state: { projType: 'game', teamShape: 'small-team' }, payload: TAILORED });
|
|
453
|
+
await h.open();
|
|
454
|
+
|
|
455
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
456
|
+
assert.deepEqual(h.why(), [], 'no adjustment, no sentence');
|
|
457
|
+
assert.equal(h.whyShown(), false, 'and no empty block sitting on the panel');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('an added module is preselected and explained', async () => {
|
|
461
|
+
const h = boot({ state: { projType: 'research', teamShape: 'community' }, payload: TAILORED });
|
|
462
|
+
await h.open();
|
|
463
|
+
|
|
464
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'discord']);
|
|
465
|
+
assert.deepEqual(h.why(), ['You said an open community is building this, so somewhere for it to gather is switched on.']);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('answering the About step later re-preselects, with no second read', async () => {
|
|
469
|
+
// The two questions live on two earlier panels and are both changeable. The
|
|
470
|
+
// whole table arrived in one read precisely so going back costs nothing.
|
|
471
|
+
const h = boot({ state: { projType: 'research' }, payload: TAILORED });
|
|
472
|
+
await h.open();
|
|
473
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'unanswered: the raw bundle');
|
|
474
|
+
|
|
475
|
+
h.state.teamShape = 'small-team';
|
|
476
|
+
h.render();
|
|
477
|
+
|
|
478
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'dev-box']);
|
|
479
|
+
assert.equal(h.fetches(), 1, 'and the answer cost no round trip');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('Skip after an answered About step still yields the set the panel showed', async () => {
|
|
483
|
+
// ADR 0240 §2's property, extended to the interview's answer (ADR 0243): the
|
|
484
|
+
// preselect and the server's fallback are the same declaration, so skipping
|
|
485
|
+
// must still answer NOTHING and still land the tailored set.
|
|
486
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
|
|
487
|
+
await h.open();
|
|
488
|
+
|
|
489
|
+
h.skip();
|
|
490
|
+
|
|
491
|
+
assert.deepEqual(h.shown, [5]);
|
|
492
|
+
assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
|
|
493
|
+
assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
|
|
494
|
+
assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('once the owner has answered, the reasons go — the panel stops arguing', async () => {
|
|
498
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
|
|
499
|
+
await h.open();
|
|
500
|
+
assert.equal(h.why().length, 1);
|
|
501
|
+
|
|
502
|
+
h.click('dev-box');
|
|
503
|
+
|
|
504
|
+
assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'their toggle stands over the rule');
|
|
505
|
+
assert.deepEqual(h.why(), [], 'and the rule stops explaining a set that is no longer its own');
|
|
506
|
+
assert.equal(h.whyShown(), false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('a payload with no byTeamShape degrades to the untailored bundle, never to nothing', async () => {
|
|
510
|
+
// A server that predates R22 still serves `bundles[].bundle`. The panel must
|
|
511
|
+
// preselect that rather than empty out — untailored is never wrong, only less.
|
|
512
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: PAYLOAD });
|
|
513
|
+
await h.open();
|
|
514
|
+
|
|
515
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
516
|
+
assert.deepEqual(h.why(), []);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('an off-vocabulary team shape adjusts nothing', async () => {
|
|
520
|
+
// A value that was valid when the draft was stored and has since left the
|
|
521
|
+
// vocabulary reads as no answer, exactly as normalizeProjectDetail drops one.
|
|
522
|
+
const h = boot({ state: { projType: 'game', teamShape: 'a-huge-corporation' }, payload: TAILORED });
|
|
523
|
+
await h.open();
|
|
524
|
+
|
|
525
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
526
|
+
assert.deepEqual(h.why(), []);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('a couldn\'t-load read clears any reasons it had already painted', async () => {
|
|
530
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
|
|
531
|
+
await h.open();
|
|
532
|
+
assert.equal(h.why().length, 1);
|
|
533
|
+
|
|
534
|
+
h.sandbox.starterBundles = null;
|
|
535
|
+
h.render();
|
|
536
|
+
|
|
537
|
+
assert.deepEqual(h.why(), [], 'a stale reason for a set no longer on screen is a lie');
|
|
538
|
+
assert.equal(h.whyShown(), false);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('a reason goes through the page escaper like every other rendered value', async () => {
|
|
542
|
+
const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
|
|
543
|
+
await h.open();
|
|
544
|
+
h.sandbox.starterBundles = {
|
|
545
|
+
core: [], optional: [],
|
|
546
|
+
bundles: [{ type: 'game', bundle: [], byTeamShape: { solo: { bundle: [], adjustments: [{ reason: '<img src=x onerror=alert(1)>' }] } } }],
|
|
547
|
+
};
|
|
548
|
+
h.render();
|
|
549
|
+
|
|
550
|
+
const html = h.sandbox.$('modWhy').innerHTML;
|
|
551
|
+
assert.ok(!html.includes('<img'), 'a hostile reason cannot inject markup');
|
|
552
|
+
assert.ok(html.includes('<img src=x onerror=alert(1)>'), 'it is shown escaped');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// The pin that makes the two ends of ADR 0243 unable to drift. Everything above
|
|
556
|
+
// runs the picker against a FIXTURE; this one runs it against the REAL payload the
|
|
557
|
+
// route serves, and asserts the panel's preselect is the same set the server would
|
|
558
|
+
// resolve for a row with those answers and a skipped picker. If a rule is ever
|
|
559
|
+
// added, changed or moved, one of these twenty combinations fails here rather than
|
|
560
|
+
// silently handing an owner a project that is not the one they were shown.
|
|
561
|
+
test('the picker and the server resolve the same set for every answer an owner can give', async () => {
|
|
562
|
+
const LIVE = { core: [...ALWAYS_ON_CORE], bundles: starterBundleAnswerTable(), optional: optionalModules() };
|
|
563
|
+
const shapes = [null, ...RULED_TEAM_SHAPES];
|
|
564
|
+
|
|
565
|
+
for (const preset of LIVE.bundles) {
|
|
566
|
+
for (const teamShape of shapes) {
|
|
567
|
+
const h = boot({ state: { projType: preset.type, teamShape }, payload: LIVE });
|
|
568
|
+
await h.open();
|
|
569
|
+
|
|
570
|
+
// what the server gives a project that answered the interview and then
|
|
571
|
+
// skipped the picker — `modules: null`, resolved at read time
|
|
572
|
+
const server = effectiveModules({ type: preset.type, modules: null, detail: teamShape ? { team_shape: teamShape } : {} });
|
|
573
|
+
|
|
574
|
+
assert.deepEqual(h.picked(), server.selected,
|
|
575
|
+
`type=${preset.type} team_shape=${teamShape}: the panel preselects what the server resolves`);
|
|
576
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), server.selected,
|
|
577
|
+
`type=${preset.type} team_shape=${teamShape}: and the toggles show it`);
|
|
578
|
+
assert.deepEqual(h.why().length, server.adjustments.length,
|
|
579
|
+
`type=${preset.type} team_shape=${teamShape}: one sentence per adjustment the server made`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
@@ -37,8 +37,11 @@ import {
|
|
|
37
37
|
OPTIONAL_MODULES,
|
|
38
38
|
STARTER_BUNDLES,
|
|
39
39
|
HUB_ONLY_MODULES,
|
|
40
|
+
RULED_TEAM_SHAPES,
|
|
40
41
|
bundleForProjectType,
|
|
41
42
|
allStarterBundles,
|
|
43
|
+
adjustBundle,
|
|
44
|
+
starterBundleAnswerTable,
|
|
42
45
|
normalizeModuleSelection,
|
|
43
46
|
effectiveModules,
|
|
44
47
|
} from '../modules/provisioning/starter-bundles.js';
|
|
@@ -259,8 +262,60 @@ test('GET /provisioning/starter-bundles is public and serves the resolved bundle
|
|
|
259
262
|
// Assert
|
|
260
263
|
assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
|
|
261
264
|
assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
|
|
262
|
-
|
|
265
|
+
// `starterBundleAnswerTable` since R22 (task 1002341): every bundle, plus the
|
|
266
|
+
// same bundle re-resolved for each About-step answer. It is a superset of
|
|
267
|
+
// `allStarterBundles()` — the untailored keys are byte-for-byte what they were.
|
|
268
|
+
assert.deepEqual(res.body.bundles, starterBundleAnswerTable());
|
|
263
269
|
assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
|
|
270
|
+
for (const [i, plain] of allStarterBundles().entries()) {
|
|
271
|
+
const { byTeamShape, ...rest } = res.body.bundles[i];
|
|
272
|
+
assert.deepEqual(rest, plain, `bundle ${plain.type} kept its pre-R22 shape`);
|
|
273
|
+
assert.ok(byTeamShape, `bundle ${plain.type} carries the tailored table`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('every bundle carries a resolution for every team shape a rule can fire on', async () => {
|
|
278
|
+
// The picker preselects straight out of this table with no second read (ADR
|
|
279
|
+
// 0243), so a missing row is a panel that silently preselects the untailored
|
|
280
|
+
// set for an answer the owner did give.
|
|
281
|
+
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
282
|
+
|
|
283
|
+
for (const row of res.body.bundles) {
|
|
284
|
+
assert.deepEqual(Object.keys(row.byTeamShape).sort(), [...RULED_TEAM_SHAPES].sort(),
|
|
285
|
+
`${row.type} answers every team shape`);
|
|
286
|
+
for (const [shape, tailored] of Object.entries(row.byTeamShape)) {
|
|
287
|
+
assert.ok(Array.isArray(tailored.bundle), `${row.type}/${shape} names a module list`);
|
|
288
|
+
assert.ok(Array.isArray(tailored.adjustments), `${row.type}/${shape} names its adjustments`);
|
|
289
|
+
// never the always-on core, never a key off the catalog
|
|
290
|
+
for (const key of tailored.bundle) {
|
|
291
|
+
assert.ok(OPTIONAL_MODULES.some((m) => m.key === key),
|
|
292
|
+
`${row.type}/${shape} preselects "${key}", which is not a choice the picker offers`);
|
|
293
|
+
}
|
|
294
|
+
// an adjustment is a DELTA: it only claims what it actually changed
|
|
295
|
+
for (const adj of tailored.adjustments) {
|
|
296
|
+
const inBase = row.bundle.includes(adj.module);
|
|
297
|
+
const inNow = tailored.bundle.includes(adj.module);
|
|
298
|
+
assert.notEqual(inBase, inNow, `${row.type}/${shape}: "${adj.rule}" claims a change it did not make`);
|
|
299
|
+
assert.ok(adj.reason && adj.reason.length > 20, `${row.type}/${shape}: "${adj.rule}" needs an owner-facing reason`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('an adjusted bundle is in CATALOG order, like every other module list', () => {
|
|
306
|
+
// normalizeModuleSelection stores a chosen set in catalog order and the picker
|
|
307
|
+
// re-derives its toggles that way, so an appended adjustment would make one set
|
|
308
|
+
// of modules read as two different lists depending on whether the owner
|
|
309
|
+
// answered the picker or skipped it.
|
|
310
|
+
const order = OPTIONAL_MODULES.map((m) => m.key);
|
|
311
|
+
const sorted = (list) => [...list].sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
312
|
+
|
|
313
|
+
for (const preset of allStarterBundles()) {
|
|
314
|
+
for (const shape of RULED_TEAM_SHAPES) {
|
|
315
|
+
const { modules } = adjustBundle(preset.bundle, shape);
|
|
316
|
+
assert.deepEqual(modules, sorted(modules), `${preset.type}/${shape} is in catalog order`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
264
319
|
});
|
|
265
320
|
|
|
266
321
|
test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
|
|
@@ -355,6 +410,40 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
|
|
|
355
410
|
assert.equal(none.source, 'chosen');
|
|
356
411
|
});
|
|
357
412
|
|
|
413
|
+
test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
|
|
414
|
+
// BV1.R22 / ADR 0243 — the property ADR 0240 §2 leans the skip on ("the
|
|
415
|
+
// preselect and the fallback are the same declaration"), now that the panel
|
|
416
|
+
// preselects the team-shape-adjusted set. Without this the picker would show a
|
|
417
|
+
// solo owner one thing and the project would quietly get another.
|
|
418
|
+
const eff = effectiveModules({ type: 'game', modules: null, detail: { team_shape: 'solo' } });
|
|
419
|
+
|
|
420
|
+
assert.deepEqual(eff.selected, ['discord'], 'the shared environment the rule removes is not silently restored');
|
|
421
|
+
assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
|
|
422
|
+
assert.deepEqual(eff.adjustments.map((a) => a.module), ['dev-box']);
|
|
423
|
+
assert.match(eff.adjustments[0].reason, /building this alone/, 'carrying the reason the panel showed');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
|
|
427
|
+
// A rule corrects a PRESELECT the owner did not make. Once they have toggled,
|
|
428
|
+
// re-applying it would overrule them with an answer they had already refused.
|
|
429
|
+
const eff = effectiveModules({ type: 'game', modules: ['dev-box'], detail: { team_shape: 'solo' } });
|
|
430
|
+
|
|
431
|
+
assert.deepEqual(eff.selected, ['dev-box'], 'solo removes dev-box, and the owner put it back');
|
|
432
|
+
assert.equal(eff.source, 'chosen');
|
|
433
|
+
assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('effectiveModules: a detail that says nothing usable adjusts nothing', () => {
|
|
437
|
+
// {} (skipped), a shape that has left the vocabulary, and a non-object column
|
|
438
|
+
// value all read as NO ANSWER — normalizeProjectDetail's rule, reached without
|
|
439
|
+
// importing it.
|
|
440
|
+
for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
|
|
441
|
+
const eff = effectiveModules({ type: 'game', modules: null, detail });
|
|
442
|
+
assert.deepEqual(eff.selected, ['dev-box', 'discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
|
|
443
|
+
assert.deepEqual(eff.adjustments, []);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
358
447
|
test('effectiveModules: an undeclared type resolves to the core, never a guess', () => {
|
|
359
448
|
for (const row of [{ type: null, modules: null }, { type: 'not-sure', modules: null }, {}]) {
|
|
360
449
|
const eff = effectiveModules(row);
|