@bongos/core 1.19.814 → 1.19.816
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 +31 -26
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- package/modules/ideas/inbox.js +29 -1
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/db-goals.js +36 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/goal-suggest.js +75 -9
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +43 -8
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +33 -2
- package/tests/capture_goal_routing.mjs +65 -1
- package/tests/discord_channels.mjs +166 -0
- package/tests/goal_suggest.mjs +148 -1
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
// Run: node tests/discord_channels.mjs
|
|
25
25
|
|
|
26
26
|
import { strict as assert } from 'node:assert';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
27
30
|
import { createRequire } from 'node:module';
|
|
28
31
|
const require = createRequire(import.meta.url);
|
|
29
32
|
const ch = require('../modules/discord/discord-channels.js');
|
|
@@ -365,5 +368,168 @@ await t('the real config plans cleanly against an empty guild and never deletes'
|
|
|
365
368
|
assert.ok(!ops.some((o) => String(o.op).startsWith('delete')), 'reconcile must never delete');
|
|
366
369
|
});
|
|
367
370
|
|
|
371
|
+
// ---- seeding channels.json from a live guild (task 1003841) ----------------
|
|
372
|
+
//
|
|
373
|
+
// WHY THESE EXIST. Seeding hangs on one subtle rule, and I read it wrong the
|
|
374
|
+
// first time: an omitted `view` has TWO possible causes that look identical in
|
|
375
|
+
// the config, and only one of them is a problem.
|
|
376
|
+
//
|
|
377
|
+
// • the channel's view EQUALS its category's -> omitted, NO warning (fine)
|
|
378
|
+
// • the channel has no managed VIEW overwrite -> omitted, WITH a warning
|
|
379
|
+
//
|
|
380
|
+
// configFromSnapshot emits `view` only when it differs from the parent, because
|
|
381
|
+
// the planner reads `ch.view || cat.view` and repeating the value is noise that
|
|
382
|
+
// drifts later. So "the field is missing" is NOT evidence that a channel is
|
|
383
|
+
// unmanaged — the WARNING is. These pin that distinction, because acting on the
|
|
384
|
+
// wrong one means writing view values into the file that the module deliberately
|
|
385
|
+
// omits, which is a diff against the guild rather than a record of it.
|
|
386
|
+
|
|
387
|
+
const V_ALLOW = String(ch.VIEW_CHANNEL);
|
|
388
|
+
const allowEveryone = [{ id: EVERYONE, type: 0, allow: V_ALLOW, deny: '0' }];
|
|
389
|
+
const publicCat = (catOverwrites, chOverwrites) => ({
|
|
390
|
+
categories: [{ id: 'CAT_PUB', name: 'PUBLIC', overwrites: catOverwrites }],
|
|
391
|
+
channels: [{ id: 'CH_NEWS', type: 0, name: '📯-ship-news', parentId: 'CAT_PUB', topic: 'Ships land here.', overwrites: chOverwrites }],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t('an omitted view means INHERITS when the channel matches its category — and warns nothing (task 1003841)', () => {
|
|
395
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, allowEveryone), CTX);
|
|
396
|
+
const news = config.categories[0].channels[0];
|
|
397
|
+
assert.equal(news.view, undefined, 'a view identical to the category is deliberately omitted');
|
|
398
|
+
assert.deepEqual(warnings, [], 'and nothing is wrong, so nothing is warned about');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await t('an omitted view means UNMANAGED when the channel has no overwrite — and that WARNS (task 1003841)', () => {
|
|
402
|
+
// The distinguishing signal. Same missing field, different meaning.
|
|
403
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, []), CTX);
|
|
404
|
+
assert.equal(config.categories[0].channels[0].view, undefined, 'also omitted — the config alone cannot tell them apart');
|
|
405
|
+
assert.equal(warnings.length, 1, 'but the warning does');
|
|
406
|
+
assert.match(warnings[0], /no VIEW overwrite/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await t('the seeded file round-trips to ZERO ops for every channel that exists (task 1003841)', () => {
|
|
410
|
+
// The done-when, demonstrated rather than argued. Feeding the snapshot-derived
|
|
411
|
+
// config back against the same guild must plan to nothing — that is the whole
|
|
412
|
+
// correctness property of configFromSnapshot, and it is what makes committing
|
|
413
|
+
// the snapshot safe: applying it cannot edit a live channel.
|
|
414
|
+
const guild = publicCat(allowEveryone, allowEveryone);
|
|
415
|
+
const { config } = ch.configFromSnapshot(guild, CTX);
|
|
416
|
+
assert.deepEqual(ch.planChannelReconcile(guild, config, CTX).ops, [], 'a faithful snapshot is a no-op plan');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await t('writing view:everyone where the snapshot omitted it would ADD an op — which is why it is not written (task 1003841)', () => {
|
|
420
|
+
// The mistake this file avoids. Spelling out a value identical to the category
|
|
421
|
+
// is harmless to READ, but it is not what the module emits, and on a channel
|
|
422
|
+
// that genuinely lacks an overwrite it turns a record into an edit of the live
|
|
423
|
+
// guild. Pinned so nobody "tidies" the omitted fields back in.
|
|
424
|
+
const guild = publicCat(allowEveryone, []);
|
|
425
|
+
const spelled = {
|
|
426
|
+
categories: [{
|
|
427
|
+
name: 'PUBLIC', view: 'everyone',
|
|
428
|
+
channels: [{ key: 'ship-news', name: '📯-ship-news', view: 'everyone', topic: 'Ships land here.' }],
|
|
429
|
+
}],
|
|
430
|
+
};
|
|
431
|
+
const { ops } = ch.planChannelReconcile(guild, spelled, CTX);
|
|
432
|
+
assert.ok(ops.some((o) => o.op === 'edit-channel'), 'it edits the live channel');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await t('the committed file is the guild, plus board-room (task 1003841)', () => {
|
|
436
|
+
const cfg = ch.loadConfig();
|
|
437
|
+
const keys = cfg.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
438
|
+
assert.ok(keys.length >= 15, `expected the seeded guild, got ${keys.length} channels`);
|
|
439
|
+
assert.ok(keys.includes('board-room'), 'board-room stays declared — the bot never deletes');
|
|
440
|
+
for (const cat of cfg.categories) {
|
|
441
|
+
assert.ok(cat.view, `category ${cat.name} needs a view`);
|
|
442
|
+
for (const c of cat.channels) assert.ok(c.key && c.name, 'every channel carries its key and real name');
|
|
443
|
+
}
|
|
444
|
+
// The four PUBLIC channels inherit deliberately; asserting they have no view
|
|
445
|
+
// is what stops a later edit from spelling them out and creating a diff.
|
|
446
|
+
const pub = cfg.categories.find((c) => c.name === 'PUBLIC');
|
|
447
|
+
assert.equal(pub.view, 'everyone');
|
|
448
|
+
for (const c of pub.channels) assert.equal(c.view, undefined, `${c.key} must inherit, not repeat, its category view`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---- the config path on a DEPLOYED instance (task 1003973) ---------------
|
|
452
|
+
//
|
|
453
|
+
// channels.json describes ONE guild, so it is instance identity and is NOT
|
|
454
|
+
// published in the neutral core (ADR 0062 §7). The module used to resolve it
|
|
455
|
+
// against its OWN directory, which on a deployed instance is
|
|
456
|
+
// node_modules/@bongos/core/modules/discord/ — the file is not there, readFileSync
|
|
457
|
+
// threw ENOENT, computePlan() threw, and the route returned a bare 500 that was
|
|
458
|
+
// indistinguishable from a bot outage. These pin both halves of the fix.
|
|
459
|
+
|
|
460
|
+
await t('resolveConfigPath prefers the INSTANCE root over the core package', () => {
|
|
461
|
+
// Point the instance root somewhere that is NOT the core checkout and give it
|
|
462
|
+
// its own channels.json. If resolution still lands on the core copy, a deployed
|
|
463
|
+
// instance would read cloudbongos.com's channel names — the thing ADR 0062 §7
|
|
464
|
+
// forbids — so this must fail loudly rather than pass because the two roots
|
|
465
|
+
// happen to coincide in a single checkout.
|
|
466
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-inst-1003973-'));
|
|
467
|
+
const own = path.join(root, 'docs', 'discord', 'channels.json');
|
|
468
|
+
fs.mkdirSync(path.dirname(own), { recursive: true });
|
|
469
|
+
fs.writeFileSync(own, JSON.stringify({ categories: [{ name: 'X', channels: [] }] }));
|
|
470
|
+
const prev = process.env.BONGOS_INSTANCE_ROOT;
|
|
471
|
+
process.env.BONGOS_INSTANCE_ROOT = root;
|
|
472
|
+
try {
|
|
473
|
+
assert.equal(ch.resolveConfigPath(), own,
|
|
474
|
+
'a deployed instance must read its OWN docs/discord/channels.json, not the core package copy');
|
|
475
|
+
assert.notEqual(ch.resolveConfigPath(), ch.CORE_CONFIG_PATH);
|
|
476
|
+
assert.deepEqual(ch.loadConfig().categories.map((c) => c.name), ['X'],
|
|
477
|
+
'loadConfig must follow the same resolution, not a stale module-scope constant');
|
|
478
|
+
} finally {
|
|
479
|
+
if (prev === undefined) delete process.env.BONGOS_INSTANCE_ROOT;
|
|
480
|
+
else process.env.BONGOS_INSTANCE_ROOT = prev;
|
|
481
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
// and with no pointer set, a single checkout still resolves to the repo file
|
|
484
|
+
assert.ok(fs.existsSync(ch.resolveConfigPath()), 'the single-checkout path must still resolve');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await t('loadConfig on a missing file throws a TAGGED error naming the path', () => {
|
|
488
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
489
|
+
assert.throws(() => ch.loadConfig(missing), (err) => {
|
|
490
|
+
assert.equal(err.code, 'CONFIG_NOT_FOUND', 'ENOENT must be tagged, not left as a raw fs error');
|
|
491
|
+
assert.equal(err.configPath, missing);
|
|
492
|
+
assert.ok(err.message.includes(missing), 'the message must name the path the operator should fix');
|
|
493
|
+
return true;
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await t('computePlan with no config returns {ok:false, no_config} instead of throwing', () => {
|
|
498
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
499
|
+
const deps = {
|
|
500
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
501
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
502
|
+
loadRoleMap: () => ({}),
|
|
503
|
+
};
|
|
504
|
+
const r = ch.computePlan(deps);
|
|
505
|
+
assert.equal(r.ok, false);
|
|
506
|
+
assert.equal(r.reason, 'no_config', 'a missing layout file must be its own reason, never a thrown 500');
|
|
507
|
+
assert.equal(r.configPath, missing, 'the caller needs the path to report it');
|
|
508
|
+
assert.deepEqual(r.ops, []);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await t('reconcileChannels with no config returns {ok:false, no_config} and writes nothing', async () => {
|
|
512
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
513
|
+
const deps = {
|
|
514
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
515
|
+
loadBotToken: () => 'tok',
|
|
516
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
517
|
+
loadRoleMap: () => ({}),
|
|
518
|
+
};
|
|
519
|
+
const r = await ch.reconcileChannels(deps);
|
|
520
|
+
assert.equal(r.ok, false);
|
|
521
|
+
assert.equal(r.reason, 'no_config');
|
|
522
|
+
assert.equal(r.applied, 0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await t('a non-ENOENT read failure still propagates — only ENOENT is tagged', () => {
|
|
526
|
+
// A directory reads as EISDIR: a genuine internal error must NOT be laundered
|
|
527
|
+
// into the friendly 'put the file here' path.
|
|
528
|
+
assert.throws(() => ch.loadConfig(os.tmpdir()), (err) => {
|
|
529
|
+
assert.notEqual(err.code, 'CONFIG_NOT_FOUND');
|
|
530
|
+
return true;
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
368
534
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
369
535
|
process.exit(failed ? 1 : 0);
|
package/tests/goal_suggest.mjs
CHANGED
|
@@ -199,13 +199,23 @@ await test('the handler clamps limit into a sane range', async () => {
|
|
|
199
199
|
// confident answer and declines whenever "confident" is not available.
|
|
200
200
|
// =========================================================================
|
|
201
201
|
|
|
202
|
-
const fakeDb = ({ versions = [], goals = [], throws = null, calls = null } = {}) => ({
|
|
202
|
+
const fakeDb = ({ versions = [], goals = [], throws = null, calls = null, criteria = null } = {}) => ({
|
|
203
203
|
listVersions: async () => { if (throws === 'versions') throw new Error('boom'); return versions; },
|
|
204
204
|
listGoals: async (args) => {
|
|
205
205
|
if (calls) calls.push(args);
|
|
206
206
|
if (throws === 'goals') throw new Error('boom');
|
|
207
207
|
return goals;
|
|
208
208
|
},
|
|
209
|
+
// task 1003859: the criterion prose the ranker reads alongside the title.
|
|
210
|
+
// Omitted (criteria: null) it is absent from the fake ENTIRELY, which is also
|
|
211
|
+
// the pinned shape for a db that predates this read.
|
|
212
|
+
...(criteria ? {
|
|
213
|
+
listCriterionTextForGoals: async (ids) => {
|
|
214
|
+
if (throws === 'criteria') throw new Error('boom');
|
|
215
|
+
if (calls) calls.push({ criteriaFor: ids.map(String) });
|
|
216
|
+
return new Map(Object.entries(criteria));
|
|
217
|
+
},
|
|
218
|
+
} : {}),
|
|
209
219
|
});
|
|
210
220
|
|
|
211
221
|
const BUILDING = { id: 'BONGOS-V1', status: 'building' };
|
|
@@ -298,4 +308,141 @@ await test('suggestGoalForCapture: the catch-all is never offered as a home', as
|
|
|
298
308
|
assert.equal(got, null, 'a bucket is not a workspace — dropped for EVERY open version, not just one');
|
|
299
309
|
});
|
|
300
310
|
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// ranking on DONE-WHEN CRITERIA (task 1003859)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
//
|
|
315
|
+
// The real failure: since ADR 0264 every open BONGOS-V2 goal is titled
|
|
316
|
+
// 'Working area N — <subject>'. A subject word in a draft had nothing to overlap
|
|
317
|
+
// in the title, every goal scored 0, and the suggester returned [] for every idea
|
|
318
|
+
// walked at the 2026-09-12 triage — which also left suggested_goal_id NULL on
|
|
319
|
+
// every filing. These reproduce that board rather than a convenient one.
|
|
320
|
+
|
|
321
|
+
const WORKING_AREAS = [
|
|
322
|
+
{ id: 1000091, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' },
|
|
323
|
+
{ id: 1000094, title: 'Working area 10', scope_modules: [], version_id: 'BONGOS-V2' },
|
|
324
|
+
];
|
|
325
|
+
const WA_CRITERIA = {
|
|
326
|
+
1000091: 'A release is published and an instance can move its pin to it. The grader runs and its findings are readable.',
|
|
327
|
+
1000094: 'Every authenticated route re-checks the caller rank. A credential never reaches a log.',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
await test('the real board: title-only ranking scores every Working area goal ZERO', () => {
|
|
331
|
+
const got = suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2', includeZero: true });
|
|
332
|
+
assert.deepEqual(got.map((g) => g.score), [0, 0],
|
|
333
|
+
'this is the bug, pinned: the naming convention left the ranker nothing to match');
|
|
334
|
+
assert.deepEqual(suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2' }), [],
|
|
335
|
+
'and so the suggester returned nothing at all');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await test('with criteria, the same draft finds its goal, with the matched terms shown', () => {
|
|
339
|
+
const got = suggestGoals(
|
|
340
|
+
WORKING_AREAS,
|
|
341
|
+
{ title: 'the grader returns no findings' },
|
|
342
|
+
{ versionId: 'BONGOS-V2', criteriaByGoal: new Map(Object.entries(WA_CRITERIA)) }
|
|
343
|
+
);
|
|
344
|
+
assert.equal(got.length, 1, 'only the goal whose criteria talk about grading');
|
|
345
|
+
assert.equal(got[0].id, 1000091);
|
|
346
|
+
assert.ok(got[0].score > 0);
|
|
347
|
+
assert.ok(got[0].matched_terms.includes('grader'), `expected 'grader' among ${JSON.stringify(got[0].matched_terms)}`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await test('a criterion match weighs LESS than the same word in the title', () => {
|
|
351
|
+
const byTitle = { id: 10, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
352
|
+
const byCriteria = { id: 11, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
353
|
+
const got = suggestGoals(
|
|
354
|
+
[byCriteria, byTitle],
|
|
355
|
+
{ title: 'grader' },
|
|
356
|
+
{ versionId: 'BONGOS-V2', criteriaByGoal: new Map([['11', 'the grader runs nightly']]) }
|
|
357
|
+
);
|
|
358
|
+
assert.equal(got[0].id, 10, 'a goal NAMED for the subject still outranks one that merely mentions it');
|
|
359
|
+
assert.ok(got[0].score > got[1].score);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('a term in both title and criteria is counted ONCE, at the title weight', () => {
|
|
363
|
+
const g = { id: 12, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
364
|
+
const withCriteria = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2', criteriaByGoal: new Map([['12', 'grader grader grader']]) });
|
|
365
|
+
const without = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2' });
|
|
366
|
+
assert.equal(withCriteria[0].score, without[0].score, 'repeating itself must not inflate a goal');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await test('no criteriaByGoal: ranking is EXACTLY what it was before (the pure function stays DB-free)', () => {
|
|
370
|
+
const draft = { title: 'the hall sidebar collapses on mobile' };
|
|
371
|
+
const a = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1' });
|
|
372
|
+
const b = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1', criteriaByGoal: null });
|
|
373
|
+
assert.deepEqual(a, b);
|
|
374
|
+
assert.equal(a[0].id, 81);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await test('suggestGoalForCapture reads the criteria, so suggested_goal_id is populated on the real board', async () => {
|
|
378
|
+
const calls = [];
|
|
379
|
+
const got = await suggestGoalForCapture(
|
|
380
|
+
{ title: 'the grader returns no findings', body: '' },
|
|
381
|
+
{ db: fakeDb({ versions: [{ id: 'BONGOS-V2', status: 'building' }], goals: WORKING_AREAS, criteria: WA_CRITERIA, calls }) }
|
|
382
|
+
);
|
|
383
|
+
assert.ok(got, 'this returned NULL before task 1003859 — which is why suggested_goal_id was null on every filing');
|
|
384
|
+
assert.equal(got.id, 1000091);
|
|
385
|
+
assert.equal(got.version_id, 'BONGOS-V2');
|
|
386
|
+
assert.ok(calls.some((c) => c && c.criteriaFor), 'the criteria read must actually be issued');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await test('a criteria read that THROWS degrades to title-only ranking, never to a lost filing', async () => {
|
|
390
|
+
const got = await suggestGoalForCapture(
|
|
391
|
+
{ title: 'the hall sidebar collapses on mobile', body: '' },
|
|
392
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL], criteria: {}, throws: 'criteria' }) }
|
|
393
|
+
);
|
|
394
|
+
assert.ok(got, 'a bug report must survive a criteria read that fails');
|
|
395
|
+
assert.equal(got.id, 81, 'and still rank on what it can see');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await test('a db with NO listCriterionTextForGoals at all still works (older core, or a lean fake)', async () => {
|
|
399
|
+
const got = await suggestGoalForCapture(
|
|
400
|
+
{ title: 'the hall sidebar collapses on mobile', body: '' },
|
|
401
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL] }) }
|
|
402
|
+
);
|
|
403
|
+
assert.equal(got.id, 81);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// a PRIVATE goal's criteria are never ranked on (task 1003859, grade round 1)
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
//
|
|
410
|
+
// score + matched_terms make a ranker into a word-guessing oracle: one candidate
|
|
411
|
+
// word per request tells you whether that word appears in the ranked text. The
|
|
412
|
+
// suggester already exposes private goals' TITLES (GET /goals does too — a wider
|
|
413
|
+
// status quo this task does not settle), but their done-when prose is a different
|
|
414
|
+
// order of exposure, so the criteria read stops at the visibility line.
|
|
415
|
+
|
|
416
|
+
const PRIVATE_GOAL = { id: 900, title: 'Working area 12', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'private' };
|
|
417
|
+
const PUBLIC_GOAL = { id: 901, title: 'Working area 13', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'public' };
|
|
418
|
+
|
|
419
|
+
await test('the criteria of a PRIVATE goal are never fetched — not even to rank', async () => {
|
|
420
|
+
const asked = [];
|
|
421
|
+
const db = {
|
|
422
|
+
listGoals: async () => [PRIVATE_GOAL, PUBLIC_GOAL],
|
|
423
|
+
listCriterionTextForGoals: async (ids) => { asked.push(...ids.map(String)); return new Map(); },
|
|
424
|
+
};
|
|
425
|
+
const res = fakeRes();
|
|
426
|
+
await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })(
|
|
427
|
+
{ query: { version: 'BONGOS-V2', title: 'embargo' } }, res
|
|
428
|
+
);
|
|
429
|
+
assert.deepEqual(asked, ['901'], 'only the public goal may contribute criterion text');
|
|
430
|
+
assert.ok(!asked.includes('900'), 'a private goal must not be probeable one word at a time');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await test('a private goal still ranks on its TITLE — this narrows nothing that already worked', () => {
|
|
434
|
+
const got = suggestGoals([PRIVATE_GOAL], { title: 'working area 12' }, { versionId: 'BONGOS-V2' });
|
|
435
|
+
assert.equal(got.length, 1, 'the title-level status quo is a separate decision, left exactly as it was');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await test('every goal private ⇒ no criteria read is issued at all', async () => {
|
|
439
|
+
let called = 0;
|
|
440
|
+
const db = {
|
|
441
|
+
listGoals: async () => [PRIVATE_GOAL],
|
|
442
|
+
listCriterionTextForGoals: async () => { called++; return new Map(); },
|
|
443
|
+
};
|
|
444
|
+
await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })({ query: { version: 'BONGOS-V2', title: 'x y z' } }, fakeRes());
|
|
445
|
+
assert.equal(called, 0, 'a query with nothing rankable must not be issued');
|
|
446
|
+
});
|
|
447
|
+
|
|
301
448
|
summary();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tests/idea_cascade.mjs — the SECOND cross-role cascade: an ideator lands a Full
|
|
2
|
+
// Idea, an engineer gets a scoping task (task 1003856, part 1's residue).
|
|
3
|
+
//
|
|
4
|
+
// The first cascade (artist-review-on-ship, task 1003549) is covered by
|
|
5
|
+
// modules/lifecycle/tests/cascade.mjs. This file covers what the second one added:
|
|
6
|
+
// a trigger that is not a ship. The mechanism was ship-only, and an idea reaching
|
|
7
|
+
// grade 'full' emitted nothing at all — so the rule could not be a table entry.
|
|
8
|
+
//
|
|
9
|
+
// The runner guarantees are the same four the ship runner's header names, and the
|
|
10
|
+
// one that matters most here is the first: an ideator grading their own idea must
|
|
11
|
+
// never be able to fail on a follow-up task's account.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const C = require(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'));
|
|
22
|
+
|
|
23
|
+
const idea = (over = {}) => ({
|
|
24
|
+
id: '1001030',
|
|
25
|
+
title: 'Price swaps from mechanism',
|
|
26
|
+
grade: 'full',
|
|
27
|
+
status: 'open',
|
|
28
|
+
completeness_score: 4,
|
|
29
|
+
captured_by: '1000025',
|
|
30
|
+
suggested_goal_id: '1000095',
|
|
31
|
+
suggested_version: 'BONGOS-V2',
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const deps = (over = {}) => {
|
|
36
|
+
const created = [];
|
|
37
|
+
return {
|
|
38
|
+
created,
|
|
39
|
+
findTaskBySourceRef: async () => null,
|
|
40
|
+
createTask: async (spec) => { created.push(spec); return { id: '9001' }; },
|
|
41
|
+
...over,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------- the rule ----------
|
|
46
|
+
test('the declaration is well-formed — a malformed rule is a wiring bug, not a silent skip', () => {
|
|
47
|
+
assert.deepEqual(C.validateCascades(C.IDEA_CASCADES), []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a Full Idea matches, and files for the ENGINEER', () => {
|
|
51
|
+
const { matched, errors } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
52
|
+
assert.deepEqual(errors, []);
|
|
53
|
+
assert.equal(matched.length, 1);
|
|
54
|
+
assert.equal(matched[0].rule.discipline, 'engineer');
|
|
55
|
+
assert.match(matched[0].spec.title, /Scope the Full Idea: Price swaps from mechanism/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a spark or quick idea does NOT match — Full is the hand-off point', () => {
|
|
59
|
+
for (const grade of ['quick', 'spark', undefined]) {
|
|
60
|
+
const { matched } = C.cascadesFor(idea({ grade }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
61
|
+
assert.equal(matched.length, 0, `grade=${grade} should not file a scoping task`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a CLOSED idea does not file — triage got there first', () => {
|
|
66
|
+
for (const status of ['discarded', 'merged', 'promoted']) {
|
|
67
|
+
const { matched } = C.cascadesFor(idea({ status }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
68
|
+
assert.equal(matched.length, 0, `status=${status} should not file`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a cascade-sourced idea cannot file another cascade — the anti-recursion guard', () => {
|
|
73
|
+
const { matched } = C.cascadesFor(idea({ source: C.CASCADE_SOURCE }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
74
|
+
assert.equal(matched.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------- the generated task ----------
|
|
78
|
+
test('the task inherits the version but NEVER the advisory goal hint', () => {
|
|
79
|
+
// idea_inbox.suggested_goal_id documents itself as "Strictly advisory: no rank
|
|
80
|
+
// check, no scope wall, and no payout may read it... The BINDING home for
|
|
81
|
+
// 'which goal' is tasks.goal_id". A generated task's goal IS binding, so
|
|
82
|
+
// inheriting it from the hint would be exactly the read that column forbids.
|
|
83
|
+
// A version is a label and carries no such rule.
|
|
84
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
85
|
+
const spec = matched[0].spec;
|
|
86
|
+
assert.equal(spec.versionId, 'BONGOS-V2');
|
|
87
|
+
assert.equal(spec.goalId, null, 'an advisory hint must not place a task');
|
|
88
|
+
assert.equal(spec.allowCatchAll, true, 'so it lands in the catch-all and gets triaged like any other homeless work');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the hint is surfaced for a human rather than silently dropped', () => {
|
|
92
|
+
// Not acting on it is the rule; losing it would be a different mistake.
|
|
93
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
94
|
+
assert.match(body, /routing hint/);
|
|
95
|
+
assert.match(body, /goal 1000095/);
|
|
96
|
+
assert.match(body, /advisory/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an idea with no hint says nothing about goals at all', () => {
|
|
100
|
+
const body = C.fullIdeaScopingBody(idea({ suggested_goal_id: null }));
|
|
101
|
+
assert.doesNotMatch(body, /routing hint/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a goal-less idea still opts into the catch-all rather than vanishing', () => {
|
|
105
|
+
// The exact regression task 1003598 closed for ships: under the goal guard a
|
|
106
|
+
// null goalId THROWS, and the throw is caught per-rule — so the task would
|
|
107
|
+
// disappear with only a console line.
|
|
108
|
+
const { matched } = C.cascadesFor(idea({ suggested_goal_id: null }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
109
|
+
assert.equal(matched[0].spec.allowCatchAll, true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the preamble says what actually happened — an idea was graded, not a task shipped', () => {
|
|
113
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
114
|
+
const d = matched[0].spec.description;
|
|
115
|
+
assert.match(d, /idea 1001030 was graded Full/);
|
|
116
|
+
assert.doesNotMatch(d, /shipped/, 'the ship preamble would be a lie on this trigger');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the body tells the engineer what is already settled and what is being asked', () => {
|
|
120
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
121
|
+
assert.match(body, /reached \*\*Full\*\*/);
|
|
122
|
+
assert.match(body, /completeness 4/);
|
|
123
|
+
assert.match(body, /What would building it actually touch/);
|
|
124
|
+
// A scoping that concludes "don't build it" is a real answer; say so.
|
|
125
|
+
assert.match(body, /why it should not be built/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the source_ref pins one rule to one idea, so a re-grade cannot double-file', () => {
|
|
129
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
130
|
+
assert.equal(matched[0].spec.sourceRef, C.cascadeSourceRef('engineer-scoping-on-full-idea', '1001030'));
|
|
131
|
+
assert.equal(matched[0].spec.source, C.CASCADE_SOURCE);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------- the runner ----------
|
|
135
|
+
test('the runner files the task from the event payload alone', async () => {
|
|
136
|
+
const d = deps();
|
|
137
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
138
|
+
assert.deepEqual(report.errors, []);
|
|
139
|
+
assert.equal(report.created.length, 1);
|
|
140
|
+
assert.equal(d.created.length, 1);
|
|
141
|
+
assert.equal(d.created[0].discipline, 'engineer');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an already-filed cascade is SKIPPED, not filed twice', async () => {
|
|
145
|
+
const d = deps({ findTaskBySourceRef: async () => ({ id: '8001' }) });
|
|
146
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
147
|
+
assert.equal(report.created.length, 0);
|
|
148
|
+
assert.deepEqual(report.skipped.map((s) => s.reason), ['already filed']);
|
|
149
|
+
assert.equal(d.created.length, 0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a payload with no id is reported, never thrown', async () => {
|
|
153
|
+
const report = await C.runCascadesForIdea({}, deps());
|
|
154
|
+
assert.equal(report.created.length, 0);
|
|
155
|
+
assert.match(report.errors.join(' '), /carried no idea id/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('createTask throwing costs that rule its task and NOTHING else', async () => {
|
|
159
|
+
// Property 1: the ideator's grade is already written. A follow-up task failing
|
|
160
|
+
// must never surface as a failure of the thing that triggered it.
|
|
161
|
+
const d = deps({ createTask: async () => { throw new Error('db down'); } });
|
|
162
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
163
|
+
assert.equal(report.created.length, 0);
|
|
164
|
+
assert.match(report.errors.join(' '), /db down/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the runner never rejects, whatever the deps do', async () => {
|
|
168
|
+
const report = await C.runCascadesForIdea(idea(), { findTaskBySourceRef: async () => { throw new Error('boom'); }, createTask: async () => ({ id: '1' }) });
|
|
169
|
+
assert.ok(Array.isArray(report.errors) && report.errors.length > 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an injected reader that finds nothing is an error, not a silent fall-back', async () => {
|
|
173
|
+
const report = await C.runCascadesForIdea(idea(), deps({ getIdea: async () => null }));
|
|
174
|
+
assert.match(report.errors.join(' '), /not readable after grading/);
|
|
175
|
+
assert.equal(report.created.length, 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------- the wiring, which fails silently when it breaks ----------
|
|
179
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
180
|
+
|
|
181
|
+
test('the event is in the catalog AND something dispatches it', () => {
|
|
182
|
+
// The catalog's own comment: widening it is deliberate and must land together
|
|
183
|
+
// with a real dispatcher. Both halves asserted, because either alone is dead.
|
|
184
|
+
assert.match(src('modules/agents/lib/validate.js'), /'idea\.graded\.full'/);
|
|
185
|
+
assert.match(src('modules/ideas/inbox.js'), /api\.emit\('idea\.graded\.full'/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('the emit carries the whole idea, not just an id', () => {
|
|
189
|
+
// The listener lives in lifecycle, which owns no idea table and has no read
|
|
190
|
+
// port for one — so a bare {id} payload would leave the rules nothing to match.
|
|
191
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
192
|
+
assert.match(inbox, /const idea = await getIdea\(ideaId\)/);
|
|
193
|
+
assert.match(inbox, /\.\.\.\(idea \|\| \{\}\)/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('lifecycle subscribes to the idea trigger and tears it down again', () => {
|
|
197
|
+
const lc = src('modules/lifecycle/routes/lifecycle.js');
|
|
198
|
+
assert.match(lc, /api\.on\('idea\.graded\.full'/);
|
|
199
|
+
assert.match(lc, /if \(_ideaCascadeListenerOff\) _ideaCascadeListenerOff\(\)/);
|
|
200
|
+
// It must NOT reach into the ideas module's table to do it.
|
|
201
|
+
assert.doesNotMatch(lc, /getIdeaForCascade/, 'lifecycle must not query another module\'s rows');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the emit only fires on a REAL transition', () => {
|
|
205
|
+
// The UPDATE carries `AND grade = 'spark'`, so a no-op re-grade returns null.
|
|
206
|
+
// The emit is inside `if (graded)`, so it cannot repeat for one idea.
|
|
207
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
208
|
+
const at = inbox.indexOf("api.emit('idea.graded.full'");
|
|
209
|
+
assert.ok(at > 0);
|
|
210
|
+
assert.match(inbox.slice(0, at).split('\n').slice(-25).join('\n'), /if \(graded\) \{/);
|
|
211
|
+
});
|
|
@@ -134,11 +134,23 @@ test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
|
134
134
|
// general. A blanket `true` here would quietly re-open the hole for every
|
|
135
135
|
// follow-up task the system files.
|
|
136
136
|
const body = src('modules/lifecycle/cascade.js');
|
|
137
|
-
|
|
137
|
+
// Pinned on the RULE, not on one spelling of it: task 1003856 added an
|
|
138
|
+
// idea-triggered cascade whose subject carries its goal as `suggested_goal_id`,
|
|
139
|
+
// so the condition legitimately grew a term. What must stay true is that the
|
|
140
|
+
// hatch is conditional and covers every source the spec can inherit from.
|
|
141
|
+
const hatch = (body.match(/allowCatchAll:.*$/m) || [''])[0];
|
|
142
|
+
assert.match(hatch, /task\.goal_id == null/, 'the hatch still turns on a missing inherited goal');
|
|
138
143
|
assert.equal(/allowCatchAll: true/.test(body), false,
|
|
139
144
|
'an unconditional hatch here would cover every cascade, not just the legacy ones');
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
|
|
146
|
+
// Every field the spec inherits a goal FROM must also be a field the hatch
|
|
147
|
+
// checks, or a subject shape could arrive with a real goal and still opt into
|
|
148
|
+
// the catch-all — the hole this test exists to keep shut.
|
|
149
|
+
const goalLine = (body.match(/^\s*goalId:[\s\S]*?,$/m) || [''])[0];
|
|
150
|
+
assert.match(goalLine, /task\.goal_id != null \? Number\(task\.goal_id\)/, 'inheritance stays the primary path');
|
|
151
|
+
for (const field of (goalLine.match(/task\.[a-z_]*goal[a-z_]*/g) || [])) {
|
|
152
|
+
assert.ok(hatch.includes(field), `${field} is inherited as a goal but is not checked by the catch-all hatch`);
|
|
153
|
+
}
|
|
142
154
|
});
|
|
143
155
|
|
|
144
156
|
test('a goal-less parent does not silently lose its cascade task', async () => {
|