@bongos/core 1.19.1075 → 1.19.1076
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 +85 -100
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +1 -1
- package/docs/adr/0035-builder-onboarding-three-paths.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0045-devbox-desktop-app.md +1 -1
- package/docs/adr/0046-sandbox-first-review-gate.md +1 -1
- package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +1 -1
- package/docs/adr/0053-scoped-dev-box-session.md +1 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0057-container-cost-ledger.md +1 -1
- package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +1 -1
- package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +1 -1
- package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +1 -1
- package/docs/adr/0072-dev-box-code-staleness-visibility.md +1 -1
- package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +1 -1
- package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0145-devbox-app-branding-driven-module.md +1 -1
- package/docs/adr/0148-task-scoped-box-source-access.md +2 -2
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +1 -1
- package/docs/adr/0193-pause-task-scoped-box-slices.md +1 -1
- package/docs/adr/0277-a-box-is-in-use-only-while-a-human-is-attached.md +1 -1
- package/docs/adr/0346-dev-boxes-are-retired.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +5 -6
- package/docs/branding-contract.md +2 -2
- package/docs/canonical-permissions.md +10 -18
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +3 -3
- package/docs/design/gate-navigation-direction.md +1 -1
- package/docs/design/hall-direction-v2.md +3 -3
- package/docs/design/modular-architecture/00-research-report.md +1 -1
- package/docs/design/modular-architecture/01-architecture.md +3 -3
- package/docs/design/modular-architecture/02-module-map.md +1 -2
- package/docs/design/modular-architecture/03-builder-flows.md +22 -20
- package/docs/design/modular-architecture/04-module-lifecycle.md +1 -1
- package/docs/design/modular-architecture/README.md +4 -2
- package/docs/design/reviews/hall-v2/README.md +2 -2
- package/docs/design/vanilla-hall-ui-redesign-scope.md +4 -4
- package/docs/file-map.md +5 -5
- package/docs/handoff-template.md +1 -1
- package/docs/module-api-changelog.md +6 -0
- package/docs/modules-contract.md +11 -14
- package/docs/page-readings.json +63 -63
- package/docs/recipes/bongos-cli-release.md +1 -2
- package/docs/recipes/multi-builder-merge.md +1 -1
- package/docs/recipes/ops-gotchas.md +0 -9
- package/docs/recipes/self-host.md +1 -1
- package/docs/recipes/windows-builders.md +1 -1
- package/migrations/core_258_drop_dev_box_tables.sql +3 -0
- package/modules/hall-ui/public/palette.js +143 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/scripts/gds/dev-box-guard.js +165 -0
- package/scripts/gds/fitness-lib.js +10 -0
- package/scripts/gds/fitness.js +2 -10
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/module-scope-map.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/core_258_box_session_claims_db.mjs +180 -0
- package/tests/fitness.mjs +89 -0
- package/tests/hall_palette.mjs +224 -11
- package/tests/provisioning_recommendations.mjs +1 -1
- package/docs/design/reviews/hall-v2/harbor--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/harbor--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/harbor--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--xenos.webp +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// tests/core_258_box_session_claims_db.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004362 — core_258 must apply on a database holding a claim created from a
|
|
4
|
+
// dev-box session.
|
|
5
|
+
//
|
|
6
|
+
// WHAT BROKE. core_258 (task 1003895) deletes every builder_sessions row with source
|
|
7
|
+
// 'box'. claims.creator_session_id (migration 013) references builder_sessions with no
|
|
8
|
+
// ON DELETE, so on the live database, which had one such claim, the DELETE failed the
|
|
9
|
+
// foreign key, the whole migration rolled back, and so did every core upgrade carrying
|
|
10
|
+
// it. The fix nulls the link first; NULL is what a web claim already carries.
|
|
11
|
+
//
|
|
12
|
+
// HOW THIS PROVES IT. The lane database is already migrated, so core_258 has run and the
|
|
13
|
+
// source CHECK no longer admits 'box'. Inside ONE transaction that is ALWAYS rolled back,
|
|
14
|
+
// the test widens the CHECK back the way migration 090 had it, seeds a box session and a
|
|
15
|
+
// claim created from it, then runs core_258's own statements (its BEGIN and COMMIT
|
|
16
|
+
// stripped so they join this transaction). Every table core_258 drops is already gone,
|
|
17
|
+
// and each DROP is IF EXISTS, so they are no-ops here. Nothing is committed or truncated.
|
|
18
|
+
//
|
|
19
|
+
// Real-DB (self-skips without Postgres; belongs in the INTEGRATION set).
|
|
20
|
+
//
|
|
21
|
+
// Run: DATABASE_URL=postgres://... node tests/core_258_box_session_claims_db.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const { Pool } = require('pg');
|
|
31
|
+
|
|
32
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const migrationText = readFileSync(join(root, 'migrations/core_258_drop_dev_box_tables.sql'), 'utf8');
|
|
34
|
+
|
|
35
|
+
// The migration's own statements, minus its transaction wrapper. The count check keeps
|
|
36
|
+
// the strip honest: if the file stopped using BEGIN/COMMIT this would silently be a
|
|
37
|
+
// different migration, so say so instead.
|
|
38
|
+
const wrapper = /^\s*(BEGIN|COMMIT);\s*$/gm;
|
|
39
|
+
assert.equal((migrationText.match(wrapper) || []).length, 2, 'core_258 is expected to carry exactly one BEGIN and one COMMIT');
|
|
40
|
+
const migrationBody = migrationText.replace(wrapper, '');
|
|
41
|
+
// The pre-fix body: the same statements without the unlinking UPDATE, so the bug stays
|
|
42
|
+
// executable rather than described.
|
|
43
|
+
const preFixBody = migrationBody.replace(/UPDATE claims SET creator_session_id = NULL[\s\S]*?;/, '');
|
|
44
|
+
assert.notEqual(preFixBody, migrationBody, 'core_258 no longer unlinks claims before deleting box sessions');
|
|
45
|
+
|
|
46
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
47
|
+
|
|
48
|
+
let passed = 0;
|
|
49
|
+
let failed = 0;
|
|
50
|
+
let client;
|
|
51
|
+
async function test(name, fn) {
|
|
52
|
+
await client.query('SAVEPOINT t');
|
|
53
|
+
try {
|
|
54
|
+
await fn();
|
|
55
|
+
passed++; console.log(` ok ${name}`);
|
|
56
|
+
await client.query('RELEASE SAVEPOINT t');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failed++; console.error(` FAIL ${name}\n ${err.message}`);
|
|
59
|
+
await client.query('ROLLBACK TO SAVEPOINT t');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Schema-driven insert (the helper of tests/session_earnings_mirror_db.mjs): fills any
|
|
64
|
+
// NOT NULL column with no default. Identifiers come from this file or information_schema.
|
|
65
|
+
const schemaCache = new Map();
|
|
66
|
+
async function insertRow(table, explicit = {}) {
|
|
67
|
+
if (!schemaCache.has(table)) {
|
|
68
|
+
const { rows } = await client.query(
|
|
69
|
+
`SELECT column_name, data_type, is_nullable, column_default, is_identity
|
|
70
|
+
FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [table]);
|
|
71
|
+
schemaCache.set(table, rows);
|
|
72
|
+
}
|
|
73
|
+
const cols = schemaCache.get(table);
|
|
74
|
+
if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
|
|
75
|
+
const values = { ...explicit };
|
|
76
|
+
for (const c of cols) {
|
|
77
|
+
if (c.column_name in values || c.is_nullable === 'YES' || c.column_default !== null || c.is_identity === 'YES') continue;
|
|
78
|
+
switch (c.data_type) {
|
|
79
|
+
case 'integer': case 'bigint': case 'smallint': case 'numeric': case 'real': case 'double precision':
|
|
80
|
+
values[c.column_name] = 0; break;
|
|
81
|
+
case 'boolean': values[c.column_name] = false; break;
|
|
82
|
+
case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
|
|
83
|
+
values[c.column_name] = new Date(); break;
|
|
84
|
+
case 'json': case 'jsonb': values[c.column_name] = {}; break;
|
|
85
|
+
case 'ARRAY': values[c.column_name] = []; break;
|
|
86
|
+
default: values[c.column_name] = `c258-${c.column_name}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const names = Object.keys(values);
|
|
90
|
+
const { rows } = await client.query(
|
|
91
|
+
`INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
|
|
92
|
+
VALUES (${names.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`,
|
|
93
|
+
names.map((n) => values[n]));
|
|
94
|
+
return rows[0];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A builder with one box session and one cli session, and a released claim from each.
|
|
98
|
+
async function fixture() {
|
|
99
|
+
const tag = `c258-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
100
|
+
const builder = await insertRow('builders', { github_id: `${tag}-gh`, github_login: tag, display_name: tag, total_credits: 0 });
|
|
101
|
+
const version = await insertRow('versions', { id: `${tag}-v`, status: 'building' });
|
|
102
|
+
const claimFrom = async (source) => {
|
|
103
|
+
const session = await insertRow('builder_sessions', { builder_id: builder.id, token_hash: `${tag}-${source}`, source });
|
|
104
|
+
// discipline + kind: core_247's CHECK refuses the column default (task 1004363).
|
|
105
|
+
const task = await insertRow('tasks', { title: `${tag} ${source} task`, status: 'shipped', version_id: version.id, discipline: 'engineer', kind: 'feature' });
|
|
106
|
+
const claim = await insertRow('claims', {
|
|
107
|
+
task_id: task.id, builder_id: builder.id, creator_session_id: session.id,
|
|
108
|
+
worktree_name: `${tag}-${source}`, released_at: new Date(), outcome: 'shipped',
|
|
109
|
+
});
|
|
110
|
+
return { session, claim };
|
|
111
|
+
};
|
|
112
|
+
return { box: await claimFrom('box'), cli: await claimFrom('cli') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function main() {
|
|
116
|
+
try {
|
|
117
|
+
client = await pool.connect();
|
|
118
|
+
} catch {
|
|
119
|
+
console.log('core_258_box_session_claims_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await client.query('BEGIN');
|
|
124
|
+
try {
|
|
125
|
+
// The source CHECK as migration 090 left it, so a box session can be seeded.
|
|
126
|
+
await client.query('ALTER TABLE builder_sessions DROP CONSTRAINT IF EXISTS builder_sessions_source_check');
|
|
127
|
+
await client.query(`ALTER TABLE builder_sessions ADD CONSTRAINT builder_sessions_source_check CHECK (source IN ('cli', 'web', 'box'))`);
|
|
128
|
+
|
|
129
|
+
await test('without the unlink, the box-session DELETE fails the claims foreign key (the bug)', async () => {
|
|
130
|
+
await fixture();
|
|
131
|
+
await client.query('SAVEPOINT pre');
|
|
132
|
+
let err = null;
|
|
133
|
+
try { await client.query(preFixBody); } catch (e) { err = e; }
|
|
134
|
+
await client.query('ROLLBACK TO SAVEPOINT pre');
|
|
135
|
+
assert.ok(err, 'the pre-fix body should have failed');
|
|
136
|
+
assert.equal(err.code, '23503', `expected a foreign-key violation, got ${err.code}: ${err.message}`);
|
|
137
|
+
assert.match(err.message, /claims_creator_session_id_fkey/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('core_258 applies, unlinks the box claim, keeps it, and leaves the cli claim alone', async () => {
|
|
141
|
+
const { box, cli } = await fixture();
|
|
142
|
+
// Case 1 passed and released its savepoint, so its box session and claim are still
|
|
143
|
+
// here too; this run unlinks and deletes those as well. That is harmless and even
|
|
144
|
+
// realistic (more than one box claim), and every assertion below is scoped by id.
|
|
145
|
+
await client.query(migrationBody);
|
|
146
|
+
|
|
147
|
+
const { rows: claims } = await client.query(
|
|
148
|
+
'SELECT id, creator_session_id, worktree_name, outcome FROM claims WHERE id = ANY($1::bigint[]) ORDER BY id',
|
|
149
|
+
[[box.claim.id, cli.claim.id]]);
|
|
150
|
+
assert.equal(claims.length, 2, 'both claims must survive the migration');
|
|
151
|
+
const boxClaim = claims.find((c) => String(c.id) === String(box.claim.id));
|
|
152
|
+
const cliClaim = claims.find((c) => String(c.id) === String(cli.claim.id));
|
|
153
|
+
assert.equal(boxClaim.creator_session_id, null, 'the box claim must be unlinked');
|
|
154
|
+
assert.equal(boxClaim.outcome, 'shipped', 'the box claim keeps its history');
|
|
155
|
+
assert.equal(String(cliClaim.creator_session_id), String(cli.session.id), 'a cli claim must keep its session');
|
|
156
|
+
|
|
157
|
+
const { rows: boxSessions } = await client.query(`SELECT count(*)::int AS n FROM builder_sessions WHERE source = 'box'`);
|
|
158
|
+
assert.equal(boxSessions[0].n, 0, 'every box session must be deleted');
|
|
159
|
+
const { rows: cliSession } = await client.query('SELECT id FROM builder_sessions WHERE id = $1', [cli.session.id]);
|
|
160
|
+
assert.equal(cliSession.length, 1, 'a cli session must survive');
|
|
161
|
+
|
|
162
|
+
await client.query('SAVEPOINT chk');
|
|
163
|
+
let err = null;
|
|
164
|
+
try { await insertRow('builder_sessions', { builder_id: cli.session.builder_id, token_hash: `${cli.session.token_hash}-again`, source: 'box' }); } catch (e) { err = e; }
|
|
165
|
+
await client.query('ROLLBACK TO SAVEPOINT chk');
|
|
166
|
+
assert.equal(err && err.code, '23514', 'the narrowed CHECK must refuse a new box session');
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
await client.query('ROLLBACK');
|
|
170
|
+
client.release();
|
|
171
|
+
await pool.end();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(`\ncore_258_box_session_claims_db: ${passed} passed, ${failed} failed`);
|
|
175
|
+
// Let the socket close before exiting (a libuv abort otherwise on some boxes).
|
|
176
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
177
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
package/tests/fitness.mjs
CHANGED
|
@@ -973,3 +973,92 @@ test('KERNEL_FILES is exported exactly ONCE and still resolves (task 1002795)',
|
|
|
973
973
|
assert.ok(Array.isArray(fitness.KERNEL_FILES), 'and it still resolves to an array');
|
|
974
974
|
assert.ok(fitness.KERNEL_FILES.length > 0, 'with the real kernel roster in it');
|
|
975
975
|
});
|
|
976
|
+
|
|
977
|
+
// ---- the dev box stays retired (task 1003899, ADR 0346) ---------------------
|
|
978
|
+
//
|
|
979
|
+
// scripts/gds/dev-box-guard.js fails CI when a retired dev-box name reappears.
|
|
980
|
+
// Each rule is proven by PLANTING a violation through the injectable files/read
|
|
981
|
+
// seam, never by assuming the regex is right. The retired names are assembled at
|
|
982
|
+
// runtime (KEY = 'dev' + '-box', …) so this file never contains them literally and
|
|
983
|
+
// needs no exemption of its own.
|
|
984
|
+
const devBoxGuard = require(path.join(ROOT, 'scripts', 'gds', 'dev-box-guard.js'));
|
|
985
|
+
const KEY = 'dev' + '-box';
|
|
986
|
+
const TABLE = 'builder' + '_boxes';
|
|
987
|
+
const COLUMN = 'box' + '_blocked_reason';
|
|
988
|
+
const PERM = 'box' + '.fleet.manage';
|
|
989
|
+
const BOX = 'b' + 'ox';
|
|
990
|
+
|
|
991
|
+
function plant(rel, text) {
|
|
992
|
+
return devBoxGuard.checkNoDevBox({ files: [rel], read: () => text });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
test('dev-box guard: the clean tree passes, and it scans the real tracked files', () => {
|
|
996
|
+
const r = devBoxGuard.checkNoDevBox();
|
|
997
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
998
|
+
assert.match(r.note, /(\d{4,}) tracked file/, 'a real enumeration, not an empty one');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test('dev-box guard: it is wired into the fitness CHECKS that CI runs', () => {
|
|
1002
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'fitness.js'), 'utf8');
|
|
1003
|
+
assert.match(src, /require\('\.\/dev-box-guard\.js'\)\.checkNoDevBox/);
|
|
1004
|
+
const names = fitness.runAll().map((r) => r.name);
|
|
1005
|
+
assert.ok(names.includes('the dev box stays retired (ADR 0346)'), 'runAll() includes the guard');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test('dev-box guard: each rule FAILS on a planted violation', () => {
|
|
1009
|
+
const cases = [
|
|
1010
|
+
['module-key (quoted)', 'src/x.js', `enable('${KEY}');`],
|
|
1011
|
+
['module-key (path)', 'src/y.js', `require('../modules/${KEY}/boxes.js');`],
|
|
1012
|
+
['module-key (json key)', 'config/modules.neutral.json', `{ "modules": { "${KEY}": true } }`],
|
|
1013
|
+
['identifier (table)', 'src/z.js', `await q('SELECT * FROM ${TABLE}');`],
|
|
1014
|
+
['identifier (column)', 'src/w.js', `SELECT ${COLUMN} FROM builders`],
|
|
1015
|
+
['permission', 'modules/government/catalog.js', `{ key: '${PERM}', floor: 'metic' },`],
|
|
1016
|
+
['session-source', 'src/bongos/auth.js', `if (session.source === '${BOX}') return;`],
|
|
1017
|
+
];
|
|
1018
|
+
for (const [label, rel, text] of cases) {
|
|
1019
|
+
const r = plant(rel, text);
|
|
1020
|
+
assert.equal(r.hardFail, true, `${label}: a planted violation must fail the check`);
|
|
1021
|
+
assert.match(r.violations[0], /ADR 0346/, `${label}: the failure names the ADR`);
|
|
1022
|
+
assert.match(r.note, /DEV_BOX_EXEMPT in scripts\/gds\/dev-box-guard\.js/, `${label}: and the one exemption list`);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test('dev-box guard: a retired PATH fails even with harmless contents', () => {
|
|
1027
|
+
for (const rel of [`scripts/gds/${BOX}.js`, `infra/${BOX}-provision.sh`, `modules/${KEY}/module.json`, '.devcontainer/devcontainer.json']) {
|
|
1028
|
+
const r = plant(rel, '{}');
|
|
1029
|
+
assert.equal(r.hardFail, true, `${rel} must fail`);
|
|
1030
|
+
assert.match(r.violations.join('\n'), /path: a retired dev-box location/);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test('dev-box guard: near misses do NOT fire (plurals, filenames, prose, a server box)', () => {
|
|
1035
|
+
const clean = [
|
|
1036
|
+
`resolveModules({ modules: { '${KEY}es': true } })`, // the typo guard's test input
|
|
1037
|
+
`see docs/adr/0072-${KEY}-code-staleness-visibility.md`, // an ADR filename
|
|
1038
|
+
`the ${KEY} removal is recorded in ADR 0346`, // prose, not a key
|
|
1039
|
+
`ENV_FILE=/etc/cloudbongos/${BOX}.env`, // box.env is the control-plane runner's env file
|
|
1040
|
+
`ssh into the co-hosting ${BOX}`, // a server
|
|
1041
|
+
`const secret = require('./secret-${BOX}.js');`, // crypto, unrelated
|
|
1042
|
+
`if (session.source === 'cli') return;`,
|
|
1043
|
+
];
|
|
1044
|
+
for (const text of clean) {
|
|
1045
|
+
const r = plant('src/near-miss.js', text);
|
|
1046
|
+
assert.equal(r.hardFail, false, `should not fire: ${text}\n${r.violations.join('\n')}`);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test('dev-box guard: the exemption list is honoured, and it is the only way out', () => {
|
|
1051
|
+
const text = `const RETIRED = { '${KEY}': 'gone' };`;
|
|
1052
|
+
assert.equal(plant('src/modules.js', text).hardFail, false, 'the retirement guard file is exempt');
|
|
1053
|
+
assert.equal(plant('docs/adr/0999-anything.md', text).hardFail, false, 'the historical record is exempt by prefix');
|
|
1054
|
+
assert.equal(plant('src/other.js', text).hardFail, true, 'the same text anywhere else fails');
|
|
1055
|
+
for (const [entry, why] of devBoxGuard.DEV_BOX_EXEMPT) {
|
|
1056
|
+
assert.ok(typeof why === 'string' && why.length >= 12, `${entry} carries a reason`);
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test('dev-box guard: an empty enumeration is a scan defect, not a pass', () => {
|
|
1061
|
+
const r = devBoxGuard.checkNoDevBox({ files: [], read: () => '' });
|
|
1062
|
+
assert.equal(r.hardFail, true);
|
|
1063
|
+
assert.match(r.violations[0], /scan defect/);
|
|
1064
|
+
});
|
package/tests/hall_palette.mjs
CHANGED
|
@@ -28,21 +28,41 @@ const SHELL_SRC = read('shell.js');
|
|
|
28
28
|
|
|
29
29
|
// Boot palette.js against a DOM stub whose sidebar carries `navItems`, and whose
|
|
30
30
|
// `visibleIds` decide which section groups are on offer. Returns window.OTBPalette.
|
|
31
|
-
|
|
31
|
+
//
|
|
32
|
+
// `registry` memoizes querySelector by selector string (keyed globally within one
|
|
33
|
+
// boot, same as a real single-instance-id contract) so `build()`'s one-time
|
|
34
|
+
// `root.querySelector('#palette-input')` / `'#palette-list'` calls hand back the
|
|
35
|
+
// SAME stub every time — real enough to fire DOM events at and read innerHTML
|
|
36
|
+
// back from, which the pure-compute-only harness never needed before task 1003460
|
|
37
|
+
// wired a real fetch() into the input handler.
|
|
38
|
+
function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work', 'shell-user'], modules = null, fetchImpl = undefined } = {}) {
|
|
39
|
+
const registry = {};
|
|
32
40
|
const makeNav = (it) => ({
|
|
33
41
|
hidden: !!it.hidden,
|
|
34
42
|
target: it.external ? '_blank' : '',
|
|
35
43
|
getAttribute: (n) => (n === 'href' ? it.href : null),
|
|
36
44
|
querySelector: (sel) => (sel === '.nav-item__label' ? { textContent: it.label } : null),
|
|
37
45
|
});
|
|
38
|
-
const stubEl = () =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const stubEl = (sel) => {
|
|
47
|
+
const listeners = {};
|
|
48
|
+
const el = {
|
|
49
|
+
hidden: false, dataset: {}, textContent: '', value: '', className: '',
|
|
50
|
+
classList: { add() {}, remove() {}, contains() { return false; }, toggle() { return false; } },
|
|
51
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
52
|
+
addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
|
|
53
|
+
fire(type, ev) { (listeners[type] || []).forEach((fn) => fn(ev || {})); },
|
|
54
|
+
appendChild() {}, focus() {},
|
|
55
|
+
querySelector(childSel) {
|
|
56
|
+
if (!registry[childSel]) registry[childSel] = stubEl(childSel);
|
|
57
|
+
return registry[childSel];
|
|
58
|
+
},
|
|
59
|
+
querySelectorAll() { return []; },
|
|
60
|
+
set innerHTML(v) { this._html = v; },
|
|
61
|
+
get innerHTML() { return this._html || ''; },
|
|
62
|
+
};
|
|
63
|
+
if (sel) registry[sel] = el;
|
|
64
|
+
return el;
|
|
65
|
+
};
|
|
46
66
|
|
|
47
67
|
const documentObj = {
|
|
48
68
|
body: { dataset: { page: 'home' }, appendChild() {} },
|
|
@@ -62,17 +82,34 @@ function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work',
|
|
|
62
82
|
const windowObj = { location: { hash: '', assign() {} }, addEventListener() {}, open() {} };
|
|
63
83
|
const sandbox = {
|
|
64
84
|
window: windowObj, document: documentObj, navigator: { platform: 'Win32', userAgent: 'test' },
|
|
65
|
-
console,
|
|
85
|
+
console, setTimeout, clearTimeout, Promise, JSON, Number, String, Array, Object,
|
|
66
86
|
};
|
|
87
|
+
if (fetchImpl) sandbox.fetch = fetchImpl;
|
|
67
88
|
sandbox.globalThis = sandbox;
|
|
68
89
|
vm.createContext(sandbox);
|
|
69
90
|
vm.runInContext(PALETTE_SRC, sandbox, { filename: 'palette.js' });
|
|
70
91
|
// Copy results across the realm boundary — a vm-created array has the sandbox's
|
|
71
92
|
// Array.prototype, which deepStrictEqual rejects even when the values match.
|
|
72
93
|
const P = windowObj.OTBPalette;
|
|
73
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
compute: (q) => Array.from(P.compute(q), (e) => ({ ...e })),
|
|
96
|
+
contentEntry: (hit) => { const e = P.contentEntry(hit); return e ? { ...e } : e; },
|
|
97
|
+
open: () => P.open(),
|
|
98
|
+
close: () => P.close(),
|
|
99
|
+
listEl: () => registry['#palette-list'],
|
|
100
|
+
type(value) {
|
|
101
|
+
const el = registry['#palette-input'];
|
|
102
|
+
el.value = value;
|
|
103
|
+
el.fire('input');
|
|
104
|
+
},
|
|
105
|
+
};
|
|
74
106
|
}
|
|
75
107
|
|
|
108
|
+
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
109
|
+
// Real timers (palette.js's debounce is a real setTimeout) — settle past the
|
|
110
|
+
// 300ms debounce plus a few microtask/macrotask turns for the fetch chain.
|
|
111
|
+
async function settleSearch() { await tick(400); }
|
|
112
|
+
|
|
76
113
|
const NAV_FIXTURE = [
|
|
77
114
|
{ label: 'Home', href: '/' },
|
|
78
115
|
{ label: 'Tasks', href: '/work' },
|
|
@@ -207,3 +244,179 @@ test('every page that loads the shell also loads the palette', () => {
|
|
|
207
244
|
const missing = withShell.filter((f) => !/src="\/builders\/palette\.js/.test(read(f)));
|
|
208
245
|
assert.deepEqual(missing, [], 'Ctrl-K must work on every hall page');
|
|
209
246
|
});
|
|
247
|
+
|
|
248
|
+
// ---- server-backed content search (task 1003460) ---------------------------
|
|
249
|
+
|
|
250
|
+
const TASK_HIT = { id: 1, source_kind: 'db', source_ref: 'task:1003460', title: "Ctrl-K searches the project's memory", heading_path: null };
|
|
251
|
+
const IDEA_HIT = { id: 2, source_kind: 'db', source_ref: 'idea:42', title: 'A homeless idea' };
|
|
252
|
+
const BLOCKER_HIT = { id: 3, source_kind: 'db', source_ref: 'blocker:7', title: 'Needs the owner' };
|
|
253
|
+
const DOC_HIT = { id: 4, source_kind: 'doc', source_ref: 'docs/adr/0060-gds-retrieval-layer.md', title: 'ADR 0060' };
|
|
254
|
+
const SESSION_LOG_HIT = { id: 5, source_kind: 'db', source_ref: 'session_log:9', title: 'Session log 9 · task 1003460' };
|
|
255
|
+
|
|
256
|
+
test('contentEntry resolves task/idea/blocker hits to real hall destinations', () => {
|
|
257
|
+
const P = bootPalette({ navItems: NAV_FIXTURE });
|
|
258
|
+
assert.deepEqual(
|
|
259
|
+
{ label: P.contentEntry(TASK_HIT).label, href: P.contentEntry(TASK_HIT).href, overlay: !!P.contentEntry(TASK_HIT).overlay },
|
|
260
|
+
{ label: "Ctrl-K searches the project's memory", href: '/task/1003460', overlay: false },
|
|
261
|
+
);
|
|
262
|
+
assert.deepEqual(
|
|
263
|
+
{ href: P.contentEntry(IDEA_HIT).href, overlay: P.contentEntry(IDEA_HIT).overlay },
|
|
264
|
+
{ href: '#/idea/42', overlay: true },
|
|
265
|
+
);
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
{ href: P.contentEntry(BLOCKER_HIT).href, overlay: P.contentEntry(BLOCKER_HIT).overlay },
|
|
268
|
+
{ href: '#/blocker/7', overlay: true },
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('contentEntry drops hits the hall has no page for (doc/session-log/etc)', () => {
|
|
273
|
+
const P = bootPalette({ navItems: NAV_FIXTURE });
|
|
274
|
+
assert.equal(P.contentEntry(DOC_HIT), null, 'a repo doc chunk has no hall page to jump to');
|
|
275
|
+
assert.equal(P.contentEntry(SESSION_LOG_HIT), null, 'a session-log chunk has no hall page to jump to');
|
|
276
|
+
assert.equal(P.contentEntry(null), null);
|
|
277
|
+
assert.equal(P.contentEntry({ source_kind: 'db', source_ref: 'commit:abc123' }), null, 'an unrecognized db ref resolves to nothing, not a crash');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('contentEntry normalizes a dash the way cleanTitle() does everywhere else (V4, docs/voice.md)', () => {
|
|
281
|
+
// A task/idea/blocker title is quoted ledger data, not copy this project
|
|
282
|
+
// authored — V4 binds the dash ban to exactly that, enforced at render.
|
|
283
|
+
const P = bootPalette({ navItems: NAV_FIXTURE });
|
|
284
|
+
const hit = { source_kind: 'db', source_ref: 'task:99', title: 'V4.R18 — Ctrl-K searches the memory (follow-up)' };
|
|
285
|
+
assert.equal(P.contentEntry(hit).label, 'V4.R18, Ctrl-K searches the memory');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('contentEntry truncates a pathologically long indexed title before it becomes markup', () => {
|
|
289
|
+
const P = bootPalette({ navItems: NAV_FIXTURE });
|
|
290
|
+
const longTitle = 'x'.repeat(400);
|
|
291
|
+
const hit = { source_kind: 'db', source_ref: 'task:1', title: longTitle };
|
|
292
|
+
const label = P.contentEntry(hit).label;
|
|
293
|
+
assert.ok(label.length <= 120, `label must be capped, got ${label.length} chars`);
|
|
294
|
+
assert.ok(label.endsWith('…'), 'a truncated label is marked as such');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('typing prose merges in content matches from POST /api/bongos/search, on top of local matches', async () => {
|
|
298
|
+
const calls = [];
|
|
299
|
+
const fetchImpl = async (url, init) => {
|
|
300
|
+
calls.push({ url, body: JSON.parse(init.body) });
|
|
301
|
+
return { ok: true, json: async () => ({ query: 'ctrl-k memory', count: 1, results: [TASK_HIT] }) };
|
|
302
|
+
};
|
|
303
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
304
|
+
P.open();
|
|
305
|
+
P.type('ctrl-k memory');
|
|
306
|
+
// Local compute() ran synchronously — pages/sections still searchable immediately.
|
|
307
|
+
assert.deepEqual(P.compute('ctrl-k memory'), [], 'sanity: no local page/section matches this prose');
|
|
308
|
+
|
|
309
|
+
await settleSearch();
|
|
310
|
+
|
|
311
|
+
assert.equal(calls.length, 1, 'exactly one debounced request, not one per keystroke');
|
|
312
|
+
assert.equal(calls[0].url, '/api/bongos/search');
|
|
313
|
+
assert.equal(calls[0].body.q, 'ctrl-k memory');
|
|
314
|
+
assert.equal(calls[0].body.source_kind, 'db', 'a doc/session-log/etc hit would only ever be dropped by contentEntry — ask the server to not spend the result budget on it');
|
|
315
|
+
assert.match(P.listEl().innerHTML, /Content/, 'the merged row carries the Content group');
|
|
316
|
+
assert.match(P.listEl().innerHTML, /Ctrl-K searches the project's memory/, 'and the task title from the server hit');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('killing the search endpoint leaves the palette exactly as useful as today', async () => {
|
|
320
|
+
const fetchImpl = async () => { throw new Error('endpoint is down'); };
|
|
321
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
322
|
+
P.open();
|
|
323
|
+
P.type('economy');
|
|
324
|
+
const beforeHtml = P.listEl().innerHTML;
|
|
325
|
+
assert.match(beforeHtml, /Economy/, 'the local page match rendered synchronously');
|
|
326
|
+
|
|
327
|
+
await settleSearch();
|
|
328
|
+
|
|
329
|
+
assert.equal(P.listEl().innerHTML, beforeHtml, 'a failed fetch changes nothing — no error row, no crash');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('a 404 (endpoint removed) degrades the same as a network failure', async () => {
|
|
333
|
+
const fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({ error: 'not_found' }) });
|
|
334
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
335
|
+
P.open();
|
|
336
|
+
P.type('economy');
|
|
337
|
+
const beforeHtml = P.listEl().innerHTML;
|
|
338
|
+
|
|
339
|
+
await settleSearch();
|
|
340
|
+
|
|
341
|
+
assert.equal(P.listEl().innerHTML, beforeHtml);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a query under SEARCH_MIN_LEN never calls the endpoint at all', async () => {
|
|
345
|
+
let called = false;
|
|
346
|
+
const fetchImpl = async () => { called = true; return { ok: true, json: async () => ({ results: [] }) }; };
|
|
347
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
348
|
+
P.open();
|
|
349
|
+
// 2 chars, not 1: the endpoint's least-selective case (falls through FTS to an
|
|
350
|
+
// unindexed trigram scan plus a zero-result corpusHealth() probe) is exactly
|
|
351
|
+
// the length this gate exists to keep off the wire.
|
|
352
|
+
P.type('ab');
|
|
353
|
+
|
|
354
|
+
await settleSearch();
|
|
355
|
+
|
|
356
|
+
assert.equal(called, false, 'two characters is not worth the endpoint\'s worst-case query plan');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('closing the palette invalidates an in-flight search — it cannot land after close', async () => {
|
|
360
|
+
let resolveFetch;
|
|
361
|
+
const fetchImpl = () => new Promise((resolve) => { resolveFetch = resolve; });
|
|
362
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
363
|
+
P.open();
|
|
364
|
+
P.type('ctrl-k memory');
|
|
365
|
+
await tick(350); // past the debounce — the request is now in flight
|
|
366
|
+
P.close();
|
|
367
|
+
resolveFetch({ ok: true, json: async () => ({ results: [TASK_HIT] }) });
|
|
368
|
+
await tick(20);
|
|
369
|
+
|
|
370
|
+
// isOpen() is false, so runSearch's own guard must have thrown the response away.
|
|
371
|
+
// (compute() is pure/local and would never carry a remote hit either way — the
|
|
372
|
+
// rendered DOM is the only place a landed-too-late merge could show up.)
|
|
373
|
+
assert.ok(!/Ctrl-K searches/.test(P.listEl().innerHTML), 'a response that lands after close must not render');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a duplicate destination (already resolved locally) is not appended twice', async () => {
|
|
377
|
+
const fetchImpl = async () => ({ ok: true, json: async () => ({ results: [TASK_HIT] }) });
|
|
378
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
379
|
+
P.open();
|
|
380
|
+
P.type('1003460'); // id syntax already resolves this to the same /task/1003460 href
|
|
381
|
+
await settleSearch();
|
|
382
|
+
|
|
383
|
+
const rows = P.listEl().innerHTML.match(/data-i="\d+"/g) || [];
|
|
384
|
+
assert.equal(rows.length, 1, 'the server hit for the same task must not add a second row for the same href');
|
|
385
|
+
assert.match(P.listEl().innerHTML, /Task 1003460/, 'the id-syntax row is the one that stays');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('a repeated query within one open() replays the cache instead of re-fetching', async () => {
|
|
389
|
+
const calls = [];
|
|
390
|
+
const fetchImpl = async () => { calls.push(1); return { ok: true, json: async () => ({ results: [TASK_HIT] }) }; };
|
|
391
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
392
|
+
P.open();
|
|
393
|
+
P.type('ctrl-k memory');
|
|
394
|
+
await settleSearch();
|
|
395
|
+
assert.equal(calls.length, 1);
|
|
396
|
+
|
|
397
|
+
// Backspace, then retype back to the exact same query (the common case: a
|
|
398
|
+
// reader pauses mid-edit and returns to what they had).
|
|
399
|
+
P.type('ctrl-k mem');
|
|
400
|
+
P.type('ctrl-k memory');
|
|
401
|
+
await settleSearch();
|
|
402
|
+
|
|
403
|
+
assert.equal(calls.length, 1, 'the identical query was served from the per-open cache, not re-fetched');
|
|
404
|
+
assert.match(P.listEl().innerHTML, /Ctrl-K searches the project's memory/, 'the cached hit still renders');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('closing and reopening clears the cache — a fresh open may see new DB state', async () => {
|
|
408
|
+
const calls = [];
|
|
409
|
+
const fetchImpl = async () => { calls.push(1); return { ok: true, json: async () => ({ results: [TASK_HIT] }) }; };
|
|
410
|
+
const P = bootPalette({ navItems: NAV_FIXTURE, fetchImpl });
|
|
411
|
+
P.open();
|
|
412
|
+
P.type('ctrl-k memory');
|
|
413
|
+
await settleSearch();
|
|
414
|
+
assert.equal(calls.length, 1);
|
|
415
|
+
|
|
416
|
+
P.close();
|
|
417
|
+
P.open();
|
|
418
|
+
P.type('ctrl-k memory');
|
|
419
|
+
await settleSearch();
|
|
420
|
+
|
|
421
|
+
assert.equal(calls.length, 2, 'a fresh open must not trust a memo carried over from the previous one');
|
|
422
|
+
});
|
|
@@ -103,7 +103,7 @@ test('an open community adds somewhere to gather to a bundle that lacks one', ()
|
|
|
103
103
|
|
|
104
104
|
test('a community adds the gathering place to a bundle that lacks it', () => {
|
|
105
105
|
// The surviving bundle rule's ADD path. The two rules that added and removed
|
|
106
|
-
//
|
|
106
|
+
// the dev box by team shape went with the module (task 1003886), which leaves
|
|
107
107
|
// `effect: 'remove'` declared in adjustBundle but named by no live rule - it is
|
|
108
108
|
// kept for the next rule that needs it, not because anything exercises it today.
|
|
109
109
|
// Arrange - the research bundle is `agents` alone, so the addition bites.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|