@bongos/core 1.21.5 → 1.21.6
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 +138 -68
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +31 -4
- package/docs/page-readings.json +121 -94
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +58 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// tests/settings_main_role.mjs — the Settings main-role picker (task 1004541 /
|
|
2
|
+
// WA6.RP12, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
3
|
+
//
|
|
4
|
+
// The owner's rule (2026-09-30): the builder CHOOSES their main role. The server
|
|
5
|
+
// side existed (builders.main_discipline, PATCH /me/disciplines main_discipline,
|
|
6
|
+
// task 1004433) with nothing in the hall to make the choice. This EXECUTES the
|
|
7
|
+
// real modules/hall-ui/public/settings.js in a minimal DOM stub against a canned
|
|
8
|
+
// /me and a recording PATCH, and pins the control's contract:
|
|
9
|
+
//
|
|
10
|
+
// - it seeds from GET /me's main_discipline (and /me now carries it:
|
|
11
|
+
// db-kernel getBuilderById selects the column);
|
|
12
|
+
// - the options are "Let my work decide" plus exactly the roles ticked above,
|
|
13
|
+
// and ticking/unticking a role adds/removes its option;
|
|
14
|
+
// - unticking the main role hands the choice back to "Let my work decide";
|
|
15
|
+
// - Save sends main_discipline in the same PATCH — the pick, or null to clear —
|
|
16
|
+
// and re-seeds from the server's echo.
|
|
17
|
+
//
|
|
18
|
+
// Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import vm from 'node:vm';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
|
|
27
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
28
|
+
const KERNEL = fs.readFileSync(path.join(ROOT, 'src/bongos/db-kernel.js'), 'utf8');
|
|
29
|
+
|
|
30
|
+
let passed = 0, failed = 0;
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DISCIPLINES = ['engineer', 'artist', 'ideator', 'ui'];
|
|
37
|
+
|
|
38
|
+
// A permissive element: everything settings.js touches at boot, with listeners
|
|
39
|
+
// recorded so a test can fire them.
|
|
40
|
+
function makeEl() {
|
|
41
|
+
const listeners = {};
|
|
42
|
+
return {
|
|
43
|
+
hidden: false, textContent: '', value: '', disabled: false, href: '', className: '',
|
|
44
|
+
dataset: {}, style: {}, children: [], _html: '',
|
|
45
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
46
|
+
addEventListener(type, fn) { (listeners[type] || (listeners[type] = [])).push(fn); },
|
|
47
|
+
fire(type, ev) { for (const fn of listeners[type] || []) fn(ev); },
|
|
48
|
+
appendChild(c) { this.children.push(c); return c; },
|
|
49
|
+
append() {}, prepend() {}, remove() {}, setAttribute() {}, removeAttribute() {}, getAttribute() { return null; },
|
|
50
|
+
querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
|
|
51
|
+
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
|
52
|
+
focus() {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function boot({ builder }) {
|
|
57
|
+
const els = new Map();
|
|
58
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
59
|
+
const patches = [];
|
|
60
|
+
const api = {
|
|
61
|
+
async request(method, url, opts = {}) {
|
|
62
|
+
if (method === 'GET' && url === '/api/bongos/me') return { status: 200, ok: true, data: { builder } };
|
|
63
|
+
if (method === 'PATCH' && url === '/api/bongos/me/disciplines') {
|
|
64
|
+
const body = opts.body;
|
|
65
|
+
patches.push(body);
|
|
66
|
+
const prefs = DISCIPLINES.filter((d) => body.preferred_disciplines.includes(d));
|
|
67
|
+
const main = body.main_discipline === undefined ? builder.main_discipline : body.main_discipline;
|
|
68
|
+
return { status: 200, ok: true, data: { builder: { ...builder, preferred_disciplines: prefs, main_discipline: main } } };
|
|
69
|
+
}
|
|
70
|
+
return { status: 404, ok: false, data: null };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
74
|
+
const document = {
|
|
75
|
+
getElementById: (id) => $(`#${id}`),
|
|
76
|
+
createElement: () => makeEl(),
|
|
77
|
+
dispatchEvent() {}, addEventListener() {},
|
|
78
|
+
querySelector: () => null, querySelectorAll: () => [],
|
|
79
|
+
};
|
|
80
|
+
const window = {
|
|
81
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
82
|
+
BongosClient: { createClient: () => api },
|
|
83
|
+
OTB: {
|
|
84
|
+
$, escapeHtml, toast() {},
|
|
85
|
+
disciplines: () => DISCIPLINES,
|
|
86
|
+
roleLabel: (d) => (d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
87
|
+
hubOrigin: () => null,
|
|
88
|
+
settingsPanels: () => [],
|
|
89
|
+
},
|
|
90
|
+
OTBKit: { emptyStateHtml: (m) => m, makeSectionRail: () => ({ refresh() {}, activate() {} }) },
|
|
91
|
+
location: { hash: '', pathname: '/settings', search: '', assign() {} },
|
|
92
|
+
scrollTo() {}, confirm: () => false, addEventListener() {},
|
|
93
|
+
};
|
|
94
|
+
const sandbox = {
|
|
95
|
+
window, document, console: { log() {}, warn() {}, error() {}, info() {} },
|
|
96
|
+
history: { replaceState() {} }, URLSearchParams, setTimeout, clearTimeout,
|
|
97
|
+
};
|
|
98
|
+
sandbox.globalThis = sandbox;
|
|
99
|
+
vm.createContext(sandbox);
|
|
100
|
+
vm.runInContext(SRC, sandbox, { filename: 'settings.js' });
|
|
101
|
+
for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r));
|
|
102
|
+
|
|
103
|
+
// The control as a test sees it.
|
|
104
|
+
const options = () => [...$('#craft-main-opts').innerHTML.matchAll(/value="([^"]*)"(\s*checked)?>([^<]*)<\/label>/g)]
|
|
105
|
+
.map((m) => ({ value: m[1], checked: !!m[2], label: m[3] }));
|
|
106
|
+
const checked = () => (options().find((o) => o.checked) || {}).value;
|
|
107
|
+
const toggleRole = (d, on) => {
|
|
108
|
+
const chip = { dataset: {} };
|
|
109
|
+
const box = { value: d, checked: on, closest: (sel) => (sel === '.craft-pref' ? box : sel === '.craft-chip' ? chip : null) };
|
|
110
|
+
$('#craft-chips').fire('change', { target: box });
|
|
111
|
+
};
|
|
112
|
+
const pickMain = (v) => {
|
|
113
|
+
const pick = { value: v, closest: (sel) => (sel === '.craft-main-pick' ? pick : null) };
|
|
114
|
+
$('#craft-main-opts').fire('change', { target: pick });
|
|
115
|
+
};
|
|
116
|
+
const save = async () => {
|
|
117
|
+
$('#craft-save').fire('click', {});
|
|
118
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
|
|
119
|
+
};
|
|
120
|
+
return { $, options, checked, toggleRole, pickMain, save, patches };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const B = (over = {}) => ({ id: 4, github_login: 'b4', display_name: 'Builder 4', preferred_disciplines: ['engineer', 'ideator'], main_discipline: null, ...over });
|
|
124
|
+
|
|
125
|
+
console.log('\nthe main-role picker:');
|
|
126
|
+
|
|
127
|
+
await t('the markup carries the Main role group inside Preferred roles', () => {
|
|
128
|
+
const craft = HTML.slice(HTML.indexOf('id="craft-scroll"'), HTML.indexOf('id="craft-save"'));
|
|
129
|
+
assert.match(craft, /<fieldset class="craft-main" id="craft-main">/);
|
|
130
|
+
assert.match(craft, /<legend class="craft-main__h">Main role<\/legend>/);
|
|
131
|
+
assert.match(craft, /id="craft-main-opts"/);
|
|
132
|
+
assert.match(craft, /leads your profile and your leaderboard entry/, 'it says what a main role does');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await t('GET /me carries the saved main role (getBuilderById selects the column)', () => {
|
|
136
|
+
const sel = KERNEL.slice(KERNEL.indexOf('async function getBuilderById'), KERNEL.indexOf('FROM builders WHERE id = $1'));
|
|
137
|
+
assert.match(sel, /\bmain_discipline\b/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('options are "Let my work decide" plus exactly the ticked roles; nothing saved → by work', async () => {
|
|
141
|
+
const p = await boot({ builder: B() });
|
|
142
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'ideator']);
|
|
143
|
+
assert.equal(p.options()[0].label, 'Let my work decide');
|
|
144
|
+
assert.deepEqual(p.options().map((o) => o.label).slice(1), ['Engineer', 'Ideator']);
|
|
145
|
+
assert.equal(p.checked(), '', 'no saved main role → "Let my work decide" is chosen');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await t('a saved main role is the one chosen on load', async () => {
|
|
149
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
150
|
+
assert.equal(p.checked(), 'ideator');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('ticking a role adds its option; unticking the MAIN role hands the choice back to your work', async () => {
|
|
154
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
155
|
+
p.toggleRole('artist', true);
|
|
156
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist', 'ideator'], 'in the instance\'s role order');
|
|
157
|
+
assert.equal(p.checked(), 'ideator', 'adding a role keeps the pick');
|
|
158
|
+
p.toggleRole('ideator', false);
|
|
159
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist']);
|
|
160
|
+
assert.equal(p.checked(), '', 'a role you no longer have cannot lead');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('Save sends the pick in the same PATCH, and the echo re-seeds it', async () => {
|
|
164
|
+
const p = await boot({ builder: B() });
|
|
165
|
+
p.pickMain('ideator');
|
|
166
|
+
await p.save();
|
|
167
|
+
assert.equal(p.patches.length, 1);
|
|
168
|
+
assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer', 'ideator'], main_discipline: 'ideator' });
|
|
169
|
+
assert.equal(p.checked(), 'ideator', 'the server\'s echo is what the control shows after saving');
|
|
170
|
+
assert.match(p.$('#craft-status').textContent, /Main role: Ideator\./);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await t('"Let my work decide" saves null, which clears a saved main role', async () => {
|
|
174
|
+
const p = await boot({ builder: B({ main_discipline: 'engineer' }) });
|
|
175
|
+
p.pickMain('');
|
|
176
|
+
await p.save();
|
|
177
|
+
assert.equal(p.patches[0].main_discipline, null);
|
|
178
|
+
assert.equal(p.checked(), '');
|
|
179
|
+
assert.doesNotMatch(p.$('#craft-status').textContent, /Main role/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await t('unticking the main role, then saving, sends null — never a role outside the set', async () => {
|
|
183
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
184
|
+
p.toggleRole('ideator', false);
|
|
185
|
+
await p.save();
|
|
186
|
+
assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer'], main_discipline: null });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
190
|
+
if (failed) process.exit(1);
|
|
@@ -194,7 +194,7 @@ const STUB_UPDATE = { reading: 'ok', running: '1.19.10', offered: '1.19.13' };
|
|
|
194
194
|
test('the platform\'s hall: the Update button goes to its own door', async () => {
|
|
195
195
|
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
|
|
196
196
|
assert.equal(r.status, 200);
|
|
197
|
-
assert.deepEqual(r.body.update, STUB_UPDATE);
|
|
197
|
+
assert.deepEqual(r.body.update, { ...STUB_UPDATE, channel_source: 'default' }, 'the reader\'s answer, plus which rule it used');
|
|
198
198
|
assert.deepEqual(r.body.door, { kind: 'local', url: '/deploy' });
|
|
199
199
|
assert.equal(r.body.can_update, true);
|
|
200
200
|
});
|
|
@@ -447,3 +447,58 @@ test('the platform, which runs the door, keeps /deploy behind its own page gate'
|
|
|
447
447
|
assert.equal(r.status, 302, 'a visitor with no session meets the page gate');
|
|
448
448
|
assert.match(String(r.location), /auth|sign/i, 'and is sent to sign in, as before');
|
|
449
449
|
});
|
|
450
|
+
|
|
451
|
+
// ── the project's OWN update rule reaches the offer (task 1004535) ────────────
|
|
452
|
+
//
|
|
453
|
+
// The live defect, 2026-10-02: cloudbongos's rule on /deploy is `minor` and the sweep
|
|
454
|
+
// follows it, but GET /software-update answered `"channel": "patch"` — the platform
|
|
455
|
+
// default — so a step-up release would read "up to date" here while the sweep installed it.
|
|
456
|
+
|
|
457
|
+
test('the route hands the reader the project\'s stored update rule (task 1004535)', async () => {
|
|
458
|
+
let seen;
|
|
459
|
+
const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel: async () => 'minor',
|
|
460
|
+
canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
|
|
461
|
+
assert.equal(r.status, 200);
|
|
462
|
+
assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
|
|
463
|
+
assert.equal(r.body.update.channel_source, 'deploy', 'and the answer says which rule it used');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('no stored rule, or a failed read of it, falls back to the default — never a 500 (task 1004535)', async () => {
|
|
467
|
+
for (const selfChannel of [async () => null, async () => { throw new Error('db down'); }]) {
|
|
468
|
+
let seen;
|
|
469
|
+
const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel,
|
|
470
|
+
canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
|
|
471
|
+
assert.equal(r.status, 200);
|
|
472
|
+
assert.equal(seen.channel, null, 'null → the reader applies DEFAULT_CHANNEL');
|
|
473
|
+
assert.equal(r.body.update.channel_source, 'default');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('the REAL default reads the provisioning.selfUpdateChannel port — and with no provider, the default (task 1004535)', async () => {
|
|
478
|
+
// No selfChannel injected: the shipped storedSelfChannel → seams.resolveOptional path runs.
|
|
479
|
+
const seams = require('../src/module-seams.js');
|
|
480
|
+
const stub = { softwareUpdate: async (a) => ({ reading: 'ok', channel: a.channel }), canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) };
|
|
481
|
+
try {
|
|
482
|
+
seams._reset();
|
|
483
|
+
let r = await drive(stub);
|
|
484
|
+
assert.equal(r.status, 200);
|
|
485
|
+
assert.equal(r.body.update.channel, null, 'module off: no provider, so no rule is claimed');
|
|
486
|
+
assert.equal(r.body.update.channel_source, 'default');
|
|
487
|
+
seams.registerProvider('provisioning.selfUpdateChannel', async () => 'minor');
|
|
488
|
+
r = await drive(stub);
|
|
489
|
+
assert.equal(r.body.update.channel, 'minor', 'module on: the port the provisioning module registers is the one read');
|
|
490
|
+
assert.equal(r.body.update.channel_source, 'deploy');
|
|
491
|
+
} finally {
|
|
492
|
+
seams._reset();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('[source] the port the route reads is the port the provisioning module registers and declares (task 1004535)', () => {
|
|
497
|
+
const port = 'provisioning.selfUpdateChannel';
|
|
498
|
+
const route = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
|
|
499
|
+
const reg = fs.readFileSync(path.join(ROOT, 'modules/provisioning/seams.js'), 'utf8');
|
|
500
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/provisioning/module.json'), 'utf8'));
|
|
501
|
+
assert.ok(route.includes(`'${port}'`), 'the route asks for it');
|
|
502
|
+
assert.ok(reg.includes(`'${port}'`), 'the module registers it');
|
|
503
|
+
assert.ok(manifest.provides.includes(port), 'and declares it');
|
|
504
|
+
});
|
|
@@ -500,7 +500,7 @@ test('both commitPin successes report committed:true', () => {
|
|
|
500
500
|
|
|
501
501
|
// A resolveFrom/load pair standing in for the target instance's core, so the probe is
|
|
502
502
|
// exercised without a second core on disk.
|
|
503
|
-
function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false } = {}) {
|
|
503
|
+
function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false, own = () => null } = {}) {
|
|
504
504
|
const seen = [];
|
|
505
505
|
const resolveFrom = (dir, rel) => {
|
|
506
506
|
if (rel === 'scripts/gds/upgrade.js' && api === null) return null;
|
|
@@ -515,7 +515,7 @@ function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows =
|
|
|
515
515
|
if (materializeThrows) throw new Error('materialize blew up');
|
|
516
516
|
return { materializeWillRewrite: willRewrite === undefined ? undefined : (args) => { seen.push(args); return willRewrite; } };
|
|
517
517
|
};
|
|
518
|
-
return { resolveFrom, load, seen };
|
|
518
|
+
return { resolveFrom, load, seen, own };
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
test('laneGeneratedPaths unions the API artifacts with what materialize WOULD rewrite', () => {
|
|
@@ -545,7 +545,8 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
|
|
|
545
545
|
// The direction matters. An instance this cannot interrogate keeps the OLD, stricter guard
|
|
546
546
|
// (still wedged, and visibly so), rather than gaining a looser one that commits a stranger's work.
|
|
547
547
|
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: null, willRewrite: null })), []);
|
|
548
|
-
|
|
548
|
+
// A core that predates the preview, and no .claude/skills to walk under it: nothing to fall back on.
|
|
549
|
+
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: {}, willRewrite: undefined, own: () => () => ['CLAUDE.md'] })), []);
|
|
549
550
|
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ upgradeThrows: true, materializeThrows: true })), []);
|
|
550
551
|
// A core that exports API_ARTIFACT_FILES but whose materialize throws still contributes the
|
|
551
552
|
// half it could answer for.
|
|
@@ -555,6 +556,57 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
|
|
|
555
556
|
);
|
|
556
557
|
});
|
|
557
558
|
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// task 1004519 — a core too old to preview its own rewrite. hoodify-finance took one bump to
|
|
561
|
+
// 1.19.871 (before materializeWillRewrite, 1.19.933), was left with 33 materialized skills
|
|
562
|
+
// uncommitted, and halted on them every sweep because its core answered nothing for them.
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
function oldCoreOnDisk(skills) {
|
|
566
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'sweep-oldcore-'));
|
|
567
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
568
|
+
for (const name of skills) {
|
|
569
|
+
mkdirSync(path.join(core, '.claude', 'skills', name), { recursive: true });
|
|
570
|
+
writeFileSync(path.join(core, '.claude', 'skills', name, 'SKILL.md'), `# ${name}\n`);
|
|
571
|
+
}
|
|
572
|
+
// An instance-owned CLAUDE.md, so the starter is not "would write" here.
|
|
573
|
+
writeFileSync(path.join(dir, 'CLAUDE.md'), '# instance\n');
|
|
574
|
+
return dir;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
test('laneGeneratedPaths falls back to the sweep\'s own preview when the instance core predates it', () => {
|
|
578
|
+
const dir = oldCoreOnDisk(['ask-for-help', 'blocker-review']);
|
|
579
|
+
// The instance's core resolves claude-materialize.js but exports no materializeWillRewrite.
|
|
580
|
+
const core = fakeCore({ api: { g: ['docs/api/openapi.json'] }, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
|
|
581
|
+
const got = S.laneGeneratedPaths(dir, core);
|
|
582
|
+
for (const f of ['.claude/skills/ask-for-help/SKILL.md', '.claude/skills/blocker-review/SKILL.md', 'docs/api/openapi.json']) {
|
|
583
|
+
assert.ok(got.includes(f), `${f} is the lane's own output and must be classified so`);
|
|
584
|
+
}
|
|
585
|
+
assert.ok(!got.includes('CLAUDE.md'), 'an instance-owned CLAUDE.md is never the lane\'s');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('real git: the hoodify-finance wedge clears — old-core skills sort as generated, a hand-written skill still halts', () => {
|
|
589
|
+
const dir = oldCoreOnDisk(['ask-for-help']);
|
|
590
|
+
const g = (...a) => spawnSync('git', ['-C', dir, ...a], { encoding: 'utf8' });
|
|
591
|
+
writeFileSync(path.join(dir, '.gitignore'), 'node_modules/\n');
|
|
592
|
+
mkdirSync(path.join(dir, '.claude', 'skills', 'ask-for-help'), { recursive: true });
|
|
593
|
+
mkdirSync(path.join(dir, '.claude', 'skills', 'my-own'), { recursive: true });
|
|
594
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'old\n');
|
|
595
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'mine\n');
|
|
596
|
+
g('init', '-q'); g('add', '-A'); g('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
597
|
+
const core = fakeCore({ api: {}, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
|
|
598
|
+
// The bump rewrote the materialized skill and left it uncommitted.
|
|
599
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'new\n');
|
|
600
|
+
let pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
|
|
601
|
+
assert.deepEqual(pre.other, []);
|
|
602
|
+
assert.deepEqual(S.healDecision(pre, 'push').action, 'commit');
|
|
603
|
+
// An owner's own skill is not in the installed core's .claude/, so it is still a human's work.
|
|
604
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'edited\n');
|
|
605
|
+
pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
|
|
606
|
+
assert.deepEqual(pre.other.map((e) => e.file), ['.claude/skills/my-own/SKILL.md']);
|
|
607
|
+
assert.deepEqual(S.healDecision(pre, 'push'), { action: 'halt' });
|
|
608
|
+
});
|
|
609
|
+
|
|
558
610
|
test('the core still exports the two things the probe reads', () => {
|
|
559
611
|
// laneGeneratedPaths reads them BY NAME off a module it resolves at runtime, so a rename in the
|
|
560
612
|
// core degrades it silently to the empty set — the pre-fix wedge, back again and invisible.
|
|
@@ -600,3 +600,22 @@ test('a new owner walks from the quick checks through a fully confirmed review,
|
|
|
600
600
|
assert.deepEqual({ voice: d.voice, framing: d.framing, services: d.services, team: [d.builders_at_start, d.builders_expected], shape: d.team_shape, rewards: d.reward_intents },
|
|
601
601
|
{ voice: 'professional', framing: 'open-source', services: undefined, team: [2, 5], shape: 'small-team', rewards: ['cash', 'equity'] });
|
|
602
602
|
});
|
|
603
|
+
|
|
604
|
+
// ── the address is the founder's own choice, on every path (task 1004507, BV2.PS19) ──
|
|
605
|
+
test('no path reaches Look without the founder confirming the address: a prefill suggests it, never confirms it', async () => {
|
|
606
|
+
for (const draft of [{}, { demo: { people: 3, hours_each: 4, idea: '', invites: [] } }]) {
|
|
607
|
+
const label = draft.demo ? 'demo' : 'real project';
|
|
608
|
+
const w = await onReview({}, { draft });
|
|
609
|
+
assert.equal(w.state.confirmed.address, undefined, `${label}: the prefills leave the address unconfirmed`);
|
|
610
|
+
assert.equal(w.run("rowFixed('address')"), false, `${label}: the address is never a fixed row`);
|
|
611
|
+
assert.equal(w.row('address').querySelector('[data-row-chg]').hidden, false, `${label}: it can always be changed`);
|
|
612
|
+
for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
|
|
613
|
+
assert.deepEqual(plain(w.run('unconfirmedRows()')), ['address'], `${label}: every other row confirmed, the address still asked`);
|
|
614
|
+
assert.equal(w.go().disabled, true, `${label}: Continue stays locked`);
|
|
615
|
+
assert.equal(w.run('validateStep(4)'), false, `${label}: Continue refuses`);
|
|
616
|
+
assert.equal(w.isOpen('address'), true, `${label}: and opens the address row`);
|
|
617
|
+
w.use('address');
|
|
618
|
+
assert.equal(w.state.confirmed.address, true, `${label}: choosing it is the answer`);
|
|
619
|
+
assert.equal(w.run('validateStep(4)'), true, `${label}: then Continue goes on`);
|
|
620
|
+
}
|
|
621
|
+
});
|