@bongos/core 1.19.358 → 1.19.359
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 +42 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +19 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +345 -3
- package/docs/api-reference.md +12 -2
- package/modules/builder-settings/builder-needs.js +29 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_005_task_recommendations.sql +90 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/recommendations.js +148 -0
- package/modules/lifecycle/task-recommendations.js +247 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +15 -0
- package/src/module-api.js +3 -1
- package/tests/task_recommendations.mjs +297 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// tests/task_recommendations.mjs — passing a task RECOMMENDS it; it never
|
|
2
|
+
// transfers the claim (task 1003173 / C03, goal 1000075; criterion
|
|
3
|
+
// collab-pass-a-task, ADR 0187 §3).
|
|
4
|
+
//
|
|
5
|
+
// The criterion names four testable promises, and this file is organised around
|
|
6
|
+
// them, because each one is a thing the code could plausibly have done instead:
|
|
7
|
+
// 1. a pass creates a recommendation and never a claim;
|
|
8
|
+
// 2. the recipient's claim is their own action;
|
|
9
|
+
// 3. the recommendation and its author are readable afterwards;
|
|
10
|
+
// 4. declining leaves no penalty on either party.
|
|
11
|
+
//
|
|
12
|
+
// Promises 1 and 4 are ABSENCE claims — "this code does not do X" — so they are
|
|
13
|
+
// asserted against the source, which is the only place an absence is visible. A
|
|
14
|
+
// behavioural test can show that a claim was not created on one path; it cannot
|
|
15
|
+
// show that no path creates one.
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const recs = require(path.join(ROOT, 'modules', 'lifecycle', 'task-recommendations.js'));
|
|
26
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
27
|
+
|
|
28
|
+
const SRC = read('modules', 'lifecycle', 'task-recommendations.js');
|
|
29
|
+
const ROUTES = read('modules', 'lifecycle', 'routes', 'recommendations.js');
|
|
30
|
+
const SQL = read('modules', 'lifecycle', 'migrations', 'lifecycle_005_task_recommendations.sql');
|
|
31
|
+
|
|
32
|
+
const SENDER = 7;
|
|
33
|
+
const good = { task_id: 42, recommended_to: 9, reason: 'you shipped the last three hall surfaces' };
|
|
34
|
+
const reasons = (input, sender = SENDER) => recs.validateRecommendation(input, sender).errors.map((e) => e.reason);
|
|
35
|
+
|
|
36
|
+
// ── PROMISE 1: a pass creates a recommendation and NEVER a claim ────────────
|
|
37
|
+
|
|
38
|
+
test('THE DECISION: nothing in this surface writes a claim', () => {
|
|
39
|
+
// The criterion exists because a pass that moved a claim would be a task
|
|
40
|
+
// quietly changing hands. Asserted as an absence over the whole surface: any
|
|
41
|
+
// INSERT/UPDATE against claims, from either file, is the thing forbidden.
|
|
42
|
+
for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
|
|
43
|
+
assert.ok(!/\bINSERT\s+INTO\s+claims\b/i.test(src), `${name} must not insert a claim`);
|
|
44
|
+
assert.ok(!/\bUPDATE\s+claims\b/i.test(src), `${name} must not update a claim`);
|
|
45
|
+
assert.ok(!/\bDELETE\s+FROM\s+claims\b/i.test(src), `${name} must not delete a claim`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('and it does not reach the claim path indirectly either', () => {
|
|
50
|
+
// A require of the claim machinery would be the seam the decision refuses,
|
|
51
|
+
// even if today it went unused.
|
|
52
|
+
for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
|
|
53
|
+
assert.ok(!/require\([^)]*claim/i.test(src), `${name} must not require the claim path`);
|
|
54
|
+
assert.ok(!/claimTask/.test(src), `${name} must not call claimTask`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('THE UNUSED SEAM: there is no transfer function, and no name promising one', () => {
|
|
59
|
+
// "Do NOT build a transfer path or a seam for one; the decision was
|
|
60
|
+
// recommend-only, and an unused seam is a cost." A function called transfer/
|
|
61
|
+
// reassign/handOff would read to the next author as an intention.
|
|
62
|
+
const exported = Object.keys(recs);
|
|
63
|
+
const suspect = exported.filter((k) => /transfer|reassign|handoff|hand_off|takeover/i.test(k));
|
|
64
|
+
assert.deepEqual(suspect, [], `no transfer-shaped export: ${suspect}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the schema has NO accepted status — accepting is claiming, which lives elsewhere', () => {
|
|
68
|
+
// A status saying "accepted" would be a second, weaker record of holding work,
|
|
69
|
+
// and would make this table look like the place claims come from.
|
|
70
|
+
assert.match(SQL, /status IN \('open', 'declined', 'withdrawn'\)/);
|
|
71
|
+
// Comments STRIPPED before asserting: both files DISCUSS why there is no
|
|
72
|
+
// accepted status, and matching the prose that explains the rule as a
|
|
73
|
+
// violation of it is the over-broad-source-assertion reflex of learning
|
|
74
|
+
// 1000196 — the same trap the sibling C01 test documents.
|
|
75
|
+
const sqlCode = SQL.replace(/^\s*--.*$/gm, '');
|
|
76
|
+
const routeCode = ROUTES.replace(/^\s*\/\/.*$/gm, '');
|
|
77
|
+
assert.ok(!/'accepted'/.test(sqlCode), 'no accepted status');
|
|
78
|
+
assert.ok(!/\baccept\b/i.test(routeCode), 'and no accept route');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('the schema carries no transfer column', () => {
|
|
82
|
+
const body = SQL.slice(SQL.indexOf('CREATE TABLE'), SQL.indexOf('\n);'));
|
|
83
|
+
const columns = body.split('\n')
|
|
84
|
+
.map((l) => l.replace(/--.*$/, '').trim())
|
|
85
|
+
.filter((l) => l && !/^(CONSTRAINT|CHECK|\))/.test(l));
|
|
86
|
+
const suspect = columns.filter((l) => /transferred|claimed_by|assigned_to|accepted/i.test(l));
|
|
87
|
+
assert.deepEqual(suspect, [], `no transfer-shaped column: ${suspect}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── PROMISE 2: the recipient's claim is their own action ────────────────────
|
|
91
|
+
|
|
92
|
+
test('the recipient read reports the TASK STATUS, not a claim grant', () => {
|
|
93
|
+
// What the recipient gets is enough to judge, and nothing that says they now
|
|
94
|
+
// hold it. Claimability stays the claim path's answer — duplicating that
|
|
95
|
+
// judgement here would be a second, staler copy of a rule that has an owner.
|
|
96
|
+
const fn = SRC.slice(SRC.indexOf('async function listForRecipient'));
|
|
97
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
98
|
+
assert.match(body, /t\.status\s+AS task_status/, 'the task\'s own status is reported');
|
|
99
|
+
assert.ok(!/can_claim|claimable|is_claimable/i.test(body), 'and no claimability verdict is invented');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a recommendation to yourself is refused', () => {
|
|
103
|
+
// Not a recommendation. Checked against the AUTHENTICATED sender, never a
|
|
104
|
+
// client-supplied id — which is why validateRecommendation takes it as an
|
|
105
|
+
// argument rather than reading it out of the body.
|
|
106
|
+
assert.ok(reasons({ ...good, recommended_to: SENDER }).includes('cannot_recommend_to_self'));
|
|
107
|
+
assert.match(ROUTES, /validateRecommendation\(req\.body, req\.builder\.id\)/,
|
|
108
|
+
'the sender comes from the session');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the not-self rule is in the schema too, so neither guard is the only one', () => {
|
|
112
|
+
assert.match(SQL, /recommended_by <> recommended_to/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── PROMISE 3: the recommendation and its author are readable afterwards ────
|
|
116
|
+
|
|
117
|
+
test('THE LEDGER: the reason is required, not optional', () => {
|
|
118
|
+
// The criterion's own argument: an untraced recommendation is indistinguishable
|
|
119
|
+
// from a task quietly changing hands. A reason the sender must write is what
|
|
120
|
+
// makes this a record of a judgement rather than a nudge.
|
|
121
|
+
for (const v of [undefined, null, '', ' ', '\n\t ']) {
|
|
122
|
+
assert.ok(reasons({ ...good, reason: v }).includes('required'),
|
|
123
|
+
`${JSON.stringify(v)} is not a reason`);
|
|
124
|
+
}
|
|
125
|
+
assert.deepEqual(reasons(good), [], 'a real reason validates');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the reason is enforced in the schema and the route body as well', () => {
|
|
129
|
+
// Three guards that agree: the CHECK is the authority, the validator names the
|
|
130
|
+
// field, and the route schema refuses before either runs.
|
|
131
|
+
assert.match(SQL, /reason\s+text NOT NULL/);
|
|
132
|
+
assert.match(SQL, /length\(btrim\(reason\)\) > 0/);
|
|
133
|
+
assert.match(ROUTES, /reason: \{ required: true, type: 'string', minLength: 1/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('an over-long reason is refused rather than truncated', () => {
|
|
137
|
+
assert.ok(reasons({ ...good, reason: 'x'.repeat(recs.MAX_REASON + 1) }).includes('too_long'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the per-task history reads EVERY status, so a declined pass stays on the record', () => {
|
|
141
|
+
// "Readable afterwards" is the point. A history filtered to open would erase
|
|
142
|
+
// exactly the case the ledger exists to document.
|
|
143
|
+
const fn = SRC.slice(SRC.indexOf('async function listForTask'));
|
|
144
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
145
|
+
assert.match(body, /WHERE r\.task_id = \$1/);
|
|
146
|
+
assert.ok(!/status = 'open'/.test(body), 'the per-task history is not filtered to open');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('the history names the AUTHOR, not just the recommendation', () => {
|
|
150
|
+
const fn = SRC.slice(SRC.indexOf('async function listForTask'));
|
|
151
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
152
|
+
assert.match(body, /sender\.github_login/, 'who recommended it');
|
|
153
|
+
assert.match(body, /r\.reason/, 'and why');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("the sender's own list is unfiltered by default", () => {
|
|
157
|
+
const fn = SRC.slice(SRC.indexOf('async function listFromSender'));
|
|
158
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
159
|
+
assert.match(body, /\$2::text IS NULL OR r\.status = \$2/, 'status is an optional narrowing');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── PROMISE 4: declining leaves no penalty on either party ──────────────────
|
|
163
|
+
|
|
164
|
+
test('THE NO-PENALTY RULE: declining touches nothing but its own row', () => {
|
|
165
|
+
// Asserted as an absence, because that is what "costs nothing" means. If a
|
|
166
|
+
// penalty ever appears for declining, it did not come from here.
|
|
167
|
+
const fn = SRC.slice(SRC.indexOf('async function declineRecommendation'));
|
|
168
|
+
const body = fn.slice(0, fn.indexOf('\n}'));
|
|
169
|
+
assert.match(body, /UPDATE lifecycle_task_recommendations/);
|
|
170
|
+
assert.ok(!/builders|credits|karma|credit_log|peer_votes/i.test(body),
|
|
171
|
+
'declining writes nothing about either person');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('and no penalty machinery is reachable from this surface at all', () => {
|
|
175
|
+
for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
|
|
176
|
+
const code = src.replace(/^\s*\/\/.*$/gm, ''); // comments may DISCUSS karma
|
|
177
|
+
for (const forbidden of ['karma', 'credit', 'penalt', 'cooldown', 'strike']) {
|
|
178
|
+
assert.ok(!new RegExp(forbidden, 'i').test(code),
|
|
179
|
+
`${name} must not reach ${forbidden} machinery`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a decline is NOT final — the same task can be recommended again', () => {
|
|
185
|
+
// A permanent unique key would quietly make declining final, which is a cost.
|
|
186
|
+
// The uniqueness is partial, on open rows only.
|
|
187
|
+
assert.match(SQL, /CREATE UNIQUE INDEX[^;]*lifecycle_task_recommendations_open_uniq[^;]*WHERE status = 'open'/s);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a duplicate OPEN recommendation is a named conflict, not a 500', () => {
|
|
191
|
+
// The same judgement repeated. Not something the sender can fix by changing
|
|
192
|
+
// the body, so it is not a validation error.
|
|
193
|
+
assert.match(SRC, /ON CONFLICT DO NOTHING/);
|
|
194
|
+
assert.match(SRC, /reason: 'already_open'/);
|
|
195
|
+
assert.match(ROUTES, /recommendation_already_open', 409/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── who may settle what ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
test('the RECIPIENT declines and the SENDER withdraws — each scoped in the WHERE', () => {
|
|
201
|
+
const dec = SRC.slice(SRC.indexOf('async function declineRecommendation'));
|
|
202
|
+
assert.match(dec.slice(0, 600), /recommended_to = \$2/, 'ownership is in the WHERE, not in rank');
|
|
203
|
+
const wd = SRC.slice(SRC.indexOf('async function withdrawRecommendation'));
|
|
204
|
+
assert.match(wd.slice(0, 600), /recommended_by = \$2/);
|
|
205
|
+
for (const fn of [dec, wd]) {
|
|
206
|
+
assert.match(fn.slice(0, 600), /status = 'open'/, 'a settled recommendation cannot be re-settled');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the settle refusal does not let you enumerate ids', () => {
|
|
211
|
+
// ONE refusal for absent / not-yours / already-settled. Looking the row up to
|
|
212
|
+
// say which would leak existence — the lesson help-requests learned from its
|
|
213
|
+
// own grader round (task 1003171).
|
|
214
|
+
const patch = ROUTES.slice(ROUTES.indexOf("'PATCH /task-recommendations/:id'"));
|
|
215
|
+
const body = patch.slice(0, patch.indexOf('errorCode'));
|
|
216
|
+
assert.match(body, /recommendation_not_settleable', 404/);
|
|
217
|
+
assert.ok(!/getRecommendation|SELECT/i.test(body), 'no lookup before the refusal');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── the shape the module contract requires ──────────────────────────────────
|
|
221
|
+
|
|
222
|
+
test('the table is module-owned and lifecycle-namespaced', () => {
|
|
223
|
+
// A NEW table the module owns outright, unlike tasks/builders which are core
|
|
224
|
+
// tables it only references — so fitness check 11 permits it (ADR 0083 §5).
|
|
225
|
+
assert.match(SQL, /CREATE TABLE IF NOT EXISTS lifecycle_task_recommendations/);
|
|
226
|
+
assert.ok(!/ALTER TABLE (?!lifecycle_)/.test(SQL), 'it alters no core table');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the migration registers itself', () => {
|
|
230
|
+
assert.match(SQL, /INSERT INTO schema_migrations \(version\) VALUES \('lifecycle_005_task_recommendations'\)/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('the route is registered in the module manifest', () => {
|
|
234
|
+
// A route file nothing mounts is a file, not a surface.
|
|
235
|
+
const manifest = JSON.parse(read('modules', 'lifecycle', 'module.json'));
|
|
236
|
+
assert.ok(manifest.contributes.routes.includes('recommendations'));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('core reaches the count through the PORT, never by importing the module', () => {
|
|
240
|
+
// ADR 0093: core imports no module. /me needs the count for the needs roster,
|
|
241
|
+
// so the lifecycle port exposes it the same way it exposes the help-request one.
|
|
242
|
+
const lifecycle = read('modules', 'lifecycle', 'lifecycle.js');
|
|
243
|
+
assert.match(lifecycle, /openTaskRecommendationsFor:/);
|
|
244
|
+
const me = read('src', 'bongos', 'routes', 'me.js');
|
|
245
|
+
assert.match(me, /lifecycle\.openTaskRecommendationsFor/);
|
|
246
|
+
assert.ok(!/require\([^)]*task-recommendations/.test(me), 'core does not import the module');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── it reaches someone, rather than merely being stored ─────────────────────
|
|
250
|
+
|
|
251
|
+
const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
|
|
252
|
+
|
|
253
|
+
test('a waiting recommendation produces a need, so it is surfaced and not just stored', () => {
|
|
254
|
+
// C01's grader round established the rule for its sibling: storing is not
|
|
255
|
+
// reaching. It lands in the needs roster the hall card and the CLI nudge
|
|
256
|
+
// already render, rather than in a second notification system beside it.
|
|
257
|
+
const n = needs.taskRecommendationsNeed({ openTaskRecommendations: 2 });
|
|
258
|
+
assert.ok(n, 'a pending recommendation produces a need');
|
|
259
|
+
assert.match(n.message, /2 tasks/);
|
|
260
|
+
assert.ok(n.action && n.action.href, 'and it says where to go');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('the need says plainly that nothing has been assigned', () => {
|
|
264
|
+
// Wording that pressured the reader would BE the penalty the decision forbids.
|
|
265
|
+
const n = needs.taskRecommendationsNeed({ openTaskRecommendations: 1 });
|
|
266
|
+
assert.match(n.message, /Nothing has been assigned/);
|
|
267
|
+
assert.match(n.message, /declining costs nothing/);
|
|
268
|
+
assert.match(n.what, /suggestion, not an assignment/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('no recommendations produces NO need, not a cheerful empty state', () => {
|
|
272
|
+
for (const ctx of [{ openTaskRecommendations: 0 }, {}, { openTaskRecommendations: undefined }]) {
|
|
273
|
+
assert.equal(needs.taskRecommendationsNeed(ctx), null);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('the need is registered, so every renderer picks it up', () => {
|
|
278
|
+
const roster = needs.computeNeeds({ openTaskRecommendations: 1, ownKeySet: true, rank: 'metic' });
|
|
279
|
+
assert.ok(roster.items.some((i) => i.id === 'task_recommendations'), 'it is in the roster');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── bounds ──────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
test('reads are bounded, so an unsettled pile degrades the page and not the server', () => {
|
|
285
|
+
assert.equal(recs.capRows(0), recs.MAX_ROWS);
|
|
286
|
+
assert.equal(recs.capRows(-1), recs.MAX_ROWS);
|
|
287
|
+
assert.equal(recs.capRows(recs.MAX_ROWS + 1), recs.MAX_ROWS);
|
|
288
|
+
assert.equal(recs.capRows(1.5), recs.MAX_ROWS);
|
|
289
|
+
assert.equal(recs.capRows(25), 25);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('task_id and recommended_to are required and must be positive ids', () => {
|
|
293
|
+
assert.ok(reasons({ reason: 'x' }).includes('required'));
|
|
294
|
+
assert.ok(reasons({ ...good, task_id: 0 }).includes('bad_task_id'));
|
|
295
|
+
assert.ok(reasons({ ...good, task_id: 1.5 }).includes('bad_task_id'));
|
|
296
|
+
assert.ok(reasons({ ...good, recommended_to: -3 }).includes('bad_builder_id'));
|
|
297
|
+
});
|